diff --git a/backend/alembic/versions/009_e3_operations.py b/backend/alembic/versions/009_e3_operations.py new file mode 100644 index 0000000..8eff773 --- /dev/null +++ b/backend/alembic/versions/009_e3_operations.py @@ -0,0 +1,47 @@ +"""B21/E3: поисковая операция (мультипоиск). + +Revision ID: 009_e3_operations +Revises: 008_e2_audit +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql as pg + +revision = '009_e3_operations' +down_revision = '008_e2_audit' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'search_operations', + sa.Column('id', pg.UUID(as_uuid=True), primary_key=True, + server_default=sa.text('gen_random_uuid()')), + sa.Column('title', sa.String(255), nullable=False), + sa.Column('case_id', pg.UUID(as_uuid=True), + sa.ForeignKey('cases.id', ondelete='CASCADE'), nullable=False, unique=True), + sa.Column('status', sa.String(20), nullable=False, server_default='active'), + sa.Column('unit_id', pg.UUID(as_uuid=True), + sa.ForeignKey('mchs_units.id', ondelete='SET NULL')), + sa.Column('contour_operation_id', pg.UUID(as_uuid=True)), + sa.Column('created_by', pg.UUID(as_uuid=True), + sa.ForeignKey('users.id', ondelete='SET NULL')), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')), + sa.Column('closed_at', sa.DateTime(timezone=True)), + sa.CheckConstraint( + "status IN ('planned','active','paused','completed','archived')", + name='ck_search_operations_status', + ), + ) + op.create_index('idx_search_operations_status', 'search_operations', ['status']) + op.create_index('idx_search_operations_unit', 'search_operations', ['unit_id']) + + +def downgrade() -> None: + op.drop_index('idx_search_operations_unit', table_name='search_operations') + op.drop_index('idx_search_operations_status', table_name='search_operations') + op.drop_table('search_operations') \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 2c3425a..13d84c2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from backend.database import init_db from backend.routers.admin import router as admin_router from backend.routers.admin_users import router as admin_users_router +from backend.routers.operations import router as operations_router from backend.routers.analyze import router as analyze_router from backend.routers.auth import router as auth_router from backend.routers.cases import router as cases_router @@ -55,6 +56,7 @@ app.include_router(closed_cases_router) app.include_router(stats_router) app.include_router(admin_router) app.include_router(admin_users_router) +app.include_router(operations_router) app.include_router(water_router) app.include_router(geocode_router) diff --git a/backend/models.py b/backend/models.py index 1b33f60..573d145 100644 --- a/backend/models.py +++ b/backend/models.py @@ -337,6 +337,31 @@ class SecuritySetting(Base): # ==================== B21/E2: аудит действий ==================== +class SearchOperation(Base): + """B21/E3: поисковая операция — надстройка над карточкой (cases).""" + __tablename__ = "search_operations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(255), nullable=False) + case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False, unique=True) + status = Column(String(20), nullable=False, default="active") + unit_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL")) + contour_operation_id = Column(UUID(as_uuid=True)) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now()) + closed_at = Column(DateTime(timezone=True)) + + __table_args__ = ( + sa.CheckConstraint( + "status IN ('planned','active','paused','completed','archived')", + name="ck_search_operations_status", + ), + sa.Index("idx_search_operations_status", "status"), + sa.Index("idx_search_operations_unit", "unit_id"), + ) + + class AuditEvent(Base): """Аудит действий (immutable): кто, когда, что. CREATE+READ only.""" __tablename__ = "audit_events" diff --git a/backend/routers/operations.py b/backend/routers/operations.py new file mode 100644 index 0000000..0f0dff9 --- /dev/null +++ b/backend/routers/operations.py @@ -0,0 +1,223 @@ +"""B21/E3: операции мультипоиска. + +Скоуп: unit_id операции ∈ {своё подразделение + подчинённые} (РЦУ РЧС — все). +Статусная машина: planned → active ⇄ paused → completed → archived. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.audit import audit_log, visible_unit_ids +from backend.database import get_db +from backend.models import SearchOperation, User +from backend.routers.auth import get_current_user, require_permission + +router = APIRouter(prefix='/api/v1/operations', tags=['operations']) + +ALLOWED_TRANSITIONS = { + 'planned': {'active', 'archived'}, + 'active': {'paused', 'completed', 'archived'}, + 'paused': {'active', 'completed', 'archived'}, + 'completed': {'archived'}, + 'archived': set(), +} + + +class OperationCreate(BaseModel): + title: str + case_id: str + unit_id: Optional[str] = None + + +class OperationUpdate(BaseModel): + title: Optional[str] = None + status: Optional[str] = None + unit_id: Optional[str] = None + + +def _op_scope(db: Session, user: User) -> list | None: + """UUID-объекты юнитов из скоупа, либо None (без фильтра).""" + scope = visible_unit_ids(db, user) + if scope is None: + return None + import uuid + return [uuid.UUID(h) if isinstance(h, str) else h for h in scope] + + +def _get_operation_checked(db: Session, user: User, operation_id: str) -> SearchOperation: + import uuid as uuid_mod + op = db.get(SearchOperation, uuid_mod.UUID(operation_id)) if operation_id else None + if not op: + raise HTTPException(status_code=404, detail='Операция не найдена') + scope = _op_scope(db, user) + if scope is not None and (not op.unit_id or op.unit_id not in scope): + raise HTTPException(status_code=403, detail='Операция вне вашего скоупа') + return op + + +@router.get('') +def list_operations( + status_filter: Optional[str] = Query(default=None, alias='status'), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict[str, Any]: + scope = _op_scope(db, current_user) + query = db.query(SearchOperation) + if scope is not None: + query = query.filter(SearchOperation.unit_id.in_(scope)) + if status_filter: + query = query.filter(SearchOperation.status == status_filter) + ops = query.order_by(SearchOperation.updated_at.desc()).all() + from backend.models import User as UserModel + users_by_id = {} + if ops: + creator_ids = {o.created_by for o in ops if o.created_by} + 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)} + + +def _operation_dto(op: SearchOperation, users_by_id: dict | None = None) -> dict[str, Any]: + u = users_by_id or {} + return { + 'id': str(op.id), + 'title': op.title, + 'case_id': str(op.case_id), + 'status': op.status, + 'unit_id': str(op.unit_id) if op.unit_id else None, + 'contour_operation_id': str(op.contour_operation_id) if op.contour_operation_id else None, + 'created_by_name': u.get(str(op.created_by)) if op.created_by else None, + 'created_at': op.created_at.isoformat() if op.created_at else None, + 'updated_at': op.updated_at.isoformat() if op.updated_at else None, + 'closed_at': op.closed_at.isoformat() if op.closed_at else None, + } + + +@router.get('/summary') +def operations_summary( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict[str, Any]: + """Счётчики для дашборда: активных / всего / завершённых за 24 ч.""" + scope = _op_scope(db, current_user) + query = db.query(SearchOperation) + if scope is not None: + query = query.filter(SearchOperation.unit_id.in_(scope)) + total = query.count() + active = query.filter(SearchOperation.status == 'active').count() + day_ago = datetime.now(timezone.utc) - __import__('datetime').timedelta(hours=24) + completed_24h = query.filter( + SearchOperation.status == 'completed', + SearchOperation.closed_at >= day_ago, + ).count() + return {'active': active, 'total': total, 'completed_24h': completed_24h} + + +@router.get('/{operation_id}') +def get_operation( + operation_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict[str, Any]: + op = _get_operation_checked(db, current_user, operation_id) + return _operation_dto(op) + + +@router.post('', status_code=201) +def create_operation( + payload: OperationCreate, + request: Request, + current_user: User = Depends(require_permission('create')), + db: Session = Depends(get_db), +) -> dict[str, Any]: + import uuid as uuid_mod + from backend.models import Case + case_uuid = uuid_mod.UUID(payload.case_id) + case = db.get(Case, case_uuid) + if not case: + raise HTTPException(status_code=404, detail='Карточка не найдена') + existing = db.query(SearchOperation).filter(SearchOperation.case_id == case_uuid).first() + if existing: + raise HTTPException(status_code=400, detail='Операция для этой карточки уже существует') + + unit_id = payload.unit_id + if unit_id: + from backend.audit import can_access_unit + if not can_access_unit(db, current_user, unit_id): + raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа') + else: + # По умолчанию — подразделение создателя + unit_id = getattr(current_user, 'unit_id', None) + unit_id = str(unit_id) if unit_id else None + + op = SearchOperation( + title=payload.title, + case_id=case_uuid, + status='active', + unit_id=uuid_mod.UUID(unit_id) if unit_id else None, + created_by=current_user.id, + ) + db.add(op) + db.commit() + db.refresh(op) + + from backend.audit import audit_log + audit_log(db, current_user, 'operation_create', object_type='operation', + object_id=str(op.id), request=request, + details={'title': op.title, 'case_id': payload.case_id}) + return _operation_dto(op) + + +@router.patch('/{operation_id}') +def update_operation( + operation_id: str, + payload: OperationUpdate, + request: Request, + current_user: User = Depends(require_permission('update')), + db: Session = Depends(get_db), +) -> dict[str, Any]: + import uuid as uuid_mod + op = _get_operation_checked(db, current_user, operation_id) + changes: dict[str, tuple] = {} + + if payload.title is not None and payload.title != op.title: + changes['title'] = (op.title, payload.title) + op.title = payload.title + + if payload.unit_id is not None: + from backend.audit import can_access_unit, _uuid_hex + if not can_access_unit(db, current_user, payload.unit_id): + raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа') + new_unit = uuid_mod.UUID(payload.unit_id) + if new_unit != op.unit_id: + changes['unit'] = (str(op.unit_id) if op.unit_id else None, payload.unit_id) + op.unit_id = new_unit + + if payload.status is not None and payload.status != op.status: + allowed = ALLOWED_TRANSITIONS.get(op.status, set()) + if payload.status not in allowed: + raise HTTPException( + status_code=400, + detail=f'Недопустимый переход {op.status} → {payload.status}. ' + f'Разрешено: {", ".join(sorted(allowed)) or "нет"}', + ) + changes['status'] = (op.status, payload.status) + op.status = payload.status + if payload.status in ('completed', 'archived'): + op.closed_at = datetime.now(timezone.utc) + + if not changes: + return _operation_dto(op) + + op.updated_at = datetime.now(timezone.utc) + db.commit() + from backend.audit import audit_log + audit_log(db, current_user, 'operation_update', object_type='operation', + object_id=str(op.id), changes=changes, request=request) + return _operation_dto(op) \ No newline at end of file diff --git a/backend/tests/test_e3_operations.py b/backend/tests/test_e3_operations.py new file mode 100644 index 0000000..f89c089 --- /dev/null +++ b/backend/tests/test_e3_operations.py @@ -0,0 +1,252 @@ +"""E3 (B21): тесты операций мультипоиска — статусная машина, скоуп, дашборд.""" +from __future__ import annotations + +import sys +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 ops_env(): + 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() + oblast = MchsUnit(name='Минское ОУМЧС', kind='oblast', parent_id=rcu.id) + db.add(oblast) + db.flush() + rayon = MchsUnit(name='Минское Г(Р)ОЧС', kind='gor_rayon', parent_id=oblast.id) + db.add(rayon) + db.flush() + other_rayon = MchsUnit(name='Гомельское Г(Р)ОЧС', kind='gor_rayon', parent_id=rcu.id) + db.add(other_rayon) + 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) + coord = m.User(username='coord', email='c@t.by', hashed_password=_hash('coordpass123'), + role='coordinator', is_active=True, unit_id=oblast.id) + other_op = m.User(username='other', email='o@t.by', hashed_password=_hash('otherpass123'), + role='operator', is_active=True, unit_id=other_rayon.id) + db.add_all([root, coord, other_op]) + db.commit() + + case1 = m.Case(age_years=10, gender='м', status='active') + case2 = m.Case(age_years=8, gender='ж', status='active') + case3 = m.Case(age_years=12, gender='м', status='active') + db.add_all([case1, case2, case3]) + db.commit() + + 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() + matrix = { + 'admin': tuple(perms), + 'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'), + 'operator': ('view', 'create', 'update'), + 'observer': ('view',), + } + for rn, codes in matrix.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.add(UserRole(user_id=coord.id, role_id=roles['coordinator'].id)) + db.add(UserRole(user_id=other_op.id, role_id=roles['operator'].id)) + db.commit() + + 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 + client = TestClient(app) + + def login(u, p): + return client.post('/api/v1/auth/login', data={'username': u, 'password': p}).json()['access_token'] + + yield {'db': db, 'client': client, + 'root': login('root', 'rootpass123'), + 'coord': login('coord', 'coordpass123'), + 'other': login('other', 'otherpass123'), + 'cases': {'c1': case1, 'c2': case2, 'c3': case3}, + 'units': {'rcu': rcu, 'oblast': oblast, 'rayon': rayon, 'other': other_rayon}} + + app.dependency_overrides.pop(auth_router.get_db, None) + + +def _create(client, token, title, case_id, unit_id=None): + body = {'title': title, 'case_id': case_id} + if unit_id: + body['unit_id'] = unit_id + return client.post('/api/v1/operations', headers={'Authorization': f'Bearer {token}'}, json=body) + + +class TestCreateOperation: + def test_admin_creates_with_explicit_unit(self, ops_env): + r = ops_env['client'].post( + '/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['root']}"}, + json={'title': 'Поиск: Иванов', 'case_id': str(ops_env['cases']['c1'].id), + 'unit_id': str(ops_env['units']['rayon'].id)}) + assert r.status_code == 201 + assert r.json()['status'] == 'active' + + def test_operator_defaults_to_own_unit(self, ops_env): + r = ops_env['client'].post( + '/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"}, + json={'title': 'Поиск 2', 'case_id': str(ops_env['cases']['c2'].id)}) + assert r.status_code == 201 + assert r.json()['unit_id'] == str(ops_env['units']['oblast'].id) + + def test_duplicate_case_rejected(self, ops_env): + c = ops_env['client'] + tok = ops_env['root'] + body = {'title': 'Дубль', 'case_id': str(ops_env['cases']['c1'].id), + 'unit_id': str(ops_env['units']['rayon'].id)} + c.post('/api/v1/operations', headers={'Authorization': f'Bearer {tok}'}, json=body) + r = c.post('/api/v1/operations', headers={'Authorization': f'Bearer {tok}'}, json=body) + assert r.status_code == 400 + + def test_cross_unit_create_403(self, ops_env): + """Координатор Минска не может создать операцию под Гомельским Г(Р)ОЧС.""" + r = ops_env['client'].post( + '/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"}, + json={'title': 'Чужой', 'case_id': str(ops_env['cases']['c2'].id), + 'unit_id': str(ops_env['units']['other'].id)}) + assert r.status_code == 403 + + +class TestScope: + def test_coord_sees_only_own_oblast(self, ops_env): + c = ops_env['client'] + c.post('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"}, + json={'title': 'Минская', 'case_id': str(ops_env['cases']['c1'].id)}) + c.post('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['other']}"}, + json={'title': 'Гомельская', 'case_id': str(ops_env['cases']['c2'].id)}) + r = c.get('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"}) + titles = {o['title'] for o in r.json()['items']} + assert 'Минская' in titles + assert 'Гомельская' not in titles + + def test_admin_sees_all(self, ops_env): + c = ops_env['client'] + c.post('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['coord']}"}, + json={'title': 'Минская', 'case_id': str(ops_env['cases']['c1'].id)}) + c.post('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['other']}"}, + json={'title': 'Гомельская', 'case_id': str(ops_env['cases']['c2'].id)}) + r = c.get('/api/v1/operations', headers={'Authorization': f"Bearer {ops_env['root']}"}) + assert r.json()['total'] == 2 + + +class TestStateMachine: + def _create(self, client, token, case_id): + return client.post('/api/v1/operations', headers={'Authorization': f'Bearer {token}'}, + json={'title': 'S', 'case_id': case_id}) + + def test_active_to_paused_and_back(self, ops_env): + c = ops_env['client'] + oid = self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id)).json()['id'] + h = {'Authorization': f"Bearer {ops_env['root']}"} + assert c.patch(f'/api/v1/operations/{oid}', headers=h, + json={'status': 'paused'}).json()['status'] == 'paused' + assert c.patch(f'/api/v1/operations/{oid}', headers=h, + json={'status': 'active'}).json()['status'] == 'active' + + def test_invalid_transition_rejected(self, ops_env): + c = ops_env['client'] + oid = self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id)).json()['id'] + h = {'Authorization': f"Bearer {ops_env['root']}"} + assert c.patch(f'/api/v1/operations/{oid}', headers=h, + json={'status': 'completed'}).status_code == 200 # active → completed ок + assert c.patch(f'/api/v1/operations/{oid}', headers=h, + json={'status': 'active'}).status_code == 400 # completed → active запрещён + + def test_completed_sets_closed_at(self, ops_env): + c = ops_env['client'] + oid = self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id)).json()['id'] + h = {'Authorization': f"Bearer {ops_env['root']}"} + c.patch(f'/api/v1/operations/{oid}', headers=h, json={'status': 'paused'}) + r = c.patch(f'/api/v1/operations/{oid}', headers=h, json={'status': 'completed'}) + assert r.json()['closed_at'] is not None + + def test_summary_counts(self, ops_env): + c = ops_env['client'] + self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id)) + r = c.get('/api/v1/operations/summary', headers={'Authorization': f"Bearer {ops_env['root']}"}) + d = r.json() + assert d['active'] >= 1 and d['total'] >= 1 + + def test_audit_logged(self, ops_env): + from backend.models import AuditEvent + c = ops_env['client'] + self._create(c, ops_env['root'], str(ops_env['cases']['c1'].id)) + types = {e.event_type for e in ops_env['db'].query(AuditEvent).all()} + assert 'operation_create' in types + + def test_cross_scope_access_403(self, ops_env): + c = ops_env['client'] + oid = self._create(c, ops_env['other'], str(ops_env['cases']['c3'].id)).json()['id'] + assert c.get(f'/api/v1/operations/{oid}', + headers={'Authorization': f"Bearer {ops_env['coord']}"}).status_code == 403 \ No newline at end of file