Merge pull request 'feat(#254): user_custom_pois — backend schema + CRUD + scoring integration' (#257) from feat/254-custom-pois-backend into main
This commit is contained in:
commit
8cc94472f7
7 changed files with 1011 additions and 1 deletions
119
backend/app/api/v1/custom_pois.py
Normal file
119
backend/app/api/v1/custom_pois.py
Normal 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")
|
||||||
|
|
@ -6,7 +6,7 @@ import time
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
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 import wkt as _shp_wkt
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -39,6 +39,9 @@ from app.services.site_finder.cadastre_fetch import (
|
||||||
fetch_status as _fetch_status,
|
fetch_status as _fetch_status,
|
||||||
)
|
)
|
||||||
from app.services.site_finder.competitors import get_competitors
|
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.gate_verdict import compute_gate_verdict
|
||||||
from app.services.site_finder.quarter_dump_lookup import (
|
from app.services.site_finder.quarter_dump_lookup import (
|
||||||
get_connection_points,
|
get_connection_points,
|
||||||
|
|
@ -1057,6 +1060,10 @@ def analyze_parcel(
|
||||||
AnalyzeRequest | None,
|
AnalyzeRequest | None,
|
||||||
Body(description="Опциональное тело запроса: inline POI-веса (#201)"),
|
Body(description="Опциональное тело запроса: inline POI-веса (#201)"),
|
||||||
] = None,
|
] = None,
|
||||||
|
x_session_id: Annotated[
|
||||||
|
str | None,
|
||||||
|
Header(description="Session ID пользователя для custom POI scoring (#254)"),
|
||||||
|
] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Анализ участка: близость к социалке + district context + конкуренты.
|
"""Анализ участка: близость к социалке + district context + конкуренты.
|
||||||
|
|
||||||
|
|
@ -1966,6 +1973,61 @@ def analyze_parcel(
|
||||||
"lon": centroid_lon,
|
"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
|
score_final = score + center_bonus
|
||||||
|
|
||||||
# X1 (#47): расчёт contribution_pct + top-3 / by-group для UI.
|
# X1 (#47): расчёт contribution_pct + top-3 / by-group для UI.
|
||||||
|
|
@ -2126,6 +2188,8 @@ def analyze_parcel(
|
||||||
"weights_applied": _effective_weights,
|
"weights_applied": _effective_weights,
|
||||||
"inline_weights": _inline_weights,
|
"inline_weights": _inline_weights,
|
||||||
},
|
},
|
||||||
|
# #254: custom POI scoring — user-defined points (via X-Session-Id header).
|
||||||
|
"custom_poi_score_items": custom_poi_items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from app.api.v1 import (
|
||||||
admin_weight_profiles,
|
admin_weight_profiles,
|
||||||
analytics,
|
analytics,
|
||||||
concepts,
|
concepts,
|
||||||
|
custom_pois,
|
||||||
parcels,
|
parcels,
|
||||||
photos,
|
photos,
|
||||||
)
|
)
|
||||||
|
|
@ -85,6 +86,7 @@ app.include_router(
|
||||||
tags=["admin", "site-finder"],
|
tags=["admin", "site-finder"],
|
||||||
)
|
)
|
||||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
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(
|
app.include_router(
|
||||||
admin_cadastre.router,
|
admin_cadastre.router,
|
||||||
prefix="/api/v1/admin/cadastre",
|
prefix="/api/v1/admin/cadastre",
|
||||||
|
|
|
||||||
60
backend/app/schemas/custom_poi.py
Normal file
60
backend/app/schemas/custom_poi.py
Normal 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
|
||||||
276
backend/app/services/site_finder/custom_pois.py
Normal file
276
backend/app/services/site_finder/custom_pois.py
Normal 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 []
|
||||||
455
backend/tests/api/v1/test_custom_pois.py
Normal file
455
backend/tests/api/v1/test_custom_pois.py
Normal 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()
|
||||||
34
data/sql/101_user_custom_pois.sql
Normal file
34
data/sql/101_user_custom_pois.sql
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue