From 6400ebde24391a4ef1f18811d46d17f8e4261c61 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Mon, 8 Jun 2026 12:41:39 +0500 Subject: [PATCH] =?UTF-8?q?feat(insights):=20manual-entry=20Insight=20enti?= =?UTF-8?q?ty=20CRUD=20+=20=C2=A719=20audit=20(#948=20part=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insight = аналитик пишет free-form intel про локацию/участок с пометкой «непублично» (is_confidential, §7.13/§8.9). CRUD под /api/v1/insights; created_by из X-Authenticated-User (не из тела — спуфинг автора невозможен). Зеркало custom_pois: raw-SQL service + Pydantic v2 schemas, sync def handlers (threadpool, off event loop). - data/sql/145_insight.sql: table insight (idempotent, geom GENERATED из lon/lat, индексы district/cad_num/is_confidential/created_by/GIST); аддитивно расширяет CHECK audit_log.action += insight_write (тот же constraint ck_audit_log_action, to_regclass-guarded, superset — не ломает #962-аудит; НЕ правит applied 144). - audit_middleware.classify_path: method-aware insight_write для POST/PUT/DELETE /api/v1/insights (GET-чтения не аудируются); обратно совместимо с parcels. - ACL: backend rbac_guard hard-блокирует только /api/v1/admin/*; pilot отсекается frontend RouteGuard + Caddy (как parcels/custom_pois). is_confidential — стораемая пометка без per-record backend-гейта (analyst видит, per #962-политике). - tests: 28 insight (CRUD+filters+required+#261 commit) + 5 audit. 86 зелёных, ruff. Part B (Location first-class, §8.2) — отдельный follow-up. Refs #948. --- backend/app/api/v1/insights.py | 144 ++++++++ backend/app/core/audit_middleware.py | 34 +- backend/app/main.py | 2 + backend/app/schemas/insight.py | 80 +++++ backend/app/services/insights.py | 250 +++++++++++++ backend/tests/api/v1/test_insights.py | 465 +++++++++++++++++++++++++ backend/tests/test_audit_middleware.py | 36 ++ data/sql/145_insight.sql | 113 ++++++ 8 files changed, 1115 insertions(+), 9 deletions(-) create mode 100644 backend/app/api/v1/insights.py create mode 100644 backend/app/schemas/insight.py create mode 100644 backend/app/services/insights.py create mode 100644 backend/tests/api/v1/test_insights.py create mode 100644 data/sql/145_insight.sql diff --git a/backend/app/api/v1/insights.py b/backend/app/api/v1/insights.py new file mode 100644 index 00000000..e84ab0fe --- /dev/null +++ b/backend/app/api/v1/insights.py @@ -0,0 +1,144 @@ +"""CRUD API для insight (#948 Part A, ТЗ §7.13/§8.9). + +POST /api/v1/insights → 201 InsightOut (create) +GET /api/v1/insights → InsightList (list + filters + pagination) +GET /api/v1/insights/{id} → InsightOut (one; 404 если нет) +PUT /api/v1/insights/{id} → InsightOut (partial update; 404 если нет) +DELETE /api/v1/insights/{id} → 204 (hard delete; 404 если нет) + +Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО +/api/v1/admin/* (admin-only). На /api/v1/insights pilot отсекается FRONTEND-овым +RouteGuard (allowed_paths из /me) + Caddy, НЕ бэкендом — is_path_allowed() из +backend-кода не вызывается. До эндпоинта доходят analyst+admin. is_confidential — +хранимая маркировка (per-record backend-gate НЕТ), политика #962: analyst видит +конфиденциальные данные. created_by автора берём из X-Authenticated-User (тот же +заголовок, что Caddy basic_auth пробрасывает в backend) — НЕ из тела запроса. + +Хэндлеры sync `def` (зеркало custom_pois.py): сервис ходит в БД через sync +SQLAlchemy Session, поэтому FastAPI исполняет их в threadpool — event loop не +блокируется. (Делать async def + sync db.execute было бы хуже — заблокировало +бы loop.) + +Audit (§19): WRITE-запросы (POST/PUT/DELETE) аудируются HTTP-middleware +app/core/audit_middleware.py как action='insight_write' — здесь спец-кода нет. +""" + +from __future__ import annotations + +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.schemas.insight import ( + InsightCategory, + InsightCreate, + InsightList, + InsightOut, + InsightUpdate, +) +from app.services.insights import ( + create_insight, + delete_insight, + get_insight, + list_insights, + update_insight, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _require_user(x_authenticated_user: str | None) -> str: + """Вернуть автора из X-Authenticated-User или 401. + + В проде Caddy basic_auth всегда пробрасывает заголовок для авторизованных, + а rbac_guard рубит запросы без него ещё до роутера. Эта проверка — defence + in depth (+ корректный ответ в test-mode, где rbac_guard выключен). + """ + if x_authenticated_user and x_authenticated_user.strip(): + return x_authenticated_user.strip() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="no authenticated user (X-Authenticated-User required)", + ) + + +@router.post("", response_model=InsightOut, status_code=status.HTTP_201_CREATED) +def create_insight_endpoint( + payload: InsightCreate, + db: Annotated[Session, Depends(get_db)], + x_authenticated_user: Annotated[str | None, Header()] = None, +) -> Any: + """Создать insight. created_by = X-Authenticated-User.""" + created_by = _require_user(x_authenticated_user) + return create_insight(db, created_by, payload) + + +@router.get("", response_model=InsightList) +def list_insights_endpoint( + db: Annotated[Session, Depends(get_db)], + district: Annotated[str | None, Query(description="Фильтр по району")] = None, + cad_num: Annotated[str | None, Query(description="Фильтр по кадастровому номеру")] = None, + category: Annotated[InsightCategory | None, Query(description="Фильтр по категории")] = None, + is_confidential: Annotated[ + bool | None, Query(description="Фильтр по пометке «непублично»") + ] = None, + created_by: Annotated[str | None, Query(description="Фильтр по автору")] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, +) -> Any: + """Список insight'ов с фильтрами + пагинацией.""" + return list_insights( + db, + district=district, + cad_num=cad_num, + category=category, + is_confidential=is_confidential, + created_by=created_by, + limit=limit, + offset=offset, + ) + + +@router.get("/{insight_id}", response_model=InsightOut) +def get_insight_endpoint( + insight_id: int, + db: Annotated[Session, Depends(get_db)], +) -> Any: + """Вернуть один insight. 404 если не найден.""" + insight = get_insight(db, insight_id) + if insight is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found") + return insight + + +@router.put("/{insight_id}", response_model=InsightOut) +def update_insight_endpoint( + insight_id: int, + payload: InsightUpdate, + db: Annotated[Session, Depends(get_db)], + x_authenticated_user: Annotated[str | None, Header()] = None, +) -> Any: + """Partial update insight. 404 если не найден.""" + _require_user(x_authenticated_user) + insight = update_insight(db, insight_id, payload) + if insight is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found") + return insight + + +@router.delete("/{insight_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_insight_endpoint( + insight_id: int, + db: Annotated[Session, Depends(get_db)], + x_authenticated_user: Annotated[str | None, Header()] = None, +) -> None: + """Удалить insight. 404 если не найден.""" + _require_user(x_authenticated_user) + deleted = delete_insight(db, insight_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found") diff --git a/backend/app/core/audit_middleware.py b/backend/app/core/audit_middleware.py index 9f244228..fa18f36e 100644 --- a/backend/app/core/audit_middleware.py +++ b/backend/app/core/audit_middleware.py @@ -5,12 +5,15 @@ Best-effort аудит ключевых/чувствительных дейст в обратном порядке регистрации, поэтому rbac_guard должен оставаться внешним — аудитим только запросы, которые RBAC уже пропустил). -Аудируем РОВНО три пути (sensitive actions из ТЗ §19): +Аудируем sensitive actions из ТЗ §19: - * ``POST /api/v1/parcels/{cad}/analyze`` → action='analyze' - * ``GET /api/v1/parcels/{cad}/forecast`` → action='forecast' - * ``GET /api/v1/parcels/{cad}/forecast/export`` → action='export' + * ``POST /api/v1/parcels/{cad}/analyze`` → action='analyze' + * ``GET /api/v1/parcels/{cad}/forecast`` → action='forecast' + * ``GET /api/v1/parcels/{cad}/forecast/export`` → action='export' + * ``POST|PUT|DELETE /api/v1/insights[/{id}]`` → action='insight_write' (#948) +Insight WRITES (POST/PUT/DELETE) — чувствительные ручные записи (§19); их +аудируем. GET-чтения insight'ов НЕ аудируются (read-only, не мутируют). ``/health``, статика, любые non-matched пути НЕ аудируются. Гарантии: @@ -50,23 +53,36 @@ _CAD = r"(?P[^/]+)" # Порядок ВАЖЕН: forecast/export проверяем раньше forecast, иначе export # заматчится как forecast. Каждый паттерн якорный ($) → точное совпадение пути. +# Эти пути аудируются НЕЗАВИСИМО от HTTP-метода (parcels-действия из §19). _AUDIT_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( ("export", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast/export$")), ("analyze", re.compile(rf"^/api/v1/parcels/{_CAD}/analyze$")), ("forecast", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast$")), ) +# Insight WRITES (#948, §7.13/§8.9): /api/v1/insights и /api/v1/insights/{id}. +# Аудируем ТОЛЬКО мутации (POST/PUT/DELETE) — GET-чтения insight'ов пропускаем. +_INSIGHT_PATH = re.compile(r"^/api/v1/insights(?:/[^/]+)?$") +_INSIGHT_WRITE_METHODS = frozenset({"POST", "PUT", "DELETE"}) -def classify_path(path: str) -> tuple[str, str | None] | None: - """Сопоставить *path* с аудируемым действием. - Returns ``(action, cad_num)`` если путь — один из sensitive endpoints, - иначе ``None`` (путь не аудируется). ``cad_num`` извлекается из пути. +def classify_path(path: str, method: str | None = None) -> tuple[str, str | None] | None: + """Сопоставить *path* (+ опционально *method*) с аудируемым действием. + + Returns ``(action, cad_num)`` если путь — sensitive endpoint, иначе ``None``. + ``cad_num`` извлекается из parcels-путей (для insight'ов — ``None``). + + *method* нужен только для insight-эндпоинтов (аудируем POST/PUT/DELETE, но + не GET). Parcels-паттерны от метода не зависят. Если *method* не передан + (``None``), insight-ветка пропускается — обратная совместимость со старыми + вызовами ``classify_path(path)``. """ for action, pattern in _AUDIT_PATTERNS: m = pattern.match(path) if m: return action, m.group("cad") + if method is not None and method in _INSIGHT_WRITE_METHODS and _INSIGHT_PATH.match(path): + return "insight_write", None return None @@ -140,7 +156,7 @@ async def audit_log_middleware( if settings.testing: return await call_next(request) - matched = classify_path(request.url.path) + matched = classify_path(request.url.path, request.method) # Выполняем сам запрос ВСЕГДА (даже non-matched) — сначала получаем response. response = await call_next(request) diff --git a/backend/app/main.py b/backend/app/main.py index 7ffdddf5..22e32d22 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,6 +27,7 @@ from app.api.v1 import ( analytics, concepts, custom_pois, + insights, landing, me, parcels, @@ -168,6 +169,7 @@ app.include_router( ) app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"]) app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"]) +app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"]) app.include_router( admin_cadastre.router, prefix="/api/v1/admin/cadastre", diff --git a/backend/app/schemas/insight.py b/backend/app/schemas/insight.py new file mode 100644 index 00000000..dca0ba80 --- /dev/null +++ b/backend/app/schemas/insight.py @@ -0,0 +1,80 @@ +"""Pydantic schemas для insight (#948 Part A, ТЗ §7.13/§8.9). + +InsightCreate — тело POST-запроса (title/body обязательны). +InsightUpdate — тело PUT-запроса (все поля опциональны — partial update). +InsightOut — ответ API (включает id, created_by, created_at, updated_at). + +created_by НЕ принимается из тела — проставляется роутером из X-Authenticated-User. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + +# Рекомендованные категории (мягкий enum, совпадает с CHECK ck_insight_category +# в data/sql/145_insight.sql). None допустим. +InsightCategory = Literal["competition", "permitting", "demand", "risk", "other"] + + +class InsightCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=300, description="Заголовок заметки") + body: str = Field(..., min_length=1, max_length=20000, description="Тело заметки (intel)") + category: InsightCategory | None = Field( + None, description="Категория: competition|permitting|demand|risk|other" + ) + is_confidential: bool = Field( + False, description="«Непублично» (§7.13): маркировка чувствительной заметки" + ) + district: str | None = Field( + None, + max_length=200, + description="Location ref (район); Part B промоутит в Location FK", + ) + cad_num: str | None = Field( + None, max_length=100, description="Опциональный кадастровый номер участка" + ) + lon: float | None = Field(None, ge=-180.0, le=180.0, description="Долгота WGS-84 (map-pin)") + lat: float | None = Field(None, ge=-90.0, le=90.0, description="Широта WGS-84 (map-pin)") + source: str | None = Field(None, max_length=500, description="Источник intel") + + +class InsightUpdate(BaseModel): + """Partial update (PUT) — переданные поля заменяются, отсутствующие не трогаются.""" + + title: str | None = Field(None, min_length=1, max_length=300) + body: str | None = Field(None, min_length=1, max_length=20000) + category: InsightCategory | None = None + is_confidential: bool | None = None + district: str | None = Field(None, max_length=200) + cad_num: str | None = Field(None, max_length=100) + lon: float | None = Field(None, ge=-180.0, le=180.0) + lat: float | None = Field(None, ge=-90.0, le=90.0) + source: str | None = Field(None, max_length=500) + + +class InsightOut(BaseModel): + id: int + created_by: str + title: str + body: str + category: str | None + is_confidential: bool + district: str | None + cad_num: str | None + lon: float | None + lat: float | None + source: str | None + created_at: datetime + updated_at: datetime + + +class InsightList(BaseModel): + """Ответ list-эндпоинта: total + страница строк (как admin_leads).""" + + total: int + limit: int + offset: int + rows: list[InsightOut] diff --git a/backend/app/services/insights.py b/backend/app/services/insights.py new file mode 100644 index 00000000..1cdf7f2c --- /dev/null +++ b/backend/app/services/insights.py @@ -0,0 +1,250 @@ +"""CRUD-сервис для insight (#948 Part A, ТЗ §7.13/§8.9). + +API: +- create_insight(db, created_by, payload) → InsightOut +- list_insights(db, filters..., limit, offset) → InsightList +- get_insight(db, insight_id) → InsightOut | None +- update_insight(db, insight_id, payload) → InsightOut | None +- delete_insight(db, insight_id) → bool + +Паттерн: raw SQL через SQLAlchemy text() + CAST(:x AS type), psycopg v3 +(зеркало app/services/site_finder/custom_pois.py #254). db.commit() после +каждой мутации (regression guard #261). + +Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО +/api/v1/admin/*. На /api/v1/insights pilot отсекается FRONTEND-овым RouteGuard +(allowed_paths из /me) + Caddy, НЕ бэкендом. До эндпоинта доходят analyst+admin. +is_confidential здесь — хранимый+фильтруемый флаг (per-record backend-gate НЕТ), +НЕ скрывает строки от analyst (политика #962: analyst видит конфиденциальные +insight'ы). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import text + +from app.schemas.insight import InsightCreate, InsightList, InsightOut, InsightUpdate + +logger = logging.getLogger(__name__) + +# ── SQL ──────────────────────────────────────────────────────────────────────── + +# geom не отдаём (бинарь); lon/lat достаточно для фронта/карты. +_SELECT_COLS = """ + id, created_by, title, body, category, is_confidential, + district, cad_num, lon, lat, source, created_at, updated_at +""" + +_INSERT_SQL = f""" + INSERT INTO insight + (created_by, title, body, category, is_confidential, + district, cad_num, lon, lat, source) + VALUES + (:created_by, :title, :body, :category, CAST(:is_confidential AS boolean), + :district, :cad_num, CAST(:lon AS double precision), + CAST(:lat AS double precision), :source) + RETURNING {_SELECT_COLS} +""" + +_SELECT_BY_ID = f""" + SELECT {_SELECT_COLS} + FROM insight + WHERE id = :insight_id +""" + +_DELETE_SQL = """ + DELETE FROM insight + WHERE id = :insight_id + RETURNING id +""" + + +# ── Row mapper ───────────────────────────────────────────────────────────────── + + +def _row_to_out(r: Any) -> InsightOut: + return InsightOut( + id=int(r["id"]), + created_by=r["created_by"], + title=r["title"], + body=r["body"], + category=r["category"], + is_confidential=bool(r["is_confidential"]), + district=r["district"], + cad_num=r["cad_num"], + lon=float(r["lon"]) if r["lon"] is not None else None, + lat=float(r["lat"]) if r["lat"] is not None else None, + source=r["source"], + created_at=r["created_at"], + updated_at=r["updated_at"], + ) + + +# ── CRUD ─────────────────────────────────────────────────────────────────────── + + +def create_insight(db: Any, created_by: str, payload: InsightCreate) -> InsightOut: + """Создать insight. created_by — автор из X-Authenticated-User.""" + row = ( + db.execute( + text(_INSERT_SQL), + { + "created_by": created_by, + "title": payload.title, + "body": payload.body, + "category": payload.category, + "is_confidential": payload.is_confidential, + "district": payload.district, + "cad_num": payload.cad_num, + "lon": payload.lon, + "lat": payload.lat, + "source": payload.source, + }, + ) + .mappings() + .first() + ) + db.commit() + assert row is not None, "INSERT RETURNING вернул пустой результат" + logger.info( + "insight created: id=%s by=%s confidential=%s district=%s cad=%s", + row["id"], + created_by, + payload.is_confidential, + payload.district, + payload.cad_num, + ) + return _row_to_out(row) + + +def list_insights( + db: Any, + *, + district: str | None = None, + cad_num: str | None = None, + category: str | None = None, + is_confidential: bool | None = None, + created_by: str | None = None, + limit: int = 50, + offset: int = 0, +) -> InsightList: + """Список insight'ов с фильтрами + пагинацией (зеркало admin_leads). + + analyst видит ВСЁ, включая is_confidential=True (политика #962); фильтр + is_confidential — это сужение выборки, НЕ access-gate. + """ + where: list[str] = [] + params: dict[str, Any] = {"lim": limit, "off": offset} + if district is not None: + where.append("district = :district") + params["district"] = district + if cad_num is not None: + where.append("cad_num = :cad_num") + params["cad_num"] = cad_num + if category is not None: + where.append("category = :category") + params["category"] = category + if is_confidential is not None: + where.append("is_confidential = CAST(:is_confidential AS boolean)") + params["is_confidential"] = is_confidential + if created_by is not None: + where.append("created_by = :created_by") + params["created_by"] = created_by + + where_sql = "WHERE " + " AND ".join(where) if where else "" + + rows = ( + db.execute( + text( + f"SELECT {_SELECT_COLS} FROM insight {where_sql} " + "ORDER BY created_at DESC LIMIT :lim OFFSET :off" + ), + params, + ) + .mappings() + .all() + ) + total = db.execute( + text(f"SELECT COUNT(*) FROM insight {where_sql}"), + {k: v for k, v in params.items() if k not in ("lim", "off")}, + ).scalar_one() + + return InsightList( + total=int(total or 0), + limit=limit, + offset=offset, + rows=[_row_to_out(r) for r in rows], + ) + + +def get_insight(db: Any, insight_id: int) -> InsightOut | None: + """Вернуть один insight по id.""" + row = db.execute(text(_SELECT_BY_ID), {"insight_id": insight_id}).mappings().first() + if row is None: + return None + return _row_to_out(row) + + +def update_insight(db: Any, insight_id: int, payload: InsightUpdate) -> InsightOut | None: + """Partial update insight. Возвращает None если не найден. + + Зеркало update_custom_poi: динамический SET по переданным полям. `is None` + как «не передано» — корректно для is_confidential, т.к. False is not None. + """ + existing = get_insight(db, insight_id) + if existing is None: + return None + + sets: list[str] = ["updated_at = NOW()"] + params: dict[str, Any] = {"insight_id": insight_id} + + if payload.title is not None: + sets.append("title = :title") + params["title"] = payload.title + if payload.body is not None: + sets.append("body = :body") + params["body"] = payload.body + if payload.category is not None: + sets.append("category = :category") + params["category"] = payload.category + if payload.is_confidential is not None: + sets.append("is_confidential = CAST(:is_confidential AS boolean)") + params["is_confidential"] = payload.is_confidential + if payload.district is not None: + sets.append("district = :district") + params["district"] = payload.district + if payload.cad_num is not None: + sets.append("cad_num = :cad_num") + params["cad_num"] = payload.cad_num + # lon/lat меняем независимо — geom GENERATED пересчитается (NULL если одна из них NULL). + if payload.lon is not None: + sets.append("lon = CAST(:lon AS double precision)") + params["lon"] = payload.lon + if payload.lat is not None: + sets.append("lat = CAST(:lat AS double precision)") + params["lat"] = payload.lat + if payload.source is not None: + sets.append("source = :source") + params["source"] = payload.source + + if len(sets) > 1: + db.execute( + text(f"UPDATE insight SET {', '.join(sets)} WHERE id = :insight_id"), + params, + ) + db.commit() + + return get_insight(db, insight_id) + + +def delete_insight(db: Any, insight_id: int) -> bool: + """Удалить insight (hard delete — зеркало custom_pois). True если удалён.""" + result = db.execute(text(_DELETE_SQL), {"insight_id": insight_id}).first() + if result is None: + return False + db.commit() + logger.info("insight deleted: id=%s", insight_id) + return True diff --git a/backend/tests/api/v1/test_insights.py b/backend/tests/api/v1/test_insights.py new file mode 100644 index 00000000..ad202afd --- /dev/null +++ b/backend/tests/api/v1/test_insights.py @@ -0,0 +1,465 @@ +"""Тесты для insight CRUD (#948 Part A, ТЗ §7.13/§8.9). + +Покрывает (зеркало test_custom_pois.py): +1. POST /api/v1/insights → 201, created_by из X-Authenticated-User +2. POST без title/body → 422 (Pydantic required-field validation) +3. POST без X-Authenticated-User → 401 +4. GET /api/v1/insights → InsightList (total/limit/offset/rows) +5. GET /api/v1/insights? → фильтры (district/cad_num/category/is_confidential) + пробрасываются в сервис +6. GET /api/v1/insights/{id} → 200; 404 если нет +7. PUT /api/v1/insights/{id} → 200 updated; 404 если нет +8. DELETE /api/v1/insights/{id} → 204; 404 если нет +9. is_confidential хранится и фильтруется +10. Service-level: db.commit() в create/update/delete (#261 regression guard) + +Стратегия mock: сервисные функции патчим через unittest.mock.patch, +DB — MagicMock (как test_custom_pois.py). settings.testing=True (conftest) +отключает rbac_guard, поэтому X-Authenticated-User в тесте проверяет роутерный +_require_user напрямую. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.main import app +from app.schemas.insight import InsightCreate, InsightList, InsightOut, InsightUpdate + +# ── Константы ───────────────────────────────────────────────────────────────── + +_CAD = "66:41:0204016:10" +_USER = "analysttest" +_TS = datetime(2026, 6, 7, 10, 0, 0, tzinfo=UTC) +_AUTH = {"X-Authenticated-User": _USER} + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _make_out( + insight_id: int = 1, + created_by: str = _USER, + title: str = "Конкурент строит ЖК рядом", + body: str = "Замечен котлован, забор ГК «Х».", + category: str | None = "competition", + is_confidential: bool = False, + district: str | None = "Октябрьский", + cad_num: str | None = None, + lon: float | None = None, + lat: float | None = None, +) -> InsightOut: + return InsightOut( + id=insight_id, + created_by=created_by, + title=title, + body=body, + category=category, + is_confidential=is_confidential, + district=district, + cad_num=cad_num, + lon=lon, + lat=lat, + source=None, + created_at=_TS, + updated_at=_TS, + ) + + +# ── Tests: CREATE ──────────────────────────────────────────────────────────────── + + +def test_create_insight_returns_201_and_sets_created_by() -> None: + """POST /insights → 201; created_by берётся из X-Authenticated-User, не из тела.""" + expected = _make_out() + with patch("app.api.v1.insights.create_insight", return_value=expected) as mock_create: + client = TestClient(app) + resp = client.post( + "/api/v1/insights", + json={ + "title": "Конкурент строит ЖК рядом", + "body": "Замечен котлован, забор ГК «Х».", + "category": "competition", + "district": "Октябрьский", + }, + headers=_AUTH, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["title"] == "Конкурент строит ЖК рядом" + assert body["created_by"] == _USER + assert body["is_confidential"] is False + mock_create.assert_called_once() + # created_by пробрасывается из заголовка во 2-й позиционный аргумент сервиса. + call_args = mock_create.call_args + assert call_args[0][1] == _USER + + +def test_create_confidential_insight_stores_flag() -> None: + """is_confidential=True в теле → пробрасывается в payload сервиса.""" + expected = _make_out(is_confidential=True) + with patch("app.api.v1.insights.create_insight", return_value=expected) as mock_create: + client = TestClient(app) + resp = client.post( + "/api/v1/insights", + json={"title": "Инсайдер", "body": "Закрытая инфа", "is_confidential": True}, + headers=_AUTH, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["is_confidential"] is True + payload: InsightCreate = mock_create.call_args[0][2] + assert payload.is_confidential is True + + +def test_create_insight_missing_title_returns_422() -> None: + """title обязателен → 422.""" + client = TestClient(app) + resp = client.post( + "/api/v1/insights", + json={"body": "только тело без заголовка"}, + headers=_AUTH, + ) + assert resp.status_code == 422, resp.text + + +def test_create_insight_missing_body_returns_422() -> None: + """body обязателен → 422.""" + client = TestClient(app) + resp = client.post( + "/api/v1/insights", + json={"title": "только заголовок"}, + headers=_AUTH, + ) + assert resp.status_code == 422, resp.text + + +def test_create_insight_empty_title_returns_422() -> None: + """Пустой title (min_length=1) → 422.""" + client = TestClient(app) + resp = client.post( + "/api/v1/insights", + json={"title": "", "body": "непустое тело"}, + headers=_AUTH, + ) + assert resp.status_code == 422, resp.text + + +def test_create_insight_bad_category_returns_422() -> None: + """category вне enum → 422 (Literal).""" + client = TestClient(app) + resp = client.post( + "/api/v1/insights", + json={"title": "t", "body": "b", "category": "totally-unknown"}, + headers=_AUTH, + ) + assert resp.status_code == 422, resp.text + + +def test_create_insight_without_auth_header_returns_401() -> None: + """Нет X-Authenticated-User → 401 (роутерный _require_user; rbac выключен в тестах).""" + client = TestClient(app) + resp = client.post( + "/api/v1/insights", + json={"title": "t", "body": "b"}, + ) + assert resp.status_code == 401, resp.text + + +# ── Tests: LIST ────────────────────────────────────────────────────────────────── + + +def test_list_insights_returns_envelope() -> None: + """GET /insights → InsightList {total, limit, offset, rows}.""" + listing = InsightList(total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)]) + with patch("app.api.v1.insights.list_insights", return_value=listing): + client = TestClient(app) + resp = client.get("/api/v1/insights", headers=_AUTH) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["total"] == 2 + assert body["limit"] == 50 + assert body["offset"] == 0 + assert len(body["rows"]) == 2 + assert body["rows"][0]["id"] == 1 + + +def test_list_insights_passes_filters_to_service() -> None: + """Фильтры district/cad_num/category/is_confidential → в сервис как kwargs.""" + listing = InsightList(total=0, limit=50, offset=0, rows=[]) + with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list: + client = TestClient(app) + resp = client.get( + "/api/v1/insights", + params={ + "district": "Октябрьский", + "cad_num": _CAD, + "category": "risk", + "is_confidential": "true", + }, + headers=_AUTH, + ) + assert resp.status_code == 200, resp.text + kwargs = mock_list.call_args.kwargs + assert kwargs["district"] == "Октябрьский" + assert kwargs["cad_num"] == _CAD + assert kwargs["category"] == "risk" + assert kwargs["is_confidential"] is True + + +def test_list_insights_filter_confidential_false() -> None: + """is_confidential=false тоже пробрасывается (не путается с «не задано»).""" + listing = InsightList(total=0, limit=50, offset=0, rows=[]) + with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list: + client = TestClient(app) + resp = client.get( + "/api/v1/insights", params={"is_confidential": "false"}, headers=_AUTH + ) + assert resp.status_code == 200, resp.text + assert mock_list.call_args.kwargs["is_confidential"] is False + + +# ── Tests: GET one ─────────────────────────────────────────────────────────────── + + +def test_get_insight_returns_200() -> None: + with patch("app.api.v1.insights.get_insight", return_value=_make_out(7)): + client = TestClient(app) + resp = client.get("/api/v1/insights/7", headers=_AUTH) + assert resp.status_code == 200, resp.text + assert resp.json()["id"] == 7 + + +def test_get_insight_not_found_returns_404() -> None: + with patch("app.api.v1.insights.get_insight", return_value=None): + client = TestClient(app) + resp = client.get("/api/v1/insights/999", headers=_AUTH) + assert resp.status_code == 404, resp.text + + +# ── Tests: UPDATE ──────────────────────────────────────────────────────────────── + + +def test_put_insight_returns_updated() -> None: + updated = _make_out(title="Обновлено", is_confidential=True) + with patch("app.api.v1.insights.update_insight", return_value=updated): + client = TestClient(app) + resp = client.put( + "/api/v1/insights/1", + json={"title": "Обновлено", "is_confidential": True}, + headers=_AUTH, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["title"] == "Обновлено" + assert body["is_confidential"] is True + + +def test_put_insight_not_found_returns_404() -> None: + with patch("app.api.v1.insights.update_insight", return_value=None): + client = TestClient(app) + resp = client.put( + "/api/v1/insights/999", json={"title": "x"}, headers=_AUTH + ) + assert resp.status_code == 404, resp.text + + +def test_put_insight_without_auth_returns_401() -> None: + client = TestClient(app) + resp = client.put("/api/v1/insights/1", json={"title": "x"}) + assert resp.status_code == 401, resp.text + + +# ── Tests: DELETE ──────────────────────────────────────────────────────────────── + + +def test_delete_insight_returns_204() -> None: + with patch("app.api.v1.insights.delete_insight", return_value=True): + client = TestClient(app) + resp = client.delete("/api/v1/insights/1", headers=_AUTH) + assert resp.status_code == 204, resp.text + + +def test_delete_insight_not_found_returns_404() -> None: + with patch("app.api.v1.insights.delete_insight", return_value=False): + client = TestClient(app) + resp = client.delete("/api/v1/insights/999", headers=_AUTH) + assert resp.status_code == 404, resp.text + + +def test_delete_insight_without_auth_returns_401() -> None: + client = TestClient(app) + resp = client.delete("/api/v1/insights/1") + assert resp.status_code == 401, resp.text + + +# ── Service-level: commit + mapping (#261 regression guard) ───────────────────── + + +def _make_row_data( + insight_id: int = 1, + created_by: str = _USER, + title: str = "T", + body: str = "B", + category: str | None = "competition", + is_confidential: bool = False, + district: str | None = "Октябрьский", + cad_num: str | None = None, + lon: float | None = None, + lat: float | None = None, +) -> dict[str, Any]: + return { + "id": insight_id, + "created_by": created_by, + "title": title, + "body": body, + "category": category, + "is_confidential": is_confidential, + "district": district, + "cad_num": cad_num, + "lon": lon, + "lat": lat, + "source": None, + "created_at": _TS, + "updated_at": _TS, + } + + +def _make_db_with_row(row_data: dict[str, Any] | None) -> MagicMock: + """MagicMock-сессия: execute().mappings().first() → row_data mapping.""" + db = MagicMock() + row_mock: MagicMock | None = None + if row_data is not None: + row_mock = MagicMock() + row_mock.__getitem__ = lambda self, k: row_data[k] + row_mock.get = lambda k, default=None: row_data.get(k, default) + + mapping_mock = MagicMock() + mapping_mock.first.return_value = row_mock + + exec_mock = MagicMock() + exec_mock.mappings.return_value = mapping_mock + exec_mock.first.return_value = row_mock # DELETE RETURNING id (не mappings) + + db.execute.return_value = exec_mock + return db + + +def test_service_create_insight_commits_and_sets_created_by() -> None: + """create_insight вызывает db.commit() и мапит created_by — regression #261.""" + from app.services.insights import create_insight + + db = _make_db_with_row(_make_row_data(is_confidential=True)) + payload = InsightCreate(title="T", body="B", is_confidential=True) + + result = create_insight(db, _USER, payload) + + db.commit.assert_called_once() + assert result.id == 1 + assert result.created_by == _USER + assert result.is_confidential is True + + +def test_service_update_insight_commits_when_fields_given() -> None: + """update_insight вызывает db.commit() при наличии изменяемых полей — regression #261.""" + from app.services.insights import update_insight + + db = _make_db_with_row(_make_row_data(title="new")) + payload = InsightUpdate(title="new") + + result = update_insight(db, insight_id=1, payload=payload) + + db.commit.assert_called_once() + assert result is not None + + +def test_service_update_insight_confidential_false_triggers_update() -> None: + """is_confidential=False — это изменение (False is not None) → commit.""" + from app.services.insights import update_insight + + db = _make_db_with_row(_make_row_data(is_confidential=False)) + payload = InsightUpdate(is_confidential=False) + + update_insight(db, insight_id=1, payload=payload) + + db.commit.assert_called_once() + + +def test_service_update_insight_no_commit_when_payload_empty() -> None: + """Пустой payload → только updated_at в sets → UPDATE не выполняется, commit не зовётся.""" + from app.services.insights import update_insight + + db = _make_db_with_row(_make_row_data()) + update_insight(db, insight_id=1, payload=InsightUpdate()) + + db.commit.assert_not_called() + + +def test_service_update_insight_no_commit_when_not_found() -> None: + """update_insight НЕ коммитит если insight не найден.""" + from app.services.insights import update_insight + + db = _make_db_with_row(None) + result = update_insight(db, insight_id=999, payload=InsightUpdate(title="x")) + + db.commit.assert_not_called() + assert result is None + + +def test_service_delete_insight_commits_when_found() -> None: + """delete_insight коммитит когда строка найдена — regression #261.""" + from app.services.insights import delete_insight + + db = _make_db_with_row(_make_row_data()) + db.execute.return_value.first.return_value = MagicMock() # RETURNING id truthy + + assert delete_insight(db, insight_id=1) is True + db.commit.assert_called_once() + + +def test_service_delete_insight_no_commit_when_not_found() -> None: + """delete_insight НЕ коммитит если строки нет.""" + from app.services.insights import delete_insight + + db = _make_db_with_row(None) + db.execute.return_value.first.return_value = None # RETURNING id пусто + + assert delete_insight(db, insight_id=999) is False + db.commit.assert_not_called() + + +def test_service_list_insights_builds_filtered_query() -> None: + """list_insights собирает WHERE по фильтрам + возвращает InsightList с total.""" + from app.services.insights import list_insights + + db = MagicMock() + row = _make_row_data(1, is_confidential=True) + row_mock = MagicMock() + row_mock.__getitem__ = lambda self, k: row[k] + + rows_result = MagicMock() + rows_result.mappings.return_value.all.return_value = [row_mock] + count_result = MagicMock() + count_result.scalar_one.return_value = 1 + db.execute.side_effect = [rows_result, count_result] + + out = list_insights(db, district="Октябрьский", is_confidential=True, limit=10, offset=0) + + assert isinstance(out, InsightList) + assert out.total == 1 + assert out.limit == 10 + assert len(out.rows) == 1 + assert out.rows[0].is_confidential is True + + # Первый execute — SELECT с WHERE по district + is_confidential, psycopg v3 CAST. + select_sql = str(db.execute.call_args_list[0].args[0]) + assert "WHERE" in select_sql + assert "district = :district" in select_sql + assert "is_confidential = CAST(:is_confidential AS boolean)" in select_sql + assert "::" not in select_sql + select_params = db.execute.call_args_list[0].args[1] + assert select_params["district"] == "Октябрьский" + assert select_params["is_confidential"] is True diff --git a/backend/tests/test_audit_middleware.py b/backend/tests/test_audit_middleware.py index 9b24e7e3..c02e684f 100644 --- a/backend/tests/test_audit_middleware.py +++ b/backend/tests/test_audit_middleware.py @@ -103,6 +103,42 @@ def test_classify_path_unmatched_returns_none() -> None: assert audit_mod.classify_path("/api/v1/parcels/X/forecast/export/extra") is None +# --------------------------------------------------------------------------- +# classify_path — insight writes (#948) +# --------------------------------------------------------------------------- + + +def test_classify_path_insight_write_methods() -> None: + """POST/PUT/DELETE на /insights[/{id}] → ('insight_write', None).""" + assert audit_mod.classify_path("/api/v1/insights", "POST") == ("insight_write", None) + assert audit_mod.classify_path("/api/v1/insights/42", "PUT") == ("insight_write", None) + assert audit_mod.classify_path("/api/v1/insights/42", "DELETE") == ("insight_write", None) + + +def test_classify_path_insight_get_not_audited() -> None: + """GET insight'ов (list/one) НЕ аудируется — это read-only.""" + assert audit_mod.classify_path("/api/v1/insights", "GET") is None + assert audit_mod.classify_path("/api/v1/insights/42", "GET") is None + + +def test_classify_path_insight_without_method_skipped() -> None: + """Без method insight-ветка не срабатывает (обратная совместимость).""" + assert audit_mod.classify_path("/api/v1/insights") is None + assert audit_mod.classify_path("/api/v1/insights/42") is None + + +def test_classify_path_insight_nested_path_not_matched() -> None: + """Вложенные пути под /insights/{id}/... не матчатся (якорь $).""" + assert audit_mod.classify_path("/api/v1/insights/42/comments", "POST") is None + + +def test_classify_path_parcels_method_ignored() -> None: + """Parcels-паттерны не зависят от method — analyze матчится при любом методе.""" + assert audit_mod.classify_path( + "/api/v1/parcels/66:41:0204016:10/analyze", "GET" + ) == ("analyze", "66:41:0204016:10") + + # --------------------------------------------------------------------------- # write_audit_row # --------------------------------------------------------------------------- diff --git a/data/sql/145_insight.sql b/data/sql/145_insight.sql new file mode 100644 index 00000000..4a10e1af --- /dev/null +++ b/data/sql/145_insight.sql @@ -0,0 +1,113 @@ +-- 145_insight.sql +-- #948 Part A (ТЗ §7.13/§8.9): manual-entry "insight" — аналитик пишет свободную +-- заметку/intel про локацию или участок, с пометкой «непублично» (is_confidential). +-- +-- Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО +-- /api/v1/admin/*. На /api/v1/insights pilot отсекается FRONTEND-овым RouteGuard +-- (allowed_paths из /me) + Caddy, НЕ бэкендом; до эндпоинта доходят analyst+admin. +-- is_confidential — хранимая маркировка (per-record backend-gate НЕТ; будущий gate +-- публичной выдачи), сейчас просто хранимый+фильтруемый флаг (политика #962: analyst +-- видит конфиденциальные данные). created_by фиксирует автора (из X-Authenticated-User). +-- +-- Location ref: пока plain TEXT district + опциональный cad_num (ссылка на участок). +-- Part B (#948, §8.2) промоутит district в Location FK — НЕ в этом таске. +-- +-- Audit (§19, интеграция с #962): WRITE-запросы (POST/PUT/DELETE) аудируются +-- middleware app/core/audit_middleware.py с action='insight_write'. Поэтому ниже +-- (блок 4) аддитивно расширяем CHECK audit_log.action, чтобы 'insight_write' +-- проходил констрейнт. Это idempotent ALTER (DROP IF EXISTS + ADD) — НЕ правит +-- 144_audit_log.sql (тот уже применён на проде/смёржен; правка применённой +-- миграции не переиграется через _schema_migrations). +-- +-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=145). +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded CHECK. + +BEGIN; + +-- ── 1. Таблица insight ──────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS insight ( + id BIGSERIAL PRIMARY KEY, + created_by TEXT NOT NULL, -- автор, из X-Authenticated-User + title TEXT NOT NULL, -- заголовок заметки + body TEXT NOT NULL, -- тело заметки (free-form intel) + category TEXT, -- competition|permitting|demand|risk|other + is_confidential BOOLEAN NOT NULL DEFAULT FALSE, -- «непублично» (§7.13) + district TEXT, -- location ref (Part B → Location FK) + cad_num TEXT, -- опциональная ссылка на участок + lon DOUBLE PRECISION CHECK (lon IS NULL OR lon BETWEEN -180 AND 180), + lat DOUBLE PRECISION CHECK (lat IS NULL OR lat BETWEEN -90 AND 90), + -- Опциональный map-pin: GENERATED из lon/lat (как user_custom_pois.geom #254). + -- NULL если координаты не заданы (обе должны быть заданы вместе). + geom GEOMETRY(POINT, 4326) GENERATED ALWAYS AS ( + CASE + WHEN lon IS NOT NULL AND lat IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(lon, lat), 4326) + END + ) STORED, + source TEXT, -- источник intel (опционально) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE insight IS + 'Manual-entry аналитические заметки/intel про локацию или участок (#948 Part A, ' + 'ТЗ §7.13/§8.9). is_confidential = маркировка «непублично». created_by = автор ' + 'из X-Authenticated-User. district — plain TEXT (Part B промоутит в Location FK).'; +COMMENT ON COLUMN insight.is_confidential IS + '«Непублично» (§7.13): маркировка чувствительной заметки. Сейчас хранимый+' + 'фильтруемый флаг; будущий gate публичной выдачи.'; +COMMENT ON COLUMN insight.category IS + 'Свободная категория (рекоменд.: competition|permitting|demand|risk|other).'; +COMMENT ON COLUMN insight.geom IS + 'Опциональный map-pin, GENERATED из lon/lat. NULL если координаты не заданы.'; + +-- ── 2. CHECK на category (guarded — idempotent при ре-apply) ────────────────── +-- Мягкий enum: разрешаем рекомендованные значения + NULL. Расширять список — +-- через новую миграцию (DROP IF EXISTS + ADD), не правкой этой. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ck_insight_category' + ) THEN + ALTER TABLE insight + ADD CONSTRAINT ck_insight_category + CHECK ( + category IS NULL + OR category IN ('competition', 'permitting', 'demand', 'risk', 'other') + ); + END IF; +END +$$; + +-- ── 3. Индексы: фильтры list-эндпоинта ──────────────────────────────────────── +CREATE INDEX IF NOT EXISTS idx_insight_district + ON insight (district); +CREATE INDEX IF NOT EXISTS idx_insight_cad_num + ON insight (cad_num); +CREATE INDEX IF NOT EXISTS idx_insight_is_confidential + ON insight (is_confidential); +CREATE INDEX IF NOT EXISTS idx_insight_created_by + ON insight (created_by); +-- Опциональный geo-lookup (как user_custom_pois_geom_gist #254). +CREATE INDEX IF NOT EXISTS idx_insight_geom_gist + ON insight USING GIST (geom); + +-- ── 4. Расширяем CHECK audit_log.action → +'insight_write' (§19, #962) ───────── +-- Middleware аудирует WRITE insight'ов как action='insight_write'. 144_audit_log +-- разрешал только analyze|forecast|export|other; добавляем 'insight_write'. +-- Idempotent: DROP IF EXISTS + ADD пере-создаёт констрейнт с расширенным набором +-- (повторный apply этой миграции даст тот же результат). Таблица audit_log создана +-- в 144 (она в HEAD/проде до этой миграции) — но guard'имся to_regclass на случай +-- среды, где 144 ещё не накатан, чтобы 145 не падал. +DO $$ +BEGIN + IF to_regclass('public.audit_log') IS NOT NULL THEN + ALTER TABLE audit_log DROP CONSTRAINT IF EXISTS ck_audit_log_action; + ALTER TABLE audit_log + ADD CONSTRAINT ck_audit_log_action + CHECK (action IN ('analyze', 'forecast', 'export', 'insight_write', 'other')); + END IF; +END +$$; + +COMMIT;