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).
199 lines
8.2 KiB
Python
199 lines
8.2 KiB
Python
"""GenDesign FastAPI application entrypoint."""
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||
from contextlib import asynccontextmanager
|
||
|
||
import sentry_sdk
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, Response
|
||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||
|
||
from app.api.v1 import (
|
||
admin_cadastre,
|
||
admin_etl,
|
||
admin_jobs,
|
||
admin_leads,
|
||
admin_scrape,
|
||
admin_weight_profiles,
|
||
analytics,
|
||
concepts,
|
||
custom_pois,
|
||
insights,
|
||
landing,
|
||
locations,
|
||
me,
|
||
parcels,
|
||
photos,
|
||
pilot,
|
||
ping,
|
||
trade_in,
|
||
users,
|
||
)
|
||
from app.core.audit_middleware import audit_log_middleware
|
||
from app.core.auth import get_role
|
||
from app.core.config import settings
|
||
from app.observability.sentry_scrub import scrub_sensitive_query
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Инициализируем SDK до создания FastAPI app, чтобы все инструменты
|
||
# (middleware, маршруты) видели активный client с самого старта процесса.
|
||
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
|
||
if settings.glitchtip_dsn:
|
||
sentry_sdk.init(
|
||
dsn=settings.glitchtip_dsn,
|
||
environment=settings.environment,
|
||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||
profiles_sample_rate=0.0,
|
||
send_default_pii=False,
|
||
before_send_transaction=scrub_sensitive_query,
|
||
integrations=[
|
||
StarletteIntegration(),
|
||
FastApiIntegration(),
|
||
CeleryIntegration(monitor_beat_tasks=True),
|
||
SqlalchemyIntegration(),
|
||
HttpxIntegration(),
|
||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||
],
|
||
)
|
||
logger.info(
|
||
"GlitchTip SDK initialised (env=%s, traces=%.2f)",
|
||
settings.environment,
|
||
settings.glitchtip_traces_sample_rate,
|
||
)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||
yield
|
||
|
||
|
||
app = FastAPI(title="GenDesign API", version="0.1.0", lifespan=lifespan)
|
||
|
||
# §19 audit log (#962, EPIC18): аудит sensitive actions (analyze/forecast/export).
|
||
# Регистрируем ПЕРВЫМ → Starlette применяет HTTP-middleware в LIFO-порядке
|
||
# (add_middleware insert(0); build_middleware_stack wraps reversed), поэтому
|
||
# rbac_guard (зарегистрирован НИЖЕ) оказывается ВНЕШНИМ слоем, а audit — внутри:
|
||
# request flow = rbac_guard → audit → router. Аудитим ТОЛЬКО запросы, которые
|
||
# rbac_guard уже пропустил (его 401/403 short-circuit'ы не доходят до audit).
|
||
# Best-effort: сбой записи не ломает запрос (см. app/core/audit_middleware.py).
|
||
app.middleware("http")(audit_log_middleware)
|
||
|
||
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
|
||
# (см. app/core/auth.py + auth/roles.yaml). Правила:
|
||
# 1) Любой non-public path требует X-Authenticated-User — иначе 401
|
||
# (Caddy пробрасывает заголовок только для авторизованных юзеров; нет
|
||
# заголовка = локальный curl мимо Caddy = доступ запрещён).
|
||
# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
|
||
# не видит»). Это закрывает кейс если Caddy basic_auth пропустил кого-то
|
||
# из снятой записи, или прокси-подмена заголовка.
|
||
# 3) /api/v1/admin/* — только role=admin, иначе 403.
|
||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки —
|
||
# X-Authenticated-User там просто не приходит из Caddy.
|
||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
|
||
|
||
|
||
@app.middleware("http")
|
||
async def rbac_guard(
|
||
request: Request,
|
||
call_next: Callable[[Request], Awaitable[Response]],
|
||
) -> Response:
|
||
# Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User.
|
||
# СТРОГО gated на settings.testing (default False) — прод RBAC не затронут.
|
||
# RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware).
|
||
if settings.testing:
|
||
return await call_next(request)
|
||
|
||
path = request.url.path
|
||
if path in _PUBLIC_PATHS:
|
||
return await call_next(request)
|
||
|
||
username = request.headers.get("X-Authenticated-User")
|
||
if not username:
|
||
# Любой non-public path без auth-header → 401. Локальный curl мимо Caddy
|
||
# или прокси-фронт без header_up. 401 точнее чем 403 — "сначала
|
||
# аутентифицируйся".
|
||
return JSONResponse(
|
||
status_code=401,
|
||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||
)
|
||
|
||
try:
|
||
role = get_role(username)
|
||
except KeyError:
|
||
# Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ.
|
||
# Decided 2026-05-25: «человек без ролей вообще ничего не видит».
|
||
logger.warning("RBAC: unknown user %r tried %s", username, path)
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"detail": "user not in roles config"},
|
||
)
|
||
|
||
if _ADMIN_API_RE.match(path) and role != "admin":
|
||
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"detail": "admin only"},
|
||
)
|
||
return await call_next(request)
|
||
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.cors_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
|
||
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
||
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
|
||
app.include_router(admin_jobs.router, prefix="/api/v1/admin/jobs", tags=["admin"])
|
||
app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"])
|
||
app.include_router(
|
||
admin_weight_profiles.router,
|
||
prefix="/api/v1/admin/site-finder/weight-profiles",
|
||
tags=["admin", "site-finder"],
|
||
)
|
||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
||
app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"])
|
||
app.include_router(locations.router, prefix="/api/v1/locations", tags=["locations"])
|
||
app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"])
|
||
app.include_router(
|
||
admin_cadastre.router,
|
||
prefix="/api/v1/admin/cadastre",
|
||
tags=["admin", "cadastre"],
|
||
)
|
||
app.include_router(
|
||
admin_etl.router,
|
||
prefix="/api/v1/admin/etl",
|
||
tags=["admin", "etl"],
|
||
)
|
||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||
app.include_router(landing.router, prefix="/api/v1", tags=["landing"])
|
||
app.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"])
|
||
app.include_router(users.router, prefix="/api/v1", tags=["users"])
|
||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||
app.include_router(ping.router, prefix="/api/v1", tags=["ping"])
|
||
|
||
|
||
@app.get("/health")
|
||
async def health() -> dict[str, str]:
|
||
return {
|
||
"status": "ok",
|
||
"environment": settings.environment,
|
||
"version": app.version,
|
||
}
|