All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 5m2s
Deploy Trade-In / build-backend (push) Successful in 5m38s
Deploy Trade-In / deploy (push) Successful in 1m4s
170 lines
8.4 KiB
Python
170 lines
8.4 KiB
Python
"""Trade-In MVP — FastAPI entry point.
|
||
|
||
Standalone версия, выделена из основного gendesign repo для локальной разработки.
|
||
Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from collections.abc import AsyncGenerator
|
||
from contextlib import asynccontextmanager
|
||
|
||
import sentry_sdk
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
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,
|
||
audit,
|
||
brand,
|
||
buildings,
|
||
geocode,
|
||
lead,
|
||
me,
|
||
search,
|
||
support,
|
||
trade_in,
|
||
)
|
||
from app.core.config import settings
|
||
from app.core.db import SessionLocal
|
||
from app.core.fdw import ensure_fdw_user_mapping
|
||
from app.core.ratelimit import RateLimitMiddleware
|
||
from app.core.rbac import rbac_guard
|
||
from app.core.request_audit import RequestAuditMiddleware
|
||
from app.observability.sentry_scrub import scrub_pii_event
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||
)
|
||
|
||
# #tgsupport-web: этот процесс теперь тоже зовёт Telegram Bot API напрямую
|
||
# (app/api/v1/support.py — sendMessage при отправке веб-сообщения в топик), не
|
||
# только изолированный tgbot_main.py. httpx-INFO логирует ПОЛНЫЙ request URL,
|
||
# включая токен в пути (https://api.telegram.org/bot<TOKEN>/...) — то же самое
|
||
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
|
||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||
|
||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
||
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
||
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
||
# Logging (logger.error → events). БЕЗ CeleryIntegration — prod не гоняет celery
|
||
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
||
# frontend), отдельного broker нет → мониторить нечего.
|
||
if settings.glitchtip_dsn:
|
||
from app.observability.sentry_scrub import redact_telegram_bot_token
|
||
|
||
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
|
||
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
|
||
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
|
||
sendMessage, а include_local_variables=False ниже — первый рубеж защиты)."""
|
||
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||
if scrubbed is None:
|
||
return None
|
||
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||
|
||
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=0.0, # только ошибки, без performance-трейсов
|
||
send_default_pii=False, # не шлём client_name / client_phone в отчёты
|
||
include_local_variables=False, # #tgsupport-web: TelegramClient._request
|
||
# держит base URL с токеном в локальных переменных стек-фрейма — default
|
||
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
|
||
before_send=_before_send,
|
||
integrations=[
|
||
StarletteIntegration(),
|
||
FastApiIntegration(),
|
||
SqlalchemyIntegration(),
|
||
HttpxIntegration(),
|
||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||
],
|
||
)
|
||
logging.getLogger("app.main").info("GlitchTip monitoring enabled")
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||
# #2213 defense-in-depth: если общий секрет не задан — trusted-header auth
|
||
# уязвим к подделке X-Authenticated-User изнутри docker-сети gendesign_shared.
|
||
# Один явный WARNING на старте, чтобы это не осталось незамеченным в проде.
|
||
if not settings.tradein_internal_auth_secret:
|
||
logger.warning(
|
||
"defense-in-depth НЕ активен: X-Authenticated-User принимается без "
|
||
"проверки внутреннего секрета — задай TRADEIN_INTERNAL_AUTH_SECRET в "
|
||
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
|
||
)
|
||
|
||
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
||
# Best-effort: failure does not abort startup, just logs.
|
||
try:
|
||
with SessionLocal() as db:
|
||
ensure_fdw_user_mapping(db)
|
||
except Exception:
|
||
logger.exception("FDW user mapping bootstrap failed — cadastral queries may fail")
|
||
|
||
# #2397 Part C: legacy in-app scheduler launch removed — scheduling lives exclusively
|
||
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
|
||
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
|
||
# this API process never actually launched scheduler_loop() in production.
|
||
yield
|
||
|
||
|
||
app = FastAPI(
|
||
title="Trade-In MVP API",
|
||
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
|
||
version="0.1.0",
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
# 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.
|
||
# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
|
||
# не видит» — decided 2026-05-25).
|
||
# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
|
||
# `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
|
||
# Guard body живёт в app/core/rbac.py (без DB/lifespan side effects), чтобы
|
||
# тесты могли импортировать РЕАЛЬНЫЙ guard вместо hand-maintained копии.
|
||
app.middleware("http")(rbac_guard)
|
||
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.cors_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
|
||
app.add_middleware(RateLimitMiddleware)
|
||
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
|
||
app.add_middleware(RequestAuditMiddleware)
|
||
|
||
|
||
@app.get("/health")
|
||
def health() -> dict[str, str]:
|
||
return {"status": "ok", "environment": settings.environment}
|
||
|
||
|
||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
|
||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|