Реорганизация /admin: операции, честные метрики, синхронизация закрытия

- Вкладка «Операции»: список search_operations (статус/подразделение/обновлена,
  фильтр, переход в анализ кейса), unit_name в DTO.
- Метрики: + активные/завершённые операции (stats из search_operations);
  «Активность 7 дней» теперь честная (created_at >= now-7d, была = total).
- Синхронизация: закрытие кейса через /admin PATCH → активная операция
  автоматически completed + closed_at (аудит в логе).
- ФИКС legacy-бага PATCH /cases: default_factory-списки затирали
  terrain/diagnosis_type пустыми массивами — выкидываются пустые списки.
- Редактор исхода: «Кто нашёл» → select WHO_FOUND_TYPES.
- Кнопка «Пользователи · Аудит» → /users.
- Тесты: автозакрытие операции, метрики (232 passed).
This commit is contained in:
2026-09-09 22:41:14 +03:00
parent 4fe60b0772
commit a1d33a2b28
8 changed files with 320 additions and 6 deletions
+134
View File
@@ -0,0 +1,134 @@
"""Ревизия /admin: автозакрытие операции при закрытии кейса, честная активность 7д."""
from __future__ import annotations
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import bcrypt
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import sqlalchemy # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
from sqlalchemy.ext.compiler import compiles # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
@compiles(PG_UUID, 'sqlite')
def _uuid_sqlite(type_, compiler, **kw):
return 'CHAR(36)'
@compiles(JSONB, 'sqlite')
def _jsonb_sqlite(type_, compiler, **kw):
return 'JSON'
@compiles(sqlalchemy.ARRAY, 'sqlite')
def _array_sqlite(type_, compiler, **kw):
return 'TEXT'
@pytest.fixture()
def admin_env(monkeypatch):
from backend import models as m
from backend.database import Base
from backend.models import (MchsUnit, Permission, Role, RolePermission,
SecuritySetting, UserRole)
engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False},
poolclass=StaticPool)
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
rcu = MchsUnit(name='РЦУ РЧС', kind='rcu')
db.add(rcu); db.flush()
def _hash(pw): return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
root = m.User(username='root', email='r@t.by', hashed_password=_hash('rootpass123'),
role='admin', is_active=True, unit_id=rcu.id)
db.add(root); db.commit()
case = m.Case(age_years=10, gender='м', status='analyzed')
db.add(case); db.flush(); db.commit()
case_id = case.id
op = m.SearchOperation(title='Оп', case_id=case_id, status='active', unit_id=rcu.id)
db.add(op); db.commit()
op_id = op.id
roles = {}
for name in ('admin', 'coordinator', 'operator', 'observer'):
r_ = Role(name=name, is_system=True); db.add(r_); roles[name] = r_
perms = {}
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
'manage_roles', 'view_audit', 'manage_security'):
p = Permission(code=code); db.add(p); perms[code] = p
db.commit()
for rn, codes in {'admin': tuple(perms), 'observer': ('view',)}.items():
for c in codes:
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
db.add(UserRole(user_id=root.id, role_id=roles['admin'].id))
db.commit()
from backend.database import SQLCaseRepository
from backend.rbac import SECURITY_DEFAULTS
for k, v in SECURITY_DEFAULTS.items():
db.add(SecuritySetting(key=k, value=v))
db.commit()
from backend.main import app
from backend.routers import auth as auth_router
from fastapi.testclient import TestClient
def override():
yield db
app.dependency_overrides[auth_router.get_db] = override
# legacy-пути (db.update_case, stats-операции) — направляю в тестовую БД
import backend.database as database_mod
import backend.routers.admin as admin_mod
test_session = sessionmaker(bind=engine)
monkeypatch.setattr(database_mod, 'SessionLocal', test_session)
monkeypatch.setattr(admin_mod, 'db', SQLCaseRepository())
client = TestClient(app)
tok = client.post('/api/v1/auth/login', data={'username': 'root', 'password': 'rootpass123'}).json()['access_token']
yield {'db': db, 'client': client, 'token': tok, 'case_id': case_id, 'op_id': op_id}
app.dependency_overrides.pop(auth_router.get_db, None)
class TestAdminCaseCloseSync:
def test_closing_case_completes_operation(self, admin_env):
env = admin_env
h = {'Authorization': f"Bearer {env['token']}"}
r = env['client'].patch(f"/api/v1/admin/cases/{env['case_id']}",
headers=h, json={'status': 'closed', 'found_alive': True})
assert r.status_code == 200, r.json()
from backend.models import SearchOperation
op = env['db'].query(SearchOperation).filter(SearchOperation.id == env['op_id']).first()
assert op.status == 'completed'
assert op.closed_at is not None
def test_stats_activity_is_real(self, admin_env):
env = admin_env
# Кейс старше 7 дней — не должен считаться активным
from backend.models import Case
case = env['db'].query(Case).filter(Case.id == env['case_id']).first()
case.created_at = datetime.now(timezone.utc) - timedelta(days=10)
env['db'].commit()
h = {'Authorization': f"Bearer {env['token']}"}
r = env['client'].get('/api/v1/admin/dashboard', headers=h)
assert r.status_code == 200
d = r.json()
assert d['recent_activity_count'] == 0
assert d['active_operations'] == 1
assert 'completed_operations' in d