Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b84f1fe815 | ||
|
|
8d7fab97b9 |
@@ -41,6 +41,8 @@ jobs:
|
||||
alembic current
|
||||
- name: Run Python tests
|
||||
run: pytest -q
|
||||
- name: Verify generated OpenAPI contract
|
||||
run: python apps/api/export_openapi.py --check
|
||||
|
||||
astro-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -50,6 +50,8 @@ 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. Декомпозиция выполняется инкрементально: общий `Db` вынесен в dependencies, каталог рыб/водоёмов/приманок и public spot pages уже обслуживает отдельный `APIRouter`.
|
||||
|
||||
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||
|
||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||
|
||||
@@ -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 .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)
|
||||
|
||||
@@ -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}")]
|
||||
@@ -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
@@ -51,12 +51,12 @@
|
||||
|
||||
Аудит выполнен на старой базе `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.
|
||||
- [ ] **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, зависших импортов, ошибок миграции и компрометации секретов.
|
||||
- [ ] **Q18 · Минимальная observability.** До открытой альфы определить дешёвые метрики request count/latency/error rate, глубины moderation/staging и возраста последнего успешного импорта. Формат и exporter выбрать после выбора мониторинга сервера; полноценный tracing не внедрять без подтверждённой потребности.
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user