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:
@@ -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')
|
||||||
@@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from backend.database import init_db
|
from backend.database import init_db
|
||||||
from backend.routers.admin import router as admin_router
|
from backend.routers.admin import router as admin_router
|
||||||
from backend.routers.admin_users import router as admin_users_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.analyze import router as analyze_router
|
||||||
from backend.routers.auth import router as auth_router
|
from backend.routers.auth import router as auth_router
|
||||||
from backend.routers.cases import router as cases_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(stats_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(admin_users_router)
|
app.include_router(admin_users_router)
|
||||||
|
app.include_router(operations_router)
|
||||||
app.include_router(water_router)
|
app.include_router(water_router)
|
||||||
app.include_router(geocode_router)
|
app.include_router(geocode_router)
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,31 @@ class SecuritySetting(Base):
|
|||||||
|
|
||||||
# ==================== B21/E2: аудит действий ====================
|
# ==================== 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):
|
class AuditEvent(Base):
|
||||||
"""Аудит действий (immutable): кто, когда, что. CREATE+READ only."""
|
"""Аудит действий (immutable): кто, когда, что. CREATE+READ only."""
|
||||||
__tablename__ = "audit_events"
|
__tablename__ = "audit_events"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user