Задачи 006-010: пользователи, исправление удаления, журнал отказов, групповые операции, автовыделение префиксов
006 Пользователи: API /users (CRUD, смена своего пароля), раздел UI «Пользователи»,
события журнала user.*, защита от отключения/удаления себя и последнего админа.
007 Исправление удаления организации: VRF удаляются явным DELETE до организации
(без relationship() порядок DELETE не гарантирован → ложный 409).
008 Журнал фиксирует отказы в удалении (<entity>.delete_blocked) со списком
мешающих объектов в «Данных»: организация, VRF, тип устройства, префикс, пользователь.
009 Выбор строк чекбоксами и групповые операции в UI (удаление, смена типа устройств,
статус префиксов и адресов, доступ пользователей); цикл запросов из UI, итог и список отказов.
010 Автовыделение следующего вложенного префикса: POST/GET /prefixes/{id}/subnets/next,
первый свободный выровненный блок; пункт «Добавить вложенный (авто)» в меню префикса.
Тесты: 14 (добавлены сценарии для 006, 007/008, 010); исправлена нестабильность
тестов журнала (IPv6-группы с ведущими нулями нормализуются PostgreSQL).
Документация: README.md, docs/changes/006-010 (планы и итоги).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a846d30872
commit
cd09ef0805
23 files changed
+988
-49
No files matched your search
@@ -0,0 +1,52 @@
|
||||
# Раздел интерфейса «Пользователи» (изменение 006)
|
||||
|
||||
## Context
|
||||
Роли `admin` (запись) и `viewer` (чтение) заявлены в README и в UI (журнал: настройки и очистка скрыты для не-админа),
|
||||
но назначить роль негде: таблица `users` заполняется только записью администратора при первом старте (`seed()` в `app/main.py`),
|
||||
эндпоинтов управления пользователями нет, сменить пароль — тоже нельзя. Нужен раздел интерфейса, где администратор
|
||||
заводит учётные записи, меняет им роль/пароль и отключает доступ, а любой пользователь может сменить свой пароль.
|
||||
|
||||
## Модель (без изменения схемы БД)
|
||||
Таблица `users` (миграция 0001) уже содержит всё необходимое: `id`, `username` (уникальный), `password_hash` (argon2),
|
||||
`role` (`user_role`: admin/viewer), `is_active`. Миграция не требуется. Пароли не хранятся и не пишутся в журнал в открытом виде.
|
||||
|
||||
## API (`/api/v1/users`, все — под авторизацией)
|
||||
| Метод | Кто | Назначение |
|
||||
|---|---|---|
|
||||
| `GET /users` | admin | список: логин (поиск `q`), роль, признак активности, `limit`/`offset` |
|
||||
| `POST /users` | admin | создание: `username`, `password` (≥ 8), `role`, `is_active` |
|
||||
| `PATCH /users/{id}` | admin | роль, `is_active`, необязательный новый `password` |
|
||||
| `DELETE /users/{id}` | admin | удаление учётной записи |
|
||||
| `POST /users/me/password` | любой | смена **своего** пароля с подтверждением текущего |
|
||||
|
||||
Правила (иначе 409/422, состояние не меняется):
|
||||
- логин: 3–100 символов, `[A-Za-z0-9._-]`, уникален; зарезервированные `system` и `anonymous` запрещены —
|
||||
журнал различает служебные события по этим именам (`actor_of` в `app/services.py`);
|
||||
- нельзя удалить или отключить/понизить **свою** учётную запись (иначе текущая сессия теряет доступ);
|
||||
- нельзя отключить, понизить или удалить **единственного активного администратора** (система осталась бы без прав записи);
|
||||
- неверный текущий пароль при смене своего пароля → 403.
|
||||
|
||||
Аудит: `user.created`, `user.updated`, `user.password_reset` (пароль администратором и своя смена), `user.deleted`;
|
||||
в `entity_label` — логин, в `diff` — только изменённые поля (`password` отмечен как «изменён», без значения).
|
||||
Сущность `user` добавлена в словарь человекочитаемых сообщений (`_NOUNS` в `app/services.py`).
|
||||
|
||||
## UI (`web/app.js`)
|
||||
- Пункт навигации «Пользователи» — только для администратора (`NAV` фильтруется в `shell()`); при ручном вводе `#/users`
|
||||
не-админ видит заглушку «Раздел доступен только администратору».
|
||||
- Экран: счётчик записей, поиск по логину, таблица «Логин · Роль · Статус · Действия»; строка кликабельна (паттерн изменения 005),
|
||||
в меню «⋯» — «Редактировать», «Отключить/Активировать», «Удалить».
|
||||
- Окно создания/редактирования: логин (при создании), роль, флажок «Доступ разрешён», необязательное поле «Новый пароль»
|
||||
(пустое — пароль не меняется); окно смены своего пароля — в шапке («Сменить пароль»).
|
||||
- В журнале: подпись сущности «Пользователь», тип события в фильтре типов появляется автоматически (`/audit/facets`).
|
||||
|
||||
## Ограничения (осознанные)
|
||||
- JWT без списка отзыва: после **смены пароля** уже выданные токены продолжают работать до истечения `JWT_TTL_MINUTES`;
|
||||
**отключение** учётной записи действует немедленно (токен проверяется по `users.is_active` в `current_user`).
|
||||
- Логин менять нельзя (он же `sub` в токене) — вместо этого создаётся новая учётная запись.
|
||||
- Нет саморегистрации, восстановления пароля по e-mail, групп прав и журналирования последнего входа.
|
||||
|
||||
## Проверка
|
||||
- Интеграционный тест `tests/test_users.py` (один сценарий, против контейнеров): создание, дубль логина → 409,
|
||||
запрет служебного логина → 422, вход новым пользователем, запрет записи для `viewer` → 403,
|
||||
отключение → вход 401, сброс пароля → вход с новым паролем, смена своего пароля и вход с ним, удаление своей записи → 409.
|
||||
- Синтаксическая проверка Python и JS; ручной сценарий в браузере (создание, редактирование, отключение, вход отключённым, удаление).
|
||||
@@ -0,0 +1,49 @@
|
||||
# Итог: раздел интерфейса «Пользователи» (изменение 006)
|
||||
|
||||
## Что сделано
|
||||
- **API `/api/v1/users`** (`app/api/v1/users.py`, роутер подключён в `app/main.py`):
|
||||
`GET /users` (поиск по логину, `limit`/`offset`), `POST /users`, `PATCH /users/{id}`, `DELETE /users/{id}` — только `admin`;
|
||||
`POST /users/me/password` — смена своего пароля любой ролью с подтверждением текущего.
|
||||
- **Схемы** (`app/schemas.py`): `UserIn`, `UserUpdate`, `PasswordChange`, `UserOut` (`id`, `username`, `role`, `is_active`);
|
||||
валидатор логина (`Login`, 3–100 символов, `[A-Za-z0-9._-]`) и запрет служебных логинов `system`/`anonymous`.
|
||||
- **Журнал**: сущность `user` добавлена в словарь сообщений (`app/services.py`), события `user.created`, `user.updated`,
|
||||
`user.password_reset` («пароль изменён администратором» / «пароль изменён пользователем»), `user.deleted`; в `diff` — только
|
||||
изменённые поля, для пароля — пометка «изменён» без значения. В UI — подпись «Пользователь» и цвет бейджа для `password_reset`.
|
||||
- **UI** (`web/app.js`): пункт навигации «Пользователи» (только у администратора), экран со счётчиком, поиском и таблицей
|
||||
«Логин · Роль · Статус» (строка кликабельна, меню «⋯»: редактировать, отключить/разрешить доступ, удалить), окно создания
|
||||
и редактирования (логин после создания не редактируется, необязательный «Новый пароль», флажок «Доступ разрешён»),
|
||||
кнопка «Сменить пароль» в шапке, заглушка для не-администратора при ручном вводе `#/users`.
|
||||
- **Тест** `tests/test_users.py` — один сквозной сценарий.
|
||||
- **Документация**: обновлён `README.md`, план и итог в `docs/changes/006-users-management/`.
|
||||
|
||||
## Поведение
|
||||
| Ситуация | Ответ |
|
||||
|---|---|
|
||||
| Логин занят (в т. ч. в другом регистре) | 409 «Пользователь с таким логином уже существует» |
|
||||
| Логин `system`/`anonymous`, короче 3 символов, пароль короче 8 | 422 с указанием поля |
|
||||
| `viewer` читает `/users` или что-либо изменяет | 403 |
|
||||
| `PATCH`/`DELETE` своей учётной записи (роль, доступ, удаление) | 409 |
|
||||
| Отключение или удаление последнего активного администратора | 409 |
|
||||
| Неверный текущий пароль при смене своего пароля | 403 |
|
||||
| Новый пароль совпадает с текущим | 422 |
|
||||
|
||||
## Действие ролей
|
||||
- `admin` — полный доступ, включая управление пользователями и запись данных.
|
||||
- `viewer` — чтение всех справочников, реестра, журнала и обзора; запись (403) и раздел «Пользователи» недоступны.
|
||||
- **Отключение** учётной записи действует немедленно: токен проверяется по `users.is_active` в `current_user`.
|
||||
- **Смена пароля** не отзывает уже выданные токены (JWT без списка отзыва) — они действуют до истечения `JWT_TTL_MINUTES`.
|
||||
|
||||
## Инварианты
|
||||
- Схема БД не менялась: таблица `users` (миграция 0001) уже содержит `username`, `password_hash`, `role`, `is_active`.
|
||||
- Пароли хранятся только в виде argon2-хэша; значение пароля никогда не попадает в журнал.
|
||||
- Логин не меняется: он же `sub` в JWT; переименование = создание новой записи.
|
||||
- Логин уникален, в том числе без учёта регистра; служебные акторы журнала зарезервированы.
|
||||
- Перед каждым изменением проверяется, что в системе останется хотя бы один активный администратор.
|
||||
|
||||
## Проверка
|
||||
- Python: `python3 -m py_compile` по изменённым модулям — без ошибок; автоматическая проверка после правок — успешно.
|
||||
- Интеграционный тест: `docker compose up -d --build && venv/bin/python -m pytest tests/test_users.py -q`
|
||||
(создание viewer, запреты 403/409/422, вход, отключение с немедленным отзывом токена, сброс пароля администратором,
|
||||
смена своего пароля, защита своей записи и последнего администратора, удаление, записи в журнале).
|
||||
- Ручной сценарий в UI: вход администратором → «Пользователи» → создание viewer → вход в другом окне → отключение
|
||||
(вход и текущая сессия отбиваются) → сброс пароля → вход с новым паролем → «Сменить пароль» в шапке.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Исправление: удаление организации возвращает 409 (изменение 007)
|
||||
|
||||
## Context
|
||||
`DELETE /api/v1/organizations/5` отвечает `409 «Запись с такими значениями уже существует»`, хотя у организации нет префиксов, устройств и операторов
|
||||
(проверено по БД: у org 5 только служебный VRF `default`). Так же падает удаление организации 4.
|
||||
|
||||
## Причина (подтверждена логом PostgreSQL)
|
||||
`ERROR: update or delete on table "organizations" violates foreign key constraint "vrfs_organization_id_fkey" ... Key (id)=(5) is still referenced from table "vrfs"`.
|
||||
В `delete_org` (`app/api/v1/refs.py:67-79`) VRF удаляются через `db.delete(v)`, затем `db.delete(o)`, всё одним `commit`. Между `Vrf` и `Organization` нет ORM-`relationship()`,
|
||||
поэтому unit of work не гарантирует порядок DELETE, и `DELETE FROM organizations` уходит раньше `DELETE FROM vrfs`. `IntegrityError` в `commit()` (`app/services.py:52`)
|
||||
превращается в общий 409 с вводящим в заблуждение текстом.
|
||||
|
||||
## Исправление
|
||||
1. `app/api/v1/refs.py`, `delete_org`: заменить цикл `for v in ...: db.delete(v)` на `db.execute(delete(Vrf).where(Vrf.organization_id == id))`
|
||||
(добавить `delete` в импорт `sqlalchemy`) — выполняется немедленно, до `db.delete(o)`. Аудит и проверка «занято» без изменений.
|
||||
2. Схема БД, миграции и UI не меняются.
|
||||
3. Остальные `db.delete` в проекте (`vrfs`, `device-types`, `devices`, `isps`, `prefixes`, `addresses`, `users`) удаляют одиночную запись без зависимых строк, вне области правки.
|
||||
|
||||
## Артефакты (правила проекта)
|
||||
- `docs/changes/007-org-delete-fix/PLAN.md` (этот план) и `SUMMARY.md` — причина, правка, проверка.
|
||||
- `README.md`: короткая пометка в разделе об удалении организаций/поведении API (если такой раздел есть), иначе строка в списке изменений.
|
||||
|
||||
## Тест (минимум)
|
||||
Один тест в `tests/test_api.py`: создать организацию → `DELETE` → 204 → `GET` → 404; организация с префиксом → `DELETE` → 409 с текстом «Нельзя удалить…».
|
||||
|
||||
## Проверка
|
||||
1. `docker compose -p ipam_control_006 up -d --build app` (пересборка только приложения; БД и данные сохраняются, прежняя поставка `ipam_control-*` не затрагивается).
|
||||
2. `venv/bin/python -m pytest -q tests/test_api.py` — против запущенного стенда (BASE из `tests/conftest.py`).
|
||||
3. Вручную в UI (http://192.168.5.9:8088): удалить организации «ООО «Дата-Центр»» (id 5) и «ООО «СтройМонтаж»» (id 4) — 204; попытка удалить «ООО «Технологии связи»» (есть префиксы) — 409 «Нельзя удалить…»; записи `organization.deleted` появились в журнале.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Итог: исправление удаления организации (изменение 007)
|
||||
|
||||
## Проблема
|
||||
`DELETE /api/v1/organizations/{id}` возвращал `409 «Запись с такими значениями уже существует»` для организаций без префиксов, устройств и операторов.
|
||||
|
||||
## Причина
|
||||
В `delete_org` служебные VRF и сама организация удалялись одним `commit()` через `db.delete()`. Между `Vrf` и `Organization` нет ORM-`relationship()`,
|
||||
поэтому порядок DELETE в unit of work не гарантирован: `DELETE FROM organizations` выполнялся раньше `DELETE FROM vrfs` и нарушал FK `vrfs_organization_id_fkey`.
|
||||
Ошибка `IntegrityError` в `commit()` превращалась в общий 409. Тесты проблему не видели: фикстура `org` перед удалением организации вручную удаляет её VRF.
|
||||
|
||||
## Что сделано
|
||||
- `app/api/v1/refs.py`: VRF организации удаляются явным `db.execute(delete(Vrf).where(...))` до удаления организации.
|
||||
- `tests/test_api.py`: `test_delete_organization` — организация с префиксом → 409 «Нельзя удалить…»; пустая организация (VRF `default` удаляется вместе с ней) → 204, затем 404.
|
||||
- Схема БД, миграции и UI не менялись.
|
||||
|
||||
## Проверка
|
||||
- Новый тест на старом коде падал (409), на новом проходит; полный набор — 13 passed.
|
||||
- Один из прогонов сразу после перезапуска контейнера дал разовый сбой `test_journal_search_and_filters` (`total == 0`); два повторных полных прогона и отдельный прогон — без ошибок. Связь с правкой не выявлена.
|
||||
- Стенд `ipam_control_006` пересобран только по сервису `app`, БД и данные сохранены.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Журнал: события отклонённого удаления (изменение 008)
|
||||
|
||||
## Context
|
||||
Когда удаление запрещено бизнес-правилом (например, у организации есть префиксы/устройства/операторы), API отвечает 409 через `raise HTTPException`,
|
||||
а запись в журнал не пишется: `audit()` вызывается только на успешном пути, сессия закрывается без commit (`app/db.py:get_db`).
|
||||
Нужно фиксировать такие попытки как предупреждения и показывать в блоке «Данные» окна записи журнала, какие именно связанные сущности мешают удалению.
|
||||
|
||||
## Решения
|
||||
- **Тип события:** `<entity>.delete_blocked` (например `organization.delete_blocked`) — вписывается в существующую схему `entity_type` + `action`,
|
||||
фильтры и facets журнала подхватывают его без изменений (`app/api/v1/journal.py` разбирает `event_type` по точке).
|
||||
- **Охват:** все отказы в удалении по бизнес-правилам (409): организация, VRF, тип устройства (в т.ч. «по умолчанию»), префикс с адресами,
|
||||
пользователь (свой аккаунт / последний администратор). Прочие 409/422 (дубли, валидация) не журналируются.
|
||||
- **Содержимое «Данных»** — уже существующее поле `audit_log.diff` (JSONB), миграция БД не нужна:
|
||||
```json
|
||||
{"reason": "у организации есть префиксы, устройства или операторы",
|
||||
"blocked_by": {"prefixes": {"total": 13, "items": ["10.0.0.0/8 (default)", "…"]},
|
||||
"devices": {"total": 2, "items": ["db-master.internal", "…"]},
|
||||
"isps": {"total": 1, "items": ["Ростелеком"]}}}
|
||||
```
|
||||
В списке не более 20 элементов на группу, `total` — полное число. Пустые группы не выводятся. Для отказов без связанных объектов
|
||||
(свой аккаунт, тип по умолчанию, последний админ) — только `reason`.
|
||||
- **Сообщение:** «Организация ООО «X»: удаление отклонено — есть связанные объекты (префиксы: 13, устройства: 2, операторы: 1)».
|
||||
|
||||
## Реализация
|
||||
1. `app/services.py`: хелпер `refuse_delete(db, user, entity_type, entity, label, reason, blocked_by=None)` — пишет `audit(..., "delete_blocked", label, diff, message=...)`,
|
||||
делает `commit(db)` (в транзакции только запись журнала, других изменений к этому моменту нет) и поднимает `HTTPException(409, reason_text)`.
|
||||
Текст ответа API остаётся прежним. Хелпер `_blockers(db, stmt, label_col, limit=20)` — `{"total", "items"}` по запросу (переиспользуем `count()`).
|
||||
2. Вызовы вместо прямых `raise HTTPException(409, …)` в:
|
||||
- `app/api/v1/refs.py`: `delete_org` (префиксы: `prefix (vrf)`, устройства: `name`, операторы: `name`), `delete_vrf` (префиксы), `delete_type` (устройства + случай `is_default`);
|
||||
- `app/api/v1/prefixes.py`: `delete_prefix` (адреса, до 20 шт.);
|
||||
- `app/api/v1/users.py`: `delete_user` (два отказа: свой аккаунт, единственный активный админ).
|
||||
Для организации проверка «занято» переписывается с `or`-цепочки на три подсчёта, чтобы собрать все группы сразу.
|
||||
3. `web/app.js`: `eventBadge` — цвет `amber` для `delete_blocked`; в `actionBadge` обзора — `delete_blocked: ["amber", "отклонено"]`.
|
||||
Блок «Данные» (`entryDialog`) уже выводит `diff` как JSON — отдельная вёрстка не нужна.
|
||||
4. `_VERBS` в `make_message` не трогаем: сообщение передаётся явно.
|
||||
|
||||
## Артефакты (правила проекта)
|
||||
`docs/changes/008-blocked-delete-audit/PLAN.md` (копия этого плана) и `SUMMARY.md`; `README.md` — в раздел «Журнал»: событие `*.delete_blocked`, состав «Данных».
|
||||
|
||||
## Тест (минимум)
|
||||
Расширить `test_delete_organization` в `tests/test_api.py`: после 409 на организации с префиксом найти в `/audit?event_type=organization.delete_blocked&q=<имя>`
|
||||
запись, проверить `diff.blocked_by.prefixes.total == 1` и наличие CIDR в `items`.
|
||||
|
||||
## Проверка
|
||||
1. `docker compose -p ipam_control_006 up -d --build app` (БД и прежняя поставка не затрагиваются); `venv/bin/python -m pytest -q`.
|
||||
2. В UI (http://192.168.5.9:8088): «Организации» → «Удалить» у «ООО «Технологии связи»» → 409; в «Журнале» появилась запись `organization.delete_blocked`
|
||||
(жёлтый бейдж), в окне записи блок «Данные» показывает списки префиксов/устройств/операторов; фильтр по типу события находит её.
|
||||
3. Отказ на удаление VRF с префиксами и своего аккаунта — аналогичные записи.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Итог: журнал фиксирует отклонённые удаления (изменение 008)
|
||||
|
||||
## Что сделано
|
||||
- **Событие `<entity>.delete_blocked`** (`organization`, `vrf`, `device_type`, `prefix`, `user`) пишется при каждом отказе в удалении по бизнес-правилу (409).
|
||||
Ответ API не менялся: тот же код и текст. Фильтры и список типов событий журнала подхватили новый тип без правок.
|
||||
- **`app/services.py`**: `refuse_delete()` (запись в журнал + commit + 409) и `blockers()` (общее число и до 20 названий мешающих объектов).
|
||||
- **Вызовы**: `delete_org` (префиксы `CIDR (VRF)`, устройства, операторы — все группы сразу), `delete_vrf` (префиксы), `delete_type` (устройства; тип по умолчанию),
|
||||
`delete_prefix` (адреса, кроме `force=true`), `delete_user` (свой аккаунт, единственный активный администратор).
|
||||
- **«Данные» записи** (`audit_log.diff`, схема БД не менялась):
|
||||
`{"reason": "…", "blocked_by": {"prefixes": {"total": 13, "items": ["10.0.0.0/8 (default)", …]}, "devices": {…}, "isps": {…}}}`;
|
||||
для отказов без связанных объектов — только `reason`. Окно записи уже выводит `diff` как JSON.
|
||||
- **UI** (`web/app.js`): жёлтый бейдж для `delete_blocked` в журнале и подпись «отклонено» в последних изменениях на «Обзоре».
|
||||
- **Тест**: `test_delete_organization` расширен — запись `organization.delete_blocked` с `blocked_by.prefixes`.
|
||||
|
||||
## Проверка
|
||||
- Полный набор тестов: 13 passed. Вручную отказы на организацию, VRF, свой аккаунт и тип по умолчанию дали записи с ожидаемым содержимым.
|
||||
- Первая версия падала 500 (JOIN без левой стороны в запросе списка префиксов организации) — поймано тестом, исправлено `select_from(Prefix)`.
|
||||
- При ручной проверке демо-префикс `10.0.0.0/8` был удалён ошибочно (у него нет собственных адресов, отказа не было) и восстановлен через API: дерево прежнее, но id стал 67.
|
||||
- Стенд `ipam_control_006` пересобран только по сервису `app`.
|
||||
|
||||
## Ограничения
|
||||
- Журналируются только отказы удаления; прочие 409 (дубли, «нет свободных адресов», отказ понизить свой аккаунт при `PATCH`) — нет.
|
||||
- Каждая повторная попытка даёт новую запись (дедупликации нет).
|
||||
@@ -0,0 +1,53 @@
|
||||
# Множественный выбор и групповые операции в UI (изменение 009)
|
||||
|
||||
## Context
|
||||
Сейчас каждую запись можно удалить или изменить только по одной через меню «⋯». Нужен выбор нескольких строк чекбоксами и групповые операции
|
||||
над выбранными на всех экранах управления, кроме «Журнала». Решения пользователя: операции — удаление везде, плюс доступ пользователей,
|
||||
смена типа устройств, статусы префиксов и адресов; выполнение — цикл запросов из UI (бэкенд, API, схема БД и миграции **не меняются**).
|
||||
|
||||
## Охват
|
||||
| Экран | Чекбокс у строк | Групповые операции |
|
||||
|---|---|---|
|
||||
| Организации | все | Удалить |
|
||||
| Операторы | все | Удалить |
|
||||
| Устройства | все | Удалить; «Сменить тип ▾» |
|
||||
| Префиксы | только листовые (как и меню «⋯» сейчас) | Удалить; «Статус ▾» (Активен/Резерв/Устарел) |
|
||||
| Адреса подсети | только занятые/резерв/устаревшие (у «Свободен» нет id) | Удалить; «Статус ▾» (Назначен/Резерв/Устаревший) |
|
||||
| Пользователи | все, кроме своей учётной записи (чекбокс disabled) | Удалить; «Разрешить доступ»; «Отключить доступ» |
|
||||
| Журнал | — не затрагивается | — |
|
||||
Не входят: окна «Управление VRF» и «Управление типами» (это модальные мини-списки, а не вкладки). Для роли `viewer` чекбоксов нет (запись запрещена).
|
||||
|
||||
## Реализация (`web/app.js`, `web/styles.css`)
|
||||
- **Состояние:** `S.sel` (Set id) и `S.selectable` (id выбираемых строк текущего экрана). `S` сбрасывается при смене экрана; при каждой перерисовке
|
||||
выделение усекается до присутствующих строк (`pruneSel`), поэтому поиск/фильтры не оставляют «невидимых» выбранных.
|
||||
- **Общие фрагменты** рядом с `badge/btn/iconBtn`: `selCell(id, disabled)` (чекбокс в `<label>`, `id="sel-<id>"` — фокус восстанавливается штатным кодом `draw()`),
|
||||
`selAllCell()` (в шапке; `indeterminate` выставляется после рендера в `draw()`), `selCols(cols)` (добавляет колонку `40px`; для не-админа — без неё),
|
||||
`bulkBar({...})` (панель «Выбрано: N · действия · Снять выделение», рендерится только при `S.sel.size`), `bulkMenu(id, label, items)` (на существующих `popMenu`/`data-action="menu"`).
|
||||
- **Экраны:** в `screens.orgs/isps/devices/prefixes/address/users` — `selCols` в `--cols` строк и шапки (у орг. выносится в константу), `selCell` первой ячейкой, панель `bulkBar` под фильтрами,
|
||||
класс `selected` у выбранных строк. Клики по чекбоксу не открывают строку: общий обработчик уже пропускает `input`/`label` (изменение 005).
|
||||
- **Исполнитель** `bulk(title, ids, labelOf, call, after)`: последовательно вызывает существующие `DELETE`/`PATCH` (`/organizations`, `/isps`, `/devices`, `/prefixes`, `/addresses`, `/users`),
|
||||
ловит `ApiError` по каждому элементу, останавливается на 401, защищён от повторного запуска (`S.busy`). По итогу: toast «выполнено N из M», перерисовка,
|
||||
успешные снимаются с выделения (отказавшие остаются выбранными), при отказах — окно «Не выполнено для: …» со списком «объект — причина» (`openDialog`).
|
||||
Для организаций после цикла вызывается `loadOrgs()`.
|
||||
- **Подтверждение** удаления — `confirm()`, как в одиночных действиях («Удалить выбранное: 3 оператора?»). Префиксы удаляются без `force`: с адресами будут отклонены
|
||||
и попадут в список отказов (принудительное удаление остаётся в одиночном меню).
|
||||
- **Диспетчеризация:** новые `actions`: `sel`, `sel-all`, `sel-clear`, `bulk-del`, `bulk-access`; в `actions.pick` ветка `bulk-status` / `bulk-type` до общего `rowActions`-поиска.
|
||||
Экран определяется через `route()` (`prefixes` с аргументом = «адреса»).
|
||||
- **CSS:** `.sel-cell` (центрирование, чекбокс 16 px, `accent-color: var(--primary)`), `.bulkbar` (строка 48 px, фон `#f3f7ff`, `.pop` от `top:40px`), `.tr.selected{background:#eef3ff}`.
|
||||
|
||||
## Журнал и бэкенд
|
||||
Каждое отклонённое удаление в цикле само попадает в журнал как `<entity>.delete_blocked` (изменение 008); каждое успешное действие — обычной записью по объекту.
|
||||
Групповая операция отдельным событием не пишется. Серверные правила сохраняются (нельзя удалить/понизить себя и последнего админа → строка в списке отказов).
|
||||
|
||||
## Артефакты (правила проекта)
|
||||
`docs/changes/009-bulk-actions/PLAN.md` (копия плана) и `SUMMARY.md`; `README.md` — в раздел «Поведение таблиц UI»: выбор чекбоксами, панель групповых действий, охват и ограничения.
|
||||
Автотесты: бэкенд не менялся, новых тестов нет (правило «минимум тестов»); существующие 13 должны проходить.
|
||||
|
||||
## Проверка
|
||||
1. `node --check web/app.js`; `docker compose -p ipam_control_006 up -d --build app`; `venv/bin/python -m pytest -q`.
|
||||
2. Интерфейс (http://192.168.5.9:8088), по возможности через браузерную автоматизацию (skill `claude-in-chrome`), иначе вручную:
|
||||
- Операторы: выбрать 2–3 → «Удалить» → подтверждение → строки исчезли, toast «выполнено 3 из 3»; «Выбрать все» в шапке и состояние «частично» работают.
|
||||
- Организации: выбрать организацию с префиксами и пустую → удалено 1 из 2, окно отказов с причиной; в «Журнале» появилась `organization.delete_blocked`.
|
||||
- Устройства: «Сменить тип»; Префиксы/Адреса: «Статус»; Пользователи: «Отключить/Разрешить доступ», у своей строки чекбокс недоступен.
|
||||
- Для `viewer` чекбоксов и панели нет; на «Журнале» — нет.
|
||||
3. Тестовые данные для проверки создавать отдельными демо-записями (не удалять существующие демо-данные стенда).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Итог: выбор нескольких объектов и групповые операции (изменение 009)
|
||||
|
||||
## Что сделано
|
||||
- **Чекбоксы и панель действий** на экранах «Организации», «Операторы», «Устройства», «Префиксы», «Адреса подсети», «Пользователи» (`web/app.js`, `web/styles.css`).
|
||||
Чекбокс в шапке — «выбрать все» (состояние «частично» при неполном выборе). Выбранные строки подсвечены; над таблицей — панель «Выбрано: N · действия · Снять выделение».
|
||||
«Журнал» не затронут; окна «Управление VRF/типами» не входят. Роль `viewer` чекбоксов и панели не видит.
|
||||
- **Операции:** удаление на всех экранах; «Сменить тип» (устройства); «Статус» (префиксы: Активен/Резерв/Устарел; адреса: Назначен/Резерв/Устаревший);
|
||||
«Разрешить/Отключить доступ» (пользователи).
|
||||
- **Что выбирается:** префиксы — только листовые (как и меню «⋯»); адреса — только не «Свободен»; пользователи — все, кроме своей записи (чекбокс недоступен).
|
||||
Выделение усекается до видимых строк: поиск, фильтр, свёрнутая ветка префиксов и смена экрана не оставляют «невидимых» выбранных.
|
||||
- **Выполнение** — последовательные вызовы существующих `DELETE`/`PATCH` из UI (`bulk()`); бэкенд, API, БД и миграции не менялись.
|
||||
Итог — toast «Удаление: выполнено N из M». Отказ по объекту не прерывает остальные: причины показаны в окне «Выполнено не для всех объектов»,
|
||||
отказавшие остаются выбранными, выполненные — снимаются с выделения. Серверные правила сохраняются (свой аккаунт, последний администратор, зависимые объекты).
|
||||
Каждый отказ удаления сам попадает в журнал как `<сущность>.delete_blocked` (изменение 008). Групповая операция отдельным событием не пишется.
|
||||
- **Удаление префиксов** — без `force`: префикс с адресами отклоняется и попадает в список отказов; принудительное удаление остаётся в одиночном меню.
|
||||
- **Артефакты:** этот итог, план, раздел «Поведение таблиц UI» в `README.md`.
|
||||
|
||||
## Проверка
|
||||
- `node --check web/app.js`; стенд `ipam_control_006` пересобран (только `app`); backend-тесты — 13 passed (новых нет: бэкенд не менялся).
|
||||
- Сквозная проверка в headless Chromium (Playwright) на временных данных, 25 проверок, все пройдены: удаление 3 операторов; удаление пары организаций (1 удалена, 1 отклонена
|
||||
с окном причин и записью `organization.delete_blocked`); смена типа и удаление устройств; отключение доступа и удаление пользователей (у своей записи чекбокс недоступен);
|
||||
смена статуса префиксов и отказ удаления префикса с адресами; смена статуса и «выбрать все» + удаление адресов; в «Журнале» и для `viewer` чекбоксов нет.
|
||||
Временные данные удалены. Скрипт проверки в репозиторий не добавлялся (правило «минимум тестов»).
|
||||
|
||||
## Ограничения
|
||||
- Операции выполняются по одному запросу на объект, без общей транзакции: при обрыве связи часть объектов может остаться необработанной (видно по итоговому сообщению).
|
||||
- «Выбрать все» действует на загруженные строки (на экране адресов — на уже показанные, «Показать ещё 100» подгружает следующие).
|
||||
- Свёртывание ветки префиксов снимает выделение со скрытых дочерних строк.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Автовыделение следующего вложенного префикса (изменение 010)
|
||||
|
||||
## Context
|
||||
Когда префикс-контейнер (например `172.20.0.0/24`) дробят на подсети одного размера (`/30`), свободный блок приходится считать вручную.
|
||||
Нужна кнопка: пользователь выбирает родителя и размер (`/30`), система сама находит первый свободный выровненный блок внутри родителя и создаёт дочерний префикс.
|
||||
|
||||
## Алгоритм выбора блока (`app/services.py`, чистая функция, без БД)
|
||||
`next_free_subnet(parent_cidr, length, occupied) -> str | None`:
|
||||
1. `occupied` — диапазоны `[start, end]` (целые) всех **уже существующих префиксов внутри родителя в том же VRF** (все уровни вложенности)
|
||||
и адресов, записанных на самом родителе (чтобы не выдать блок, где уже есть назначенные IP). Диапазоны сортируются и склеиваются.
|
||||
2. Кандидат начинается с адреса родителя и выравнивается по размеру блока (`ceil(start / size) * size`); если пересекает занятый диапазон — перескок за его конец, повторное выравнивание.
|
||||
Сложность O(n) от числа занятых диапазонов, без перебора всех подсетей (работает и для `/8 → /30`, и для IPv6).
|
||||
3. Кандидат не выходит за границу родителя; иначе `None`. Первый подходящий (наименьший адрес) — предсказуемо: `.0/30`, `.4/30`, `.8/30`…; для смешанных размеров блок выравнивается сам (`/29` после `.0/30`, `.4/30` → `.8/29`).
|
||||
|
||||
## API (`app/api/v1/prefixes.py`, схемы в `app/schemas.py`)
|
||||
- `POST /prefixes/{id}/subnets/next` (admin) — тело `SubnetNextIn`: `length` (int), `description`, `status` (по умолчанию `active`), `is_pool`, `note`. Создаёт префикс в VRF и организации родителя, `parent_id = id`.
|
||||
Возвращает `PrefixOut` (201). Ошибки: `length` не больше длины родителя или больше 32/128 → 422 (с пределами в тексте); нет свободного блока → 409 «В префиксе нет свободного блока /30».
|
||||
Родитель блокируется `SELECT … FOR UPDATE` — параллельные запросы не выдают один блок; уникальность `(vrf_id, prefix)` остаётся страховкой (`flush` → 409).
|
||||
Переиспользуются `get_or_404`, `flush/commit`, `attach_to_tree(keep_parent=True)` (подхват существующих вложенных, если они шире), `audit(... "created", …, {"vrf": …, "allocated_from": "<родитель>"})`, `_prefix_outs`.
|
||||
- `GET /prefixes/{id}/subnets/next?length=30` — предпросмотр без создания: `{"prefix": "172.20.0.4/30"|null, "length_min": 25, "length_max": 32}` (для окна в UI; те же проверки диапазона).
|
||||
- БД, миграции, существующие эндпоинты не меняются.
|
||||
|
||||
## UI (`web/app.js`)
|
||||
- Пункт **«Добавить вложенный (авто)»** в меню «⋯» строки префикса: у листовых — рядом с «Открыть адреса/Редактировать/Удалить»; у родителей (сейчас без меню) — меню из этого одного пункта.
|
||||
Доступно только admin (как остальные меню записи).
|
||||
- Окно `subnetDialog(parent)` «Новый вложенный префикс в 172.20.0.0/24»: выпадающий «Размер» (`/25 … /32` для v4, для v6 — до `/128`; по умолчанию — длина последнего дочернего префикса, иначе `/30` (v4) / `/64` (v6) в пределах допустимого),
|
||||
строка «Будет создан: **172.20.0.4/30**» (обновляется по `change` списка через `GET …/subnets/next`; при отсутствии места — предупреждение и заблокированная кнопка), «Описание», «Статус», «Примечание», флажок «Пул». Использует `openDialog`, `formBody`, `fSelect`, `fInput`, `dlgFoot`, `S.dialog`.
|
||||
- После успеха: toast «Создан префикс 172.20.0.4/30», родитель раскрывается (`S.collapsed.delete(parent.id)`), `draw()`.
|
||||
|
||||
## Артефакты и тесты
|
||||
- `docs/changes/010-next-free-prefix/PLAN.md` (копия) и `SUMMARY.md`; `README.md` — раздел API/Модель данных: алгоритм и эндпоинты.
|
||||
- Один тест `test_allocate_next_subnet` в `tests/test_api.py`: родитель `/24` + ручной `/30` в начале → `/30` даёт `.4/30`; `/29` даёт `.8/29` (выравнивание); `parent_id` = родитель; предпросмотр совпадает с созданием;
|
||||
`length` ≤ длины родителя → 422; после заполнения всех блоков `/25` → 409. Плюс мини-проверка чистой функции на IPv6 (в том же тесте).
|
||||
|
||||
## Проверка
|
||||
1. `node --check web/app.js`; `docker compose -p ipam_control_006 up -d --build app`; `venv/bin/python -m pytest -q`.
|
||||
2. Браузером (Playwright headless, как в 009): создать `172.20.0.0/24` → вручную `172.20.0.0/30` → на родителе «⋯» → «Добавить вложенный (авто)» → в окне «Будет создан: 172.20.0.4/30» → создать 3 раза подряд (`.4`, `.8`, `.12`) → строки в дереве под родителем, родитель раскрыт;
|
||||
размер `/29` → следующий выровненный блок; в «Журнале» записи `prefix.created`. Тестовые данные удалить.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Итог: автовыделение следующего вложенного префикса (изменение 010)
|
||||
|
||||
## Что сделано
|
||||
- **Алгоритм** `next_free_subnet()` (`app/services.py`): первый свободный блок заданного размера внутри родителя, выровненный по границе блока; занятое — все префиксы внутри родителя в том же VRF
|
||||
(любой вложенности) и адреса, записанные на самом родителе. Перескок за конец занятого диапазона, без перебора подсетей (работает для IPv4 и IPv6).
|
||||
Примеры: `.0/30` занят → `.4/30`; после `.0/.4/.8` блок `/29` → `.16/29` (выравнивание); `/25` при занятой нижней половине → `.128/25`.
|
||||
- **API** (`app/api/v1/prefixes.py`, `app/schemas.py`): `POST /prefixes/{id}/subnets/next` (admin; `length`, `description`, `status`, `is_pool`, `note`) создаёт вложенный префикс в VRF и организации родителя, 201;
|
||||
`GET /prefixes/{id}/subnets/next?length=` — предпросмотр без создания. Размер вне `/родитель+1 … /32|/128` → 422; нет свободного блока → 409 «В префиксе … нет свободного блока /N».
|
||||
Родитель блокируется `FOR UPDATE` (параллельные запросы не получают один блок); журнал — `prefix.created` с `allocated_from`.
|
||||
- **UI** (`web/app.js`): пункт «Добавить вложенный (авто)» в меню «⋯» строки префикса (у листовых — вместе с прежними, у родителей — новое меню из одного пункта; только admin).
|
||||
Окно: «Размер» (по умолчанию — как у последнего дочернего, иначе /30 для IPv4 и /64 для IPv6), живая строка «Будет создан: 100.70.0.4/30», описание, статус, примечание, флажок «Пул».
|
||||
Если места нет — предупреждение и заблокированная кнопка. После создания родитель раскрывается.
|
||||
- БД и миграции не менялись. Документация: `README.md`, план и итог.
|
||||
|
||||
## Проверка
|
||||
- `test_allocate_next_subnet` (14 passed всего): предпросмотр = создание, `.4/30`, `.8/30`, выравнивание `/29`, 422 на неверный размер, `/25`, затем 409, проверка IPv6 в чистой функции.
|
||||
- Сквозная проверка в headless Chromium: родитель + ручной /30 → три вызова из меню подряд дали `.4`, `.8`, `.12`; смена размера на /29 → предпросмотр `.16/29`; пункт есть у листа; запись в журнале с `allocated_from`.
|
||||
Тестовые данные удалены.
|
||||
|
||||
## Ограничения
|
||||
- Блок всегда первый по возрастанию адреса (без поиска «наилучшего» по фрагментации). Освободившийся раньше блок будет выдан повторно.
|
||||
- За раз создаётся один префикс; массовое выделение (N подряд) можно добавить отдельно.
|
||||
- Адреса, привязанные к самому родителю, считаются занятыми: блок с ними не выдаётся.
|
||||
|
||||
## Попутно: нестабильные тесты журнала
|
||||
Тесты `tests/test_journal.py` изредка падали (`assert 0 >= 1`, `IndexError`), в изменениях 007–009 это отмечалось как «разовый сбой без выявленной причины». Причина найдена: тестовый IPv6-префикс собирался из
|
||||
случайных hex-групп с ведущими нулями (`fd00:0e3a:…`), а PostgreSQL хранит CIDR в нормализованном виде (`fd00:e3a:…`), поэтому поиск по журналу не находил запись (~1 из 8 запусков на тест).
|
||||
Исправлено в тестах (группы без ведущих нулей); 6 полных прогонов подряд — 14 passed. Код приложения не менялся.
|
||||
Reference in new issue
Block a user