Задачи 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
@@ -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`:
|
||||
|
||||
+44
-3
@@ -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)
|
||||
|
||||
+18
-13
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
+2
-2
@@ -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)
|
||||
|
||||
|
||||
+59
-2
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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): фиксируем предупреждение в журнале (`<entity>.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))}
|
||||
|
||||
@@ -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. Код приложения не менялся.
|
||||
@@ -1,3 +1,4 @@
|
||||
import ipaddress
|
||||
import httpx
|
||||
|
||||
from tests.conftest import BASE, ENV
|
||||
@@ -45,6 +46,37 @@ def test_in_use_objects_cannot_be_deleted(client, org):
|
||||
assert client.delete(f"/device-types/{t['id']}").status_code == 409
|
||||
|
||||
|
||||
def test_delete_organization(client, org):
|
||||
_prefix(client, org, "10.206.0.0/24")
|
||||
busy = client.delete(f"/organizations/{org['id']}") # с префиксом — нельзя
|
||||
assert busy.status_code == 409 and "Нельзя удалить" in busy.json()["message"]
|
||||
logged = client.get("/audit", params={"event_type": "organization.delete_blocked", "q": org["name"]}).json()["items"] # отказ попадает в журнал
|
||||
assert logged and logged[0]["entity_id"] == org["id"] and "удаление отклонено" in logged[0]["message"]
|
||||
assert logged[0]["diff"]["blocked_by"]["prefixes"] == {"total": 1, "items": ["10.206.0.0/24 (default)"]} and "devices" not in logged[0]["diff"]["blocked_by"]
|
||||
empty = client.post("/organizations", json={"name": f"empty-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
|
||||
assert client.delete(f"/organizations/{empty['id']}").status_code == 204 # служебный VRF default удаляется вместе с организацией
|
||||
assert client.get(f"/organizations/{empty['id']}").status_code == 404
|
||||
assert client.get("/vrfs", params={"organization_id": empty["id"]}).json()["items"] == []
|
||||
|
||||
|
||||
def test_allocate_next_subnet(client, org):
|
||||
parent = _prefix(client, org, "10.207.0.0/24").json()
|
||||
assert _prefix(client, org, "10.207.0.0/30").json()["parent_id"] == parent["id"]
|
||||
url = f"/prefixes/{parent['id']}/subnets/next"
|
||||
assert client.get(url, params={"length": 30}).json()["prefix"] == "10.207.0.4/30" # предпросмотр
|
||||
a = client.post(url, json={"length": 30, "description": "auto"})
|
||||
assert a.status_code == 201 and a.json()["prefix"] == "10.207.0.4/30" and a.json()["parent_id"] == parent["id"] and a.json()["vrf_id"] == org["vrf_id"]
|
||||
assert client.post(url, json={"length": 30}).json()["prefix"] == "10.207.0.8/30"
|
||||
assert client.post(url, json={"length": 29}).json()["prefix"] == "10.207.0.16/29" # выравнивание по размеру блока
|
||||
assert client.post(url, json={"length": 24}).status_code == 422 and client.post(url, json={"length": 33}).status_code == 422
|
||||
assert client.post(url, json={"length": 25}).json()["prefix"] == "10.207.0.128/25"
|
||||
assert client.post(url, json={"length": 25}).status_code == 409 # свободной половины больше нет
|
||||
assert client.get(url, params={"length": 25}).json()["prefix"] is None
|
||||
from app.services import next_free_subnet
|
||||
v6 = int(ipaddress.ip_address("fd00::"))
|
||||
assert next_free_subnet("fd00::/64", 66, [(v6, v6 + 5)]) == "fd00::4000:0:0:0/66" # IPv6: первый блок занят, берётся второй
|
||||
|
||||
|
||||
def test_vrf_name_unique_per_organization(client, org):
|
||||
other = client.post("/organizations", json={"name": f"other-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
|
||||
try:
|
||||
|
||||
@@ -9,7 +9,7 @@ from tests.conftest import BASE
|
||||
|
||||
|
||||
def test_journal_search_and_filters(client, org):
|
||||
cidr = f"fd00:{uuid.uuid4().hex[:4]}:{uuid.uuid4().hex[:4]}::/64" # уникальный для запуска: журнал общий и накапливается
|
||||
cidr = f"fd00:{uuid.uuid4().int % 0xF000 + 0x1000:x}:{uuid.uuid4().int % 0xF000 + 0x1000:x}::/64" # уникальный для запуска (журнал общий); группы без ведущих нулей — иначе PostgreSQL CIDR нормализует запись и поиск по тексту промахивается
|
||||
client.post("/prefixes", json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
|
||||
hit = client.get("/audit", params={"q": cidr}).json()
|
||||
assert hit["total"] >= 1
|
||||
@@ -56,7 +56,7 @@ def test_clear_requires_password_and_locks_out(db):
|
||||
|
||||
def test_client_ip_and_request_meta_recorded(client, org):
|
||||
hdr = {"User-Agent": "ipam-tests/1.0", "X-Forwarded-For": "203.0.113.9"} # подделка XFF от недоверенного пира
|
||||
cidr = f"fd00:{uuid.uuid4().hex[:4]}:{uuid.uuid4().hex[:4]}::/64"
|
||||
cidr = f"fd00:{uuid.uuid4().int % 0xF000 + 0x1000:x}:{uuid.uuid4().int % 0xF000 + 0x1000:x}::/64"
|
||||
client.post("/prefixes", headers=hdr, json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
|
||||
entry = client.get("/audit", params={"q": cidr}).json()["items"][0]
|
||||
assert ipaddress.ip_address(entry["client_ip"])
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Управление пользователями: один сквозной сценарий (создание → права → отключение → пароли → удаление)."""
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from tests.conftest import BASE
|
||||
|
||||
|
||||
def _client(username: str, password: str):
|
||||
"""Клиент с токеном пользователя; None — вход не удался."""
|
||||
c = httpx.Client(base_url=BASE, timeout=30)
|
||||
r = c.post("/auth/login", json={"username": username, "password": password})
|
||||
if r.status_code != 200:
|
||||
c.close()
|
||||
return None
|
||||
c.headers["Authorization"] = "Bearer " + r.json()["access_token"]
|
||||
return c
|
||||
|
||||
|
||||
def test_users_management(client):
|
||||
name, uid, other = f"qa-{uuid.uuid4().hex[:8]}", None, None
|
||||
try:
|
||||
created = client.post("/users", json={"username": name, "password": "start-pass-123", "role": "viewer"})
|
||||
assert created.status_code == 201, created.text
|
||||
uid = created.json()["id"]
|
||||
assert created.json()["role"] == "viewer" and created.json()["is_active"] is True
|
||||
|
||||
assert client.post("/users", json={"username": name.upper(), "password": "start-pass-123"}).status_code == 409 # логин занят без учёта регистра
|
||||
assert client.post("/users", json={"username": "system", "password": "start-pass-123"}).status_code == 422 # служебный логин журнала
|
||||
assert client.post("/users", json={"username": "ab", "password": "start-pass-123"}).status_code == 422 # короткий логин
|
||||
assert client.post("/users", json={"username": "short-pw", "password": "123"}).status_code == 422 # короткий пароль
|
||||
|
||||
other = _client(name, "start-pass-123")
|
||||
assert other is not None
|
||||
assert other.get("/auth/me").json()["role"] == "viewer"
|
||||
assert other.get("/users").status_code == 403 # список пользователей только для админа
|
||||
assert other.post("/organizations", json={"name": "qa", "inn": "1234567890"}).status_code == 403 # роль «просмотр» — только чтение
|
||||
|
||||
# отключение действует немедленно, включая ранее выданный токен
|
||||
assert client.patch(f"/users/{uid}", json={"is_active": False}).json()["is_active"] is False
|
||||
assert other.get("/auth/me").status_code == 401
|
||||
assert _client(name, "start-pass-123") is None
|
||||
|
||||
# возврат доступа и сброс пароля администратором
|
||||
client.patch(f"/users/{uid}", json={"is_active": True, "password": "reset-pass-123"})
|
||||
assert _client(name, "start-pass-123") is None
|
||||
other = _client(name, "reset-pass-123")
|
||||
assert other is not None
|
||||
|
||||
# смена своего пароля: нужен текущий, новый не должен совпадать с ним
|
||||
assert other.post("/users/me/password", json={"current_password": "wrong", "new_password": "third-pass-123"}).status_code == 403
|
||||
assert other.post("/users/me/password", json={"current_password": "reset-pass-123", "new_password": "reset-pass-123"}).status_code == 422
|
||||
assert other.post("/users/me/password", json={"current_password": "reset-pass-123", "new_password": "third-pass-123"}).status_code == 200
|
||||
assert _client(name, "third-pass-123") is not None
|
||||
assert other.get("/auth/me").status_code == 200 # выданный ранее токен продолжает работать
|
||||
|
||||
# свою учётную запись удалить или отключить нельзя
|
||||
me = client.get("/auth/me").json()
|
||||
assert client.delete(f"/users/{me['id']}").status_code == 409
|
||||
assert client.patch(f"/users/{me['id']}", json={"is_active": False}).status_code == 409
|
||||
assert client.patch(f"/users/{uid}", json={"role": "admin"}).json()["role"] == "admin"
|
||||
assert client.delete(f"/users/{uid}").status_code == 204
|
||||
uid = None
|
||||
assert client.get("/audit", params={"event_type": "user.created"}).json()["total"] >= 1
|
||||
assert client.get("/audit", params={"event_type": "user.password_reset"}).json()["total"] >= 1
|
||||
finally:
|
||||
if other is not None:
|
||||
other.close()
|
||||
if uid is not None:
|
||||
client.delete(f"/users/{uid}")
|
||||
+230
-23
@@ -87,6 +87,27 @@ const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =
|
||||
const searchBox = (value, placeholder, width) =>
|
||||
`<label class="search" style="width:${width}px"><span class="ico">${I.search()}</span><input type="search" id="q" data-input="q" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" value="${esc(value)}"></label>`;
|
||||
const currentOrg = () => orgs.find((o) => o.id === store.orgId);
|
||||
const isAdmin = () => user?.role === "admin";
|
||||
const ROLE_RU = { admin: "Администратор", viewer: "Просмотр" };
|
||||
|
||||
/* ------------------------------------------------ выбор строк и групповые операции (только admin) */
|
||||
const selSet = () => (S.sel ||= new Set());
|
||||
const selCols = (cols) => (isAdmin() ? "40px " + cols : cols);
|
||||
function setSelectable(ids) { // выбираемые строки экрана; выделение усекается до присутствующих (фильтр/поиск не оставляют «невидимых»)
|
||||
S.selectable = ids;
|
||||
const keep = new Set(ids);
|
||||
S.sel = new Set([...selSet()].filter((x) => keep.has(x)));
|
||||
}
|
||||
const selCell = (id, disabled = false) => (isAdmin()
|
||||
? `<label class="sel-cell"><input type="checkbox" id="sel-${id}" data-action="sel" data-id="${id}" aria-label="Выбрать" ${selSet().has(id) ? "checked" : ""} ${disabled ? "disabled" : ""}></label>` : "");
|
||||
const selBlank = () => (isAdmin() ? "<span></span>" : "");
|
||||
const selAllCell = () => (isAdmin()
|
||||
? `<label class="sel-cell"><input type="checkbox" id="sel-all" data-action="sel-all" aria-label="Выбрать все" ${S.selectable?.length && selSet().size === S.selectable.length ? "checked" : ""}></label>` : "");
|
||||
const selRow = (id) => (selSet().has(id) ? " selected" : "");
|
||||
const bulkMenu = (id, label, items) =>
|
||||
`<span class="rel"><button type="button" class="btn" data-action="menu" data-menu="${id}">${label}${I.chevD(14)}</button>${popMenu(id, items)}</span>`;
|
||||
const bulkBar = (extra = "") => (isAdmin() && selSet().size
|
||||
? `<div class="bulkbar"><b>Выбрано: ${selSet().size}</b>${extra}${btn("Удалить", "bulk-del", { cls: "danger", icon: I.trash(14) })}<span class="grow"></span>${btn("Снять выделение", "sel-clear", { cls: "ghost" })}</div>` : "");
|
||||
|
||||
function popMenu(id, items, extra = "") {
|
||||
if (menu?.id !== id) return "";
|
||||
@@ -103,11 +124,12 @@ function orgSwitcher() {
|
||||
const crumbsOrg = () => `<div class="crumbs"><a href="#/orgs" style="color:var(--muted)">Организации</a>${I.chevR(14)}<span class="cur">${esc(currentOrg()?.name ?? "")}</span></div>`;
|
||||
const header = (title, sub, actions = "") => `<div class="head"><div><h1>${esc(title)}</h1><div class="sub">${sub}</div></div><div class="actions">${actions}</div></div>`;
|
||||
|
||||
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"]];
|
||||
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "admin"]];
|
||||
function shell(active, content) {
|
||||
const nav = NAV.filter(([, , role]) => role !== "admin" || isAdmin()); // раздел «Пользователи» виден только администратору
|
||||
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
|
||||
<nav class="nav">${NAV.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
|
||||
<div class="grow"></div><span class="user">${esc(user?.username ?? "")}</span>${btn("Выйти", "logout", { cls: "ghost" })}</div>
|
||||
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
|
||||
<div class="grow"></div><span class="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}</span>${btn("Сменить пароль", "pw-change", { cls: "ghost" })}${btn("Выйти", "logout", { cls: "ghost" })}</div>
|
||||
<div class="page">${content}</div>`;
|
||||
}
|
||||
|
||||
@@ -117,7 +139,7 @@ function openDialog({ title, width = 560, note = "", body, foot }) {
|
||||
<div class="dialog-head"><h2>${esc(title)}</h2>${iconBtn(I.close(), "close-dialog", "Закрыть")}</div>${note ? `<div class="dialog-note">${note}</div>` : ""}${body}${foot}</div></div>`;
|
||||
$("#dlg-form .input, #dlg-form .select")?.focus();
|
||||
}
|
||||
const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; };
|
||||
const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; S.subnetRefresh = null; };
|
||||
const opt = (label) => `<span class="opt"> ${label}</span>`;
|
||||
const fInput = (name, label, { value = "", ph = "", mono = false, optional = false, type = "text" } = {}) =>
|
||||
`<label class="field"><span>${label}${optional ? opt("необязательно") : ""}</span><input class="input${mono ? " mono" : ""}" type="${type}" name="${name}" value="${esc(value)}" placeholder="${esc(ph)}"></label>`;
|
||||
@@ -168,7 +190,7 @@ const screens = {};
|
||||
screens.overview = async () => {
|
||||
const o = await api("/overview");
|
||||
const [, tc] = utilColor(o.utilization);
|
||||
const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"] };
|
||||
const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"], delete_blocked: ["amber", "отклонено"] };
|
||||
const top = o.top_prefixes.map((p, i, a) => {
|
||||
const [bar, txt] = utilColor(p.utilization);
|
||||
return `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:2fr 1fr 1fr;--h:48px"><span class="m13">${esc(p.prefix)}</span><span style="color:${txt === "#58657a" ? "var(--muted)" : txt};font-weight:${txt === "#58657a" ? 400 : 500}">${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)</span><div class="bar"><div class="track" style="height:4px"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div></div></div>`;
|
||||
@@ -194,14 +216,16 @@ screens.orgs = async () => {
|
||||
const q = S.q || "";
|
||||
const { items, total } = await api("/organizations", { params: { q, limit: 500 } });
|
||||
const all = q ? (await api("/organizations", { params: { limit: 1 } })).total : total;
|
||||
const rows = items.map((o, i) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px">
|
||||
setSelectable(items.map((o) => o.id));
|
||||
const cols = selCols("minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px");
|
||||
const rows = items.map((o, i) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
|
||||
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
|
||||
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }, { label: "Удалить", value: "del:" + o.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
|
||||
S.rows = items;
|
||||
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
|
||||
<div class="tr th" style="--cols:minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px"><span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
|
||||
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
|
||||
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
|
||||
};
|
||||
function orgDialog(o) {
|
||||
@@ -229,15 +253,16 @@ screens.isps = async () => {
|
||||
const { items } = await api("/isps", { params: { q, limit: 500 } });
|
||||
const all = q ? (await api("/isps", { params: { limit: 1 } })).total : items.length;
|
||||
S.rows = items;
|
||||
const cols = "minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px";
|
||||
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}"><span style="font-weight:500">${esc(i.name)}</span>
|
||||
const cols = selCols("minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px");
|
||||
setSelectable(items.map((i) => i.id));
|
||||
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(i.id)}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}">${selCell(i.id)}<span style="font-weight:500">${esc(i.name)}</span>
|
||||
<a href="#/prefixes" data-action="open-org" data-id="${i.organization_id}" style="font-size:13px">${esc(i.organization_name)}</a>
|
||||
<span style="display:flex;align-items:center"><span class="mono" style="font-size:13px">${esc(i.networks[0] ?? "—")}</span>${i.networks.length > 1 ? `<span class="more">+${i.networks.length - 1}</span>` : ""}</span>
|
||||
<span class="mono muted" style="font-size:13px">${esc(i.hotline)}</span><span class="muted">${esc(i.contract_number)}</span><span class="muted ell">${esc(i.note)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="isp-row-${i.id}"`)}${popMenu("isp-row-" + i.id, [{ label: "Редактировать", value: "edit:" + i.id }, { label: "Удалить", value: "del:" + i.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
|
||||
return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск: оператор, организация, IP", 300)}</div>
|
||||
<div class="tr th" style="--cols:${cols}"><span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
|
||||
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
|
||||
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}</div></div>`);
|
||||
};
|
||||
function ispDialog(i) {
|
||||
@@ -268,7 +293,8 @@ screens.devices = async () => {
|
||||
]);
|
||||
S.types = types.items;
|
||||
S.rows = list.items;
|
||||
const cols = "minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px";
|
||||
const cols = selCols("minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px");
|
||||
setSelectable(list.items.map((d) => d.id));
|
||||
const q = S.q || "";
|
||||
const typeItems = [{ label: "любой", value: "" }, ...types.items.map((t) => ({ label: t.name, value: t.id }))];
|
||||
const rows = list.items.map((d, n) => {
|
||||
@@ -276,14 +302,14 @@ screens.devices = async () => {
|
||||
const ips = d.ip_addresses.length
|
||||
? `<span style="display:flex;align-items:center"><a href="#/prefixes/${d.first_prefix_id}" style="font:500 13px var(--sans)">${d.ip_addresses.length}</a>${d.ip_addresses.length > 1 ? `<span class="more">+${d.ip_addresses.length - 1}</span>` : ""}</span>`
|
||||
: `<span class="muted" style="font-size:13px">0</span>`;
|
||||
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}"><span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
|
||||
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}${selRow(d.id)}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}">${selCell(d.id)}<span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
|
||||
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="right-link"><a href="#" data-action="types">${I.gear()}Управление типами</a></div>
|
||||
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
|
||||
<div class="tr th" style="--cols:${cols}"><span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
|
||||
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
|
||||
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
|
||||
};
|
||||
function deviceDialog(d) {
|
||||
@@ -351,7 +377,8 @@ screens.prefixes = async () => {
|
||||
const byId = Object.fromEntries(all.map((p) => [p.id, p]));
|
||||
const hidden = (p) => { for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) if (collapsed.has(x.id)) return true; return false; };
|
||||
const visible = filtered.filter((p) => !hidden(p));
|
||||
const cols = "44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px";
|
||||
const cols = selCols("44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px");
|
||||
setSelectable(visible.filter((p) => !hasKids.has(p.id)).map((p) => p.id)); // как и меню «⋯»: только листовые
|
||||
const tab = (id, label, n) => `<button type="button" class="tab${(S.vrf || 0) === id ? " active" : ""}" data-action="vrf-tab" data-id="${id}">${esc(label)} <span class="n">${n}</span></button>`;
|
||||
const rows = visible.map((p, n) => {
|
||||
const [bar, txt] = utilColor(p.utilization);
|
||||
@@ -360,16 +387,16 @@ screens.prefixes = async () => {
|
||||
const chev = hasKids.has(p.id) ? `<span class="chev ${collapsed.has(p.id) ? "" : "open"}"><button type="button" data-action="toggle" data-id="${p.id}" aria-label="Свернуть/развернуть" aria-expanded="${!collapsed.has(p.id)}">${I.chevR(16)}</button></span>` : "<span></span>";
|
||||
const pad = p.depth * 20;
|
||||
const cidr = leaf ? `<a href="#/prefixes/${p.id}" class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</a>` : `<span class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</span>`;
|
||||
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
|
||||
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}${leaf ? selRow(p.id) : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${leaf ? selCell(p.id) : selBlank()}${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
|
||||
<div class="bar"><div class="track"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div><span class="pct" style="color:${txt}">${p.utilization}%</span></div><span class="muted" style="font-size:13px">${fmtNum(p.used)}</span>
|
||||
<span class="cell-actions rel">${leaf ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }, { label: "Удалить", value: "del:" + p.id, cls: "danger" }], "row-pop") : ""}</span></div>`;
|
||||
<span class="cell-actions rel">${leaf || isAdmin() ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [...(leaf ? [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }] : []), ...(isAdmin() ? [{ label: "Добавить вложенный (авто)", value: "subnet:" + p.id }] : []), ...(leaf ? [{ label: "Удалить", value: "del:" + p.id, cls: "danger" }] : [])], "row-pop") : ""}</span></div>`;
|
||||
}).join("");
|
||||
const statusItems = [{ label: "любой", value: "" }, { label: "Активен", value: "active" }, { label: "Резерв", value: "reserved" }, { label: "Устарел", value: "deprecated" }];
|
||||
const famItems = [{ label: "любое", value: "" }, { label: "IPv4", value: "4" }, { label: "IPv6", value: "6" }];
|
||||
return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${all.length} ${plural(all.length, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="tabs">${tab(0, "Все", all.length)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}<div class="grow"></div><button type="button" class="link-muted" data-action="vrfs">${I.gear()}Управление VRF</button></div>
|
||||
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}</div>
|
||||
<div class="tr th" style="--cols:${cols};--h:40px"><span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
|
||||
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
|
||||
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}</div></div>`);
|
||||
};
|
||||
function prefixDialog(p) {
|
||||
@@ -400,6 +427,45 @@ ${fArea("note", "Примечание", { value: p?.note, ph: "Любая доп
|
||||
foot: dlgFoot(edit ? "Сохранить" : "Добавить префикс"),
|
||||
});
|
||||
}
|
||||
// «следующий свободный» вложенный префикс: система сама выбирает первый свободный выровненный блок родителя
|
||||
function subnetDialog(parent) {
|
||||
const plen = Number(parent.prefix.split("/")[1]), max = parent.family === 6 ? 128 : 32, lo = plen + 1;
|
||||
if (lo > max) return toast("Префикс нельзя дробить: это одиночный адрес", true);
|
||||
const kids = (S.prefixes || []).filter((x) => x.parent_id === parent.id);
|
||||
const lastKid = kids.reduce((a, b) => (a && a.id > b.id ? a : b), null);
|
||||
const def = lastKid ? Number(lastKid.prefix.split("/")[1]) : Math.min(Math.max(parent.family === 6 ? 64 : 30, lo), max);
|
||||
const sizes = Array.from({ length: max - lo + 1 }, (_, i) => ({ value: lo + i, label: "/" + (lo + i) }));
|
||||
S.dialog = async (v) => {
|
||||
const r = await api(`/prefixes/${parent.id}/subnets/next`, { method: "POST", body: { length: Number(v.length), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } });
|
||||
S.collapsed?.delete(parent.id); // показать созданный префикс под родителем
|
||||
toast(`Создан префикс ${r.prefix}`);
|
||||
await draw();
|
||||
};
|
||||
let seq = 0;
|
||||
S.subnetRefresh = async () => { // предпросмотр: какой блок будет выделен
|
||||
const len = $("#dlg-form [name=length]")?.value, box = $("#sn-preview"), go = $("#modal-root [data-action=submit-form]"), n = ++seq;
|
||||
if (!len || !box) return;
|
||||
let text, ok = false;
|
||||
try {
|
||||
const r = await api(`/prefixes/${parent.id}/subnets/next`, { params: { length: len } });
|
||||
ok = !!r.prefix; text = ok ? `Будет создан: <b class="mono">${esc(r.prefix)}</b>` : `В ${esc(parent.prefix)} нет свободного блока /${esc(len)}`;
|
||||
} catch (e) { text = esc(e.message); }
|
||||
if (n !== seq || !$("#sn-preview")) return; // устаревший ответ
|
||||
$("#sn-preview").innerHTML = text; go.disabled = !ok;
|
||||
};
|
||||
openDialog({
|
||||
title: `Новый вложенный префикс в ${parent.prefix}`, width: 560,
|
||||
note: "Система выберет первый свободный блок выбранного размера внутри родительского префикса и создаст в нём вложенный.",
|
||||
body: formBody(`${fSelect("length", "Размер", sizes, def)}
|
||||
<div class="info" id="sn-preview">Подбираем блок…</div>
|
||||
${fInput("description", "Описание", { value: "", optional: true })}
|
||||
${fSelect("status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ value, label })), "active")}
|
||||
${fArea("note", "Примечание", { value: "" })}
|
||||
<label class="check"><input type="checkbox" name="is_pool"><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически</small></span></label>`),
|
||||
foot: dlgFoot("Создать префикс"),
|
||||
});
|
||||
S.subnetRefresh();
|
||||
}
|
||||
async function vrfsDialog() {
|
||||
const org = currentOrg();
|
||||
const { items } = await api("/vrfs", { params: { organization_id: org.id } });
|
||||
@@ -438,13 +504,14 @@ screens.address = async (id) => {
|
||||
const chain = [];
|
||||
for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x);
|
||||
const sm = page.summary;
|
||||
const cols = "minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px";
|
||||
const cols = selCols("minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px");
|
||||
setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи
|
||||
const rows = page.items.map((a, n) => {
|
||||
const [c, t] = ADDR_STATUS[a.status];
|
||||
const free = a.status === "free";
|
||||
const key = "adr-row-" + (a.id ?? a.address);
|
||||
const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }];
|
||||
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px"><span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
|
||||
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}${free ? "" : selRow(a.id)}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px">${free ? selBlank() : selCell(a.id)}<span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
|
||||
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="muted">${esc(a.description || (free ? "—" : ""))}</span><span>${badge(c, t)}</span>
|
||||
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
@@ -458,7 +525,7 @@ screens.address = async (id) => {
|
||||
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:#2450c8"></div><div style="width:${pct(sm.reserved)}%;background:#d97706"></div></div></div>
|
||||
<div class="legend"><span><i style="background:#2450c8"></i>Назначено ${sm.assigned}</span><span><i style="background:#d97706"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:#dde3ea"></i>Свободно ${sm.free}</span></div></div>
|
||||
<div class="card"><div class="filters">${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}</div>
|
||||
<div class="tr th" style="--cols:${cols};--h:40px"><span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div>
|
||||
${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div>
|
||||
${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`);
|
||||
};
|
||||
function addressDialog(a, presetIp = "") {
|
||||
@@ -487,14 +554,13 @@ ${fArea("note", "Примечание", { value: a?.note })}`),
|
||||
}
|
||||
|
||||
// ---- journal (audit)
|
||||
const ENTITY_RU = { organization: "Организация", vrf: "VRF", prefix: "Префикс", address: "Адрес", device: "Устройство", device_type: "Тип устройства", isp: "Оператор", session: "Сессия", journal: "Журнал" };
|
||||
const ENTITY_RU = { organization: "Организация", vrf: "VRF", prefix: "Префикс", address: "Адрес", device: "Устройство", device_type: "Тип устройства", isp: "Оператор", user: "Пользователь", session: "Сессия", journal: "Журнал" };
|
||||
const eventBadge = (ev) => {
|
||||
const [entity, action] = ev.split(".");
|
||||
const cls = { created: "blue", assigned: "green", updated: "amber", deleted: "red", failed: "red" }[action] || "";
|
||||
const cls = { created: "blue", assigned: "green", updated: "amber", password_reset: "amber", deleted: "red", failed: "red", delete_blocked: "amber" }[action] || "";
|
||||
return badge(cls, ev);
|
||||
};
|
||||
const fmtDateSec = (iso) => (iso ? iso.replace("T", " ").slice(0, 19) : "—");
|
||||
const isAdmin = () => user?.role === "admin";
|
||||
|
||||
screens.journal = async () => {
|
||||
const limit = S.limit || 100;
|
||||
@@ -594,6 +660,75 @@ async function doClear() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- users
|
||||
screens.users = async () => {
|
||||
if (!isAdmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только администратору</div></div>`);
|
||||
const q = S.q || "";
|
||||
const { items, total } = await api("/users", { params: { q, limit: 500 } });
|
||||
S.rows = items;
|
||||
const cols = selCols("minmax(200px,1.5fr) 190px 190px minmax(140px,1fr) 44px");
|
||||
setSelectable(items.filter((u) => u.id !== user?.id).map((u) => u.id)); // свою запись выбрать нельзя
|
||||
const rows = items.map((u, n) => {
|
||||
const self = u.id === user?.id;
|
||||
const items2 = [
|
||||
{ label: "Редактировать", value: "edit:" + u.id },
|
||||
{ label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" },
|
||||
{ label: "Удалить", value: "del:" + u.id, cls: "danger" },
|
||||
];
|
||||
return `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(u.id)}" data-row="1" data-action="user-edit" data-id="${u.id}" style="--cols:${cols}">${selCell(u.id, self)}
|
||||
<span class="m13" style="font-weight:600">${esc(u.username)}${self ? ` <span class="muted" style="font:400 12px var(--sans)">это вы</span>` : ""}</span>
|
||||
<span>${badge(u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
|
||||
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</span>
|
||||
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: администратор — запись, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск по логину", 260)}</div>
|
||||
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Статус</span><span></span><span></span></div>
|
||||
${rows || '<div class="empty">Пользователей нет</div>'}<div class="foot">Показано ${items.length} из ${total} ${plural(total, "записи", "записей", "записей")}</div></div>`);
|
||||
};
|
||||
function userDialog(u) {
|
||||
const edit = !!u;
|
||||
const self = edit && u.id === user?.id;
|
||||
S.dialog = async (v) => {
|
||||
if (edit) {
|
||||
const body = { is_active: v.is_active === true };
|
||||
if (v.role) body.role = v.role;
|
||||
if (v.password) body.password = v.password;
|
||||
await api(`/users/${u.id}`, { method: "PATCH", body });
|
||||
} else {
|
||||
await api("/users", { method: "POST", body: { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false } });
|
||||
}
|
||||
toast(edit ? "Пользователь сохранён" : "Пользователь добавлен");
|
||||
await draw();
|
||||
};
|
||||
openDialog({
|
||||
title: edit ? `Пользователь ${u.username}` : "Новый пользователь",
|
||||
note: "«Администратор» может изменять данные, «Просмотр» — только читать их. Логин после создания не меняется.",
|
||||
body: formBody(`${edit ? `<label class="field"><span>Логин</span><input class="input" value="${esc(u.username)}" disabled></label>` : fInput("username", "Логин", { ph: "ivanov", hint: "латиница, цифры, . _ -" })}
|
||||
${self ? "" : fInput("password", edit ? "Новый пароль" : "Пароль", { type: "password", optional: edit, ph: edit ? "не менять" : "", hint: "минимум 8 символов" })}
|
||||
${self ? "" : fSelect("role", "Роль", [{ value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], u?.role ?? "viewer")}
|
||||
<label class="check"><input type="checkbox" name="is_active" ${edit ? (u.is_active ? "checked" : "") : "checked"} ${self ? "disabled" : ""}><span>Доступ разрешён<small>Отключённый пользователь не входит в UI; его текущий токен перестаёт действовать сразу</small></span></label>
|
||||
${self ? `<div class="info">Свою учётную запись нельзя понизить, отключить или удалить — пароль меняется кнопкой «Сменить пароль» в шапке.</div>` : ""}`),
|
||||
foot: dlgFoot(edit ? "Сохранить" : "Добавить пользователя"),
|
||||
});
|
||||
}
|
||||
function passwordDialog() {
|
||||
S.dialog = async (v) => {
|
||||
if (v.new_password !== v.confirm_password) throw new ApiError(422, { message: "Пароли не совпадают", fields: { confirm_password: "пароли не совпадают" } });
|
||||
await api("/users/me/password", { method: "POST", body: { current_password: v.current_password, new_password: v.new_password } });
|
||||
toast("Пароль изменён");
|
||||
};
|
||||
openDialog({
|
||||
title: "Сменить пароль",
|
||||
note: `Учётная запись: ${esc(user?.username ?? "")}`,
|
||||
body: formBody(`${fInput("current_password", "Текущий пароль", { type: "password" })}
|
||||
<div class="grid2">${fInput("new_password", "Новый пароль", { type: "password", hint: "минимум 8 символов" })}${fInput("confirm_password", "Повторите пароль", { type: "password" })}</div>
|
||||
<div class="info">Смена пароля не завершает уже открытые сессии: выданный ранее токен действует до истечения своего срока.</div>`),
|
||||
foot: dlgFoot("Сохранить пароль"),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- login
|
||||
function loginScreen(err = "") {
|
||||
return `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
|
||||
@@ -629,6 +764,8 @@ async function draw() {
|
||||
const active = document.activeElement;
|
||||
const focusId = active?.id, caret = active?.selectionStart;
|
||||
app.innerHTML = await fn();
|
||||
const all = $("#sel-all");
|
||||
if (all) all.indeterminate = selSet().size > 0 && selSet().size < S.selectable.length;
|
||||
if (focusId) { const el = document.getElementById(focusId); if (el) { el.focus(); try { el.setSelectionRange(caret, caret); } catch { /* ignore */ } } }
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) return;
|
||||
@@ -644,6 +781,52 @@ async function guarded(fn) {
|
||||
}
|
||||
const rowById = (id) => S.rows?.find((r) => r.id === Number(id));
|
||||
|
||||
// групповые операции: последовательные вызовы существующих DELETE/PATCH; отказ по одному объекту не прерывает остальные
|
||||
const BULK = {
|
||||
orgs: { forms: ["организация", "организации", "организаций"], url: (id) => `/organizations/${id}`, label: (id) => rowById(id)?.name, after: loadOrgs },
|
||||
isps: { forms: ["оператор", "оператора", "операторов"], url: (id) => `/isps/${id}`, label: (id) => rowById(id)?.name },
|
||||
devices: { forms: ["устройство", "устройства", "устройств"], url: (id) => `/devices/${id}`, label: (id) => rowById(id)?.name },
|
||||
prefixes: { forms: ["префикс", "префикса", "префиксов"], url: (id) => `/prefixes/${id}`, label: (id) => S.prefixes.find((p) => p.id === id)?.prefix },
|
||||
address: { forms: ["адрес", "адреса", "адресов"], url: (id) => `/addresses/${id}`, label: (id) => S.page.items.find((a) => a.id === id)?.address },
|
||||
users: { forms: ["пользователь", "пользователя", "пользователей"], url: (id) => `/users/${id}`, label: (id) => rowById(id)?.username },
|
||||
};
|
||||
const screenKey = () => { const { path, arg } = route(); return path === "prefixes" && arg ? "address" : path; };
|
||||
function failuresDialog(title, failed) {
|
||||
openDialog({
|
||||
title: "Выполнено не для всех объектов", width: 640,
|
||||
note: `${esc(title)}: не выполнено ${failed.length}. Эти объекты остались выбранными.`,
|
||||
body: `<div class="dialog-body"><div class="mini-table" style="max-height:320px;overflow:auto">${failed.map(([label, msg]) =>
|
||||
`<div class="tr" style="--cols:minmax(120px,1fr) 2fr;height:auto;min-height:48px;padding:10px 12px"><span class="m13" style="font-weight:600;overflow-wrap:anywhere">${esc(label)}</span><span class="muted" style="overflow-wrap:anywhere">${esc(msg)}</span></div>`).join("")}</div></div>`,
|
||||
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog", { cls: "primary" })}</div>`,
|
||||
});
|
||||
}
|
||||
async function bulk(title, ids, labelOf, call, after) {
|
||||
const st = S;
|
||||
if (st.busy || !ids.length) return;
|
||||
st.busy = true;
|
||||
const failed = [];
|
||||
let ok = 0;
|
||||
try {
|
||||
for (const id of ids) {
|
||||
try { await call(id); ok++; st.sel.delete(id); } catch (e) {
|
||||
if (!(e instanceof ApiError)) throw e;
|
||||
if (e.status === 401) return;
|
||||
failed.push([labelOf(id) ?? `#${id}`, e.message]);
|
||||
}
|
||||
}
|
||||
} finally { st.busy = false; }
|
||||
toast(`${title}: выполнено ${ok} из ${ids.length}`, failed.length > 0);
|
||||
await after?.();
|
||||
if (S !== st) return; // пока шли запросы, пользователь ушёл на другой экран
|
||||
await draw();
|
||||
if (failed.length) failuresDialog(title, failed);
|
||||
}
|
||||
function bulkPick(id, value) {
|
||||
const k = screenKey(), ids = [...selSet()];
|
||||
if (id === "bulk-type") return bulk("Смена типа", ids, BULK.devices.label, (x) => api(`/devices/${x}`, { method: "PATCH", body: { device_type_id: Number(value) } }));
|
||||
return bulk("Смена статуса", ids, BULK[k].label, (x) => api(BULK[k].url(x), { method: "PATCH", body: { status: value } }));
|
||||
}
|
||||
|
||||
const rowActions = {
|
||||
"org-row": async (act, id) => {
|
||||
if (act === "open") { store.orgId = Number(id); location.hash = "#/prefixes"; }
|
||||
@@ -661,6 +844,7 @@ const rowActions = {
|
||||
"pfx-row": async (act, id) => {
|
||||
if (act === "open") location.hash = `#/prefixes/${id}`;
|
||||
else if (act === "edit") prefixDialog(S.prefixes.find((p) => p.id === Number(id)));
|
||||
else if (act === "subnet") subnetDialog(S.prefixes.find((p) => p.id === Number(id)));
|
||||
else if (act === "del" && confirmDel("префикс")) await guarded(async () => {
|
||||
try { await api(`/prefixes/${id}`, { method: "DELETE" }); } catch (e) {
|
||||
if (e.status === 409 && confirm(`${e.message}. Удалить вместе с адресами?`)) await api(`/prefixes/${id}?force=true`, { method: "DELETE" }); else throw e;
|
||||
@@ -673,6 +857,15 @@ const rowActions = {
|
||||
else if (act === "edit") addressDialog(S.page.items.find((a) => a.id === Number(id)));
|
||||
else if (act === "del" && confirmDel("адрес")) await guarded(async () => { await api(`/addresses/${id}`, { method: "DELETE" }); toast("Адрес удалён"); await draw(); });
|
||||
},
|
||||
"user-row": async (act, id) => {
|
||||
const u = rowById(id);
|
||||
if (act === "edit") userDialog(u);
|
||||
else if (act === "toggle") await guarded(async () => {
|
||||
await api(`/users/${id}`, { method: "PATCH", body: { is_active: !u.is_active } });
|
||||
toast(u.is_active ? "Доступ отключён" : "Доступ разрешён"); await draw();
|
||||
});
|
||||
else if (act === "del" && confirmDel(`пользователя ${u.username}`)) await guarded(async () => { await api(`/users/${id}`, { method: "DELETE" }); toast("Пользователь удалён"); await draw(); });
|
||||
},
|
||||
};
|
||||
|
||||
const actions = {
|
||||
@@ -689,6 +882,7 @@ const actions = {
|
||||
else if (id === "jtype") { S.jtype = value; S.limit = 100; }
|
||||
else if (id === "jactor") { S.jactor = value; S.limit = 100; }
|
||||
else if (id === "jentity") { S.jentity = value; S.limit = 100; }
|
||||
else if (id === "bulk-status" || id === "bulk-type") return bulkPick(id, value);
|
||||
else {
|
||||
const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-"));
|
||||
const [act, rid] = value.split(":");
|
||||
@@ -716,10 +910,20 @@ const actions = {
|
||||
box.querySelector(".select").focus();
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
},
|
||||
sel: (d, el) => { el.checked ? selSet().add(Number(d.id)) : selSet().delete(Number(d.id)); draw(); },
|
||||
"sel-all": (d, el) => { S.sel = new Set(el.checked ? S.selectable : []); draw(); },
|
||||
"sel-clear": () => { S.sel = new Set(); draw(); },
|
||||
"bulk-del": () => {
|
||||
const c = BULK[screenKey()], ids = [...selSet()];
|
||||
if (ids.length && confirm(`Удалить выбранное: ${ids.length} ${plural(ids.length, ...c.forms)}?`)) return bulk("Удаление", ids, c.label, (id) => api(c.url(id), { method: "DELETE" }), c.after);
|
||||
},
|
||||
"bulk-access": (d) => bulk(d.value === "1" ? "Разрешение доступа" : "Отключение доступа", [...selSet()], BULK.users.label,
|
||||
(id) => api(`/users/${id}`, { method: "PATCH", body: { is_active: d.value === "1" } })),
|
||||
"pfx-open": (d) => { location.hash = `#/prefixes/${d.id}`; },
|
||||
"org-open": (d) => { store.orgId = Number(d.id); location.hash = "#/prefixes"; },
|
||||
"isp-edit": (d) => ispDialog(rowById(d.id)),
|
||||
"dev-edit": (d) => deviceDialog(rowById(d.id)),
|
||||
"user-edit": (d) => userDialog(rowById(d.id)),
|
||||
"adr-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))),
|
||||
"adr-assign": (d) => addressDialog(null, d.ip),
|
||||
"jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)),
|
||||
@@ -733,6 +937,8 @@ const actions = {
|
||||
"submit-form": submitDialog,
|
||||
"open-org": (d) => { store.orgId = Number(d.id); },
|
||||
"org-new": () => orgDialog(null),
|
||||
"users-new": () => userDialog(null),
|
||||
"pw-change": () => passwordDialog(),
|
||||
"isp-new": () => ispDialog(null),
|
||||
"dev-new": () => deviceDialog(null),
|
||||
"pfx-new": () => prefixDialog(null),
|
||||
@@ -797,6 +1003,7 @@ document.addEventListener("input", (e) => {
|
||||
});
|
||||
document.addEventListener("change", (e) => {
|
||||
if (e.target.id === "jfrom" || e.target.id === "jto") { S[e.target.id] = e.target.value; S.limit = 100; draw(); return; }
|
||||
if (e.target.name === "length" && S.subnetRefresh) { S.subnetRefresh(); return; }
|
||||
const hint = $("#vrf-hint");
|
||||
if (hint && e.target.name === "vrf_id") hint.hidden = e.target.value === S.vrfHint?.original;
|
||||
});
|
||||
|
||||
@@ -182,3 +182,10 @@ button.select.ph{color:var(--faint)}
|
||||
.warn{padding:10px 12px;border-radius:8px;background:#fdf0d5;color:#7c4a00;font:400 13px/1.4 var(--sans)}
|
||||
.btn.danger:disabled{background:#f4f6f9;color:var(--faint);border-color:var(--line);cursor:not-allowed;opacity:1}
|
||||
.tr.clickable:focus-visible{outline:2px solid var(--primary);outline-offset:-2px}
|
||||
|
||||
.sel-cell{display:flex;align-items:center;justify-content:center;height:100%;cursor:pointer}
|
||||
.sel-cell input{width:16px;height:16px;margin:0;accent-color:var(--primary);cursor:pointer}
|
||||
.sel-cell input:disabled{cursor:not-allowed}
|
||||
.tr.selected,.tr.selected:hover{background:#eef3ff}
|
||||
.bulkbar{display:flex;align-items:center;gap:8px;height:48px;padding:0 16px;background:#f3f7ff;border-top:1px solid var(--line);font:400 14px/1 var(--sans);position:relative}
|
||||
.bulkbar .pop{top:40px}
|
||||
Reference in new issue
Block a user