Реорганизация /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
+19 -1
View File
@@ -273,13 +273,31 @@ class SQLCaseRepository:
found_alive = sum(1 for c in cases if c.found_alive is True)
found_deceased = sum(1 for c in cases if c.found_alive is False)
distances = [c.found_distance_km for c in cases if c.found_distance_km is not None]
# Активность 7 дней: кейсы, созданные за последнюю неделю (было: = total)
from datetime import timedelta as _td
cutoff = datetime.now(timezone.utc) - _td(days=7)
recent = sum(1 for c in cases
if c.created_at is not None
and (c.created_at if c.created_at.tzinfo else c.created_at.replace(tzinfo=timezone.utc)) >= cutoff)
# Счётчики операций (B21) — для дашборда админа
active_ops = completed_ops = 0
try:
from backend.models import SearchOperation as _Op
with SessionLocal() as session:
from sqlalchemy import select as _select, func as _func
active_ops = session.query(_func.count(_Op.id)).filter(_Op.status.in_(['planned', 'active', 'paused'])).scalar() or 0
completed_ops = session.query(_func.count(_Op.id)).filter(_Op.status.in_(['completed', 'archived'])).scalar() or 0
except Exception:
pass
return {
'total_cases': total,
'found_alive_count': found_alive,
'found_deceased_count': found_deceased,
'unknown_outcome_count': total - found_alive - found_deceased,
'median_found_distance_km': median(distances) if distances else None,
'recent_activity_count': total,
'recent_activity_count': recent,
'active_operations': active_ops,
'completed_operations': completed_ops,
}
def heatmap(self) -> list[dict[str, Any]]:
+25
View File
@@ -5,12 +5,17 @@ import re
import xml.etree.ElementTree as ET
import zipfile
from datetime import datetime, timezone
import logging
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from backend.database import db
from backend.routers.auth import require_roles
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse, ParseDocResponse
log = logging.getLogger('vector.admin')
router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['admin']))])
@@ -120,12 +125,32 @@ def admin_cases(
@router.patch('/cases/{case_id}', response_model=CaseResponse)
def admin_update_case(case_id: str, payload: CaseUpdate) -> dict:
data = payload.model_dump(exclude_none=True)
# default_factory-списки ([]) при exclude_none всё равно попадают в data и
# ЗАТИРАЮТ реальные terrain/diagnosis_type — выкидываем пустые списки:
data = {k: v for k, v in data.items() if not (isinstance(v, list) and not v)}
note = data.pop('note', None)
if note is not None and 'notes' not in data:
data['notes'] = note
case = db.update_case(case_id, **data)
if not case:
raise HTTPException(status_code=404, detail='Case not found')
# B21-синхронизация: кейс закрыт вручную → его операция завершается автоматически
if data.get('status') == 'closed':
import uuid as _uuid
from backend.models import SearchOperation as _Op
from backend.database import SessionLocal as _SL
case_uuid = _uuid.UUID(str(case.id)) if isinstance(case.id, str) else case.id
with _SL() as session:
ops = session.query(_Op).filter(_Op.case_id == case_uuid,
_Op.status.in_(['planned', 'active', 'paused'])).all()
for op in ops:
op.status = 'completed'
op.closed_at = datetime.now(timezone.utc)
session.commit()
closed_ops = len(ops)
if closed_ops:
log.info('admin closed case %s → %d операция(ий) завершены автоматически', case_id, closed_ops)
return case.to_detail()
+2
View File
@@ -45,6 +45,8 @@ def get_case(case_id: str) -> dict:
@router.patch('/{case_id}', response_model=CaseResponse)
def patch_case(case_id: str, payload: CaseUpdate) -> dict:
data = payload.model_dump(exclude_none=True)
# default_factory-списки затирали terrain/diagnosis_type пустыми массивами
data = {k: v for k, v in data.items() if not (isinstance(v, list) and not v)}
note = data.pop('note', None)
if note is not None and 'notes' not in data:
data['notes'] = note
+7 -1
View File
@@ -85,7 +85,13 @@ def list_operations(
if creator_ids:
for u in db.query(UserModel).filter(UserModel.id.in_(creator_ids)).all():
users_by_id[str(u.id)] = u.full_name or u.username
return {'items': [_operation_dto(o, users_by_id) for o in ops], 'total': len(ops)}
units_by_id = {}
unit_ids = {o.unit_id for o in ops if o.unit_id}
if unit_ids:
from backend.models import MchsUnit
for un in db.query(MchsUnit).filter(MchsUnit.id.in_(unit_ids)).all():
units_by_id[str(un.id)] = un.name
return {'items': [_operation_dto(o, users_by_id, units_by_id) for o in ops], 'total': len(ops)}
def _operation_dto(op: SearchOperation, users_by_id: dict | None = None) -> dict[str, Any]:
+2
View File
@@ -111,6 +111,8 @@ class DashboardResponse(BaseModel):
unknown_outcome_count: int
median_found_distance_km: float | None
recent_activity_count: int
active_operations: int = 0
completed_operations: int = 0
class RecommendationRequest(BaseModel):
+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