Add administrative official import API
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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 завершается понятной ошибкой.
|
||||||
|
|
||||||
Критерий готовности: официальный импорт запускается вручную и по расписанию, наблюдаем, идемпотентен и безопасно переживает недоступность источника.
|
Критерий готовности: официальный импорт запускается вручную и по расписанию, наблюдаем, идемпотентен и безопасно переживает недоступность источника.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user