- 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
276 lines
9.1 KiB
Python
276 lines
9.1 KiB
Python
"""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 []
|