Реорганизация /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:
+19
-1
@@ -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]]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -437,3 +437,7 @@
|
||||
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; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 ClosedCases from './ClosedCases';
|
||||
import './AdminDashboard.css';
|
||||
@@ -30,6 +31,107 @@ function Badge({ children, tone = 'neutral' }) {
|
||||
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() {
|
||||
const { user, logout } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('cases'); // 'cases' | 'closed'
|
||||
@@ -337,12 +439,20 @@ function AdminDashboard() {
|
||||
<button type="button" className="admin-secondary-button" onClick={logout}>
|
||||
Выйти{user ? ` · ${user.username}` : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = '/users'; }}
|
||||
style={{ padding: '8px 14px', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
Пользователи · Аудит
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-tabs" style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
|
||||
{[
|
||||
{ id: 'cases', label: 'Кейсы' },
|
||||
{ id: 'operations', label: 'Операции' },
|
||||
{ id: 'closed', label: 'Завершённые поиски' },
|
||||
].map((t) => (
|
||||
<button
|
||||
@@ -365,7 +475,9 @@ function AdminDashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'closed' ? (
|
||||
{activeTab === 'operations' ? (
|
||||
<OperationsTab />
|
||||
) : activeTab === 'closed' ? (
|
||||
<ClosedCases />
|
||||
) : (
|
||||
<>
|
||||
@@ -396,6 +508,14 @@ function AdminDashboard() {
|
||||
<span className="admin-metric-label">Активность 7 дней</span>
|
||||
<strong>{dashboardLoading ? '…' : dashboard?.recent_activity_count ?? 0}</strong>
|
||||
</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 className="admin-panel admin-filters-panel">
|
||||
@@ -618,10 +738,13 @@ function AdminDashboard() {
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Кто нашёл</span>
|
||||
<input
|
||||
<select
|
||||
value={editForm.who_found ?? ''}
|
||||
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 className="admin-field">
|
||||
<span>Найден живым</span>
|
||||
|
||||
Reference in New Issue
Block a user