From b84f1fe815a2d5c425595cc3ef0c8f8775e3d7be Mon Sep 17 00:00:00 2001 From: IK Date: Sat, 12 Sep 2026 21:05:13 +0700 Subject: [PATCH] refactor: extract public catalog router --- README.md | 2 +- apps/api/app/dependencies.py | 8 ++++++++ apps/api/app/main.py | 31 ++++------------------------- apps/api/app/routers/__init__.py | 1 + apps/api/app/routers/catalog.py | 34 ++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 2 +- 6 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 apps/api/app/dependencies.py create mode 100644 apps/api/app/routers/__init__.py create mode 100644 apps/api/app/routers/catalog.py diff --git a/README.md b/README.md index 5e52166..f42769e 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Production release отделяет Alembic от runtime: одноразовый Тяжёлый production bootstrap вынесен в отдельный ручной/еженедельный CI workflow с 30-минутным timeout и сохраняемыми diagnostics; обычный push по-прежнему использует быстрый Compose E2E. -Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. +Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. Декомпозиция выполняется инкрементально: общий `Db` вынесен в dependencies, каталог рыб/водоёмов/приманок и public spot pages уже обслуживает отдельный `APIRouter`. После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации. diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py new file mode 100644 index 0000000..b6b74ba --- /dev/null +++ b/apps/api/app/dependencies.py @@ -0,0 +1,8 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.orm import Session + +from .database import get_session + +Db = Annotated[Session, Depends(get_session)] diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 7f6a76d..fa6343f 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -22,15 +22,16 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload from .activity import activity_rows -from .database import get_session from .config import settings +from .dependencies import Db from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize from .logging_config import configure_logging from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .readiness import readiness_report +from .routers.catalog import router as catalog_router from .public_cache import public_cache -from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PaginatedOfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut +from .schemas import ActivityOut, AdminCatchReportOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PaginatedOfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot @@ -43,7 +44,6 @@ app.add_middleware( allow_methods=["GET", "POST", "PATCH", "DELETE"], allow_headers=["Authorization", "Content-Type"], ) -Db = Annotated[Session, Depends(get_session)] @app.middleware("http") @@ -98,30 +98,7 @@ def ready(db: Db) -> JSONResponse: ) -@app.get("/api/v1/fishes", response_model=list[FishOut]) -def fishes(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Fish]: - return list(db.scalars(select(Fish).order_by(Fish.name_ru, Fish.id).offset(offset).limit(limit))) - - -@app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut]) -def waterbodies(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Waterbody]: - return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru, Waterbody.id).offset(offset).limit(limit))) - - -@app.get("/api/v1/baits", response_model=list[BaitOut]) -def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Bait]: - return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit))) - - -@app.get("/api/v1/public-spot-pages") -def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]: - rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug) - .select_from(CatchReport).join(Spot, CatchReport.spot_id == Spot.id) - .join(Waterbody, Spot.waterbody_id == Waterbody.id).join(Fish, CatchReport.fish_id == Fish.id) - .where(CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)) - .distinct().order_by(Waterbody.slug, Spot.x, Spot.y, Fish.slug).offset(offset).limit(limit)) - return [path for water, x, y, fish in rows for path in - (f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")] +app.include_router(catalog_router) @app.get("/api/v1/activity", response_model=PaginatedActivityOut) diff --git a/apps/api/app/routers/__init__.py b/apps/api/app/routers/__init__.py new file mode 100644 index 0000000..597ad75 --- /dev/null +++ b/apps/api/app/routers/__init__.py @@ -0,0 +1 @@ +"""HTTP route groups for the RF4 Spotter API.""" diff --git a/apps/api/app/routers/catalog.py b/apps/api/app/routers/catalog.py new file mode 100644 index 0000000..ccde394 --- /dev/null +++ b/apps/api/app/routers/catalog.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Query +from sqlalchemy import select + +from ..dependencies import Db +from ..models import Bait, CatchReport, Fish, ModerationStatus, Spot, Waterbody +from ..schemas import BaitOut, FishOut, WaterbodyOut + +router = APIRouter() + + +@router.get("/api/v1/fishes", response_model=list[FishOut]) +def fishes(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Fish]: + return list(db.scalars(select(Fish).order_by(Fish.name_ru, Fish.id).offset(offset).limit(limit))) + + +@router.get("/api/v1/waterbodies", response_model=list[WaterbodyOut]) +def waterbodies(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Waterbody]: + return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru, Waterbody.id).offset(offset).limit(limit))) + + +@router.get("/api/v1/baits", response_model=list[BaitOut]) +def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Bait]: + return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit))) + + +@router.get("/api/v1/public-spot-pages") +def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]: + rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug) + .select_from(CatchReport).join(Spot, CatchReport.spot_id == Spot.id) + .join(Waterbody, Spot.waterbody_id == Waterbody.id).join(Fish, CatchReport.fish_id == Fish.id) + .where(CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)) + .distinct().order_by(Waterbody.slug, Spot.x, Spot.y, Fish.slug).offset(offset).limit(limit)) + return [path for water, x, y, fish in rows for path in + (f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 63ef68c..6e69934 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog. -- [ ] **Q11 · Декомпозиция API.** Инкрементально разделить `apps/api/app/main.py` (638 строк, 26 маршрутов) на `APIRouter` по публичному каталогу/activity, submissions и admin/community. Сначала зафиксировать OpenAPI snapshot и сохранить URL, response models, middleware и dependency-поведение без функциональных изменений. +- [ ] **Q11 · Декомпозиция API — в работе.** После фиксации OpenAPI catalog routes (рыбы, водоёмы, приманки, public spot pages) вынесены в первый `APIRouter`, общий `Db` — в dependencies. URL, response models и generated contract сохранены. Далее: activity/spots, submissions и admin/community отдельными пакетами. - [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95. - [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics. - [ ] **Q14 · Полная CSP.** Расширить текущую CSP (`frame-ancestors`, `base-uri`, `object-src`) до `default-src`, `script-src`, `style-src`, `img-src`, `connect-src` и `form-action`. Сначала инвентаризировать inline scripts/styles Astro, затем внедрить nonce/hash или безопасное вынесение; проверить report/admin/OG без ослабления до произвольных внешних origin.