442-sweep instructions #16 Dockerfile: runner (lean, 1.6 GB) + runner-with-chromium (2.9 GB). Compose + deploy.yml pushes 2 GHCR images. Backend saves 1.3 GB. #15 5 backend object endpoints + /analytics/objects/[id] page (gallery, POI table, sale chart) + sparkline-driven PrinzipObjectsTable. #14 prinzip_leads/deals + 2 MVs. Import script with PII-hashing & --inspect-only mode. 3 funnel endpoints + Sankey/Monthly charts. #13 README section with UI/CLI/SQL for 442-object sweep.
204 lines
7.3 KiB
Python
204 lines
7.3 KiB
Python
"""Analytics endpoints for the Sverdlovsk dashboard."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Annotated, Any, Literal
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
from app.services import analytics_queries as q
|
||
|
||
router = APIRouter()
|
||
|
||
DEV_ID_RE = re.compile(r"^\d+_\d+$")
|
||
|
||
|
||
def _validate_dev(dev_id: str) -> str:
|
||
if not DEV_ID_RE.match(dev_id):
|
||
raise HTTPException(status_code=400, detail="developer_id must match \\d+_\\d+")
|
||
return dev_id
|
||
|
||
|
||
# ---- Sverdlovsk region ------------------------------------------------------
|
||
|
||
|
||
@router.get("/sverdl/market-pulse")
|
||
def sverdl_market_pulse(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
"""Monthly series Jan 2025 → present: total square (тыс м²), sold%, avg price."""
|
||
return q.market_pulse(db, region_code=66)
|
||
|
||
|
||
@router.get("/sverdl/quartirography")
|
||
def sverdl_quartirography(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
source: Literal["portfolio", "deals"] = "portfolio",
|
||
) -> list[dict[str, Any]]:
|
||
"""Что строится (portfolio) vs что покупают (deals) по сегментам."""
|
||
return q.quartirography(db, source=source, region_id=66)
|
||
|
||
|
||
@router.get("/sverdl/pipeline")
|
||
def sverdl_pipeline(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
"""По году ввода: total / sold% / unsold% / unopened%."""
|
||
return q.pipeline_by_year(db, region_code=66)
|
||
|
||
|
||
@router.get("/sverdl/districts")
|
||
def sverdl_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
"""ЕКБ-районы: ЖК-count, flat-count, area, цены."""
|
||
return q.districts(db)
|
||
|
||
|
||
@router.get("/sverdl/yandex-listings")
|
||
def sverdl_yandex_listings(db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
||
"""Снимок активных новостроек Яндекс.Недвижимости."""
|
||
return q.yandex_listings(db)
|
||
|
||
|
||
# ---- Developers -------------------------------------------------------------
|
||
|
||
|
||
@router.get("/developers/top")
|
||
def developers_top(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
limit: Annotated[int, Query(ge=1, le=50)] = 15,
|
||
) -> list[dict[str, Any]]:
|
||
"""Топ-девелоперы Свердл по объёму с Δ sold% за всю доступную историю."""
|
||
return q.top_developers(db, region_code=66, limit=limit)
|
||
|
||
|
||
@router.get("/developers/history")
|
||
def developers_history(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
ids: Annotated[str, Query(min_length=1)],
|
||
) -> list[dict[str, Any]]:
|
||
"""Comma-separated developer_ids → time series sold_perc."""
|
||
dev_ids = [_validate_dev(i.strip()) for i in ids.split(",") if i.strip()]
|
||
if not dev_ids:
|
||
raise HTTPException(status_code=400, detail="ids must contain at least one developer_id")
|
||
return q.developer_history(db, developer_ids=dev_ids, region_code=66)
|
||
|
||
|
||
@router.get("/developers/{developer_id}")
|
||
def developer_detail(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
developer_id: str,
|
||
) -> dict[str, Any]:
|
||
dev = _validate_dev(developer_id)
|
||
result = q.developer_detail(db, developer_id=dev)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="developer not found")
|
||
return result
|
||
|
||
|
||
@router.get("/developers/{developer_id}/portfolio")
|
||
def developer_portfolio(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
developer_id: str,
|
||
) -> list[dict[str, Any]]:
|
||
dev = _validate_dev(developer_id)
|
||
return q.developer_portfolio(db, developer_id=dev)
|
||
|
||
|
||
# ---- Per-object drill-in (sale_graph / sales_agg / infra / photos) ----------
|
||
|
||
|
||
@router.get("/object/{obj_id}/sale_graph")
|
||
def object_sale_graph(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
type_filter: Annotated[str | None, Query(alias="type")] = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""Time-series продаж per-ЖК. type=apartments|parking, без фильтра — обе серии."""
|
||
return q.object_sale_graph(db, obj_id=obj_id, type_filter=type_filter)
|
||
|
||
|
||
@router.get("/object/{obj_id}/sales_agg")
|
||
def object_sales_agg(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> list[dict[str, Any]]:
|
||
"""3 строки: apartments + nonliv + parking (total / realised / sold%)."""
|
||
return q.object_sales_agg(db, obj_id=obj_id)
|
||
|
||
|
||
@router.get("/object/{obj_id}/infrastructure")
|
||
def object_infrastructure(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
category: Annotated[str | None, Query()] = None,
|
||
max_distance: Annotated[int, Query(ge=0, le=10000)] = 5000,
|
||
) -> list[dict[str, Any]]:
|
||
"""POI вокруг ЖК. Фильтр по category (Спорт/Образование/...) и radius."""
|
||
return q.object_infrastructure(db, obj_id=obj_id, category=category, max_distance=max_distance)
|
||
|
||
|
||
@router.get("/object/{obj_id}/photos")
|
||
def object_photos(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||
) -> list[dict[str, Any]]:
|
||
"""Фото-метаданные с photo_url, отсортированные по period_dt DESC."""
|
||
return q.object_photos(db, obj_id=obj_id, limit=limit)
|
||
|
||
|
||
@router.get("/object/{obj_id}")
|
||
def object_detail(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
obj_id: int,
|
||
) -> dict[str, Any]:
|
||
"""Базовая инфа объекта из domrf_kn_objects (имя, адрес, координаты, девелопер)."""
|
||
result = q.object_detail(db, obj_id=obj_id)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="object not found")
|
||
return result
|
||
|
||
|
||
# ---- PRINZIP-specific -------------------------------------------------------
|
||
|
||
|
||
@router.get("/prinzip/insights")
|
||
def prinzip_insights() -> dict[str, Any]:
|
||
"""Static recommendations + benchmarks (from PRINZIP_Strategy_Apr27)."""
|
||
return q.prinzip_insights()
|
||
|
||
|
||
@router.get("/prinzip/districts")
|
||
def prinzip_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
return q.prinzip_district_distribution(db, developer_id="6208_0")
|
||
|
||
|
||
@router.get("/prinzip/objects")
|
||
def prinzip_objects(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||
"""Список 28 ЖК PRINZIP с агрегатами sales_agg + sparkline-данными для UI таблицы."""
|
||
return q.prinzip_objects_with_velocity(db)
|
||
|
||
|
||
@router.get("/prinzip/funnel/monthly")
|
||
def prinzip_funnel_monthly(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
months: Annotated[int, Query(ge=1, le=120)] = 24,
|
||
) -> list[dict[str, Any]]:
|
||
"""Воронка по месяцам: leads → engaged → converted (с разбивкой по source)."""
|
||
return q.prinzip_funnel_monthly(db, months=months)
|
||
|
||
|
||
@router.get("/prinzip/funnel/by-source")
|
||
def prinzip_funnel_by_source(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
months: Annotated[int, Query(ge=1, le=120)] = 12,
|
||
) -> list[dict[str, Any]]:
|
||
"""Splitting по source: кто конвертит лучше за последние N месяцев."""
|
||
return q.prinzip_funnel_by_source(db, months=months)
|
||
|
||
|
||
@router.get("/prinzip/funnel/by-object")
|
||
def prinzip_funnel_by_object(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> list[dict[str, Any]]:
|
||
"""Воронка для каждого ЖК PRINZIP: leads / deals / revenue."""
|
||
return q.prinzip_funnel_by_object(db)
|