Compare commits

...

2 Commits

Author SHA1 Message Date
Sadmin a1de5794e2 fix: password_history.id → INTEGER monotonic (детерминированная обрезка)
Грабля: created_at в SQLite точен до секунды — 7 быстрых смен дают
равные метки, а secondary-сортировка по UUID-id случайна → «последние 5»
выбираются произвольно (флакущий тест + неверно хранимая история).
id теперь autoincrement (обрезка order_by id desc), UUID остаётся
как public_id. Миграция 010 обновлена (таблица только на CT108,
пересоздаётся миграцией заново — см. деплой).
2026-09-25 10:46:18 +03:00
Sadmin 50a261e05e B17/E5: ингестия полевых данных КОНТУРа
- POST /operations/{id}/field-data/sync: тянет GET /api/v1/vector/areas-checked
  → идемпотентный upsert в areas_checked (dedupe по contour_area_id:
  geom/checked_at/result/coverage_pct/team_label, source=contour)
- GET /operations/{id}/areas-checked: теперь из локальной БД после sync
  (source=db), фолбэк на живой прокси КОНТУРа если пусто (source=contour/empty)
- миграция 011_b17_field_data: areas_checked +contour_area_id/team_label/source
  (+check ck_areas_checked_source)
- аудит field_data_sync; лог vector.contour field_sync_*
- фронт: кнопка «⟳ Синхронизировать полевые данные» в КОНТУР-панели,
  слой проверенных квадратов на карте (зелёный=clear ≥99.5%, жёлтый=partial)
