diff --git a/backend/main.py b/backend/main.py index 13d84c2..1869190 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ 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.contour import router as contour_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 @@ -57,6 +58,7 @@ app.include_router(stats_router) app.include_router(admin_router) app.include_router(admin_users_router) app.include_router(operations_router) +app.include_router(contour_router) app.include_router(water_router) app.include_router(geocode_router) diff --git a/backend/routers/contour.py b/backend/routers/contour.py new file mode 100644 index 0000000..ccacabb --- /dev/null +++ b/backend/routers/contour.py @@ -0,0 +1,200 @@ +"""B21/E4: отправка приоритетных зон в КОНТУР (контракт B20, outbound). + +POST /api/v1/operations/{id}/send-to-contour: + анализ кейса → SearchZones[] (GeoJSON-сектора направлений) → + POST {CONTOUR_API_URL}/api/v1/vector/zones?operation_id= + +Требуется: у операции задан contour_operation_id (UUID операции в КОНТУРе), +env CONTOUR_API_URL и CONTOUR_TOKEN (межсервисный, в secrets). +""" +from __future__ import annotations + +import math +import os +import uuid as uuid_mod +from datetime import datetime, timezone +from typing import Any, Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from backend.audit import audit_log, can_access_unit +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-contour']) + +def _contour_url() -> str: + return os.getenv('CONTOUR_API_URL', 'http://192.168.0.130:8000') + + +def _contour_token() -> str: + return os.getenv('CONTOUR_TOKEN', '') +SCHEMA_VERSION = '1.0' + +_DIRECTIONS = { + 'N': 0, 'NE': 45, 'E': 90, 'SE': 135, + 'S': 180, 'SW': 225, 'W': 270, 'NW': 315, +} + + +class SendResult(BaseModel): + sent_zones: int + contour_operation_id: str + contour_response: dict[str, Any] + + +def _sector_polygon(lat: float, lon: float, radius_km: float, direction: str, + segments: int = 24) -> dict: + """GeoJSON Polygon сектора ±22.5° вокруг направления (для контракта B20).""" + az = _DIRECTIONS.get(direction, 0) + half = 22.5 + ring: list[list[float]] = [[lon, lat]] + for i in range(segments + 1): + bearing = math.radians(az - half + (2 * half) * i / segments) + dlat = (radius_km / 111.0) * math.cos(bearing) + dlon = (radius_km / (111.0 * math.cos(math.radians(lat)))) * math.sin(bearing) + ring.append([round(lon + dlon, 7), round(lat + dlat, 7)]) + ring.append([lon, lat]) + return {'type': 'Polygon', 'coordinates': [ring]} + + +def _probability_from_priority(priority: int, total: int) -> float: + """Градация: топ-зона → наибольшая вероятность (0..1).""" + if total <= 1: + return 0.8 + return round(max(0.1, 0.9 - 0.7 * (priority - 1) / max(1, total - 1)), 3) + + +def _load_analysis(db, case_id) -> dict: + """Последний анализ кейса: search_models (B15) или analysis_log (legacy).""" + from sqlalchemy import text as sa_text + row = db.execute( + sa_text("SELECT model_json FROM search_models " + "WHERE case_id = :cid ORDER BY version DESC LIMIT 1"), + {'cid': str(case_id)}, + ).first() + if row and row[0]: + return dict(row[0]) + from backend.models import Case + case = db.get(__import__('backend.models', fromlist=['Case']).Case, case_id) + if case and case.analysis_log and isinstance(case.analysis_log, dict): + return dict(case.analysis_log) + raise HTTPException(status_code=400, detail='У карточки нет анализа — запустите анализ сначала') + + +def _build_b20_payload(op: SearchOperation, analysis: dict) -> dict: + """SearchZones[] из primary_zones анализа (полигоны направлений).""" + lat = analysis.get('lat') or analysis.get('tnp_lat') + lon = analysis.get('lon') or analysis.get('tnp_lon') + if lat is None or lon is None: + raise HTTPException(status_code=400, + detail='В анализе нет координат ТНП — невозможны геометрии зон') + max_distance = float(analysis.get('max_distance_km') or 3.0) + zones_in = analysis.get('primary_zones') or [] + if not zones_in: + raise HTTPException(status_code=400, detail='В анализе нет приоритетных зон') + + out_zones = [] + for z in zones_in[:5]: + direction = z.get('direction') + distance = float(z.get('distance') or max_distance * 0.5) + priority = int(z.get('priority') or 1) + out_zones.append({ + 'zone_id': f"vec:{direction}:{int(distance * 1000)}m", + 'geom': _sector_polygon(float(lat), float(lon), distance, direction), + 'priority': priority, + 'probability': _probability_from_priority(priority, len(zones_in[:5])), + 'reasoning': z.get('reason') or f'Приоритет {priority} по модели ВЕКТОРа', + }) + + return { + 'schema_version': SCHEMA_VERSION, + 'case_id': str(op.case_id), + 'model_version': f"vector:{(analysis.get('analyzed_at') or datetime.now(timezone.utc).isoformat())[:19]}", + 'generated_at': analysis.get('analyzed_at') or datetime.now(timezone.utc).isoformat(), + 'max_distance_km': max_distance, + 'zones': out_zones, + } + + +@router.post('/{operation_id}/send-to-contour') +def send_to_contour( + operation_id: str, + request: Request, + current_user: User = Depends(require_permission('update')), + db = Depends(get_db), +) -> SendResult: + op = _load_operation(db, current_user, operation_id) + + if not op.contour_operation_id: + raise HTTPException( + status_code=400, + detail='У операции не задан contour_operation_id (UUID операции в КОНТУРе) — ' + 'укажите его в карточке операции (PATCH /operations/{id})', + ) + if not _contour_token(): + raise HTTPException(status_code=503, detail='CONTOUR_TOKEN не настроен') + + analysis = _load_analysis(db, op.case_id) + payload = _build_b20_payload(op, analysis) + + url = f"{_contour_url()}/api/v1/vector/zones?operation_id={op.contour_operation_id}" + try: + resp = httpx.post(url, json=payload, + headers={'Authorization': f'Bearer {_contour_token()}'}, + timeout=30.0) + except httpx.HTTPError as e: + audit_log(db, current_user, 'contour_send_failed', object_type='operation', + object_id=str(op.id), request=request, details={'error': str(e)}) + raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e + + if resp.status_code >= 400: + audit_log(db, current_user, 'contour_send_failed', object_type='operation', + object_id=str(op.id), request=request, + details={'status': resp.status_code, 'body': resp.text[:500]}) + raise HTTPException(status_code=502, + detail=f'КОНТУР отклонил импорт: {resp.status_code} {resp.text[:300]}') + + result = resp.json() + audit_log(db, current_user, 'contour_send_ok', object_type='operation', + object_id=str(op.id), request=request, + details={'zones': result.get('zones_total'), 'status': result.get('status')}) + return SendResult( + sent_zones=payload['zones'].__len__(), + contour_operation_id=str(op.contour_operation_id), + contour_response=result, + ) + + +def _load_operation(db, user: User, operation_id: str) -> SearchOperation: + op = db.get(SearchOperation, uuid_mod.UUID(operation_id)) if operation_id else None + if not op: + raise HTTPException(status_code=404, detail='Операция не найдена') + if not can_access_unit(db, user, str(op.unit_id) if op.unit_id else None): + raise HTTPException(status_code=403, detail='Операция вне вашего скоупа') + return op + + +@router.get('/{operation_id}/areas-checked') +def areas_checked( + operation_id: str, + current_user: User = Depends(get_current_user), + db = Depends(get_db), +) -> dict[str, Any]: + """Прокси: проверенные квадраты операции из КОНТУРа (inbound B17-заготовка).""" + op = _load_operation(db, current_user, operation_id) + if not op.contour_operation_id: + raise HTTPException(status_code=400, detail='contour_operation_id не задан') + if not _contour_token(): + raise HTTPException(status_code=503, detail='CONTOUR_TOKEN не настроен') + url = f"{_contour_url()}/api/v1/vector/areas-checked?operation_id={op.contour_operation_id}" + try: + resp = httpx.get(url, headers={'Authorization': f'Bearer {_contour_token()}'}, timeout=30.0) + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}') + return resp.json() \ No newline at end of file diff --git a/backend/routers/operations.py b/backend/routers/operations.py index 0f0dff9..1fdc605 100644 --- a/backend/routers/operations.py +++ b/backend/routers/operations.py @@ -38,6 +38,7 @@ class OperationUpdate(BaseModel): title: Optional[str] = None status: Optional[str] = None unit_id: Optional[str] = None + contour_operation_id: Optional[str] = None # UUID операции в КОНТУРе def _op_scope(db: Session, user: User) -> list | None: @@ -63,6 +64,7 @@ def _get_operation_checked(db: Session, user: User, operation_id: str) -> Search @router.get('') def list_operations( status_filter: Optional[str] = Query(default=None, alias='status'), + case_id: Optional[str] = Query(default=None), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict[str, Any]: @@ -72,6 +74,9 @@ def list_operations( query = query.filter(SearchOperation.unit_id.in_(scope)) if status_filter: query = query.filter(SearchOperation.status == status_filter) + if case_id: + import uuid as uuid_mod + query = query.filter(SearchOperation.case_id == uuid_mod.UUID(case_id)) ops = query.order_by(SearchOperation.updated_at.desc()).all() from backend.models import User as UserModel users_by_id = {} @@ -199,6 +204,13 @@ def update_operation( changes['unit'] = (str(op.unit_id) if op.unit_id else None, payload.unit_id) op.unit_id = new_unit + if payload.contour_operation_id is not None: + new_contour = uuid_mod.UUID(payload.contour_operation_id) + if new_contour != op.contour_operation_id: + changes['contour_operation_id'] = (str(op.contour_operation_id) if op.contour_operation_id else None, + payload.contour_operation_id) + op.contour_operation_id = new_contour + if payload.status is not None and payload.status != op.status: allowed = ALLOWED_TRANSITIONS.get(op.status, set()) if payload.status not in allowed: diff --git a/backend/tests/test_e4_contour.py b/backend/tests/test_e4_contour.py new file mode 100644 index 0000000..06e86cd --- /dev/null +++ b/backend/tests/test_e4_contour.py @@ -0,0 +1,203 @@ +"""E4 (B21): тесты отправки зон в КОНТУР — build payload, мок httpx, аудиты.""" +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' + + +ANALYSIS = { + 'lat': 53.9, 'lon': 27.56, 'max_distance_km': 5.4, + 'analyzed_at': '2026-09-09T12:00:00+00:00', + 'primary_zones': [ + {'priority': 1, 'direction': 'NE', 'distance': 2.0, 'reason': 'лес+вода'}, + {'priority': 2, 'direction': 'E', 'distance': 2.0, 'reason': 'лес'}, + ], +} + + +@pytest.fixture() +def contour_env(monkeypatch): + from backend import models as m + from backend.database import Base + from backend.models import (MchsUnit, Permission, Role, RolePermission, + SecuritySetting, SearchOperation, UserRole) + + engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False}, + poolclass=StaticPool) + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + + rcu = MchsUnit(name='РЦУ РЧС', kind='rcu') + db.add(rcu); db.flush() + + def _hash(pw): return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode() + + root = m.User(username='root', email='r@t.by', hashed_password=_hash('rootpass123'), + role='admin', is_active=True, unit_id=rcu.id) + db.add(root); db.commit() + + case = m.Case(age_years=10, gender='м', status='analyzed') + db.add(case); db.flush() + case.analysis_log = dict(ANALYSIS) + db.commit() + + contour_op_id = '22222222-2222-4222-8222-000000000001' # как в КОНТУРе e2e + + op = SearchOperation(title='Тестовая операция', case_id=case.id, status='active', + unit_id=rcu.id, created_by=root.id, + contour_operation_id=__import__('uuid').UUID(contour_op_id)) + db.add(op); 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() + for rn, codes in {'admin': tuple(perms), 'observer': ('view',)}.items(): + for c in codes: + db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id)) + db.add(UserRole(user_id=root.id, role_id=roles['admin'].id)) + db.commit() + + from backend.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) + tok = client.post('/api/v1/auth/login', data={'username': 'root', 'password': 'rootpass123'}).json()['access_token'] + + # env для contour + monkeypatch.setenv('CONTOUR_API_URL', 'http://contour-mock:8000') + monkeypatch.setenv('CONTOUR_TOKEN', 'test-token') + + yield {'db': db, 'client': client, 'token': tok, 'op': op, + 'case': case, 'contour_op_id': contour_op_id} + + app.dependency_overrides.pop(auth_router.get_db, None) + + +class TestSendToContour: + def test_missing_contour_id_400(self, contour_env): + from backend.models import SearchOperation + op = contour_env['op'] + op.contour_operation_id = None + contour_env['db'].commit() + r = contour_env['client'].post( + f'/api/v1/operations/{op.id}/send-to-contour', + headers={'Authorization': f"Bearer {contour_env['token']}"}) + assert r.status_code == 400 + assert 'contour_operation_id' in r.json()['detail'] + + def test_send_builds_valid_b20_payload(self, contour_env, monkeypatch): + captured = {} + + def fake_post(url, json=None, headers=None, timeout=None): + captured['url'] = url + captured['json'] = json + captured['headers'] = headers + + class R: + status_code = 200 + text = '' + def json(self): + return {'status': 'applied', 'zones_total': 2, 'accepted': 2} + return R() + + import httpx + monkeypatch.setattr(httpx, 'post', fake_post) + + r = contour_env['client'].post( + f"/api/v1/operations/{contour_env['op'].id}/send-to-contour", + headers={'Authorization': f"Bearer {contour_env['token']}"}) + assert r.status_code == 200, r.json() + body = r.json() + assert body['sent_zones'] == 2 + assert body['contour_operation_id'] == contour_env['contour_op_id'] + + payload = captured['json'] + assert payload['schema_version'] == '1.0' + assert payload['max_distance_km'] == 5.4 + assert len(payload['zones']) == 2 + z1 = payload['zones'][0] + assert z1['zone_id'] == 'vec:NE:2000m' + assert z1['geom']['type'] == 'Polygon' + ring = z1['geom']['coordinates'][0] + assert ring[0] == ring[-1] # замкнут + assert 0 < z1['probability'] <= 1 + # operation_id в query + assert contour_env['contour_op_id'] in captured['url'] + # auth на КОНТУР + assert captured['headers']['Authorization'] == 'Bearer test-token' + + def test_send_failure_audited(self, contour_env, monkeypatch): + import httpx + + def fake_post(url, json=None, headers=None, timeout=None): + class R: + status_code = 400 + text = 'bad zones' + def json(self): + return {'detail': 'bad zones'} + return R() + + monkeypatch.setattr(httpx, 'post', fake_post) + r = contour_env['client'].post( + f"/api/v1/operations/{contour_env['op'].id}/send-to-contour", + headers={'Authorization': f"Bearer {contour_env['token']}"}) + assert r.status_code == 502 + from backend.models import AuditEvent + types = {e.event_type for e in contour_env['db'].query(AuditEvent).all()} + assert 'contour_send_failed' in types + + def test_sector_polygon_geometry(self, contour_env): + from backend.routers.contour import _sector_polygon + poly = _sector_polygon(53.9, 27.56, 2.0, 'N') + ring = poly['coordinates'][0] + assert poly['type'] == 'Polygon' + assert ring[0] == ring[-1] + # Север: lat растёт + lats = [p[1] for p in ring[1:-1]] + assert max(lats) > 53.9 + # Дуга ~2 км + assert max(lats) < 53.9 + (2.0 / 111.0) * 1.05 \ No newline at end of file diff --git a/frontend/src/pages/analysis/AnalysisResult.css b/frontend/src/pages/analysis/AnalysisResult.css index 081648b..d4513ca 100644 --- a/frontend/src/pages/analysis/AnalysisResult.css +++ b/frontend/src/pages/analysis/AnalysisResult.css @@ -679,4 +679,37 @@ .case-info { flex-direction: column; align-items: flex-start; gap: 6px; } .separator { display: none; } .tactics-grid { grid-template-columns: 1fr; } -} \ No newline at end of file +} +/* E4: панель КОНТУР */ +.contour-panel { + margin-top: 12px; + padding: 12px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; +} +.contour-title { font-size: 13px; font-weight: 600; color: #e6edf3; margin-bottom: 8px; } +.contour-row { display: flex; gap: 8px; margin-bottom: 8px; } +.contour-input { + flex: 1; + padding: 8px 10px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #e6edf3; + font-size: 13px; +} +.contour-save, .contour-send { + padding: 8px 12px; + border: none; + border-radius: 6px; + color: #fff; + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} +.contour-save { background: #21262d; color: #e6edf3; } +.contour-send { background: #238636; } +.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; } +.contour-ok { font-size: 12px; color: #3fb950; } +.contour-err { font-size: 12px; color: #f85149; } diff --git a/frontend/src/pages/analysis/AnalysisResult.jsx b/frontend/src/pages/analysis/AnalysisResult.jsx index 110c42f..1d8e9b9 100644 --- a/frontend/src/pages/analysis/AnalysisResult.jsx +++ b/frontend/src/pages/analysis/AnalysisResult.jsx @@ -36,6 +36,9 @@ const AnalysisResult = () => { const [caseData, setCaseData] = useState(null); const [analysisData, setAnalysisData] = useState(null); const [waterData, setWaterData] = useState(null); + const [operation, setOperation] = useState(null); + const [contourId, setContourId] = useState(''); + const [contourStatus, setContourStatus] = useState(null); // {ok, message} useEffect(() => { const loadCaseAndAnalyze = async () => { @@ -49,6 +52,18 @@ const AnalysisResult = () => { const caseResult = await caseResponse.json(); setCaseData(caseResult); + // E4: операция этого кейса (для КОНТУР-отправки) + try { + const opsResp = await apiFetch(`/api/v1/operations?case_id=${caseId}`); + if (opsResp.ok) { + const opsData = await opsResp.json(); + if (opsData.items?.length) { + setOperation(opsData.items[0]); + setContourId(opsData.items[0].contour_operation_id || ''); + } + } + } catch { /* операции может не быть — панель скрыта */ } + // 2. Анализ — единый источник правды на бэкенде const analyzeResponse = await apiFetch(`/api/v1/analyze`, { method: 'POST', @@ -228,6 +243,49 @@ const AnalysisResult = () => { ) : (
ТНП не задана — карта недоступна
)} + {operation && ( +
+
🔗 КОНТУР (полевая координация)
+
+ setContourId(e.target.value)} + /> + +
+
+ +
+ {contourStatus && ( +
+ {contourStatus.message} +
+ )} +
+ )}
ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'} {caseData.tnp_address && ·}