Add administrative official import API

This commit is contained in:
ik
2026-09-03 07:59:15 +07:00
parent 44c3e797aa
commit ddd8b84055
7 changed files with 104 additions and 13 deletions
+3
View File
@@ -7,3 +7,6 @@ S3_PUBLIC_ENDPOINT_URL=http://localhost:9000
S3_ACCESS_KEY=rf4-local S3_ACCESS_KEY=rf4-local
S3_SECRET_KEY=rf4-local-secret S3_SECRET_KEY=rf4-local-secret
S3_BUCKET=catch-screenshots S3_BUCKET=catch-screenshots
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
OFFICIAL_RECORDS_REGION=RU
OFFICIAL_RECORDS_CATEGORY=records
+3
View File
@@ -10,6 +10,9 @@ class Settings(BaseSettings):
s3_secret_key: str = "rf4-local-secret" s3_secret_key: str = "rf4-local-secret"
s3_bucket: str = "catch-screenshots" s3_bucket: str = "catch-screenshots"
screenshot_max_bytes: int = 8 * 1024 * 1024 screenshot_max_bytes: int = 8 * 1024 * 1024
official_records_url: str = "https://rf4game.de/records/region/RU/"
official_records_region: str = "RU"
official_records_category: str = "records"
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+34 -7
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
import httpx
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
@@ -13,7 +14,7 @@ from sqlalchemy.orm import Session, joinedload
from .activity import activity_rows from .activity import activity_rows
from .database import get_session from .database import get_session
from .config import settings from .config import settings
from .importer import normalize from .importer import ImportSourceError, import_records, normalize
from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, signed_screenshot_url, upload_screenshot from .storage import ScreenshotError, signed_screenshot_url, upload_screenshot
@@ -103,11 +104,43 @@ def records(
return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items] return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items]
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
if not authorization or authorization != f"Bearer {settings.admin_token}":
raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
return "admin"
@app.get("/api/v1/imports", response_model=list[ImportRunOut]) @app.get("/api/v1/imports", response_model=list[ImportRunOut])
def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecordImport]: def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecordImport]:
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit))) return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit)))
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
def admin_imports(
db: Db,
_: Annotated[str, Depends(_admin)],
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[OfficialRecordImport]:
query = select(OfficialRecordImport).order_by(
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()
).offset(offset).limit(limit)
return list(db.scalars(query))
@app.post("/api/v1/admin/imports/official-records", response_model=ImportRunOut, status_code=201)
def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> OfficialRecordImport:
try:
return import_records(
db,
url=settings.official_records_url,
region=settings.official_records_region,
category=settings.official_records_category,
)
except (ImportSourceError, httpx.HTTPError) as exc:
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
@app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201) @app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201)
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated: def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated:
if payload.website: if payload.website:
@@ -150,12 +183,6 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) ->
return Response(status_code=204) return Response(status_code=204)
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
if not authorization or authorization != f"Bearer {settings.admin_token}":
raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
return "admin"
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut]) @app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]: def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]:
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status).order_by(CatchReport.reported_at).limit(limit))) reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status).order_by(CatchReport.reported_at).limit(limit)))
+29 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy.pool import StaticPool
from app.database import Base, get_session from app.database import Base, get_session
from app.main import app from app.main import app
from app.models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody from app.models import Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
@@ -85,6 +85,34 @@ def test_user_report_requires_moderation_before_activity() -> None:
def test_admin_requires_token() -> None: def test_admin_requires_token() -> None:
assert client.get("/api/v1/admin/catch-reports").status_code == 401 assert client.get("/api/v1/admin/catch-reports").status_code == 401
assert client.get("/api/v1/admin/imports").status_code == 401
assert client.post("/api/v1/admin/imports/official-records").status_code == 401
def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
def fake_import(db: Session, **_: str) -> OfficialRecordImport:
run = OfficialRecordImport(
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
status=ImportStatus.success,
source_url="fixture://admin",
rows_seen=2,
rows_created=2,
rows_updated=0,
)
db.add(run)
db.commit()
db.refresh(run)
return run
monkeypatch.setattr("app.main.import_records", fake_import)
headers = {"Authorization": "Bearer change-me-in-production"}
started = client.post("/api/v1/admin/imports/official-records", headers=headers)
assert started.status_code == 201
assert started.json()["source_url"] == "fixture://admin"
listed = client.get("/api/v1/admin/imports?limit=1&offset=0", headers=headers)
assert listed.status_code == 200
assert listed.json()[0]["id"] == started.json()["id"]
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None: def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
+29 -2
View File
@@ -2,12 +2,13 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
from sqlalchemy import create_engine, func, select from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import Base from app.database import Base
from app.importer import import_records, parse_html from app.importer import ImportSourceError, import_records, parse_html
from app.models import CatchReport, OfficialRecordImport, SourceType from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html" FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
@@ -28,3 +29,29 @@ def test_parser_and_import_are_idempotent() -> None:
assert (second.rows_created, second.rows_updated) == (0, 2) assert (second.rows_created, second.rows_updated) == (0, 2)
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2 assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2 assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
def test_failed_import_preserves_previous_records_and_is_logged() -> None:
html = FIXTURE.read_text(encoding="utf-8")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
import_records(db, url="fixture://records", region="RU", category="records", html=html)
before = db.scalar(select(func.count()).select_from(CatchReport))
with pytest.raises(ImportSourceError, match="records table not found"):
import_records(db, url="fixture://broken", region="RU", category="records", html="<html></html>")
assert db.scalar(select(func.count()).select_from(CatchReport)) == before
failed = db.scalar(select(OfficialRecordImport).where(OfficialRecordImport.status == ImportStatus.failed))
assert failed is not None
assert failed.source_url == "fixture://broken"
assert "records table not found" in (failed.error_summary or "")
def test_import_rejects_changed_column_contract() -> None:
html = FIXTURE.read_text(encoding="utf-8").replace(
'class="col data"', 'class="col changed"', 1
)
with pytest.raises(ImportSourceError, match="record columns changed"):
parse_html(html, region="RU", category="records")
+3
View File
@@ -35,6 +35,9 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-rf4-local} S3_ACCESS_KEY: ${S3_ACCESS_KEY:-rf4-local}
S3_SECRET_KEY: ${S3_SECRET_KEY:-rf4-local-secret} S3_SECRET_KEY: ${S3_SECRET_KEY:-rf4-local-secret}
S3_BUCKET: ${S3_BUCKET:-catch-screenshots} S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
OFFICIAL_RECORDS_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/}
OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU}
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+3 -3
View File
@@ -18,12 +18,12 @@
## Этап 2 — завершить официальный импорт ## Этап 2 — завершить официальный импорт
- [ ] Добавить административный endpoint ручного запуска импорта `POST /api/v1/admin/imports/official-records`. - [x] Добавить административный endpoint ручного запуска импорта `POST /api/v1/admin/imports/official-records` (проверено API-тестом).
- [ ] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой. - [x] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой (проверено API-тестом).
- [ ] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа. - [ ] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа.
- [ ] Добавить планировщик импорта с безопасной частотой по умолчанию один раз в 60 минут; отдельный контейнер/процесс без дублирования запусков. - [ ] Добавить планировщик импорта с безопасной частотой по умолчанию один раз в 60 минут; отдельный контейнер/процесс без дублирования запусков.
- [ ] Проверить актуальные `robots.txt` и условия использования перед включением расписания; результат записать в `docs/data-sources.md`. - [ ] Проверить актуальные `robots.txt` и условия использования перед включением расписания; результат записать в `docs/data-sources.md`.
- [ ] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой. - [x] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой.
Критерий готовности: официальный импорт запускается вручную и по расписанию, наблюдаем, идемпотентен и безопасно переживает недоступность источника. Критерий готовности: официальный импорт запускается вручную и по расписанию, наблюдаем, идемпотентен и безопасно переживает недоступность источника.