diff --git a/backend/database.py b/backend/database.py index 0b5b330..d379ff0 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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]]: diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 90abe1f..299e992 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -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() diff --git a/backend/routers/cases.py b/backend/routers/cases.py index 61b82bb..805255f 100644 --- a/backend/routers/cases.py +++ b/backend/routers/cases.py @@ -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 diff --git a/backend/routers/operations.py b/backend/routers/operations.py index 5486a77..2be4eeb 100644 --- a/backend/routers/operations.py +++ b/backend/routers/operations.py @@ -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]: diff --git a/backend/schemas.py b/backend/schemas.py index d53b5e2..f2ae606 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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): diff --git a/backend/tests/test_admin_reorg.py b/backend/tests/test_admin_reorg.py new file mode 100644 index 0000000..6cab2ff --- /dev/null +++ b/backend/tests/test_admin_reorg.py @@ -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 diff --git a/frontend/src/pages/AdminDashboard.css b/frontend/src/pages/AdminDashboard.css index 15fd2eb..9bd0268 100644 --- a/frontend/src/pages/AdminDashboard.css +++ b/frontend/src/pages/AdminDashboard.css @@ -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; } diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index d070eae..2177d59 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -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 {children}; } + +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 ( +
+
+ + + всего: {ops.length} +
+ + {loading &&

Загрузка…

} + {error &&

{error}

} + {!loading && !error && ops.length === 0 &&

Операций нет.

} + + {!loading && ops.length > 0 && ( + + + + + + + + + + + + {ops.map((op) => ( + + + + + + + + ))} + +
ОперацияСтатусПодразделениеОбновлена
+
{op.title}
+
кейс {String(op.case_id).slice(0, 8)}
+
+ {OP_STATUS_LABEL[op.status] || op.status} + {op.unit_name || '—'}{op.updated_at ? new Date(op.updated_at).toLocaleString('ru-RU') : '—'} + +
+ )} +
+ ); +} + function AdminDashboard() { const { user, logout } = useAuth(); const [activeTab, setActiveTab] = useState('cases'); // 'cases' | 'closed' @@ -337,12 +439,20 @@ function AdminDashboard() { +
{[ { id: 'cases', label: 'Кейсы' }, + { id: 'operations', label: 'Операции' }, { id: 'closed', label: 'Завершённые поиски' }, ].map((t) => (