gendesign/backend/app/api/v1/custom_pois.py
lekss361 485f489bd2 feat(#254): user_custom_pois — backend schema + CRUD + scoring integration
- 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
2026-05-17 09:34:33 +03:00

119 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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