All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 15m13s
Дефолт AUTH_MODE=legacy — сегодняшнее поведение байт-в-байт: соединение с БД auth не открывается, кука не читается, отсутствие настроек не роняет старт. Popup Caddy стоит и снимается последним PR эпика — инвариант «гейт уходит последним» не нарушен. У Site Finder не было авторизации вообще: rbac_guard доверял заголовку X-Authenticated-User от Caddy. Теперь он умеет резолвить сессионную куку общего реестра. ВЫДАВАТЬ сессии «Птица» не будет — логин один, у «Меры», а кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов. Меньше кода и меньше мест, где можно ошибиться. Режим трёхзначный, а не булев: legacy | dual | db_only. Это прямое следствие ревью. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после снятия popup превращался бы в полный обход аутентификации, и ничто в коде не заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401. Резолв уехал в threadpool. Три независимых ревьюера нашли одно и то же: sync-запрос к БД в async-guard блокирует event loop на каждом non-public запросе — ровно инцидент #1202, который в этом же файле уже лечили. Кука разбирается на loop'е, в поток уезжает только строка токена; запрос без куки не платит ни за поток, ни за коннект. Срок годности сессии считают часы БД, а не приложения. Раньше проверка шла в Python, а sliding-refresh переписывал строку через now() базы — при расхождении часов истёкшая сессия не просто проходила, а продлевалась заново, то есть воскресала навсегда. Теперь `expires_at > now()` в самом SELECT; питоновская проверка оставлена вторым поясом. Fail-fast на старте проверяет не синтаксис DSN, а живое соединение: SELECT 1. Иначе неверный пароль или хост выглядели бы как «ни у кого нет сессии» — сутками, потому что ошибку ловил бы except в guard'е. Ещё из ревью: connect_timeout и statement_timeout по 3с (недоступный хост вешал коннект на минуты); throttling логов сбоя реестра (иначе шторм в GlitchTip на каждый запрос); тела SQL закреплены ассертами формы — мутация любого фрагмента теперь красит тесты, до этого не красила ничего. Дефолт хоста БД — postgres, и это зеркально «Мере». У неё gendesign-postgres, потому что внутри её стека `postgres` — чужой контейнер; здесь стек главный, и `postgres` из корневого compose и есть нужный сервер. Алиас gendesign-postgres дефолтом был бы багом: контейнер beat состоит только в сети default и это имя из него не разрезолвится. Сверка реестра с ролевой картой сделана на живом проде: все 13 юзеров auth.users присутствуют в auth/roles.yaml, ни один не получит 403 на всё. Четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в yaml, но не в реестре — после снятия popup войти ими через браузер будет нельзя, только внутрисетевым заголовком. Осознанный долг, вписан ⚠️-блоком перед guard'ом: paths/deny из roles.yaml бэкендом не применяются (их энфорсит фронтовый RouteGuard), guard проверяет только известность username и admin-пути. Это предсуществующее поведение; менять его здесь значило бы изменить и легаси-ветку, то есть нарушить «дефолт = сегодня». Тесты: 4594 passed, 0 failed. Главный — подделка: валидная кука плюс присланный клиентом X-Authenticated-User другого пользователя, выигрывает кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы» таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть полной, а не skip-if-present (CRITICAL #2552).
607 lines
47 KiB
Python
607 lines
47 KiB
Python
import os
|
||
import warnings
|
||
from typing import Annotated, Literal
|
||
from urllib.parse import quote
|
||
|
||
from pydantic import SecretStr, field_validator, model_validator
|
||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||
|
||
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ─────
|
||
# Вынесены константами, потому что используются ДВАЖДЫ: как дефолт поля и как
|
||
# запасное значение, если переменная окружения задана ПУСТОЙ строкой
|
||
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
|
||
#
|
||
# ⚠️ ХОСТ — главная ловушка, и для «Птицы» она ЗЕРКАЛЬНА ловушке «Меры».
|
||
# У «Меры» (tradein-mvp/backend/app/core/config.py:27) дефолт — `gendesign-postgres`,
|
||
# потому что внутри ЕЁ стека имя `postgres` резолвится в её собственный контейнер
|
||
# (tradein-mvp/docker-compose.prod.yml:143 собирает им продуктовый DATABASE_URL
|
||
# `...@postgres:5432/tradein`), и БД `auth` там нет.
|
||
#
|
||
# У «Птицы» ровно наоборот: её стек и есть главный. Сервис `postgres` в корневом
|
||
# docker-compose.prod.yml:22 (postgis/postgis:16-3.4) — это И ЕСТЬ тот сервер, где
|
||
# живёт БД `auth`: bootstrap и миграции data/sql/auth/*.sql применяет к нему шаг
|
||
# «Apply DB migrations» в .forgejo/workflows/deploy.yml:339-375. Соседи по тому же
|
||
# compose-проекту так к нему и обращаются — `@postgres:5432` (docker-compose.prod.yml:232
|
||
# и :265, DATABASE_URL сервисов glitchtip).
|
||
#
|
||
# Алиас `gendesign-postgres` (docker-compose.prod.yml:43-45) навешен ТОЛЬКО в внешней
|
||
# сети `shared` (gendesign_shared) и заведён ради ЧУЖИХ стеков — им и пользуется
|
||
# «Мера». Ставить его дефолтом здесь нельзя: в сети `shared` состоят лишь backend и
|
||
# worker (`networks: [default, shared]`, строки 152 и 199), а `beat` (строки 201-217)
|
||
# сетей не объявляет вовсе — он только в `default`, и `gendesign-postgres` из него
|
||
# просто не разрезолвится. `postgres` резолвится из всех трёх.
|
||
#
|
||
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера. Публикация `127.0.0.1:5432:5432`
|
||
# (docker-compose.prod.yml:31-32) существует только ради SSH-туннеля с хоста и к
|
||
# этому пути отношения не имеет.
|
||
_AUTH_DB_DEFAULT_HOST = "postgres"
|
||
_AUTH_DB_DEFAULT_PORT = 5432
|
||
_AUTH_DB_DEFAULT_NAME = "auth"
|
||
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege: SELECT/
|
||
# INSERT/UPDATE/DELETE на sessions, SELECT + column-level UPDATE на users).
|
||
_AUTH_DB_DEFAULT_USER = "auth_app"
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||
|
||
database_url: str = "postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign"
|
||
redis_url: str = "redis://localhost:6379/0"
|
||
cors_origins: list[str] = ["http://localhost:3000"]
|
||
# GlitchTip error tracking (Sentry-compatible self-hosted).
|
||
# Формат DSN: https://<key>@errors.gendsgn.ru/<project_id>
|
||
# Пустая строка / None = SDK не инициализируется (no-op).
|
||
glitchtip_dsn: str | None = None
|
||
glitchtip_traces_sample_rate: float = 0.05
|
||
environment: str = "dev"
|
||
# Test-mode flag (env TESTING=1). СТРОГО default False — в проде RBAC-гейт
|
||
# (app/main.py rbac_guard) активен. True только в pytest (tests/conftest.py),
|
||
# где запросы идут по app мимо Caddy и не несут X-Authenticated-User.
|
||
testing: bool = False
|
||
|
||
# ── §25.3 «own portfolio» (#1169) ─────────────────────────────────────────
|
||
# Целочисленные DOM.РФ developer-id «наших» застройщиков (env OWN_DEVELOPER_IDS,
|
||
# comma-separated → list[int]; пусто/не задано → []). Источник «текущих» проектов
|
||
# для движка каннибализации §25.3: domrf_kn_objects фильтруется по этим id (по
|
||
# числовому префиксу composite dev_id '<devId>_<suffix>', см.
|
||
# site_finder/own_portfolio.py).
|
||
#
|
||
# СТРОГО default [] и НИ ОДНОГО реального id в коде — конфигурируется только через
|
||
# окружение прод-контейнера. Пустой список = «текущих» проектов нет: §25.3 own-
|
||
# portfolio деградирует ИЗЯЩНО (get_own_portfolio вернёт только manual/future-строки,
|
||
# а при их отсутствии — пустой список; PR2-движок честно отдаёт прокси/None вместо
|
||
# фабрикации), а НЕ падает.
|
||
#
|
||
# NoDecode: отключает дефолтный JSON-предпарсинг сложного типа (pydantic-settings для
|
||
# list[int] иначе ждёт JSON-литерал '[1,2]' и падает на «человеческой» строке
|
||
# '12345,67890'). С NoDecode сырое env-значение доходит до _parse_own_developer_ids
|
||
# (mode='before'), где и парсится comma-separated.
|
||
own_developer_ids: Annotated[list[int], NoDecode] = []
|
||
|
||
@field_validator("own_developer_ids", mode="before")
|
||
@classmethod
|
||
def _parse_own_developer_ids(cls, value: object) -> object:
|
||
"""Распарсить OWN_DEVELOPER_IDS из comma-separated env-строки в list[int].
|
||
|
||
pydantic-settings по умолчанию ждёт для list[int] JSON-литерал ('[1,2]'),
|
||
поэтому «человеческую» строку '12345,67890' нужно распарсить вручную. Правила:
|
||
• пусто / не задано / '' → [] (graceful default, §25.3 деградирует изящно);
|
||
• '12345, 67890 ,42' → [12345, 67890, 42] (trim + пропуск пустых токенов);
|
||
• уже list (тесты/JSON-env) → нормализуем элементы в int as-is.
|
||
Нечисловой токен → ValueError (явная ошибка конфигурации, а не тихий дроп).
|
||
"""
|
||
if value is None:
|
||
return []
|
||
if isinstance(value, str):
|
||
stripped = value.strip()
|
||
if not stripped:
|
||
return []
|
||
return [int(tok.strip()) for tok in stripped.split(",") if tok.strip()]
|
||
if isinstance(value, list | tuple):
|
||
return [int(item) for item in value]
|
||
return value
|
||
|
||
@model_validator(mode="after")
|
||
def _promote_legacy_sentry_dsn(self) -> "Settings":
|
||
"""Backward-compat: legacy SENTRY_DSN → glitchtip_dsn ТОЛЬКО для self-hosted.
|
||
|
||
Старый VPS .env.runtime мог содержать SENTRY_DSN=https://...@sentry.io/...
|
||
(legacy SaaS Sentry). Промоутить его НЕЛЬЗЯ — события пойдут в чужой проект.
|
||
Принимаем только URLs указывающие на наш errors.gendsgn.ru host.
|
||
"""
|
||
if not self.glitchtip_dsn:
|
||
legacy = os.getenv("SENTRY_DSN")
|
||
if legacy:
|
||
if "errors.gendsgn.ru" in legacy:
|
||
warnings.warn(
|
||
"SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. "
|
||
"Auto-promoting for backward compat.",
|
||
DeprecationWarning,
|
||
stacklevel=2,
|
||
)
|
||
self.glitchtip_dsn = legacy
|
||
else:
|
||
warnings.warn(
|
||
f"SENTRY_DSN points to non-GlitchTip host "
|
||
f"({legacy.split('@', 1)[-1][:40]}...) — ignoring. "
|
||
"Set GLITCHTIP_DSN explicitly.",
|
||
UserWarning,
|
||
stacklevel=2,
|
||
)
|
||
return self
|
||
|
||
# External APIs (Stage 2)
|
||
rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1"
|
||
overpass_url: str = "https://overpass-api.de/api/interpreter"
|
||
|
||
# Scraper schedule (наш.дом.рф kn-API).
|
||
# Crontab format: "minute hour day_of_month month day_of_week" (Celery crontab).
|
||
# Default: каждый понедельник в окне 04:00–05:00 по МСК.
|
||
scrape_kn_cron: str = "15 4 * * mon"
|
||
# Random delay window in seconds added on top of scheduled start so the worker
|
||
# does not hit DOM.РФ at exactly the same minute every cycle. 0 = disabled.
|
||
scrape_kn_jitter_seconds: int = 1800
|
||
# Default region(s) for scheduled sweeps. Comma-separated.
|
||
scrape_kn_default_regions: str = "66"
|
||
# Path to a pre-captured Playwright storage_state.json (committed in repo,
|
||
# used by worker to skip cold-start WAF challenge).
|
||
scrape_kn_state_path: str = "data/playwright_state.json"
|
||
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
|
||
# Empty string = endpoint disabled.
|
||
scrape_admin_token: str = ""
|
||
|
||
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
|
||
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
|
||
# captcha — подтверждено): per-region sweep гонит ~1548 объектов × 11
|
||
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
|
||
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
|
||
#
|
||
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
|
||
# Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
|
||
# (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
|
||
# catalog-object scrapers) тоже переиспользуют эти же значения — они бьют
|
||
# по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24
|
||
# (#2443). nspd и прочие скраперы вне этого path family продолжают
|
||
# использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений.
|
||
# 2 — эмпирически безопасный потолок против volume-бана.
|
||
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
|
||
scrape_kn_browser_concurrency: int = 2
|
||
# Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
|
||
# flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
|
||
# (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban.
|
||
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
|
||
# ширим до 1200–3000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
|
||
scrape_kn_request_jitter_min_ms: int = 1200
|
||
scrape_kn_request_jitter_max_ms: int = 3000
|
||
# Рычаг 2 — прокси (ОПЦИОНАЛЬНО, default None → прямое подключение, поведение
|
||
# без изменений). Когда задан — KN-sweep BrowserSession запускает Chromium
|
||
# через этот прокси (формат http://user:pass@host:port; парсится в
|
||
# Playwright proxy={server,username,password}). Сильнейший рычаг против
|
||
# IP-бана: переиспользует тот же mobile-proxy паттерн, что tradein-стек
|
||
# (SCRAPER_PROXY_URL). Заводится ТОЛЬКО через окружение прод-контейнера.
|
||
# ENV: SCRAPE_KN_PROXY_URL.
|
||
scrape_kn_proxy_url: str | None = None
|
||
# Рычаг 3 (ГЛАВНЫЙ unblock #1945) — изоляция flats от extras.
|
||
# Эмпирически (prod, 2026-06-27): flats endpoint /portal-kn/api/sales/portal/table
|
||
# на concurrency=2 = 0 WAF-бан на 100+ объектах; extras /сервисы/api/object/{id}/*
|
||
# отдают 403 СРАЗУ (volume-independent, мертвы с 2026-06-03) И ТРАВЯТ cookies
|
||
# сессии → последующие flats на той же сессии тоже 403. flats_count (метрика
|
||
# #1945) рухнул 3670→9 именно из-за этого.
|
||
#
|
||
# True (дефолт, NEW): flats тянутся в ЧИСТОЙ flats-only сессии (extras на ней
|
||
# НЕ дёргаются НИКОГДА) → flats_count восстанавливается; extras идут отдельным
|
||
# best-effort проходом с recycle сессии на каждый WAF-403 (яд не накапливается
|
||
# и НЕ касается flats). False: старое поведение (flats+extras в одной сессии,
|
||
# poison-prone). ENV: SCRAPE_KN_EXTRAS_ISOLATED.
|
||
scrape_kn_extras_isolated: bool = True
|
||
|
||
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
|
||
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
|
||
# Settings оставлены deprecated на случай отката (можно удалить позже).
|
||
scrape_nspd_cron: str = "30 3 20 2,5,8,11 *" # DEPRECATED
|
||
scrape_nspd_default_regions: str = "66" # DEPRECATED
|
||
scrape_nspd_rate_ms: int = 600 # DEPRECATED — see nspd_lite_rate_ms
|
||
|
||
# Objective.ru API (api.objctv.ru) — платная аналитика первички/вторички.
|
||
# Лежит наряду с DOM.РФ kn + rosreestr CSV как 3-й источник истины с
|
||
# самой богатой моделью (per-flat per-day, escrow/банк-долг, ЕГРН-ИНН).
|
||
# Получить ключ: личный закрытый ApiKey от Объектива (тариф per-территория).
|
||
# Tank-схема:
|
||
# 1. GET /Users/User/GetToken?apiKey=<KEY> → JSON с Bearer-токеном
|
||
# 2. GET /v2/Report/GetReport?... + Authorization: Bearer <token>
|
||
objective_api_key: str | None = None
|
||
objective_base_url: str = "https://api.objctv.ru"
|
||
# Группа = «настройка территории» внутри Объектива. Чаще всего совпадает с
|
||
# городом/областью. Доступные группы определяются тарифом ключа.
|
||
objective_default_group: str = "Екатеринбург"
|
||
# Список групп для еженедельного auto-sync (csv). Эмпирически проверено
|
||
# 2026-05-10 что эти группы доступны на нашем тарифе:
|
||
# - "Свердловская область" → 1626 corp_sum/3д (= ЕКБ + спутники + регион)
|
||
# - "Челябинск" → 355
|
||
# - "Тюмень" → 985
|
||
# - "Пермь" → 423
|
||
# Стратегия Variant B (включить наш sync параллельно Антоновому ETL):
|
||
# Антон тянет только Екб (303k lots). Наш sync даёт +30k Свердл.обл
|
||
# за пределами Екб + ~700k квартир УрФО (Челябинск, Тюмень, Пермь).
|
||
# Итог в PG: ~1М квартир уральского + западносибирского рынка.
|
||
# Между группами task делает паузу _OBJECTIVE_INTER_GROUP_DELAY = 30с
|
||
# чтобы не упереться в rate-limit Объектива.
|
||
objective_sync_groups: str = "Свердловская область,Челябинск,Тюмень,Пермь"
|
||
# Токен Bearer короткоживущий (предположительно 30 мин — уточнить).
|
||
# Кешируем в Redis с этим TTL (с запасом 5 мин).
|
||
objective_token_ttl_seconds: int = 25 * 60
|
||
# Расписание sync — еженедельно по вторникам в 06:00 МСК.
|
||
# ПОЧЕМУ ВТОРНИК (а не понедельник как раньше): Антон тянет в понедельник,
|
||
# к утру вторника его SQLite уже обновлён — наш sync захватывает свежий
|
||
# snapshot за тот же отчётный период.
|
||
objective_sync_cron: str = "0 6 * * tue"
|
||
|
||
# NSPD lite (urllib через WAF) — feature toggle для переключения с
|
||
# Playwright-based nspd_kn.py на минималистичный nspd_lite.py.
|
||
# 2026-05-11: эмпирически проверено что urllib проходит WAF nspd.gov.ru
|
||
# (TLS-fingerprint stdlib не блокируется). Старый Playwright-путь
|
||
# остаётся как fallback на случай возврата WAF-проблем.
|
||
use_nspd_lite: bool = True
|
||
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
||
nspd_lite_rate_ms: int = 600
|
||
|
||
# Каталог хранения собранных PDF-отчётов ПТИЦА (эпик #2259 PR-D). Worker пишет
|
||
# PDF сюда, backend читает файл для /report/download. На проде — общий writable
|
||
# bind-mount `./reports:/app/reports` у сервисов backend+worker (docker-compose.prod.yml),
|
||
# иначе backend не увидит файл, записанный воркером. Для локальной разработки —
|
||
# относительный путь ОК (тест мокает render_full_report_pdf, файл не пишется).
|
||
reports_dir: str = "/app/reports"
|
||
|
||
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
|
||
# На проде монтируется bind-mount-ом docker-compose:
|
||
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
||
# Триггерится вручную через POST /api/v1/admin/scrape/objective.
|
||
# Для локальной разработки — указать абсолютный путь к скачанному snapshot:
|
||
# OBJECTIVE_ANTON_SQLITE_PATH=C:/Users/user/source/repos/gendesign/sf_anton_snapshot.db
|
||
objective_anton_sqlite_path: str = "/data/anton-sqlite/analysis.db"
|
||
|
||
# Cross-load ETL tradein→gendesign (#976 950-E5).
|
||
# Прямой psycopg-коннект к tradein-postgres через gendesign_shared network.
|
||
# Пример: postgresql://gendesign_reader:<pw>@tradein-postgres:5432/tradein
|
||
# Если пусто — ETL отключён (warn-log, задача возвращает {"disabled": true}).
|
||
tradein_database_url: str = ""
|
||
|
||
# OpenRouteService API (https://openrouteservice.org/dev/#/signup).
|
||
# Free tier: 2000 запросов/день. Используется в /parcels/{cad}/isochrones.
|
||
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
|
||
openrouteservice_api_key: str = ""
|
||
|
||
# ── OSRM road-distance в /analyze (#39 A2) ────────────────────────────────
|
||
# Реальное дорожное расстояние центроид→POI из локального OSRM-сервиса
|
||
# (self-hosted, /table GET с annotations=distance → метры) вместо
|
||
# straight-line ST_Distance в POI-скоринге.
|
||
#
|
||
# use_osrm_distances СТРОГО default False: включение сдвигает ВСЕ POI-score
|
||
# на ~20-30% (дорога длиннее прямой), что есть осознанное ПОЗДНЕЕ решение
|
||
# (после валидации), а НЕ этот деплой. При OFF /analyze байт-в-байт как
|
||
# сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
|
||
# битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
|
||
use_osrm_distances: bool = False
|
||
# URL локального OSRM (docker-compose service `osrm`, DRIVING-граф). /table:
|
||
# GET {url}/table/v1/driving/{coords}?sources=0&annotations=distance
|
||
osrm_local_url: str = "http://osrm:5000"
|
||
# Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
|
||
# сервисе — при превышении → fallback на straight-line.
|
||
osrm_distance_timeout_s: float = 12.0
|
||
|
||
# ── Per-category OSRM routing (#39 A3) ────────────────────────────────────
|
||
# Валидация показала: DRIVING-расстояние ПЕРЕОЦЕНИВАЕТ пешеходно-релевантные
|
||
# POI (школа/магазин/парк) — медиана 1.6–2.9× straight-line. Фикс: walk-POI
|
||
# маршрутизируем по FOOT-графу (отдельный сервис `osrm-walk`), car-POI — по
|
||
# существующему DRIVING-графу.
|
||
#
|
||
# URL локального OSRM с FOOT-графом (docker-compose service `osrm-walk`):
|
||
# GET {url}/table/v1/foot/{coords}?sources=0&annotations=distance
|
||
osrm_walk_local_url: str = "http://osrm-walk:5000"
|
||
# Категории POI, маршрутизируемые по FOOT-графу (пешая доступность). Всё, чего
|
||
# НЕТ в этом множестве — incl. `shop_mall`, `hospital` и любая неизвестная/None
|
||
# категория — идёт через DRIVING-граф как безопасный дефолт (drive-side).
|
||
osrm_walk_categories: frozenset[str] = frozenset(
|
||
{
|
||
"shop_small",
|
||
"pharmacy",
|
||
"shop_supermarket",
|
||
"kindergarten",
|
||
"school",
|
||
"park",
|
||
"bus_stop",
|
||
"tram_stop",
|
||
"metro_stop",
|
||
}
|
||
)
|
||
|
||
# ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
|
||
# parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
|
||
# ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
|
||
# zone_regulation_cache (#1102). Override через env ENABLE_IRD_ANALYZE=false остаётся
|
||
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
|
||
enable_ird_analyze: bool = True
|
||
|
||
# РИАСУРТ Свердл gate в analyze (#108, multi-city scaling): поле `gate.riasurt` в ответе
|
||
# analyze — пересечения участка с зонами РИАСУРТ Свердл (тер.зона/функц.зона/красные линии/
|
||
# СЗЗ/ЗСО/затопление/КРТ). ТОЛЬКО для участков в агломерации ЕКБ, НЕ в самом ЕКБ-сити
|
||
# (is_in_aglomeration_but_not_ekb). По умолчанию OFF: таблица riasurt_sverdl наполняется
|
||
# post-deploy harvest'ом на уточнённых bbox МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO).
|
||
# Override через env ENABLE_RIASURT_GATE=true.
|
||
enable_riasurt_gate: bool = False
|
||
|
||
# РИАСУРТ Свердл harvest kill-switch (#108 review): ежеквартальный beat
|
||
# `harvest_all_riasurt_sverdl` прогоняет grid-walk по MO_BBOXES. Эти bbox — ПЛЕЙСХОЛДЕРЫ
|
||
# (грубые ±6 км вокруг центров МО), реальные административные границы резолвятся post-deploy.
|
||
# По умолчанию OFF, иначе beat на следующем тике дёргает WMS с мусорными bbox.
|
||
# Включать ТОЛЬКО после замены MO_BBOXES на реальные границы. Override через
|
||
# env ENABLE_RIASURT_HARVEST=true. Single-MO harvest_riasurt_sverdl_for_mo не гейтится
|
||
# (callable вручную для smoke-тестирования конкретного bbox).
|
||
enable_riasurt_harvest: bool = False
|
||
|
||
# Реальный ПЗЗ-градрегламент зоны (КСИТ/max_far, высота, этажность, %застройки,
|
||
# min площадь ЗУ) в ответе analyze: поле `nspd_zoning` дополняется числовыми
|
||
# предельными параметрами из zone_regulation_cache (Route 2 — coordinate-based
|
||
# resolver get_or_fetch_zone_regulation, cache-first + bounded live geoportal на
|
||
# miss). Кэш прогрет для ЕКБ (33 зоны), поэтому live-вызовы редки и закапаны
|
||
# коротким timeout'ом. Hot-path-safe: любой сбой/таймаут → поля None, analyze не
|
||
# падает. По умолчанию ON; выключение (env ENABLE_ZONING_REGULATION_IN_ANALYZE=false)
|
||
# полностью пропускает резолв — поведение analyze без изменений.
|
||
enable_zoning_regulation_in_analyze: bool = True
|
||
|
||
# Единый bounded-timeout (сек) для live geoportal-вызовов резолвера ПЗЗ-регламента в
|
||
# hot-пути analyze (#1850). Раньше было два расходящихся значения: parcels.py=3s,
|
||
# ird_analyze.py=4s для ОДНОГО и того же резолвера. Унифицировано в 4s (более безопасный
|
||
# запас). Оба колл-сайта работают cache-first (+ мемоизация coord→zone_index), поэтому
|
||
# живые вызовы редки. Дефолт клиента EKBGeoportalClient (20s) слишком долог для sync-хэндлера.
|
||
# Override через env GEOPORTAL_TIMEOUT_S.
|
||
geoportal_timeout_s: int = 4
|
||
|
||
# Area-gate для blocker'а «инженерная/утилитарная охранная зона» в gate_verdict.
|
||
# ЗОУИТ охранной зоны инж.сети (ЛЭП/газ/трубопровод/тепло/электро) в РФ ограничивает
|
||
# застройку ВНУТРИ полосы (отступы, запрет капстроя над линией), а НЕ стерилизует весь
|
||
# участок — МКД сажается на необременённом остатке. Поэтому такая зона блокирует МКД
|
||
# ТОЛЬКО когда покрывает > этой доли площади участка (фактически стерилизует его);
|
||
# ниже порога — warning «учесть при посадке». Default 0.6: на проде ЕКБ медиана покрытия
|
||
# ~6%, p90 ~47% → блокирует лишь экстремальный хвост (1/17 участков). Override через env
|
||
# GATE_ZOUIT_ENGINEERING_BLOCKER_MIN_COVERAGE.
|
||
gate_zouit_engineering_blocker_min_coverage: float = 0.6
|
||
|
||
# ── LLM infrastructure (#960) ────────────────────────────────────────────
|
||
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
|
||
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
|
||
# fallback (см. app/services/llm/client.py).
|
||
#
|
||
# llm_enabled СТРОГО default False: пока секреты не настроены в проде, клиент
|
||
# НЕ делает сетевых вызовов вообще (guard #2 в client.complete). Включать только
|
||
# после того как OPENAI_API_KEY заведён в окружение прод-контейнера И принято
|
||
# решение по §19 data-residency (провайдер внешний — данные покидают РФ).
|
||
llm_enabled: bool = False
|
||
# Ключ читается ТОЛЬКО отсюда (env OPENAI_API_KEY). Нигде в коде/тестах нет
|
||
# литерала ключа. None = ключ не задан → клиент ведёт себя как при llm_enabled=False.
|
||
openai_api_key: str | None = None
|
||
llm_model: str = "gpt-4o-mini"
|
||
llm_base_url: str = "https://api.openai.com/v1"
|
||
# Таймаут одного HTTP-вызова к провайдеру (сек). Воркер не должен висеть.
|
||
llm_timeout_s: float = 30.0
|
||
llm_max_output_tokens: int = 1024
|
||
# Верхняя граница числа LLM-вызовов в рамках одной логической операции
|
||
# (граддок-extraction / chat-turn) — защита от случайного цикла у консьюмера.
|
||
llm_max_calls_per_request: int = 4
|
||
# Бюджетный потолок (USD) — пока только логируется как оценка (tokens→$).
|
||
# None = без потолка. Жёсткий enforcement добавит консьюмер при необходимости.
|
||
llm_daily_cost_cap_usd: float | None = None
|
||
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
|
||
llm_max_retries: int = 2
|
||
|
||
# ── DaData /clean/address геокод (objective_backfill geo-pass, #2177) ──────
|
||
# Токен + секрет для DaData /clean/address (обогащение адреса → geo_lat/geo_lon).
|
||
# На gendesign-проде уже заведены в окружении контейнера (проверено 2026-07-03).
|
||
# Оба нужны для clean_address (в отличие от suggest, которому хватает токена).
|
||
# None/пусто → dadata_client.clean_address graceful-возвращает None (geo-pass
|
||
# тогда reject'ит всё «нет геокода», не падает). ENV: DADATA_API_TOKEN /
|
||
# DADATA_API_SECRET. Литерала ключа в коде/тестах нет.
|
||
dadata_api_token: str | None = None
|
||
dadata_api_secret: str | None = None
|
||
# Таймаут одного HTTP-вызова к DaData (сек). Воркер geo-pass не должен висеть
|
||
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
|
||
dadata_timeout_s: float = 8.0
|
||
|
||
# ── Эпик «единый вход»: «Птица» ПРИНИМАЕТ сессию общего реестра ────────────
|
||
# Форма входа во всём продукте одна и живёт у «Меры» (/trade-in/login): она
|
||
# проверяет пароль и выдаёт сессию в auth.sessions. «Птица» сессии НЕ выдаёт и
|
||
# НЕ отзывает — только читает куку и резолвит её в человека. Кука host-only на
|
||
# gendsgn.ru с path="/" (tradein-mvp/backend/app/api/v1/auth.py:173-181),
|
||
# поэтому браузер шлёт её на оба продукта одного домена.
|
||
#
|
||
# Режим — ТРЁХЗНАЧНЫЙ, а не булев флаг, и это сделано ради последнего PR эпика:
|
||
# legacy (ДЕФОЛТ) — сегодняшнее поведение бит-в-бит: кука не читается вовсе,
|
||
# личность берётся из X-Authenticated-User (Caddy basic_auth);
|
||
# engine БД `auth` не создаётся, соединение не открывается,
|
||
# отсутствие AUTH_* в окружении не роняет старт;
|
||
# dual — сначала кука общего реестра, при её отсутствии/сбое реестра
|
||
# деградация на легаси-заголовок (переходный режим: popup
|
||
# Caddy ещё стоит и прикрывает заголовок от подделки);
|
||
# db_only — легаси-ветка НЕДОСТИЖИМА: нет валидной сессии → 401, даже
|
||
# если X-Authenticated-User присутствует.
|
||
#
|
||
# Почему именно так, а не `AUTH_SESSION_ENABLED=true/false`. В dual-режиме сбой
|
||
# реестра (или просто отсутствие куки) уводит запрос на trusted-header. Пока
|
||
# popup стоит, это безопасно: заголовок на `/api/*` перезаписывает Caddy из
|
||
# basic_auth (Caddyfile:178-182), клиент подставить его не может. Ровно в тот
|
||
# момент, когда последний PR эпика снимет `basic_auth` + `header_up`, заголовок
|
||
# станет полностью клиентским — и та же деградация превратится в ПОЛНЫЙ обход
|
||
# аутентификации (`curl -H 'X-Authenticated-User: admin'`). Булев флаг оставлял бы
|
||
# это на память мейнтейнера («не забыть выпилить фолбэк»); режим делает переход
|
||
# сменой ОДНОГО значения (`AUTH_MODE=db_only`), а недостижимость легаси-ветки в
|
||
# нём закреплена тестами (tests/test_auth_session_guard.py, секция db_only).
|
||
# Зеркало «Меры»: tradein-mvp/backend/app/core/config.py:91 (`auth_mode`); там
|
||
# значений два — легаси-режима у неё уже нет, она на реестре с #2552.
|
||
#
|
||
# ⚠️ ДЕФОЛТ `legacy` — ЧАСТЬ КОНТРАКТА PR, А НЕ ЗАГЛУШКА: после мержа прод обязан
|
||
# работать ровно как сегодня (popup Caddy снимается последним PR эпика).
|
||
# Читатели режима: `app.main.rbac_guard` (какой источник личности и есть ли
|
||
# фолбэк), `app.services.auth_session.resolve_session_token` и
|
||
# `app.core.auth_db.require_auth_db_configured` — через производное свойство
|
||
# `auth_session_enabled` ниже.
|
||
#
|
||
# Включение на проде = одна переменная: AUTH_DB_PASSWORD в backend/.env.runtime
|
||
# уже есть (её пишет ops и читает .forgejo/workflows/deploy.yml:381-386, чтобы
|
||
# сделать ALTER ROLE auth_app), остальные части DSN имеют прод-дефолты.
|
||
# ENV: AUTH_MODE.
|
||
auth_mode: Literal["legacy", "dual", "db_only"] = "legacy"
|
||
|
||
# DSN БД `auth` целиком. Пусто по умолчанию — задавать руками не обязательно:
|
||
# см. `resolved_auth_database_url` ниже, при пустом значении DSN собирается из
|
||
# AUTH_DB_PASSWORD + частей. Явное значение, если оно есть, выигрывает всегда
|
||
# (аварийный обход: другой хост, sslmode, байпас пула). ENV: AUTH_DATABASE_URL.
|
||
auth_database_url: str = ""
|
||
# Пароль роли auth_app. Живёт в ОДНОМ месте — этой переменной: требовать вдобавок
|
||
# целиковый AUTH_DATABASE_URL значило бы держать один секрет в двух местах
|
||
# (сменили пароль роли, забыли переписать DSN → вход ложится молча и целиком).
|
||
#
|
||
# SecretStr, а не str как у соседних секретов файла: `repr(settings)` и
|
||
# `settings.model_dump()` печатают обычные str-поля ДОСЛОВНО. Сегодня их никто не
|
||
# рендерит, но появиться такой рендер может тихо — с SecretStr он напечатает
|
||
# `SecretStr('**********')`. Значение достаётся ровно в одном месте —
|
||
# `.get_secret_value()` в резолвере ниже. Соседи (openai_api_key, dadata_api_secret,
|
||
# database_url) остались str — это предсуществующее положение, а не «там безопасно».
|
||
# ENV: AUTH_DB_PASSWORD.
|
||
auth_db_password: SecretStr = SecretStr("")
|
||
# Остальные части — с дефолтами, верными для ЭТОГО стека (см. константы выше и
|
||
# разбор ловушки хоста). Переопределяются через ENV для локального запуска (напр.
|
||
# AUTH_DB_HOST=localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
|
||
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
|
||
auth_db_host: str = _AUTH_DB_DEFAULT_HOST
|
||
auth_db_port: int = _AUTH_DB_DEFAULT_PORT
|
||
auth_db_name: str = _AUTH_DB_DEFAULT_NAME
|
||
auth_db_user: str = _AUTH_DB_DEFAULT_USER
|
||
|
||
# Имя cookie сессии. ОБЯЗАНО совпадать с тем, которым пользуется «Мера»
|
||
# (tradein-mvp/backend/app/core/config.py:84-86) — иначе браузер шлёт куку, а
|
||
# «Птица» её не узнаёт и молча остаётся без сессии.
|
||
#
|
||
# ⚠️ Имя ИСТОРИЧЕСКОЕ: оно родилось в trade-in до того, как реестр стал общим, и
|
||
# «tradein_» в нём теперь ни о чём не говорит. Переименование разлогинивает ВСЕХ
|
||
# и СРАЗУ в обоих продуктах (старую куку никто больше не читает), поэтому меняется
|
||
# только отдельным решением — синхронно в обоих стеках и с обдуманным моментом.
|
||
# ENV: SESSION_COOKIE_NAME.
|
||
session_cookie_name: str = "tradein_session"
|
||
# TTL сессии в часах (720 = 30 дней) — тот же дефолт, что у «Меры»
|
||
# (tradein-mvp/backend/app/core/config.py:88). «Птица» сессии не выдаёт, поэтому
|
||
# значение используется ЕДИНСТВЕННЫМ образом: на сколько sliding-refresh отодвигает
|
||
# expires_at (app/services/auth_session.py). Держать его РАВНЫМ значению «Меры»
|
||
# обязательно — иначе срок жизни сессии начнёт зависеть от того, в каком продукте
|
||
# человек кликнул последним. ENV: SESSION_TTL_HOURS.
|
||
session_ttl_hours: int = 720
|
||
|
||
@field_validator("auth_mode", mode="before")
|
||
@classmethod
|
||
def _blank_auth_mode_means_legacy(cls, value: object) -> object:
|
||
"""`AUTH_MODE=` (пустая строка) → `legacy`, а не ValidationError на импорте.
|
||
|
||
Та же ловушка, что у `AUTH_DB_PORT` ниже: `settings = Settings()` выполняется на
|
||
уровне модуля, поэтому невалидное значение роняет ИМПОРТ конфига и уводит
|
||
контейнер в restart-loop. Сценарий тот же — ops копирует блок AUTH_* в
|
||
.env.runtime и заполняет только пароль. Пустое значение обязано означать
|
||
«оставили как было», то есть сегодняшнее поведение.
|
||
|
||
Регистр и обрамляющие пробелы нормализуются: `AUTH_MODE=DB_ONLY ` — очевидная
|
||
опечатка со смыслом, а не запрос на падение. Непустой мусор (`AUTH_MODE=off`)
|
||
по-прежнему валится, и правильно: молча трактовать его как `legacy` значило бы
|
||
тихо оставить продукт на trusted-header после снятия popup'а.
|
||
"""
|
||
if isinstance(value, str):
|
||
normalized = value.strip().lower()
|
||
return normalized or "legacy"
|
||
return value
|
||
|
||
@property
|
||
def auth_session_enabled(self) -> bool:
|
||
"""Читает ли «Птица» сессионную куку общего реестра (то есть режим не `legacy`).
|
||
|
||
Производное от `auth_mode`, а не отдельное поле: два независимых переключателя
|
||
рано или поздно разъезжаются, и получилось бы состояние «куку читаем, но режим
|
||
легаси» (или наоборот), которого нет ни в одном настоящем сценарии.
|
||
|
||
Держит инвариант «`legacy` = ни одного коннекта к реестру»: по этому свойству
|
||
закорачиваются `app.services.auth_session.resolve_session_token` и
|
||
`app.core.auth_db.require_auth_db_configured`. Разница между `dual` и `db_only`
|
||
свойству не видна и не должна быть — она касается только фолбэка на
|
||
легаси-заголовок и живёт в `app.main.rbac_guard`.
|
||
"""
|
||
return self.auth_mode != "legacy"
|
||
|
||
@field_validator("auth_db_port", mode="before")
|
||
@classmethod
|
||
def _blank_auth_db_port_means_default(cls, value: object) -> object:
|
||
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
|
||
|
||
Симметрия с host/name/user, у которых пустое значение переменной падает
|
||
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
|
||
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
|
||
`settings = Settings()` выполняется на уровне модуля — то есть `AUTH_DB_PORT=`
|
||
в .env.runtime роняло бы ValidationError на импорте конфига и уводило контейнер
|
||
в restart-loop. Причём В ЛЮБОМ режиме, включая дефолтный (флаг выключен), где к
|
||
БД `auth` не идёт ни одного обращения — ровно тот инвариант «дефолт не трогаем»,
|
||
который держит весь этот PR.
|
||
|
||
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
|
||
заполняет только пароль — остальные строки остаются пустыми намеренно.
|
||
|
||
`mode="before"` — потому что вмешаться надо ДО приведения к int. Непустой мусор
|
||
(`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно: это опечатка со смыслом,
|
||
а не «оставил пустым».
|
||
"""
|
||
if isinstance(value, str) and not value.strip():
|
||
return _AUTH_DB_DEFAULT_PORT
|
||
return value
|
||
|
||
@property
|
||
def resolved_auth_database_url(self) -> str:
|
||
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
|
||
|
||
Приоритет:
|
||
1. `AUTH_DATABASE_URL`, если задан — выигрывает всегда.
|
||
2. Иначе, если задан `AUTH_DB_PASSWORD` — DSN собирается из частей.
|
||
3. Иначе — пустая строка, то есть «не сконфигурировано». Это НЕ ошибка сама
|
||
по себе: при `AUTH_MODE=legacy` (дефолт) сюда не заходит никто.
|
||
Ошибку — явную, а не тихий фолбэк — поднимает `app.core.auth_db`, и только
|
||
когда реестр реально понадобился.
|
||
|
||
⚠️ Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
|
||
исключений, не отдавать наружу (`/health`, `/docs`, метрики).
|
||
|
||
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`, `#`,
|
||
`%`) внутри пароля иначе порвал бы URL по своей грамматике — `@` сдвинул бы
|
||
границу host, `/` открыл бы path. Разбор дал бы либо ошибку, либо, что хуже,
|
||
МОЛЧА другой хост/базу. По той же причине экранируется имя пользователя.
|
||
|
||
А вот имя БД и хост — НЕ экранируются, и это не забывчивость: SQLAlchemy
|
||
раскодирует обратно только userinfo (user/password), а path отдаёт как есть.
|
||
Прогони мы имя БД через `quote`, в сервер уехало бы литеральное `c%2Fd` вместо
|
||
`c/d`. Хосту %-кодирование тоже только мешает — оно поломало бы IPv6-скобки.
|
||
"""
|
||
explicit = self.auth_database_url.strip()
|
||
if explicit:
|
||
return explicit
|
||
|
||
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
|
||
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
|
||
# ведущий/хвостовой пробел может быть частью настоящего пароля.
|
||
password = self.auth_db_password.get_secret_value()
|
||
if not password.strip():
|
||
return ""
|
||
|
||
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
|
||
secret = quote(password, safe="")
|
||
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
|
||
port = self.auth_db_port
|
||
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
|
||
# Схема — ровно та же, что у продуктового database_url (psycopg v3;
|
||
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
|
||
# зависимостях нет).
|
||
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
|
||
|
||
|
||
settings = Settings()
|