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
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).
58 lines
2.7 KiB
Python
58 lines
2.7 KiB
Python
"""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
|