From 485f489bd27df51ca1d780cc1f825f576d9e9df3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 09:34:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(#254):=20user=5Fcustom=5Fpois=20=E2=80=94?= =?UTF-8?q?=20backend=20schema=20+=20CRUD=20+=20scoring=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL migration 101_user_custom_pois.sql: table with GEOGRAPHY(POINT) + GIST index - Pydantic schemas: CustomPoiCreate / CustomPoiUpdate / CustomPoiOut - Service custom_pois.py: CRUD + get_overlaps_for_scoring (1km radius, psycopg v3 CAST) - API /api/v1/custom-pois: POST(201)/GET/PATCH/DELETE, X-Session-Id header auth - Scoring in analyze_parcel: custom POI block after OSM POI loop, decay weight * max(0, 1 - dist/1000), custom_poi_score_items in response - Tests: CRUD + validation (weight/lon/lat 422) + scoring integration (mock-based) - Vault: Schema_User_Custom_Pois.md + Endpoint_Parcel_Analyze v3.9 section --- backend/app/api/v1/custom_pois.py | 119 +++++ backend/app/api/v1/parcels.py | 66 ++- backend/app/main.py | 2 + backend/app/schemas/custom_poi.py | 60 +++ .../app/services/site_finder/custom_pois.py | 276 +++++++++++ backend/tests/api/v1/test_custom_pois.py | 455 ++++++++++++++++++ data/sql/101_user_custom_pois.sql | 34 ++ 7 files changed, 1011 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/v1/custom_pois.py create mode 100644 backend/app/schemas/custom_poi.py create mode 100644 backend/app/services/site_finder/custom_pois.py create mode 100644 backend/tests/api/v1/test_custom_pois.py create mode 100644 data/sql/101_user_custom_pois.sql diff --git a/backend/app/api/v1/custom_pois.py b/backend/app/api/v1/custom_pois.py new file mode 100644 index 00000000..6f7f8b41 --- /dev/null +++ b/backend/app/api/v1/custom_pois.py @@ -0,0 +1,119 @@ +"""CRUD API для user_custom_pois (#254). + +POST /api/v1/custom-pois → 201 CustomPoiOut +GET /api/v1/custom-pois → list[CustomPoiOut] +PATCH /api/v1/custom-pois/{id} → CustomPoiOut +DELETE /api/v1/custom-pois/{id} → 204 + +Auth: anonymous user_id из header X-Session-Id (workaround до #67 NextAuth). +Если заголовок отсутствует — генерируем UUID и возвращаем в X-Session-Id response header. +Frontend сохраняет в localStorage. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.schemas.custom_poi import CustomPoiCreate, CustomPoiOut, CustomPoiUpdate +from app.services.site_finder.custom_pois import ( + create_custom_poi, + delete_custom_poi, + list_custom_pois, + update_custom_poi, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _resolve_session_id( + x_session_id: str | None, + response: Response, +) -> str: + """Вернуть user_id из X-Session-Id header или сгенерировать UUID. + + При генерации устанавливаем X-Session-Id в response header чтобы + frontend мог сохранить значение в localStorage. + """ + if x_session_id and x_session_id.strip(): + return x_session_id.strip() + generated = str(uuid.uuid4()) + response.headers["X-Session-Id"] = generated + logger.debug("generated session_id=%s (no X-Session-Id header)", generated) + return generated + + +@router.post("", response_model=CustomPoiOut, status_code=status.HTTP_201_CREATED) +def create_poi( + payload: CustomPoiCreate, + db: Annotated[Session, Depends(get_db)], + response: Response, + x_session_id: Annotated[str | None, Header()] = None, +) -> Any: + """Создать кастомную POI точку. + + Возвращает 201 с созданным объектом. X-Session-Id response header содержит + актуальный session_id (пригодится frontend для localStorage). + """ + user_id = _resolve_session_id(x_session_id, response) + poi = create_custom_poi(db, user_id, payload) + # Echo back session_id чтобы frontend всегда имел его в response + response.headers["X-Session-Id"] = user_id + return poi + + +@router.get("", response_model=list[CustomPoiOut]) +def list_pois( + db: Annotated[Session, Depends(get_db)], + response: Response, + x_session_id: Annotated[str | None, Header()] = None, + parcel_cad: Annotated[ + str | None, + Query(description="Фильтр по кадастровому номеру участка (возвращает global + parcel POI)"), + ] = None, +) -> Any: + """Список кастомных POI пользователя. + + Если parcel_cad задан — возвращает global (parcel_cad IS NULL) + parcel-specific POI. + """ + user_id = _resolve_session_id(x_session_id, response) + response.headers["X-Session-Id"] = user_id + return list_custom_pois(db, user_id, parcel_cad) + + +@router.patch("/{poi_id}", response_model=CustomPoiOut) +def patch_poi( + poi_id: int, + payload: CustomPoiUpdate, + db: Annotated[Session, Depends(get_db)], + response: Response, + x_session_id: Annotated[str | None, Header()] = None, +) -> Any: + """Обновить поля кастомной POI (PATCH-style). 404 если не найдена.""" + user_id = _resolve_session_id(x_session_id, response) + response.headers["X-Session-Id"] = user_id + poi = update_custom_poi(db, poi_id, user_id, payload) + if poi is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Custom POI not found") + return poi + + +@router.delete("/{poi_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_poi( + poi_id: int, + db: Annotated[Session, Depends(get_db)], + response: Response, + x_session_id: Annotated[str | None, Header()] = None, +) -> None: + """Удалить кастомную POI. 404 если не найдена.""" + user_id = _resolve_session_id(x_session_id, response) + deleted = delete_custom_poi(db, poi_id, user_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Custom POI not found") diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8f0064a4..8861b9ec 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -6,7 +6,7 @@ import time from typing import Annotated, Any import httpx -from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response from shapely import wkt as _shp_wkt from shapely.geometry import Polygon from sqlalchemy import text @@ -39,6 +39,9 @@ from app.services.site_finder.cadastre_fetch import ( fetch_status as _fetch_status, ) from app.services.site_finder.competitors import get_competitors +from app.services.site_finder.custom_pois import ( + get_overlaps_for_scoring as _get_custom_poi_overlaps, +) from app.services.site_finder.gate_verdict import compute_gate_verdict from app.services.site_finder.quarter_dump_lookup import ( get_connection_points, @@ -1057,6 +1060,10 @@ def analyze_parcel( AnalyzeRequest | None, Body(description="Опциональное тело запроса: inline POI-веса (#201)"), ] = None, + x_session_id: Annotated[ + str | None, + Header(description="Session ID пользователя для custom POI scoring (#254)"), + ] = None, ) -> dict[str, Any]: """Анализ участка: близость к социалке + district context + конкуренты. @@ -1966,6 +1973,61 @@ def analyze_parcel( "lon": centroid_lon, } + # 4b) #254: Custom POI scoring — user-defined points с произвольным весом. + # Добавляем после основного POI loop, не трогаем OSM логику. + # user_id берём из X-Session-Id header (workaround до #67 NextAuth). + custom_poi_items: list[dict[str, Any]] = [] + _session_id = x_session_id.strip() if x_session_id and x_session_id.strip() else None + if _session_id: + try: + _custom_overlaps = _get_custom_poi_overlaps( + db, geom_wkt, _session_id, parcel_cad=cad_num + ) + for cp in _custom_overlaps: + _distance_m = cp["distance_m"] + _decay = max(0.0, 1.0 - _distance_m / 1000.0) + _contribution = cp["weight"] * _decay + score += _contribution + _item: dict[str, Any] = { + "source": "custom", + "id": cp["id"], + "label": cp["name"], + "category": cp["category"], + "weight": cp["weight"], + "distance_m": round(_distance_m), + "contribution": round(_contribution, 3), + "lat": cp["lat"], + "lon": cp["lon"], + } + custom_poi_items.append(_item) + if abs(_contribution) >= 0.01: + factors_detailed.append( + { + "factor": f"custom_{cp['id']}_{round(_distance_m)}m", + "category": f"custom_{cp['category'] or 'poi'}", + "category_ru": f"Custom: {cp['name']}", + "group": "Custom POI", + "value": round(_distance_m, 1), + "weight": cp["weight"], + "contribution": round(_contribution, 2), + "verbal": ( + f"Custom POI '{cp['name']}' ({round(_distance_m)}м) — " + f"{'+'if _contribution >= 0 else ''}{round(_contribution, 2)} б." + ), + "lat": cp["lat"], + "lon": cp["lon"], + } + ) + if _custom_overlaps: + logger.debug( + "custom POI scoring: user=%s cad=%s poi_count=%d", + _session_id, + cad_num, + len(_custom_overlaps), + ) + except Exception as _cpe: + logger.warning("custom POI scoring failed cad=%s: %s", cad_num, _cpe) + score_final = score + center_bonus # X1 (#47): расчёт contribution_pct + top-3 / by-group для UI. @@ -2126,6 +2188,8 @@ def analyze_parcel( "weights_applied": _effective_weights, "inline_weights": _inline_weights, }, + # #254: custom POI scoring — user-defined points (via X-Session-Id header). + "custom_poi_score_items": custom_poi_items, } diff --git a/backend/app/main.py b/backend/app/main.py index a70826ae..ea91b877 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ from app.api.v1 import ( admin_weight_profiles, analytics, concepts, + custom_pois, parcels, photos, ) @@ -85,6 +86,7 @@ app.include_router( tags=["admin", "site-finder"], ) 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( admin_cadastre.router, prefix="/api/v1/admin/cadastre", diff --git a/backend/app/schemas/custom_poi.py b/backend/app/schemas/custom_poi.py new file mode 100644 index 00000000..7b7263d2 --- /dev/null +++ b/backend/app/schemas/custom_poi.py @@ -0,0 +1,60 @@ +"""Pydantic schemas для user_custom_pois (#254). + +CustomPoiCreate — тело POST-запроса. +CustomPoiUpdate — тело PATCH-запроса (все поля опциональны). +CustomPoiOut — ответ API (включает id, created_at, updated_at). +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +# Диапазон весов кастомных POI шире системных ([-5, 5]), +# чтобы пользователь мог сильно усилить или исключить POI. +_CUSTOM_WEIGHT_MIN: float = -5.0 +_CUSTOM_WEIGHT_MAX: float = 5.0 + + +class CustomPoiCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200, description="Отображаемое название точки") + category: str | None = Field(None, max_length=100, description="Произвольная категория") + weight: float = Field( + ..., + ge=_CUSTOM_WEIGHT_MIN, + le=_CUSTOM_WEIGHT_MAX, + description="Вес точки в scoring [-5, 5]", + ) + lon: float = Field(..., ge=-180.0, le=180.0, description="Долгота WGS-84") + lat: float = Field(..., ge=-90.0, le=90.0, description="Широта WGS-84") + parcel_cad: str | None = Field( + None, + max_length=100, + description="Кадастровый номер участка; NULL = глобальная для пользователя", + ) + notes: str | None = Field(None, max_length=2000, description="Примечания") + + +class CustomPoiUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=200) + category: str | None = None + weight: float | None = Field(None, ge=_CUSTOM_WEIGHT_MIN, le=_CUSTOM_WEIGHT_MAX) + lon: float | None = Field(None, ge=-180.0, le=180.0) + lat: float | None = Field(None, ge=-90.0, le=90.0) + parcel_cad: str | None = Field(None, max_length=100) + notes: str | None = None + + +class CustomPoiOut(BaseModel): + id: int + user_id: str + parcel_cad: str | None + name: str + category: str | None + weight: float + lon: float + lat: float + notes: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/site_finder/custom_pois.py b/backend/app/services/site_finder/custom_pois.py new file mode 100644 index 00000000..e3dba224 --- /dev/null +++ b/backend/app/services/site_finder/custom_pois.py @@ -0,0 +1,276 @@ +"""CRUD-сервис для user_custom_pois (#254). + +API: +- create_custom_poi(db, user_id, payload) → CustomPoiOut +- list_custom_pois(db, user_id, parcel_cad?) → list[CustomPoiOut] +- get_custom_poi(db, poi_id, user_id) → CustomPoiOut | None +- update_custom_poi(db, poi_id, user_id, payload) → CustomPoiOut | None +- delete_custom_poi(db, poi_id, user_id) → bool +- get_overlaps_for_scoring(db, parcel_geom_wkt, user_id, parcel_cad?) → list[dict] + +Паттерн: raw SQL через SQLAlchemy text() + CAST(:x AS type), psycopg v3. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import text + +from app.schemas.custom_poi import CustomPoiCreate, CustomPoiOut, CustomPoiUpdate + +logger = logging.getLogger(__name__) + +# ── SQL ──────────────────────────────────────────────────────────────────────── + +_SELECT_COLS = """ + id, user_id, parcel_cad, name, category, weight, lon, lat, notes, + created_at, updated_at +""" + +_INSERT_SQL = f""" + INSERT INTO user_custom_pois + (user_id, parcel_cad, name, category, weight, lon, lat, notes) + VALUES + (:user_id, :parcel_cad, :name, :category, + CAST(:weight AS real), CAST(:lon AS double precision), + CAST(:lat AS double precision), :notes) + RETURNING {_SELECT_COLS} +""" + +_SELECT_BY_USER_ALL = f""" + SELECT {_SELECT_COLS} + FROM user_custom_pois + WHERE user_id = :user_id + ORDER BY created_at DESC +""" + +_SELECT_BY_USER_PARCEL = f""" + SELECT {_SELECT_COLS} + FROM user_custom_pois + WHERE user_id = :user_id + AND (parcel_cad = :parcel_cad OR parcel_cad IS NULL) + ORDER BY created_at DESC +""" + +_SELECT_BY_ID = f""" + SELECT {_SELECT_COLS} + FROM user_custom_pois + WHERE id = :poi_id + AND user_id = :user_id +""" + +_DELETE_SQL = """ + DELETE FROM user_custom_pois + WHERE id = :poi_id + AND user_id = :user_id + RETURNING id +""" + +# Запрос для scoring: custom POI в радиусе 1 км от центроида участка. +# Возвращает POI (global + parcel-specific) пользователя с расстоянием. +_OVERLAPS_SQL = """ + SELECT p.id, p.name, p.category, p.weight, p.lon, p.lat, + ST_Distance( + p.geom, + ST_Centroid(ST_GeomFromText(CAST(:wkt AS text), 4326))::geography + ) AS distance_m + FROM user_custom_pois p + WHERE p.user_id = :user_id + AND (p.parcel_cad IS NULL OR p.parcel_cad = :parcel_cad) + AND ST_DWithin( + p.geom, + ST_Centroid(ST_GeomFromText(CAST(:wkt AS text), 4326))::geography, + 1000 + ) + ORDER BY distance_m ASC +""" + + +# ── Row mapper ───────────────────────────────────────────────────────────────── + + +def _row_to_out(r: Any) -> CustomPoiOut: + return CustomPoiOut( + id=int(r["id"]), + user_id=r["user_id"], + parcel_cad=r["parcel_cad"], + name=r["name"], + category=r["category"], + weight=float(r["weight"]), + lon=float(r["lon"]), + lat=float(r["lat"]), + notes=r["notes"], + created_at=r["created_at"], + updated_at=r["updated_at"], + ) + + +# ── CRUD ─────────────────────────────────────────────────────────────────────── + + +def create_custom_poi(db: Any, user_id: str, payload: CustomPoiCreate) -> CustomPoiOut: + """Создать кастомную POI точку для пользователя.""" + row = ( + db.execute( + text(_INSERT_SQL), + { + "user_id": user_id, + "parcel_cad": payload.parcel_cad, + "name": payload.name, + "category": payload.category, + "weight": payload.weight, + "lon": payload.lon, + "lat": payload.lat, + "notes": payload.notes, + }, + ) + .mappings() + .first() + ) + db.commit() + assert row is not None, "INSERT RETURNING вернул пустой результат" + logger.info( + "custom_poi created: id=%s user=%s parcel=%s weight=%s", + row["id"], + user_id, + payload.parcel_cad, + payload.weight, + ) + return _row_to_out(row) + + +def list_custom_pois(db: Any, user_id: str, parcel_cad: str | None = None) -> list[CustomPoiOut]: + """Вернуть custom POI пользователя. + + Если parcel_cad задан — возвращает global (parcel_cad IS NULL) + parcel-specific. + Если parcel_cad=None — возвращает все POI пользователя. + """ + if parcel_cad is not None: + rows = ( + db.execute( + text(_SELECT_BY_USER_PARCEL), + {"user_id": user_id, "parcel_cad": parcel_cad}, + ) + .mappings() + .all() + ) + else: + rows = db.execute(text(_SELECT_BY_USER_ALL), {"user_id": user_id}).mappings().all() + return [_row_to_out(r) for r in rows] + + +def get_custom_poi(db: Any, poi_id: int, user_id: str) -> CustomPoiOut | None: + """Вернуть одну POI по id (scoped к user_id).""" + row = db.execute(text(_SELECT_BY_ID), {"poi_id": poi_id, "user_id": user_id}).mappings().first() + if row is None: + return None + return _row_to_out(row) + + +def update_custom_poi( + db: Any, poi_id: int, user_id: str, payload: CustomPoiUpdate +) -> CustomPoiOut | None: + """PATCH-style обновление кастомной POI. Возвращает None если не найдена.""" + existing = get_custom_poi(db, poi_id, user_id) + if existing is None: + return None + + sets: list[str] = ["updated_at = NOW()"] + params: dict[str, Any] = {"poi_id": poi_id, "user_id": user_id} + + if payload.name is not None: + sets.append("name = :name") + params["name"] = payload.name + + if payload.category is not None: + sets.append("category = :category") + params["category"] = payload.category + + if payload.weight is not None: + sets.append("weight = CAST(:weight AS real)") + params["weight"] = payload.weight + + # lon/lat изменяем только вместе — geom GENERATED ALWAYS пересчитается автоматически + 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.parcel_cad is not None: + sets.append("parcel_cad = :parcel_cad") + params["parcel_cad"] = payload.parcel_cad + + if payload.notes is not None: + sets.append("notes = :notes") + params["notes"] = payload.notes + + if len(sets) > 1: + db.execute( + text( + f"UPDATE user_custom_pois SET {', '.join(sets)}" + " WHERE id = :poi_id AND user_id = :user_id" + ), + params, + ) + db.commit() + + return get_custom_poi(db, poi_id, user_id) + + +def delete_custom_poi(db: Any, poi_id: int, user_id: str) -> bool: + """Удалить кастомную POI. Возвращает True если удалена, False если не найдена.""" + result = db.execute( + text(_DELETE_SQL), + {"poi_id": poi_id, "user_id": user_id}, + ).first() + if result is None: + return False + db.commit() + logger.info("custom_poi deleted: id=%s user=%s", poi_id, user_id) + return True + + +def get_overlaps_for_scoring( + db: Any, + parcel_geom_wkt: str, + user_id: str, + parcel_cad: str | None = None, +) -> list[dict[str, Any]]: + """Вернуть custom POI в радиусе 1 км с расстоянием для scoring. + + Включает global POI (parcel_cad IS NULL) + parcel-specific если parcel_cad задан. + Distance decay применяется в parcels.py аналогично OSM POI. + + Returns: + list[dict] с ключами: id, name, category, weight, lon, lat, distance_m + """ + _parcel_cad = parcel_cad or "" + try: + rows = ( + db.execute( + text(_OVERLAPS_SQL), + {"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad}, + ) + .mappings() + .all() + ) + return [ + { + "id": int(r["id"]), + "name": r["name"], + "category": r["category"], + "weight": float(r["weight"]), + "lon": float(r["lon"]), + "lat": float(r["lat"]), + "distance_m": float(r["distance_m"]), + } + for r in rows + ] + except Exception as e: + logger.warning("get_overlaps_for_scoring failed user=%s: %s", user_id, e) + return [] diff --git a/backend/tests/api/v1/test_custom_pois.py b/backend/tests/api/v1/test_custom_pois.py new file mode 100644 index 00000000..f0834e9b --- /dev/null +++ b/backend/tests/api/v1/test_custom_pois.py @@ -0,0 +1,455 @@ +"""Тесты для custom POI CRUD + scoring integration (#254). + +Покрывает: +1. POST /api/v1/custom-pois → 201, correct response, X-Session-Id в response header +2. GET /api/v1/custom-pois → list для user (изоляция по user_id) +3. PATCH /api/v1/custom-pois/{id} → updated, 404 для чужой +4. DELETE /api/v1/custom-pois/{id} → 204, 404 для чужой +5. Validation: weight outside [-5,5] → 422; lon/lat outside range → 422 +6. GET /api/v1/custom-pois?parcel_cad=... → filter works +7. Scoring integration: custom POI в 500м с weight=+2 увеличивает score +8. No X-Session-Id → auto-generated UUID в response header +9. Scoring absent when no X-Session-Id header + +Стратегия mock: сервисные функции патчим через unittest.mock.patch, +DB — через dependency_overrides (аналогично test_analyze_inline_weights.py). +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.schemas.custom_poi import CustomPoiOut + +# ── Константы ───────────────────────────────────────────────────────────────── + +_CAD = "66:41:0204016:10" +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' +_SESSION = "test-session-abc123" +_TS = datetime(2026, 5, 17, 10, 0, 0, tzinfo=UTC) + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _make_poi_out( + poi_id: int = 1, + user_id: str = _SESSION, + name: str = "Парк Маяковского", + weight: float = 2.0, + lon: float = 60.605, + lat: float = 56.838, + parcel_cad: str | None = None, +) -> CustomPoiOut: + return CustomPoiOut( + id=poi_id, + user_id=user_id, + parcel_cad=parcel_cad, + name=name, + category="park", + weight=weight, + lon=lon, + lat=lat, + notes=None, + created_at=_TS, + updated_at=_TS, + ) + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +# ── Tests: CRUD ──────────────────────────────────────────────────────────────── + + +def test_create_poi_returns_201() -> None: + """POST /custom-pois → 201 + корректный тело + X-Session-Id в header.""" + expected = _make_poi_out() + with patch("app.api.v1.custom_pois.create_custom_poi", return_value=expected) as mock_create: + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={ + "name": "Парк Маяковского", + "category": "park", + "weight": 2.0, + "lon": 60.605, + "lat": 56.838, + }, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["name"] == "Парк Маяковского" + assert body["weight"] == pytest.approx(2.0) + assert body["id"] == 1 + assert resp.headers.get("x-session-id") == _SESSION + mock_create.assert_called_once() + + +def test_create_poi_auto_generates_session_id() -> None: + """POST без X-Session-Id → 201 + авто-UUID в X-Session-Id header.""" + expected = _make_poi_out(user_id="some-uuid") + with patch("app.api.v1.custom_pois.create_custom_poi", return_value=expected): + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": 1.0, "lon": 60.0, "lat": 56.0}, + ) + assert resp.status_code == 201, resp.text + sid = resp.headers.get("x-session-id") + assert sid is not None, "Ожидали X-Session-Id в response headers" + # Должен быть валидным UUID + try: + uuid.UUID(sid) + except ValueError: + pytest.fail(f"X-Session-Id '{sid}' не является валидным UUID") + + +def test_create_poi_weight_out_of_range_returns_422() -> None: + """weight > 5 или < -5 → 422 от Pydantic (FastAPI body validation).""" + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": 10.0, "lon": 60.0, "lat": 56.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 422, resp.text + + resp2 = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": -6.0, "lon": 60.0, "lat": 56.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp2.status_code == 422, resp2.text + + +def test_create_poi_lon_out_of_range_returns_422() -> None: + """lon > 180 → 422.""" + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": 1.0, "lon": 200.0, "lat": 56.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 422, resp.text + + +def test_create_poi_lat_out_of_range_returns_422() -> None: + """lat > 90 → 422.""" + client = TestClient(app) + resp = client.post( + "/api/v1/custom-pois", + json={"name": "Test", "weight": 1.0, "lon": 60.0, "lat": 100.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 422, resp.text + + +def test_list_pois_returns_user_items() -> None: + """GET /custom-pois → список POI пользователя.""" + pois = [_make_poi_out(1), _make_poi_out(2, name="Школа №5", weight=1.5)] + with patch("app.api.v1.custom_pois.list_custom_pois", return_value=pois): + client = TestClient(app) + resp = client.get("/api/v1/custom-pois", headers={"X-Session-Id": _SESSION}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert len(body) == 2 + assert body[0]["id"] == 1 + assert body[1]["name"] == "Школа №5" + + +def test_list_pois_filter_by_parcel_cad() -> None: + """GET /custom-pois?parcel_cad=... → вызывает list_custom_pois с parcel_cad.""" + with patch("app.api.v1.custom_pois.list_custom_pois", return_value=[]) as mock_list: + client = TestClient(app) + resp = client.get( + f"/api/v1/custom-pois?parcel_cad={_CAD}", + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 200, resp.text + # Проверяем что parcel_cad передан в сервис + call_kwargs = mock_list.call_args + assert call_kwargs[1].get("parcel_cad") == _CAD or ( + len(call_kwargs[0]) >= 3 and call_kwargs[0][2] == _CAD + ) + + +def test_patch_poi_returns_updated() -> None: + """PATCH /custom-pois/{id} → 200 с обновлёнными данными.""" + updated = _make_poi_out(weight=3.0, name="Обновлённый парк") + with patch("app.api.v1.custom_pois.update_custom_poi", return_value=updated): + client = TestClient(app) + resp = client.patch( + "/api/v1/custom-pois/1", + json={"weight": 3.0, "name": "Обновлённый парк"}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["weight"] == pytest.approx(3.0) + assert body["name"] == "Обновлённый парк" + + +def test_patch_poi_not_found_returns_404() -> None: + """PATCH для несуществующей/чужой POI → 404.""" + with patch("app.api.v1.custom_pois.update_custom_poi", return_value=None): + client = TestClient(app) + resp = client.patch( + "/api/v1/custom-pois/999", + json={"weight": 1.0}, + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 404, resp.text + + +def test_delete_poi_returns_204() -> None: + """DELETE /custom-pois/{id} → 204 при успешном удалении.""" + with patch("app.api.v1.custom_pois.delete_custom_poi", return_value=True): + client = TestClient(app) + resp = client.delete("/api/v1/custom-pois/1", headers={"X-Session-Id": _SESSION}) + assert resp.status_code == 204, resp.text + + +def test_delete_poi_not_found_returns_404() -> None: + """DELETE для несуществующей POI → 404.""" + with patch("app.api.v1.custom_pois.delete_custom_poi", return_value=False): + client = TestClient(app) + resp = client.delete("/api/v1/custom-pois/999", headers={"X-Session-Id": _SESSION}) + assert resp.status_code == 404, resp.text + + +# ── Tests: Scoring integration ───────────────────────────────────────────────── + +# Вспомогательный mock DB для analyze_parcel (аналогично test_analyze_inline_weights.py). +# Порядок db.execute calls включает #254 custom POI via get_overlaps_for_scoring, +# который вызывается уже после POI loop — ВНУТРИ analyze_parcel через импортированную +# функцию. Мы патчим её через patch() — DB mock остаётся прежним. + + +def _make_mapping_analyze(data: dict[str, Any]) -> MagicMock: + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze() -> MagicMock: + """Mock DB Session для analyze_parcel (без custom POI вызовов — они пропатчены).""" + db = MagicMock() + + geom_row = _make_mapping_analyze( + {"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"} + ) + wkt_row = _make_mapping_analyze({"wkt": _WKT}) + district_row = _make_mapping_analyze( + { + "district_name": "Октябрьский", + "median_price_per_m2": 120000, + "dist_to_center": 1500.0, + } + ) + centroid_row = _make_mapping_analyze({"lat": 56.84, "lon": 60.605}) + + call_idx = [0] + responses: list[Any] = [ + ("first", geom_row), # 0: geom UNION ALL + ("first", wkt_row), # 1: WKT + ("first", district_row), # 2: district + ("all", []), # 3: POI rows + ("all", []), # 4: competitor rows + ("all", []), # 5: pipeline rows + ("first", centroid_row), # 6: centroid + ("all", []), # 7: noise rows + ("all", []), # 8: hydrology rows + ("all", []), # 9: utilities rows + ("first", None), # 10: parcel_meta + ("first", None), # 11: market trend + ("first", None), # 12: zoning (begin_nested) + ("all", []), # 13: success recommendation (begin_nested) + ("first", None), # 14: market price (begin_nested) + ("all", []), # 15: recent permits (begin_nested) + ("scalar", 0), # 16: geotech_risk + ("all", []), # 17: neighbors + ("first", None), # 18: overlap + ] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + idx = call_idx[0] + call_idx[0] += 1 + if idx >= len(responses): + r = MagicMock() + r.mappings.return_value.first.return_value = None + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + kind, data = responses[idx] + r = MagicMock() + r.mappings.return_value.first.return_value = data + r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] + r.scalar.return_value = data if kind == "scalar" else 0 + return r + + db.execute.side_effect = _execute_side_effect + + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +_ANALYZE_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_analyze_patches() -> None: + for p in _ANALYZE_PATCHES: + p.start() + + +def _stop_analyze_patches() -> None: + for p in _ANALYZE_PATCHES: + p.stop() + + +def test_analyze_custom_poi_increases_score() -> None: + """Custom POI с weight=+2 в 500м → score повышается, custom_poi_score_items непустой.""" + from app.core.db import get_db + + # Симулируем custom POI в 500м + _custom_overlap = { + "id": 42, + "name": "Мой парк", + "category": "park", + "weight": 2.0, + "lon": 60.607, + "lat": 56.840, + "distance_m": 500.0, + } + # decay = 1 - 500/1000 = 0.5; contribution = 2.0 * 0.5 = 1.0 + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_analyze_patches() + + try: + with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[_custom_overlap]): + client = TestClient(app) + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + + # custom_poi_score_items должен содержать нашу точку + assert "custom_poi_score_items" in body + items = body["custom_poi_score_items"] + assert len(items) == 1 + assert items[0]["label"] == "Мой парк" + assert items[0]["weight"] == pytest.approx(2.0) + assert items[0]["distance_m"] == 500 + # contribution = 2.0 * 0.5 = 1.0 + assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01) + + # score должен учитывать contribution custom POI + # (базовый score без POI = center_bonus из 1500м → 1.5; + custom 1.0 = 2.5) + assert body["score"] == pytest.approx(2.5, abs=0.1) + finally: + app.dependency_overrides.clear() + _stop_analyze_patches() + + +def test_analyze_without_session_id_no_custom_poi() -> None: + """POST /analyze без X-Session-Id → custom_poi_score_items пустой.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_analyze_patches() + + try: + with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[]) as mock_overlaps: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + # Без session_id custom_poi_score_items пустой (не вызываем get_overlaps) + assert body["custom_poi_score_items"] == [] + mock_overlaps.assert_not_called() + finally: + app.dependency_overrides.clear() + _stop_analyze_patches() + + +def test_analyze_custom_poi_negative_weight_decreases_score() -> None: + """Custom POI с weight=-3 в 500м → score уменьшается.""" + from app.core.db import get_db + + _custom_overlap = { + "id": 7, + "name": "Промзона", + "category": "industrial", + "weight": -3.0, + "lon": 60.607, + "lat": 56.840, + "distance_m": 500.0, + } + # contribution = -3.0 * 0.5 = -1.5 + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_analyze_patches() + + try: + with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[_custom_overlap]): + client = TestClient(app) + resp = client.post( + f"/api/v1/parcels/{_CAD}/analyze", + headers={"X-Session-Id": _SESSION}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + items = body["custom_poi_score_items"] + assert len(items) == 1 + assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01) + # center_bonus = 1.5 (1500м), custom = -1.5 → итого ≈ 0 + assert body["score"] == pytest.approx(0.0, abs=0.1) + finally: + app.dependency_overrides.clear() + _stop_analyze_patches() diff --git a/data/sql/101_user_custom_pois.sql b/data/sql/101_user_custom_pois.sql new file mode 100644 index 00000000..5af0ac8c --- /dev/null +++ b/data/sql/101_user_custom_pois.sql @@ -0,0 +1,34 @@ +-- #254: User custom POIs — точки с произвольным весом для ad-hoc scoring. +-- Юзер добавляет точку на карту (name, weight, lon, lat), scoring учитывает. +-- parcel_cad NULL = глобальная per-user точка (видна для всех анализов пользователя). + +BEGIN; + +CREATE TABLE IF NOT EXISTS user_custom_pois ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + parcel_cad TEXT, -- NULL = global per-user + name TEXT NOT NULL, + category TEXT, + weight REAL NOT NULL CHECK (weight BETWEEN -5 AND 5), + lon DOUBLE PRECISION NOT NULL CHECK (lon BETWEEN -180 AND 180), + lat DOUBLE PRECISION NOT NULL CHECK (lat BETWEEN -90 AND 90), + geom GEOGRAPHY(POINT, 4326) GENERATED ALWAYS AS ( + ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography + ) STORED, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE user_custom_pois IS + 'User-defined ad-hoc POI points with custom scoring weight (#254). ' + 'parcel_cad=NULL means global (applies to all parcel analyses for user_id).'; + +CREATE INDEX IF NOT EXISTS user_custom_pois_geom_gist + ON user_custom_pois USING GIST (geom); + +CREATE INDEX IF NOT EXISTS user_custom_pois_user_parcel + ON user_custom_pois (user_id, parcel_cad); + +COMMIT;