gendesign/backend/app/api/v1/locations.py
Light1YT 8da1c00138
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m11s
CI / backend-tests (push) Successful in 6m27s
CI / backend-tests (pull_request) Successful in 6m22s
feat(location): district-level Location entity + indices (#948 part B)
Promote district to a first-class `location` entity (ТЗ §8.2), ADDITIVE — no
district->FK refactor. New `location` table keyed by district_name (joinable by
string), carrying 4 normalized [0,1] indices (NULL when no data, never 0):
- infra: _district_poi_score / _city_avg_poi_score (per-district POI aggregate)
- competition: market_metrics.overstock_index (available/stuck competing supply
  — orthogonal to demand; NOT sell-through, which is market-heat correlated w/ demand)
- demand: market_metrics.unit_velocity (saturating /50)
- future_supply: future_supply_pressure.index (passthrough, already 0..1)

- data/sql/146_location.sql: idempotent table + UNIQUE(district_name) + range
  CHECK + centroid GIST
- services/site_finder/locations.py: compute_location_indices (reuses forecast
  per-district fns) + refresh_locations (SAVEPOINT per-row, CAST, ON CONFLICT)
- workers/tasks/location_refresh.py + beat (Mon 07:00 MSK, after supply-layers)
- api/v1/locations.py: read-only GET list + GET by name (analyst+admin via rbac,
  frontend pilot-gated)
- tests: 34 (normalization 0..1/null, competition⊥demand orthogonality, idempotent
  upsert, read API list/by-name/404)

Part of #948 (Part A insight shipped #1164).
2026-06-08 13:28:19 +05:00

58 lines
2.7 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.

"""Read-only API для location (#948 Part B, ТЗ §8.2).
GET /api/v1/locations → LocationList (все локации с индексами)
GET /api/v1/locations/{district_name} → LocationOut (одна; 404 если нет)
Read-only: индексы инфра/конкуренция/спрос/будущее-предложение ВЫЧИСЛЯЮТСЯ воркером
(tasks/location_refresh.py через services/site_finder/locations.py), НЕ вводятся
пользователем — поэтому CRUD нет (никаких POST/PUT/DELETE).
Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО /api/v1/admin/*
(admin-only). На /api/v1/locations pilot отсекается FRONTEND-овым RouteGuard
(allowed_paths из /me) + Caddy, НЕ бэкендом (is_path_allowed() из backend-кода тут НЕ
вызывается). До эндпоинта доходят analyst+admin (та же модель, что insights/
custom-pois — НЕ backend pilot-gating).
Хэндлеры sync `def` (зеркало insights.py/custom_pois.py): сервис ходит в БД через
sync SQLAlchemy Session → FastAPI исполняет их в threadpool, event loop не блокируется
(async def + sync db.execute было бы хуже — заблокировало бы loop).
district_name в path: FastAPI принимает Cyrillic через URL-encoding прозрачно (имена
8 админ-районов ЕКБ: «Кировский», «Ленинский», …).
"""
from __future__ import annotations
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.location import LocationList, LocationOut
from app.services.site_finder.locations import get_location, list_locations
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("", response_model=LocationList)
def list_locations_endpoint(
db: Annotated[Session, Depends(get_db)],
) -> Any:
"""Все локации (районы) с district-level индексами."""
return list_locations(db)
@router.get("/{district_name}", response_model=LocationOut)
def get_location_endpoint(
district_name: str,
db: Annotated[Session, Depends(get_db)],
) -> Any:
"""Вернуть одну локацию по district_name. 404 если не найдена."""
location = get_location(db, district_name)
if location is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Location not found")
return location