Задачи 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:
ayurishchevandClaude Sonnet 5 committed 2026-09-26 13:25:33 +03:00
1 parent a846d30872
commit cd09ef0805
23 files changed
+988 -49

No files matched your search

+52
View File
@@ -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 → вход в другом окне → отключение
(вход и текущая сессия отбиваются) → сброс пароля → вход с новым паролем → «Сменить пароль» в шапке.
+29
View File
@@ -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`) — нет.
- Каждая повторная попытка даёт новую запись (дедупликации нет).
+53
View File
@@ -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. Тестовые данные для проверки создавать отдельными демо-записями (не удалять существующие демо-данные стенда).
+28
View File
@@ -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» подгружает следующие).
- Свёртывание ветки префиксов снимает выделение со скрытых дочерних строк.
+38
View File
@@ -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. Код приложения не менялся.