- 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
119 lines
4.4 KiB
Python
119 lines
4.4 KiB
Python
"""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")
|