Реорганизация /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_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) 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] 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 { return {
'total_cases': total, 'total_cases': total,
'found_alive_count': found_alive, 'found_alive_count': found_alive,
'found_deceased_count': found_deceased, 'found_deceased_count': found_deceased,
'unknown_outcome_count': total - found_alive - found_deceased, 'unknown_outcome_count': total - found_alive - found_deceased,
'median_found_distance_km': median(distances) if distances else None, '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]]: def heatmap(self) -> list[dict[str, Any]]:
+25
View File
@@ -5,12 +5,17 @@ import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import zipfile import zipfile
from datetime import datetime, timezone
import logging
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from backend.database import db from backend.database import db
from backend.routers.auth import require_roles from backend.routers.auth import require_roles
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse, ParseDocResponse 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']))]) 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) @router.patch('/cases/{case_id}', response_model=CaseResponse)
def admin_update_case(case_id: str, payload: CaseUpdate) -> dict: def admin_update_case(case_id: str, payload: CaseUpdate) -> dict:
data = payload.model_dump(exclude_none=True) 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) note = data.pop('note', None)
if note is not None and 'notes' not in data: if note is not None and 'notes' not in data:
data['notes'] = note data['notes'] = note
case = db.update_case(case_id, **data) case = db.update_case(case_id, **data)
if not case: if not case:
raise HTTPException(status_code=404, detail='Case not found') 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() 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) @router.patch('/{case_id}', response_model=CaseResponse)
def patch_case(case_id: str, payload: CaseUpdate) -> dict: def patch_case(case_id: str, payload: CaseUpdate) -> dict:
data = payload.model_dump(exclude_none=True) 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) note = data.pop('note', None)
if note is not None and 'notes' not in data: if note is not None and 'notes' not in data:
data['notes'] = note data['notes'] = note
+7 -1
View File
@@ -85,7 +85,13 @@ def list_operations(
if creator_ids: if creator_ids:
for u in db.query(UserModel).filter(UserModel.id.in_(creator_ids)).all(): 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 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]: 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 unknown_outcome_count: int
median_found_distance_km: float | None median_found_distance_km: float | None
recent_activity_count: int recent_activity_count: int
active_operations: int = 0
completed_operations: int = 0
class RecommendationRequest(BaseModel): 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
+4
View File
@@ -437,3 +437,7 @@
flex-direction: column; flex-direction: column;
} }
} }
.admin-badge-green { background: rgba(63,185,80,0.15); color: #3fb950; }
.admin-badge-amber { background: rgba(255,209,102,0.15); color: #ffd166; }
.admin-badge-blue { background: rgba(88,166,255,0.15); color: #58a6ff; }
+127 -4
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { apiFetch } from '../api/client'; import { apiFetch, apiJson } from '../api/client';
import { WHO_FOUND_TYPES } from '../constants';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import ClosedCases from './ClosedCases'; import ClosedCases from './ClosedCases';
import './AdminDashboard.css'; import './AdminDashboard.css';
@@ -30,6 +31,107 @@ function Badge({ children, tone = 'neutral' }) {
return <span className={`admin-badge admin-badge-${tone}`}>{children}</span>; return <span className={`admin-badge admin-badge-${tone}`}>{children}</span>;
} }
const cell = { padding: '8px 10px', verticalAlign: 'top' };
const OP_STATUS_LABEL = {
planned: 'Запланирована',
active: 'Активна',
paused: 'Приостановлена',
completed: 'Завершена',
archived: 'В архиве',
};
const OP_STATUS_TONE = {
planned: 'neutral',
active: 'success',
paused: 'amber',
completed: 'blue',
archived: 'neutral',
};
function OperationsTab() {
const [ops, setOps] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const q = statusFilter ? `?status=${statusFilter}` : '';
const data = await apiJson(`/api/v1/operations${q}`);
setOps(data.items || []);
} catch (e) {
setError(e.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => { load(); }, [load]);
return (
<section className="admin-ops">
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 10 }}>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{ padding: '6px 10px', fontSize: 13, fontFamily: 'inherit' }}
>
<option value="">Все статусы</option>
{Object.entries(OP_STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button type="button" onClick={load} style={{ padding: '6px 12px', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>Обновить</button>
<span style={{ fontSize: 12, color: '#8b949e' }}>всего: {ops.length}</span>
</div>
{loading && <p>Загрузка…</p>}
{error && <p style={{ color: '#f85149' }}>{error}</p>}
{!loading && !error && ops.length === 0 && <p>Операций нет.</p>}
{!loading && ops.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', color: '#8b949e', fontSize: 12 }}>
<th style={cell}>Операция</th>
<th style={cell}>Статус</th>
<th style={cell}>Подразделение</th>
<th style={cell}>Обновлена</th>
<th style={cell}></th>
</tr>
</thead>
<tbody>
{ops.map((op) => (
<tr key={op.id} style={{ borderBottom: '1px solid #21262d' }}>
<td style={cell}>
<div style={{ fontWeight: 600 }}>{op.title}</div>
<div style={{ fontSize: 12, color: '#8b949e' }}>кейс {String(op.case_id).slice(0, 8)}</div>
</td>
<td style={cell}>
<Badge tone={OP_STATUS_TONE[op.status] || 'neutral'}>{OP_STATUS_LABEL[op.status] || op.status}</Badge>
</td>
<td style={cell}>{op.unit_name || '—'}</td>
<td style={cell}>{op.updated_at ? new Date(op.updated_at).toLocaleString('ru-RU') : '—'}</td>
<td style={cell}>
<button
type="button"
onClick={() => { window.location.href = `/analysis/${op.case_id}`; }}
style={{ padding: '4px 10px', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit' }}
>
Открыть анализ
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
function AdminDashboard() { function AdminDashboard() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [activeTab, setActiveTab] = useState('cases'); // 'cases' | 'closed' const [activeTab, setActiveTab] = useState('cases'); // 'cases' | 'closed'
@@ -337,12 +439,20 @@ function AdminDashboard() {
<button type="button" className="admin-secondary-button" onClick={logout}> <button type="button" className="admin-secondary-button" onClick={logout}>
Выйти{user ? ` · ${user.username}` : ''} Выйти{user ? ` · ${user.username}` : ''}
</button> </button>
<button
type="button"
onClick={() => { window.location.href = '/users'; }}
style={{ padding: '8px 14px', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}
>
Пользователи · Аудит
</button>
</div> </div>
</header> </header>
<div className="admin-tabs" style={{ display: 'flex', gap: 4, marginBottom: 12 }}> <div className="admin-tabs" style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
{[ {[
{ id: 'cases', label: 'Кейсы' }, { id: 'cases', label: 'Кейсы' },
{ id: 'operations', label: 'Операции' },
{ id: 'closed', label: 'Завершённые поиски' }, { id: 'closed', label: 'Завершённые поиски' },
].map((t) => ( ].map((t) => (
<button <button
@@ -365,7 +475,9 @@ function AdminDashboard() {
))} ))}
</div> </div>
{activeTab === 'closed' ? ( {activeTab === 'operations' ? (
<OperationsTab />
) : activeTab === 'closed' ? (
<ClosedCases /> <ClosedCases />
) : ( ) : (
<> <>
@@ -396,6 +508,14 @@ function AdminDashboard() {
<span className="admin-metric-label">Активность 7 дней</span> <span className="admin-metric-label">Активность 7 дней</span>
<strong>{dashboardLoading ? '…' : dashboard?.recent_activity_count ?? 0}</strong> <strong>{dashboardLoading ? '…' : dashboard?.recent_activity_count ?? 0}</strong>
</article> </article>
<article className="admin-metric-card">
<span className="admin-metric-label">Активные операции</span>
<strong>{dashboardLoading ? '…' : dashboard?.active_operations ?? 0}</strong>
</article>
<article className="admin-metric-card">
<span className="admin-metric-label">Завершённые операции</span>
<strong>{dashboardLoading ? '…' : dashboard?.completed_operations ?? 0}</strong>
</article>
</section> </section>
<section className="admin-panel admin-filters-panel"> <section className="admin-panel admin-filters-panel">
@@ -618,10 +738,13 @@ function AdminDashboard() {
</label> </label>
<label className="admin-field"> <label className="admin-field">
<span>Кто нашёл</span> <span>Кто нашёл</span>
<input <select
value={editForm.who_found ?? ''} value={editForm.who_found ?? ''}
onChange={(event) => updateEditForm('who_found', event.target.value)} onChange={(event) => updateEditForm('who_found', event.target.value)}
/> >
<option value="">Не указано</option>
{WHO_FOUND_TYPES.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label> </label>
<label className="admin-field"> <label className="admin-field">
<span>Найден живым</span> <span>Найден живым</span>