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.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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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