E3 (B21): операции мультипоиска — статусная машина, скоуп, дашборд

- Модель search_operations: title, case_id (unique — операция на карточку),
  status (planned/active/paused/completed/archived, CheckConstraint),
  unit_id, contour_operation_id (заготовка B20-E4), created_by, closed_at.
- Alembic 009_e3_operations.
- backend/routers/operations.py: POST (создание; unit по умолчанию =
  подразделение создателя; скоуп-проверка), GET список (скоуп-фильтр),
  GET /{id} (403 вне скоупа), GET /summary (active/total/completed_24h),
  PATCH (title/unit/status со статусной машиной: planned→active⇄paused→
  completed→archived; недопустимые переходы 400; completed ставит closed_at).
  Всё с аудитом operation_create/operation_update.
- Скоуп: unit_id ∈ visible_unit_ids (своё+подчинённые); РЦУ РЧС — все.
- Тесты E3 (12): создание с юнитом/дефолт/дубль кейса/cross-unit 403,
  скоуп списков, статусная машина, summary, аудит, cross-scope 403.
222 passed, 5 skipped.
This commit is contained in:
2026-09-09 21:29:33 +03:00
parent e694ad1a05
commit 808ef93a52
5 changed files with 549 additions and 0 deletions
@@ -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')
+2
View File
@@ -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)
+25
View File
@@ -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"
+223
View File
@@ -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)
+252
View File
@@ -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