refactor: extract public catalog router
This commit is contained in:
@@ -50,7 +50,7 @@ Production release отделяет Alembic от runtime: одноразовый
|
|||||||
|
|
||||||
Тяжёлый production bootstrap вынесен в отдельный ручной/еженедельный CI workflow с 30-минутным timeout и сохраняемыми diagnostics; обычный push по-прежнему использует быстрый Compose E2E.
|
Тяжёлый 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 запросов, внутренних ошибок и другой диагностической информации.
|
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
+4
-27
@@ -22,15 +22,16 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from .activity import activity_rows
|
from .activity import activity_rows
|
||||||
from .database import get_session
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
from .dependencies import Db
|
||||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
||||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
|
from .routers.catalog import router as catalog_router
|
||||||
from .public_cache import public_cache
|
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
|
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_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||||
allow_headers=["Authorization", "Content-Type"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
Db = Annotated[Session, Depends(get_session)]
|
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -98,30 +98,7 @@ def ready(db: Db) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/fishes", response_model=list[FishOut])
|
app.include_router(catalog_router)
|
||||||
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.get("/api/v1/activity", response_model=PaginatedActivityOut)
|
@app.get("/api/v1/activity", response_model=PaginatedActivityOut)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP route groups for the RF4 Spotter API."""
|
||||||
@@ -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}")]
|
||||||
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog.
|
Аудит выполнен на старой базе `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.
|
- [ ] **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.
|
- [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.
|
- [ ] **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.
|
||||||
|
|||||||
Reference in New Issue
Block a user