From cd09ef0805e2a8f0575d5d9a861b2bd2c7bcc68a Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sat, 26 Sep 2026 13:25:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20006-010:?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F,=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2,=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=B0=D0=B2=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=84=D0=B8=D0=BA=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 006 Пользователи: API /users (CRUD, смена своего пароля), раздел UI «Пользователи», события журнала user.*, защита от отключения/удаления себя и последнего админа. 007 Исправление удаления организации: VRF удаляются явным DELETE до организации (без relationship() порядок DELETE не гарантирован → ложный 409). 008 Журнал фиксирует отказы в удалении (.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 --- README.md | 24 +- app/api/v1/prefixes.py | 47 +++- app/api/v1/refs.py | 31 ++- app/api/v1/users.py | 93 +++++++ app/main.py | 4 +- app/schemas.py | 61 ++++- app/security.py | 4 +- app/services.py | 40 +++ docs/changes/006-users-management/PLAN.md | 52 ++++ docs/changes/006-users-management/SUMMARY.md | 49 ++++ docs/changes/007-org-delete-fix/PLAN.md | 29 ++ docs/changes/007-org-delete-fix/SUMMARY.md | 19 ++ docs/changes/008-blocked-delete-audit/PLAN.md | 48 ++++ .../008-blocked-delete-audit/SUMMARY.md | 23 ++ docs/changes/009-bulk-actions/PLAN.md | 53 ++++ docs/changes/009-bulk-actions/SUMMARY.md | 28 ++ docs/changes/010-next-free-prefix/PLAN.md | 38 +++ docs/changes/010-next-free-prefix/SUMMARY.md | 28 ++ tests/test_api.py | 32 +++ tests/test_journal.py | 4 +- tests/test_users.py | 70 +++++ web/app.js | 253 ++++++++++++++++-- web/styles.css | 7 + 23 files changed, 988 insertions(+), 49 deletions(-) create mode 100644 app/api/v1/users.py create mode 100644 docs/changes/006-users-management/PLAN.md create mode 100644 docs/changes/006-users-management/SUMMARY.md create mode 100644 docs/changes/007-org-delete-fix/PLAN.md create mode 100644 docs/changes/007-org-delete-fix/SUMMARY.md create mode 100644 docs/changes/008-blocked-delete-audit/PLAN.md create mode 100644 docs/changes/008-blocked-delete-audit/SUMMARY.md create mode 100644 docs/changes/009-bulk-actions/PLAN.md create mode 100644 docs/changes/009-bulk-actions/SUMMARY.md create mode 100644 docs/changes/010-next-free-prefix/PLAN.md create mode 100644 docs/changes/010-next-free-prefix/SUMMARY.md create mode 100644 tests/test_users.py diff --git a/README.md b/README.md index 17d24fc..ffafac0 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,30 @@ alembic/ scripts/{gen_env,seed_demo}.py tests/ docs/changes/ имена могут совпадать; в одном VRF может быть много префиксов. Принадлежность VRF организации префикса гарантирует составной FK в БД. - Смена VRF у префикса (`PATCH /prefixes/{id}` с `vrf_id`) — только среди VRF той же организации; переносится префикс вместе с вложенными, дубль CIDR в целевом VRF → 409 (без частичных изменений), VRF другой организации → 422. -- VRF, тип устройства, организация с зависимыми объектами не удаляются (409). +- Автовыделение вложенного префикса (изменение 010): `POST /prefixes/{id}/subnets/next` с `length` (например 30) создаёт дочерний префикс в первом свободном выровненном блоке родителя (учитываются вложенные префиксы + и адреса родителя); `GET` с тем же путём и `?length=` — предпросмотр. Нет места → 409, недопустимый размер → 422. В UI — пункт «Добавить вложенный (авто)» в меню «⋯» префикса. +- VRF, тип устройства, организация с зависимыми объектами не удаляются (409). У организации без префиксов, устройств и операторов + служебный VRF `default` удаляется вместе с ней (исправлено в изменении 007: раньше такое удаление давало 409). ## API (`/api/v1`) `POST /auth/login` · `GET /auth/me` · `GET /overview` -CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixes`, `/addresses/{id}` +CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixes`, `/addresses/{id}`, `/users` Адреса префикса: `GET|POST /prefixes/{id}/addresses` (`status`, `q`, `limit`, `offset`), `POST …/addresses/next` — автоназначение из пула. Ошибки: `{code, message, fields}` (для блокировок/лимитов — дополнительные поля `attempts_left`, `retry_after_seconds`). Каждое изменение пишется в `audit_log`. +### Пользователи и роли +- `GET|POST /users`, `PATCH|DELETE /users/{id}` — управление учётными записями, только `admin`: логин (3–100 символов, латиница, + цифры, `. _ -`), роль, доступ и пароль (при сбросе администратором). Логин после создания не меняется — он же `sub` в токене. +- `POST /users/me/password {current_password, new_password}` — смена своего пароля, доступна любой роли; неверный текущий пароль → 403. +- Занятый логин (в том числе в другом регистре) → 409; служебные логины `system` и `anonymous`, короткий логин или пароль → 422. +- Свою учётную запись нельзя понизить, отключить или удалить, как и последнего активного администратора → 409. +- Отключение действует немедленно (токен проверяется по `users.is_active` на каждом запросе), а смена пароля уже выданные токены + не отзывает — они живут до истечения `JWT_TTL_MINUTES`. Роль `viewer` видит реестр и журнал, но любые изменения получает с 403. +- Изменения пишутся в журнал: `user.created`, `user.updated`, `user.password_reset`, `user.deleted` (значения паролей не сохраняются). + ### Журнал +- Отказ в удалении по бизнес-правилу (409: организация, VRF, тип устройства, префикс с адресами, пользователь) фиксируется событием `<сущность>.delete_blocked` (изменение 008); + в «Данных» записи — `reason` и `blocked_by` со списками мешающих объектов (`total` и до 20 названий: префиксы, устройства, операторы, адреса). - `GET /audit` — поиск и фильтры: `q` (сообщение, метка объекта, начало ID записи), `event_type` (`prefix.created`), `entity_type`, `actor` (`ui:admin`, `system`, `anonymous`), `date_from`/`date_to` (UTC), `limit`/`offset`; `GET /audit/summary`, `/audit/facets`, `/audit/{uid}`. - `GET|PUT /journal/settings` — ротация: `retention_days` (по умолчанию 90) и `max_entries` (100 000), `0` — без ограничения. Ротация идёт раз в час и сразу при сохранении настроек (advisory-lock защищает от параллельного запуска); каждая ротация с удалениями фиксируется записью `journal.rotated`. Запись — только admin. @@ -57,9 +72,14 @@ CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixe - В журнал пишутся также входы (`session.login`, `session.failed` — актор `anonymous`) и служебные события (`journal.*`, актор `system`). ## Поведение таблиц UI +Администратор может выбирать строки чекбоксами (в шапке — «выбрать все») на экранах «Организации», «Операторы», «Устройства», «Префиксы» (листовые), «Адреса» (кроме «Свободен») и «Пользователи» (кроме себя); +в «Журнале» выбора нет. Панель над таблицей: «Удалить» везде, «Сменить тип» (устройства), «Статус» (префиксы, адреса), «Разрешить/Отключить доступ» (пользователи). Операции идут по одному запросу +на объект (изменение 009): итог «выполнено N из M», отказы (зависимые объекты, свой аккаунт, последний администратор) показаны списком с причиной, попадают в журнал как `*.delete_blocked`, +и остаются выбранными. Префиксы удаляются без `force`: префикс с адресами удаляется из одиночного меню строки. Строка реестра кликабельна целиком (как в журнале): «Префиксы» — лист открывает адреса подсети, родитель сворачивает/разворачивает ветку; «Организации» — префиксы организации; «Операторы», «Устройства» и «Адреса» — окно редактирования (свободный адрес — «Назначить адрес» с этим IP). Ссылки, шеврон, меню «⋯» работают как раньше и не запускают действие строки; Ctrl/Shift+клик и выделение текста тоже игнорируются. +Экран «Пользователи» доступен только администратору: создание, редактирование роли и доступа, удаление на месте, а свой пароль меняется кнопкой «Сменить пароль» в шапке. ## Тесты Идут против приложения в контейнерах, учётные данные берутся из `.env`: diff --git a/app/api/v1/prefixes.py b/app/api/v1/prefixes.py index 2a15561..4002117 100644 --- a/app/api/v1/prefixes.py +++ b/app/api/v1/prefixes.py @@ -9,7 +9,8 @@ from app.db import get_db from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf from app.security import admin_user, current_user from app.services import ( - MAX_CAPACITY, apply_update, audit, capacity, commit, count, flush, get_or_404, next_free, utilization, + MAX_CAPACITY, apply_update, audit, blockers, capacity, commit, count, flush, get_or_404, next_free, next_free_subnet, refuse_delete, + utilization, ) router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"]) @@ -188,6 +189,45 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User = return _prefix_outs(db, [p])[0] +def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None, int, int]: + """(свободный блок | None, min длина, max длина) для вложенного префикса в parent.""" + net = ipaddress.ip_network(str(parent.prefix)) + lo, hi = net.prefixlen + 1, net.max_prefixlen + if not lo <= length <= hi: + raise HTTPException(422, f"Размер вложенного префикса: от /{lo} до /{hi}" if lo <= hi else "Префикс нельзя дробить: это одиночный адрес") + busy = [(int(n.network_address), int(n.broadcast_address)) for n in + (ipaddress.ip_network(str(c)) for c in db.scalars( + select(Prefix.prefix).where(Prefix.vrf_id == parent.vrf_id, Prefix.id != parent.id, Prefix.prefix.op("<<")(str(parent.prefix)))))] + busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == parent.id))] + return next_free_subnet(str(parent.prefix), length, busy), lo, hi + + +@router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview) +def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db)): + """Предпросмотр: какой блок будет выделен, без создания.""" + found, lo, hi = _find_subnet(db, get_or_404(db, Prefix, id, "Префикс"), length) + return s.SubnetPreview(prefix=found, length_min=lo, length_max=hi) + + +@router.post("/prefixes/{id}/subnets/next", response_model=s.PrefixOut, status_code=201) +def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): + """Создаёт вложенный префикс заданного размера в первом свободном выровненном блоке родителя.""" + parent = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # сериализуем параллельные выделения из одного родителя + if parent is None: + raise HTTPException(404, "Префикс не найден") + found, _, _ = _find_subnet(db, parent, body.length) + if found is None: + raise HTTPException(409, f"В префиксе {parent.prefix} нет свободного блока /{body.length}") + p = Prefix(organization_id=parent.organization_id, vrf_id=parent.vrf_id, prefix=found, parent_id=parent.id, + description=body.description, status=body.status, is_pool=body.is_pool, note=body.note) + db.add(p) + flush(db, "Такой префикс уже есть в этом VRF, повторите запрос") + attach_to_tree(db, p, keep_parent=True) + audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix)}) + commit(db, "Такой префикс уже есть в этом VRF, повторите запрос") + return _prefix_outs(db, [p])[0] + + @router.patch("/prefixes/{id}", response_model=s.PrefixOut) def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): p = get_or_404(db, Prefix, id, "Префикс") @@ -204,8 +244,9 @@ def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db), @router.delete("/prefixes/{id}", status_code=204) def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), user: User = Depends(admin_user)): p = get_or_404(db, Prefix, id, "Префикс") - if not force and count(db, select(Address.id).where(Address.prefix_id == id)): - raise HTTPException(409, "В префиксе есть адреса; удалите их или используйте force=true") + used = None if force else blockers(db, select(func.host(Address.address)).where(Address.prefix_id == id).order_by(Address.address)) + if used: + refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used}) db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id)) audit(db, user, "prefix", p, "deleted", str(p.prefix)) db.delete(p) diff --git a/app/api/v1/refs.py b/app/api/v1/refs.py index a18bec6..61bc08a 100644 --- a/app/api/v1/refs.py +++ b/app/api/v1/refs.py @@ -1,6 +1,6 @@ """Справочники: организации, VRF, операторы, типы устройств, устройства.""" from fastapi import APIRouter, Depends, Query -from sqlalchemy import String, cast, func, or_, select +from sqlalchemy import String, cast, delete, func, or_, select from sqlalchemy.orm import Session from app import schemas as s @@ -9,7 +9,7 @@ from app.models import ( Address, AddressStatus, Device, DeviceType, Isp, IspNetwork, Organization, Prefix, User, Vrf, ) from app.security import admin_user, current_user -from app.services import apply_update, audit, commit, count, flush, get_or_404 +from app.services import apply_update, audit, blockers, commit, count, flush, get_or_404, refuse_delete from fastapi import HTTPException router = APIRouter(dependencies=[Depends(current_user)]) @@ -67,12 +67,15 @@ def update_org(id: int, body: s.OrgIn, db: Session = Depends(get_db), user: User @router.delete("/organizations/{id}", status_code=204, tags=["organizations"]) def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): o = get_or_404(db, Organization, id, "Организация") - busy = count(db, select(Prefix.id).where(Prefix.organization_id == id)) or count( - db, select(Device.id).where(Device.organization_id == id)) or count(db, select(Isp.id).where(Isp.organization_id == id)) - if busy: - raise HTTPException(409, "Нельзя удалить: у организации есть префиксы, устройства или операторы") - for v in db.scalars(select(Vrf).where(Vrf.organization_id == id)): - db.delete(v) + found = { + "prefixes": blockers(db, select(func.concat(cast(Prefix.prefix, String), " (", Vrf.name, ")")).select_from(Prefix).join(Vrf, Vrf.id == Prefix.vrf_id) + .where(Prefix.organization_id == id).order_by(Prefix.id)), + "devices": blockers(db, select(Device.name).where(Device.organization_id == id).order_by(Device.id)), + "isps": blockers(db, select(Isp.name).where(Isp.organization_id == id).order_by(Isp.id)), + } + if any(found.values()): + refuse_delete(db, user, "organization", o, o.name, "Нельзя удалить: у организации есть префиксы, устройства или операторы", found) + db.execute(delete(Vrf).where(Vrf.organization_id == id)) # немедленно: между Vrf и Organization нет relationship(), порядок DELETE в UoW не гарантирован audit(db, user, "organization", o, "deleted", o.name) db.delete(o) commit(db) @@ -117,8 +120,9 @@ def update_vrf(id: int, body: s.VrfUpdate, db: Session = Depends(get_db), user: @router.delete("/vrfs/{id}", status_code=204, tags=["vrf"]) def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): v = get_or_404(db, Vrf, id, "VRF") - if count(db, select(Prefix.id).where(Prefix.vrf_id == id)): - raise HTTPException(409, "Нельзя удалить: VRF используется префиксами") + used = blockers(db, select(cast(Prefix.prefix, String)).where(Prefix.vrf_id == id).order_by(Prefix.id)) + if used: + refuse_delete(db, user, "vrf", v, v.name, "Нельзя удалить: VRF используется префиксами", {"prefixes": used}) audit(db, user, "vrf", v, "deleted", v.name) db.delete(v) commit(db) @@ -160,9 +164,10 @@ def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), us def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): t = get_or_404(db, DeviceType, id, "Тип") if t.is_default: - raise HTTPException(409, "Нельзя удалить тип по умолчанию") - if count(db, select(Device.id).where(Device.device_type_id == id)): - raise HTTPException(409, "Нельзя удалить: тип используется устройствами") + refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить тип по умолчанию") + used = blockers(db, select(Device.name).where(Device.device_type_id == id).order_by(Device.id)) + if used: + refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить: тип используется устройствами", {"devices": used}) audit(db, user, "device_type", t, "deleted", t.name) db.delete(t) commit(db) diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000..db8d0f6 --- /dev/null +++ b/app/api/v1/users.py @@ -0,0 +1,93 @@ +"""Пользователи: учётные записи UI (роли admin/viewer), смена своего пароля. + +Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить +свою учётную запись и последнего активного администратора; зарезервированные логины `system` +и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py). +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app import schemas as s +from app.db import get_db +from app.models import Role, User +from app.security import admin_user, current_user, hash_password, verify_password +from app.services import apply_update, audit, commit, count, flush, get_or_404, refuse_delete + +router = APIRouter(dependencies=[Depends(current_user)], tags=["users"]) + + +def _other_active_admins(db: Session, user_id: int) -> int: + """Активные администраторы, кроме указанного: 0 — система осталась бы без прав записи.""" + return count(db, select(User.id).where(User.role == Role.admin, User.is_active, User.id != user_id)) + + +@router.get("/users", response_model=s.Page[s.UserOut]) +def list_users( + q: str = "", limit: int = Query(100, le=500), offset: int = 0, + db: Session = Depends(get_db), admin: User = Depends(admin_user), +): + stmt = select(User) + if q.strip(): + stmt = stmt.where(User.username.ilike(f"%{q.strip()}%")) + total = count(db, stmt) + rows = db.scalars(stmt.order_by(User.username).limit(limit).offset(offset)).all() + return s.Page(items=[s.UserOut.model_validate(u) for u in rows], total=total) + + +@router.post("/users", response_model=s.UserOut, status_code=201) +def create_user(body: s.UserIn, db: Session = Depends(get_db), admin: User = Depends(admin_user)): + # логин уникален без учёта регистра: в токене и в журнале он должен опознаваться однозначно + if db.scalar(select(User.id).where(func.lower(User.username) == body.username.lower())): + raise HTTPException(409, "Пользователь с таким логином уже существует") + u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, is_active=body.is_active) + db.add(u) + flush(db, "Пользователь с таким логином уже существует") + audit(db, admin, "user", u, "created", u.username) + commit(db, "Пользователь с таким логином уже существует") + return u + + +@router.patch("/users/{id}", response_model=s.UserOut) +def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admin: User = Depends(admin_user)): + u = get_or_404(db, User, id, "Пользователь") + data = body.model_dump(exclude_unset=True, exclude_none=True) + pwd = data.pop("password", None) + role, is_active = data.get("role", u.role), data.get("is_active", u.is_active) + loses_admin = u.role == Role.admin and u.is_active and (role != Role.admin or not is_active) + if u.id == admin.id and (role != Role.admin or not is_active): + raise HTTPException(409, "Нельзя отключить или понизить свою учётную запись") + if loses_admin and _other_active_admins(db, u.id) == 0: + raise HTTPException(409, "Нельзя отключить или понизить единственного активного администратора") + changed = apply_update(u, data) + if pwd: + u.password_hash = hash_password(pwd) + if changed: + audit(db, admin, "user", u, "updated", u.username, changed) + if pwd: + audit(db, admin, "user", u, "password_reset", u.username, message=f"Пользователь {u.username}: пароль изменён администратором") + commit(db) + return u + + +@router.delete("/users/{id}", status_code=204) +def delete_user(id: int, db: Session = Depends(get_db), admin: User = Depends(admin_user)): + u = get_or_404(db, User, id, "Пользователь") + if u.id == admin.id: + refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить свою учётную запись") + if u.role == Role.admin and u.is_active and _other_active_admins(db, u.id) == 0: + refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить единственного активного администратора") + audit(db, admin, "user", u, "deleted", u.username) + db.delete(u) + commit(db) + + +@router.post("/users/me/password") +def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), user: User = Depends(current_user)): + """Смена своего пароля: доступна любой роли, требует текущий пароль.""" + if not verify_password(body.current_password, user.password_hash): + raise HTTPException(403, "Неверный текущий пароль") + user.password_hash = hash_password(body.new_password) + audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем") + commit(db) + return {"ok": True} diff --git a/app/main.py b/app/main.py index e62a7cf..6865546 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from app.api.v1 import auth, journal, overview, prefixes, refs +from app.api.v1 import auth, journal, overview, prefixes, refs, users from app.config import settings from app.db import SessionLocal from app.models import DeviceType, Role, User @@ -41,7 +41,7 @@ app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan) app.add_middleware(RequestContextMiddleware) api = APIRouter(prefix="/api/v1") -for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router): +for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router, users.router): api.include_router(r) app.include_router(api) diff --git a/app/schemas.py b/app/schemas.py index f1537fd..90ff0b1 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -5,7 +5,7 @@ from typing import Annotated, Generic, TypeVar from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator -from app.models import AddressStatus, PrefixStatus +from app.models import AddressStatus, PrefixStatus, Role T = TypeVar("T") @@ -50,9 +50,52 @@ class TokenOut(BaseModel): token_type: str = "bearer" +LOGIN_RE = re.compile(r"^[A-Za-z0-9._-]{3,100}$") +RESERVED_LOGINS = {"system", "anonymous"} # заняты служебными акторами журнала (actor_of в app/services.py) + + +def _login(v: str) -> str: + v = v.strip() + if not LOGIN_RE.match(v): + raise ValueError("Логин: 3–100 символов — латиница, цифры, точка, дефис или подчёркивание") + if v.lower() in RESERVED_LOGINS: + raise ValueError("Логин зарезервирован системой") + return v + + +Login = Annotated[str, AfterValidator(_login)] + + class UserOut(ORM): + id: int username: str - role: str + role: Role + is_active: bool = True + + +class UserIn(BaseModel): + username: Login + password: str = Field(min_length=8, max_length=128) + role: Role = Role.admin + is_active: bool = True + + +class UserUpdate(BaseModel): + role: Role | None = None + is_active: bool | None = None + password: str | None = Field(None, min_length=8, max_length=128) + + +class PasswordChange(BaseModel): + current_password: str + new_password: str = Field(min_length=8, max_length=128) + + @field_validator("new_password") + @classmethod + def _differs(cls, v: str, info) -> str: + if v == info.data.get("current_password"): + raise ValueError("Новый пароль совпадает с текущим") + return v # --- organizations @@ -188,6 +231,20 @@ class PrefixIn(BaseModel): note: str = "" +class SubnetNextIn(BaseModel): + length: int = Field(ge=1, le=128) + description: str = Field("", max_length=500) + status: PrefixStatus = PrefixStatus.active + is_pool: bool = False + note: str = "" + + +class SubnetPreview(BaseModel): + prefix: str | None + length_min: int + length_max: int + + class PrefixUpdate(BaseModel): vrf_id: int | None = None description: str | None = Field(None, max_length=500) diff --git a/app/security.py b/app/security.py index f2648a3..6a42e44 100644 --- a/app/security.py +++ b/app/security.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone import jwt from argon2 import PasswordHasher -from argon2.exceptions import VerifyMismatchError +from argon2.exceptions import VerificationError from fastapi import Depends, HTTPException from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select @@ -23,7 +23,7 @@ def hash_password(password: str) -> str: def verify_password(password: str, hashed: str) -> bool: try: return _ph.verify(hashed, password) - except VerifyMismatchError: + except VerificationError: # несовпадение пароля или повреждённый хэш — не 500, а 401/403 return False diff --git a/app/services.py b/app/services.py index 5042ea8..5854e67 100644 --- a/app/services.py +++ b/app/services.py @@ -17,6 +17,7 @@ ANONYMOUS = SimpleNamespace(username="anonymous") _NOUNS = { "organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"), "device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m"), + "user": ("Пользователь", "m"), } _VERBS = { "created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"), @@ -48,6 +49,27 @@ def audit(db: Session, user, entity_type: str, entity, action: str, label: str, )) +BLOCKERS_LIMIT = 20 +_GROUPS = {"prefixes": "префиксы", "devices": "устройства", "isps": "операторы", "addresses": "адреса"} + + +def blockers(db: Session, labels) -> dict | None: + """Мешающие удалению объекты: `labels` — select одной текстовой колонки; {"total", "items"} (до BLOCKERS_LIMIT), None — если пусто.""" + total = count(db, labels) + return {"total": total, "items": list(db.scalars(labels.limit(BLOCKERS_LIMIT)))} if total else None + + +def refuse_delete(db: Session, user, entity_type: str, entity, label: str, reason: str, blocked_by: dict | None = None): + """Отказ в удалении (409): фиксируем предупреждение в журнале (`.delete_blocked`) и отвечаем прежним текстом `reason`.""" + blocked_by = {k: v for k, v in (blocked_by or {}).items() if v} + diff = {"reason": reason, **({"blocked_by": blocked_by} if blocked_by else {})} + noun = _NOUNS.get(entity_type, (entity_type, "m"))[0] + tail = ("связанные объекты (" + ", ".join(f"{_GROUPS[k]}: {v['total']}" for k, v in blocked_by.items()) + ")") if blocked_by else reason[:1].lower() + reason[1:] + audit(db, user, entity_type, entity, "delete_blocked", label, diff, message=f"{noun} {label}: удаление отклонено — {tail}") + commit(db) # к этому моменту в транзакции только запись журнала + raise HTTPException(409, reason) + + def commit(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"): try: db.commit() @@ -97,6 +119,24 @@ def count(db: Session, stmt) -> int: return db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 +def next_free_subnet(parent: str, length: int, occupied: list[tuple[int, int]]) -> str | None: + """Первый свободный блок длины `length` внутри `parent`, выровненный по размеру блока. + `occupied` — занятые диапазоны адресов (включительно); без перебора всех подсетей: перескок за конец пересечения.""" + net = ipaddress.ip_network(parent) + size = 1 << (net.max_prefixlen - length) + first, last = int(net.network_address), int(net.broadcast_address) + cand = first + for start, end in sorted(occupied): + if end < cand: + continue + if start >= cand + size: + break # блок cand..cand+size-1 свободен + cand = -(-(end + 1) // size) * size # за конец занятого диапазона, с выравниванием + if cand + size - 1 > last: + return None + return str(ipaddress.ip_network((type(net.network_address)(cand), length))) + + def next_free(db: Session, prefix_id: int, prefix: str) -> str | None: net = ipaddress.ip_network(prefix) used = {ipaddress.ip_address(a) for a in db.scalars(select(Address.address).where(Address.prefix_id == prefix_id))} diff --git a/docs/changes/006-users-management/PLAN.md b/docs/changes/006-users-management/PLAN.md new file mode 100644 index 0000000..fc3f246 --- /dev/null +++ b/docs/changes/006-users-management/PLAN.md @@ -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; ручной сценарий в браузере (создание, редактирование, отключение, вход отключённым, удаление). diff --git a/docs/changes/006-users-management/SUMMARY.md b/docs/changes/006-users-management/SUMMARY.md new file mode 100644 index 0000000..8a96140 --- /dev/null +++ b/docs/changes/006-users-management/SUMMARY.md @@ -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 → вход в другом окне → отключение + (вход и текущая сессия отбиваются) → сброс пароля → вход с новым паролем → «Сменить пароль» в шапке. diff --git a/docs/changes/007-org-delete-fix/PLAN.md b/docs/changes/007-org-delete-fix/PLAN.md new file mode 100644 index 0000000..73fe59c --- /dev/null +++ b/docs/changes/007-org-delete-fix/PLAN.md @@ -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` появились в журнале. diff --git a/docs/changes/007-org-delete-fix/SUMMARY.md b/docs/changes/007-org-delete-fix/SUMMARY.md new file mode 100644 index 0000000..5f39268 --- /dev/null +++ b/docs/changes/007-org-delete-fix/SUMMARY.md @@ -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`, БД и данные сохранены. diff --git a/docs/changes/008-blocked-delete-audit/PLAN.md b/docs/changes/008-blocked-delete-audit/PLAN.md new file mode 100644 index 0000000..0c223ee --- /dev/null +++ b/docs/changes/008-blocked-delete-audit/PLAN.md @@ -0,0 +1,48 @@ +# Журнал: события отклонённого удаления (изменение 008) + +## Context +Когда удаление запрещено бизнес-правилом (например, у организации есть префиксы/устройства/операторы), API отвечает 409 через `raise HTTPException`, +а запись в журнал не пишется: `audit()` вызывается только на успешном пути, сессия закрывается без commit (`app/db.py:get_db`). +Нужно фиксировать такие попытки как предупреждения и показывать в блоке «Данные» окна записи журнала, какие именно связанные сущности мешают удалению. + +## Решения +- **Тип события:** `.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 с префиксами и своего аккаунта — аналогичные записи. diff --git a/docs/changes/008-blocked-delete-audit/SUMMARY.md b/docs/changes/008-blocked-delete-audit/SUMMARY.md new file mode 100644 index 0000000..7183b7b --- /dev/null +++ b/docs/changes/008-blocked-delete-audit/SUMMARY.md @@ -0,0 +1,23 @@ +# Итог: журнал фиксирует отклонённые удаления (изменение 008) + +## Что сделано +- **Событие `.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`) — нет. +- Каждая повторная попытка даёт новую запись (дедупликации нет). diff --git a/docs/changes/009-bulk-actions/PLAN.md b/docs/changes/009-bulk-actions/PLAN.md new file mode 100644 index 0000000..f5773a2 --- /dev/null +++ b/docs/changes/009-bulk-actions/PLAN.md @@ -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)` (чекбокс в `