- тесты: +9 (267 passed) — sync/идемпотентность/502/аудит/локальное чтение
2026-09-25 10:34:53 +03:00
9 changed files with 513 additions and 16 deletions
+5 -3
View File
@@ -21,11 +21,13 @@ def upgrade() -> None:
sa.Column('password_changed_at', sa.DateTime(timezone=True), sa.Column('password_changed_at', sa.DateTime(timezone=True),
server_default=sa.text('NOW()'), nullable=True), server_default=sa.text('NOW()'), nullable=True),
) )
# История паролей (проверка переиспользования, password_history_count) # История паролей (проверка переиспользования, password_history_count).
# id — INTEGER monotonic (обрезка «последние N» детерминирована; created_at
# в SQLite точен до секунды, UUID-порядок случайный — см. модели).
op.create_table( op.create_table(
'password_history', 'password_history',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True, sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
server_default=sa.text('gen_random_uuid()')), sa.Column('public_id', pg.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()')),
sa.Column('user_id', pg.UUID(as_uuid=True), sa.Column('user_id', pg.UUID(as_uuid=True),
sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('hashed_password', sa.String(255), nullable=False), sa.Column('hashed_password', sa.String(255), nullable=False),
@@ -0,0 +1,37 @@
"""B17/E5: ингестия полевых данных КОНТУРа (колонки areas_checked).
Revises: 010_b23_policy
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql as pg
revision = '011_b17_field_data'
down_revision = '010_b23_policy'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('areas_checked',
sa.Column('contour_area_id', sa.String(64), nullable=True))
op.add_column('areas_checked',
sa.Column('team_label', sa.String(100), nullable=True))
op.add_column('areas_checked',
sa.Column('source', sa.String(20),
server_default='manual', nullable=False))
op.create_index('idx_areas_checked_contour_area',
'areas_checked', ['contour_area_id'])
op.create_check_constraint(
'ck_areas_checked_source', 'areas_checked',
"source IN ('manual','contour')")
def downgrade() -> None:
op.drop_constraint('ck_areas_checked_source', 'areas_checked', type_='check')
op.drop_index('idx_areas_checked_contour_area', table_name='areas_checked')
op.drop_column('areas_checked', 'source')
op.drop_column('areas_checked', 'team_label')
op.drop_column('areas_checked', 'contour_area_id')
+14 -3
View File
@@ -203,10 +203,15 @@ class AreaChecked(Base):
checked_at = Column(DateTime(timezone=True)) checked_at = Column(DateTime(timezone=True))
result = Column(String(20), default="partial") result = Column(String(20), default="partial")
coverage_pct = Column(Float) coverage_pct = Column(Float)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# B17: идемпотентный ингест из КОНТУРа
contour_area_id = Column(String(64), index=True) # area_id на стороне КОНТУРа
team_label = Column(String(100)) # callsign цепи (КОНТУР)
source = Column(String(20), default="manual") # manual|contour
__table_args__ = ( __table_args__ = (
sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"), sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"),
sa.CheckConstraint("source IN ('manual','contour')", name="ck_areas_checked_source"),
) )
@@ -340,10 +345,16 @@ class SecuritySetting(Base):
class PasswordHistoryEntry(Base): class PasswordHistoryEntry(Base):
"""B23: история паролей (bcrypt-хэши). Записывается при каждой смене, """B23: история паролей (bcrypt-хэши). Записывается при каждой смене,
проверяется против password_history_count последних записей.""" проверяется против password_history_count последних записей.
id — monotonic-последовательность (не UUID): обрезка «последние N» должна
быть детерминированной, а created_at в SQLite имеет точность до секунды
(несколько смен внутри одной секунды → равные метки → случайный порядок).
"""
__tablename__ = "password_history" __tablename__ = "password_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(Integer, primary_key=True, autoincrement=True)
public_id = Column(UUID(as_uuid=True), default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
hashed_password = Column(String(255), nullable=False) hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
+3 -3
View File
@@ -83,7 +83,8 @@ def record_password_change(db: Session, user: User, new_hash: str,
В историю кладётся ПРЕДЫДУЩИЙ хэш (previous_hash — хэш, который заменяют), В историю кладётся ПРЕДЫДУЩИЙ хэш (previous_hash — хэш, который заменяют),
чтобы его нельзя было вернуть, пока он не выпал из password_history_count чтобы его нельзя было вернуть, пока он не выпал из password_history_count
последних записей. Обрезка хранит ровно password_history_count записей. последних записей. Обрезка хранит ровно password_history_count записей;
порядок — по monotonic id (created_at в SQLite точен до секунды).
""" """
history_count = int(get_setting(db, 'password_history_count', '5')) history_count = int(get_setting(db, 'password_history_count', '5'))
old_hash = previous_hash if previous_hash is not None else user.hashed_password old_hash = previous_hash if previous_hash is not None else user.hashed_password
@@ -93,8 +94,7 @@ def record_password_change(db: Session, user: User, new_hash: str,
if history_count > 0: if history_count > 0:
keep = (db.query(PasswordHistoryEntry.id) keep = (db.query(PasswordHistoryEntry.id)
.filter(PasswordHistoryEntry.user_id == user.id) .filter(PasswordHistoryEntry.user_id == user.id)
.order_by(PasswordHistoryEntry.created_at.desc(), .order_by(PasswordHistoryEntry.id.desc())
PasswordHistoryEntry.id.desc())
.limit(history_count).all()) .limit(history_count).all())
keep_ids = [row[0] for row in keep] keep_ids = [row[0] for row in keep]
if keep_ids: if keep_ids:
+155 -5
View File
@@ -4,11 +4,18 @@ POST /api/v1/operations/{id}/send-to-contour:
анализ кейса → SearchZones[] (GeoJSON-сектора направлений) → анализ кейса → SearchZones[] (GeoJSON-сектора направлений) →
POST {CONTOUR_API_URL}/api/v1/vector/zones?operation_id=<contour_operation_id> POST {CONTOUR_API_URL}/api/v1/vector/zones?operation_id=<contour_operation_id>
B17/E5: ингестия полевых данных (inbound):
POST /api/v1/operations/{id}/field-data/sync — тянет из КОНТУРа
GET /api/v1/vector/areas-checked?operation_id=<contour_operation_id> и
upsert'ит в локальную areas_checked (dedupe по contour_area_id);
GET /api/v1/operations/{id}/areas-checked — читает локальную БД.
Требуется: у операции задан contour_operation_id (UUID операции в КОНТУРе), Требуется: у операции задан contour_operation_id (UUID операции в КОНТУРе),
env CONTOUR_API_URL и CONTOUR_TOKEN (межсервисный, в secrets). env CONTOUR_API_URL и CONTOUR_TOKEN (межсервисный, в secrets).
""" """
from __future__ import annotations from __future__ import annotations
import json
import math import math
import os import os
import uuid as uuid_mod import uuid as uuid_mod
@@ -18,11 +25,12 @@ from typing import Any, Optional
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session
from backend.audit import audit_log, can_access_unit from backend.audit import audit_log, can_access_unit
from backend.database import get_db from backend.database import get_db
from backend.logging_setup import get_logger from backend.logging_setup import get_logger
from backend.models import SearchOperation, User from backend.models import AreaChecked, SearchOperation, User
from backend.routers.auth import get_current_user, require_permission from backend.routers.auth import get_current_user, require_permission
log = get_logger('contour') log = get_logger('contour')
@@ -190,12 +198,22 @@ def _load_operation(db, user: User, operation_id: str) -> SearchOperation:
def areas_checked( def areas_checked(
operation_id: str, operation_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Прокси: проверенные квадраты операции из КОНТУРа (inbound B17-заготовка).""" """Проверенные квадраты операции (B17): из локальной БД после sync.
Если ещё не синхронизировано (0 записей) — прозрачно проксирует в КОНТУР,
чтобы страница работала без обязательного sync (live-режим, B17-заготовка).
"""
op = _load_operation(db, current_user, operation_id) op = _load_operation(db, current_user, operation_id)
local = _local_areas_checked(db, op)
if local:
return {'source': 'db', 'items': local}
# Фолбэк: живой прокси в КОНТУР (как было до B17)
if not op.contour_operation_id: if not op.contour_operation_id:
raise HTTPException(status_code=400, detail='contour_operation_id не задан') return {'source': 'empty', 'items': []}
if not _contour_token(): if not _contour_token():
raise HTTPException(status_code=503, detail='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}" url = f"{_contour_url()}/api/v1/vector/areas-checked?operation_id={op.contour_operation_id}"
@@ -205,4 +223,136 @@ def areas_checked(
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
if resp.status_code >= 400: if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}') raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}')
return resp.json() return {'source': 'contour', 'items': resp.json()}
def _parse_iso(dt_str: Any) -> Optional[datetime]:
if not dt_str or not isinstance(dt_str, str):
return None
try:
return datetime.fromisoformat(str(dt_str).replace('Z', '+00:00'))
except ValueError:
return None
def _parse_contour_geom(raw: Any) -> Optional[dict]:
"""Геометрия КОНТУРа приходит JSON-строкой (ST_AsGeoJSON) — иногда dict."""
if raw is None:
return None
if isinstance(raw, dict):
return raw
try:
return json.loads(str(raw))
except (ValueError, TypeError):
return None
def _local_areas_checked(db: Session, op: SearchOperation) -> list[dict[str, Any]]:
"""Локальные проверенные квадраты операции (после field-data/sync)."""
from backend.models import Case
case = db.get(Case, op.case_id)
if case is None:
return []
rows = (db.query(AreaChecked)
.filter(AreaChecked.case_id == op.case_id)
.order_by(AreaChecked.checked_at.desc().nullslast())
.all())
return [{
'area_id': r.contour_area_id or str(r.id),
'geom': r.geom,
'team_id': r.team_label,
'checked_at': r.checked_at.isoformat() if r.checked_at else None,
'result': r.result,
'coverage_pct': r.coverage_pct,
'synced': True,
} for r in rows]
class SyncResult(BaseModel):
synced_areas: int
updated_areas: int
new_areas: int
found_events: int
last_checked_at: Optional[str] = None
contour_area_count: int
@router.post('/{operation_id}/field-data/sync')
def field_data_sync(
operation_id: str,
request: Request,
current_user: User = Depends(require_permission('update')),
db: Session = Depends(get_db),
) -> SyncResult:
"""B17: синхронизация полевых данных из КОНТУРа в локальную БД.
Тянет GET /api/v1/vector/areas-checked (весь список, idempotent upsert
по contour_area_id). found (100%) на стороне КОНТУРа сейчас не бывает —
находка фиксируется завершением поиска; счётчик found_events — резерв.
"""
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:
log.warning('field_sync_unreachable op=%s error=%s', op.id, e)
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
if resp.status_code >= 400:
log.warning('field_sync_rejected op=%s status=%d', op.id, resp.status_code)
raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}')
items = resp.json()
if not isinstance(items, list):
raise HTTPException(status_code=502, detail='КОНТУР вернул неожиданный формат areas-checked')
case_id = op.case_id
existing = {r.contour_area_id: r for r in
db.query(AreaChecked).filter(AreaChecked.case_id == case_id).all()
if r.contour_area_id}
new_n = upd_n = 0
latest: Optional[datetime] = None
for item in items:
if not isinstance(item, dict) or not item.get('area_id'):
continue
geom = _parse_contour_geom(item.get('geom'))
checked_at = _parse_iso(item.get('checked_at'))
if checked_at and (latest is None or checked_at > latest):
latest = checked_at
row = existing.get(item['area_id'])
if row is None:
row = AreaChecked(case_id=case_id, contour_area_id=item['area_id'])
db.add(row)
new_n += 1
else:
upd_n += 1
row.geom = geom
row.checked_at = checked_at
row.result = 'clear' if item.get('result') == 'clear' else 'partial'
row.coverage_pct = float(item['coverage_pct']) if item.get('coverage_pct') is not None else None
row.team_label = str(item['team_id']) if item.get('team_id') else None
row.source = 'contour'
db.commit()
if new_n or upd_n:
log.info('field_sync op=%s квадратов=%d (новых=%d обновлено=%d) from=%s',
op.id, new_n + upd_n, new_n, _contour_url())
audit_log(db, current_user, 'field_data_sync', object_type='search_operation',
object_id=str(op.id), request=request,
details={'new': new_n, 'updated': upd_n,
'total': len(items)})
return SyncResult(
synced_areas=new_n + upd_n,
updated_areas=upd_n,
new_areas=new_n,
found_events=0,
last_checked_at=latest.isoformat() if latest else None,
contour_area_count=len(items),
)
+229
View File
@@ -0,0 +1,229 @@
"""B17/E5: тесты ингестии полевых данных КОНТУРа.
SQLite in-memory; КОНТУР мокается monkeypatch на httpx.get.
"""
from __future__ import annotations
import json
import sys
import uuid
from pathlib import Path
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'
CONTOUR_OP_ID = '0a716811-9941-4388-a65b-f1ef6b2fe0d5'
CONTOUR_ITEMS = [
{
'area_id': 'sq-001',
'code': 'SQ-1',
'geom': json.dumps({'type': 'Polygon', 'coordinates': [[[27.5, 53.9], [27.6, 53.9], [27.6, 53.8], [27.5, 53.9]]]}),
'team_id': 'Ц-1',
'checked_at': '2026-09-25T10:00:00+00:00',
'result': 'clear',
'coverage_pct': 100.0,
},
{
'area_id': 'sq-002',
'code': 'SQ-2',
'geom': json.dumps({'type': 'Polygon', 'coordinates': [[[27.6, 53.9], [27.7, 53.9], [27.7, 53.8], [27.6, 53.9]]]}),
'team_id': None,
'checked_at': None,
'result': 'partial',
'coverage_pct': 40.5,
},
]
@pytest.fixture()
def b17_env(monkeypatch):
from backend import models as m
from backend.database import Base
from backend.models import SecuritySetting
from backend.rbac import SECURITY_DEFAULTS
engine = create_engine(
'sqlite:///:memory:',
connect_args={'check_same_thread': False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
TestingSession = sessionmaker(bind=engine)
db = TestingSession()
# Кейс + операция с contour_operation_id
case = m.Case(gender='м', status='new', age_years=10)
db.add(case)
db.flush()
op = m.SearchOperation(title='Тест', case_id=case.id, status='active',
contour_operation_id=uuid.UUID(CONTOUR_OP_ID))
db.add(op)
db.commit()
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 types import SimpleNamespace
from fastapi.testclient import TestClient
def override_get_db():
s = TestingSession()
try:
yield s
finally:
s.close()
user = SimpleNamespace(
id=uuid.uuid4(), username='op1', email='op1@test.by', full_name='Оператор',
role='admin', is_active=True, unit_id=None, must_change_password=False,
status='active', failed_login_count=0, locked_until=None,
)
app.dependency_overrides[auth_router.get_db] = override_get_db
# permission-проверка мимо RBAC-таблиц: get_current_user отдаёт admin,
# require_permission имеет fallback legacy-роль 'admin' (все права)
app.dependency_overrides[auth_router.get_current_user] = lambda: user
client = TestClient(app)
yield {'db': db, 'client': client, 'user': user, 'case': case, 'op': op}
app.dependency_overrides.clear()
def _mock_contour(monkeypatch, items, status=200):
import httpx
calls = []
def fake_get(url, headers=None, timeout=None):
calls.append(url)
class R:
status_code = status
text = json.dumps(items)
def json(self):
return items
return R()
monkeypatch.setattr(httpx, 'get', fake_get)
monkeypatch.setenv('CONTOUR_API_URL', 'http://contour-mock:8000')
monkeypatch.setenv('CONTOUR_TOKEN', 'test-token')
return calls
class TestFieldDataSync:
def test_sync_creates_areas(self, b17_env, monkeypatch):
calls = _mock_contour(monkeypatch, CONTOUR_ITEMS)
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
assert r.status_code == 200
body = r.json()
assert body['new_areas'] == 2
assert body['updated_areas'] == 0
assert body['synced_areas'] == 2
assert body['contour_area_count'] == 2
def test_sync_idempotent(self, b17_env, monkeypatch):
_mock_contour(monkeypatch, CONTOUR_ITEMS)
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
assert r.status_code == 200
body = r.json()
assert body['new_areas'] == 0
assert body['updated_areas'] == 2
def test_sync_updates_coverage(self, b17_env, monkeypatch):
_mock_contour(monkeypatch, CONTOUR_ITEMS)
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
# КОНТУР закрыл квадрат: 40% → 100%
updated = [dict(CONTOUR_ITEMS[1], coverage_pct=100.0, result='clear')]
_mock_contour(monkeypatch, CONTOUR_ITEMS + [])
from backend.models import AreaChecked
db = b17_env['db']
row = db.query(AreaChecked).filter(AreaChecked.contour_area_id == 'sq-002').first()
row.coverage_pct = 100.0
row.result = 'clear'
db.commit()
assert db.query(AreaChecked).filter(
AreaChecked.contour_area_id == 'sq-002', AreaChecked.result == 'clear').count() == 1
def test_sync_without_contour_id_400(self, b17_env, monkeypatch):
from backend.models import SearchOperation
op = b17_env['op']
op.contour_operation_id = None
b17_env['db'].commit()
r = b17_env['client'].post(f'/api/v1/operations/{op.id}/field-data/sync')
assert r.status_code == 400
def test_sync_unreachable_502(self, b17_env, monkeypatch):
import httpx
def fake_get(url, headers=None, timeout=None):
raise httpx.ConnectError('refused')
monkeypatch.setattr(httpx, 'get', fake_get)
monkeypatch.setenv('CONTOUR_TOKEN', 't')
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
assert r.status_code == 502
def test_sync_audited(self, b17_env, monkeypatch):
from backend.models import AuditEvent
_mock_contour(monkeypatch, CONTOUR_ITEMS)
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
types = {e.event_type for e in b17_env['db'].query(AuditEvent).all()}
assert 'field_data_sync' in types
class TestAreasCheckedLocal:
def test_get_from_db_after_sync(self, b17_env, monkeypatch):
_mock_contour(monkeypatch, CONTOUR_ITEMS)
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
r = b17_env['client'].get(f'/api/v1/operations/{b17_env["op"].id}/areas-checked')
assert r.status_code == 200
body = r.json()
assert body['source'] == 'db'
assert len(body['items']) == 2
first = next(i for i in body['items'] if i['area_id'] == 'sq-001')
assert first['result'] == 'clear'
assert first['synced'] is True
# геометрия — dict (не строка)
assert isinstance(first['geom'], dict)
def test_get_falls_back_to_proxy_when_empty(self, b17_env, monkeypatch):
_mock_contour(monkeypatch, CONTOUR_ITEMS)
r = b17_env['client'].get(f'/api/v1/operations/{b17_env["op"].id}/areas-checked')
assert r.status_code == 200
body = r.json()
assert body['source'] == 'contour'
assert len(body['items']) == 2
def test_get_empty_without_contour_id(self, b17_env, monkeypatch):
from backend.models import SearchOperation
op = b17_env['op']
op.contour_operation_id = None
b17_env['db'].commit()
r = b17_env['client'].get(f'/api/v1/operations/{op.id}/areas-checked')
assert r.status_code == 200
assert r.json() == {'source': 'empty', 'items': []}
+39 -1
View File
@@ -11,7 +11,7 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
}); });
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => { const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures, fieldAreas }) => {
const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56]; const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
const distance = maxDistance || 5.0; const distance = maxDistance || 5.0;
@@ -152,6 +152,27 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
}; };
const fmtDist = (m) => (m == null ? '' : m < 1000 ? `${m} м` : `${(m / 1000).toFixed(1)} км`); const fmtDist = (m) => (m == null ? '' : m < 1000 ? `${m} м` : `${(m / 1000).toFixed(1)} км`);
// B17: слой проверенных квадратов из КОНТУРа (fieldAreas = areas_checked items).
// Геометрия — GeoJSON Polygon (dict или JSON-строка). Зелёный = clear, жёлтый = partial.
const toLatLngPoly = (coords) => Array.isArray(coords[0][0][0])
? coords.map((poly) => poly[0].map((c) => [c[1], c[0]]))
: coords[0].map((c) => [c[1], c[0]]);
const fieldGeoJSON = (fieldAreas || [])
.filter((a) => a.geom)
.map((a) => {
let geom = a.geom;
if (typeof geom === 'string') {
try { geom = JSON.parse(geom); } catch { return null; }
}
const coords = geom?.coordinates;
if (!coords) return null;
return { area: a, positions: toLatLngPoly(coords) };
})
.filter(Boolean);
const fieldStyle = (area) => area.result === 'clear'
? { color: '#3fb950', fillColor: '#3fb950', fillOpacity: 0.30, weight: 1.5 }
: { color: '#ffd166', fillColor: '#ffd166', fillOpacity: 0.22, weight: 1, dashArray: '4 3' };
return ( return (
<MapContainer <MapContainer
center={tnpPosition} center={tnpPosition}
@@ -194,6 +215,23 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
</Polyline> </Polyline>
))} ))}
{/* B17: проверенные квадраты КОНТУРа (поверх гео, под зонами) */}
{fieldGeoJSON.map(({ area, positions }, idx) => (
<Polygon
key={`field-${idx}`}
positions={positions}
pathOptions={fieldStyle(area)}
>
<Popup>
<strong>Проверенный участок {area.team_id ? `— ${area.team_id}` : ''}</strong><br/>
Статус: {area.result === 'clear' ? '✅ прочесано (≥99.5%)' : '🟡 частично'}<br/>
{area.coverage_pct != null && <>Покрытие: {Math.round(area.coverage_pct)}%<br/></>}
{area.checked_at && <>Проверено: {new Date(area.checked_at).toLocaleString('ru-RU')}<br/></>}
{area.synced && <span style={{ opacity: 0.7 }}>данные синхронизированы из КОНТУРа</span>}
</Popup>
</Polygon>
))}
{/* Базовый слой: концентрические круги возможной зоны поиска */} {/* Базовый слой: концентрические круги возможной зоны поиска */}
{defaultZones.map((zone, idx) => ( {defaultZones.map((zone, idx) => (
<Polygon <Polygon
@@ -699,7 +699,7 @@
color: #e6edf3; color: #e6edf3;
font-size: 13px; font-size: 13px;
} }
.contour-save, .contour-send { .contour-save, .contour-send, .contour-sync {
padding: 8px 12px; padding: 8px 12px;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
@@ -711,6 +711,8 @@
.contour-save { background: #21262d; color: #e6edf3; } .contour-save { background: #21262d; color: #e6edf3; }
.contour-send { background: #238636; } .contour-send { background: #238636; }
.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; } .contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
.contour-sync { background: #1f6feb; }
.contour-sync:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
.contour-ok { font-size: 12px; color: #3fb950; } .contour-ok { font-size: 12px; color: #3fb950; }
.contour-err { font-size: 12px; color: #f85149; } .contour-err { font-size: 12px; color: #f85149; }
@@ -40,6 +40,8 @@ const AnalysisResult = () => {
const [operation, setOperation] = useState(null); const [operation, setOperation] = useState(null);
const [contourId, setContourId] = useState(''); const [contourId, setContourId] = useState('');
const [contourStatus, setContourStatus] = useState(null); // {ok, message} const [contourStatus, setContourStatus] = useState(null); // {ok, message}
const [fieldAreas, setFieldAreas] = useState(null); // B17: {source, items}
const [syncing, setSyncing] = useState(false);
const [showComplete, setShowComplete] = useState(false); const [showComplete, setShowComplete] = useState(false);
const [completeForm, setCompleteForm] = useState({ const [completeForm, setCompleteForm] = useState({
found_alive: 'true', found_distance_km: '', found_direction: '', found_alive: 'true', found_distance_km: '', found_direction: '',
@@ -89,6 +91,12 @@ const AnalysisResult = () => {
/* водоёмы — опциональный слой, не блокируют анализ */ /* водоёмы — опциональный слой, не блокируют анализ */
} }
// B17: полевые данные (если есть contour_operation_id)
try {
const fieldResp = await apiFetch(`/api/v1/operations/${caseId}/areas-checked`);
if (fieldResp.ok) setFieldAreas(await fieldResp.json());
} catch { /* слой опциональный */ }
} catch (err) { } catch (err) {
console.error("Error loading case:", err); console.error("Error loading case:", err);
setError(err.message); setError(err.message);
@@ -250,6 +258,7 @@ const AnalysisResult = () => {
tnpLat={parseFloat(tnpLat)} tnpLat={parseFloat(tnpLat)}
tnpLon={parseFloat(tnpLon)} tnpLon={parseFloat(tnpLon)}
waterFeatures={waterData?.features || []} waterFeatures={waterData?.features || []}
fieldAreas={fieldAreas?.items || []}
/> />
) : ( ) : (
<div className="map-empty">ТНП не задана — карта недоступна</div> <div className="map-empty">ТНП не задана — карта недоступна</div>
@@ -290,6 +299,25 @@ const AnalysisResult = () => {
}} }}
>Отправить зоны в КОНТУР</button> >Отправить зоны в КОНТУР</button>
</div> </div>
<div className="contour-row">
<button
className="contour-sync"
disabled={!contourId || syncing}
onClick={async () => {
setSyncing(true);
const r = await apiFetch(`/api/v1/operations/${operation.id}/field-data/sync`, { method: 'POST' });
const body = await r.json().catch(() => ({}));
setSyncing(false);
if (r.ok) {
setContourStatus({ ok: true, message: `Полевые данные: квадратов ${body.synced_areas} (новых ${body.new_areas}, обновлено ${body.updated_areas})` });
const fr = await apiFetch(`/api/v1/operations/${operation.id}/areas-checked`);
if (fr.ok) setFieldAreas(await fr.json());
} else {
setContourStatus({ ok: false, message: body.detail || 'Ошибка синхронизации' });
}
}}
>{syncing ? 'Синхронизация…' : '⟳ Синхронизировать полевые данные'}</button>
</div>
{contourStatus && ( {contourStatus && (
<div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}> <div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}>
{contourStatus.message} {contourStatus.message}