Compare commits

...
2 Commits
Author SHA1 Message Date
ik b84f1fe815 refactor: extract public catalog router
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-12 21:05:13 +07:00
ik 8d7fab97b9 ci: lock generated OpenAPI contract 2026-09-12 21:03:37 +07:00
10 changed files with 3378 additions and 29 deletions
+2
View File
@@ -41,6 +41,8 @@ jobs:
alembic current alembic current
- name: Run Python tests - name: Run Python tests
run: pytest -q run: pytest -q
- name: Verify generated OpenAPI contract
run: python apps/api/export_openapi.py --check
astro-build: astro-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2
View File
@@ -50,6 +50,8 @@ 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. Декомпозиция выполняется инкрементально: общий `Db` вынесен в dependencies, каталог рыб/водоёмов/приманок и public spot pages уже обслуживает отдельный `APIRouter`.
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации. После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
+8
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""HTTP route groups for the RF4 Spotter API."""
+34
View File
@@ -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}")]
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from app.main import app
TARGET = Path(__file__).with_name("openapi.json")
def rendered_contract() -> str:
return json.dumps(app.openapi(), ensure_ascii=False, indent=2, sort_keys=True) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description="Generate or verify the RF4 Spotter OpenAPI contract")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
rendered = rendered_contract()
if args.check:
if not TARGET.exists() or TARGET.read_text(encoding="utf-8") != rendered:
parser.exit(1, "OpenAPI contract is stale; run apps/api/export_openapi.py\n")
print(f"OpenAPI contract is current: {len(app.openapi()['paths'])} paths")
return 0
TARGET.write_text(rendered, encoding="utf-8")
print(f"Wrote {TARGET}: {len(app.openapi()['paths'])} paths")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -51,12 +51,12 @@
Аудит выполнен на старой базе `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.
- [ ] **Q15 · Частичная деградация главной.** Разделить получение activity, community signals и справочников так, чтобы отказ одного источника не превращал всю главную в общий 503. Для SSR не добавлять искусственный client-side loading/optimistic UI; показывать независимые `StatePanel` и корректный HTTP/cache статус. - [ ] **Q15 · Частичная деградация главной.** Разделить получение activity, community signals и справочников так, чтобы отказ одного источника не превращал всю главную в общий 503. Для SSR не добавлять искусственный client-side loading/optimistic UI; показывать независимые `StatePanel` и корректный HTTP/cache статус.
- [ ] **Q16 · Контракт OpenAPI.** Генерировать OpenAPI artifact из приложения в CI и проверять осознанные изменения контракта при декомпозиции API; не поддерживать вручную редактируемую копию. - [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` детерминированно генерируется из FastAPI; CI проверяет его актуальность после backend suite. Изменение artifact обязательно рассматривается вместе с реализацией, а ручное редактирование не используется.
- [ ] **Q17 · Эксплуатационные документы.** Добавить короткие ADR по Astro/FastAPI/PostgreSQL, локальному cache и стратегии scheduler, а также incident runbook для заполнения диска/PostgreSQL, отказа MinIO, зависших импортов, ошибок миграции и компрометации секретов. - [ ] **Q17 · Эксплуатационные документы.** Добавить короткие ADR по Astro/FastAPI/PostgreSQL, локальному cache и стратегии scheduler, а также incident runbook для заполнения диска/PostgreSQL, отказа MinIO, зависших импортов, ошибок миграции и компрометации секретов.
- [ ] **Q18 · Минимальная observability.** До открытой альфы определить дешёвые метрики request count/latency/error rate, глубины moderation/staging и возраста последнего успешного импорта. Формат и exporter выбрать после выбора мониторинга сервера; полноценный tracing не внедрять без подтверждённой потребности. - [ ] **Q18 · Минимальная observability.** До открытой альфы определить дешёвые метрики request count/latency/error rate, глубины moderation/staging и возраста последнего успешного импорта. Формат и exporter выбрать после выбора мониторинга сервера; полноценный tracing не внедрять без подтверждённой потребности.
+13
View File
@@ -0,0 +1,13 @@
# Контракт API
`apps/api/openapi.json` — автоматически сгенерированный снимок фактического FastAPI-контракта. Он фиксирует пути, методы, параметры, response schemas и security metadata перед декомпозицией routers. Файл нельзя править вручную.
После осознанного изменения API:
```bash
.venv/bin/python apps/api/export_openapi.py
git diff -- apps/api/openapi.json
.venv/bin/python apps/api/export_openapi.py --check
```
CI выполняет режим `--check` после тестов и падает, если код и committed artifact расходятся. Изменение снимка должно находиться в том же коммите, что реализация и тесты нового контракта. Перестановка routers без изменения публичного поведения не должна менять artifact.