Merge pull request 'feat(#254): user_custom_pois — backend schema + CRUD + scoring integration' (#257) from feat/254-custom-pois-backend into main
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m40s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m49s
Deploy / deploy (push) Successful in 50s

This commit is contained in:
lekss361 2026-05-17 06:42:29 +00:00
commit 8cc94472f7
7 changed files with 1011 additions and 1 deletions

View file

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

View file

@ -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,
}

View file

@ -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",

View file

@ -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

View file

@ -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 []

View file

@ -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()

View file

@ -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;