Add user catch submission and moderation workflow

This commit is contained in:
ik
2026-09-02 20:33:58 +07:00
parent d3a45248ef
commit 6d536d0ade
13 changed files with 227 additions and 8 deletions
+67 -4
View File
@@ -1,24 +1,27 @@
from __future__ import annotations
from collections import Counter
from collections import Counter, defaultdict, deque
from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .activity import activity_rows
from .database import get_session
from .models import Bait, CatchReport, Fish, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
from .schemas import ActivityOut, BaitOut, CatchOut, FishOut, ImportRunOut, OfficialRecordOut, SpotOut, WaterbodyOut
from .config import settings
from .importer import normalize
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
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:4321"], allow_methods=["GET"], allow_headers=["*"])
Db = Annotated[Session, Depends(get_session)]
_submissions: dict[str, deque[datetime]] = defaultdict(deque)
@app.get("/health")
@@ -104,5 +107,65 @@ def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecord
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit)))
@app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201)
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated:
if payload.website:
raise HTTPException(status_code=400, detail="invalid submission")
_check_rate_limit(request.client.host if request.client else "unknown")
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
if fish is None or waterbody is None:
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
spot = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == payload.x, Spot.y == payload.y))
if spot is None:
spot = Spot(waterbody=waterbody, x=payload.x, y=payload.y)
db.add(spot)
bait = None
if payload.bait_name and payload.bait_name.strip():
key = normalize(payload.bait_name)
bait = db.scalar(select(Bait).where(Bait.normalized_name == key))
if bait is None:
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
db.add(bait)
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None)
db.add(report)
db.commit()
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
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])
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)))
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment")) for r in reports]
@app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated)
def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated:
report = db.get(CatchReport, report_id)
if report is None or report.source_type != SourceType.user:
raise HTTPException(status_code=404, detail="catch report not found")
previous = report.moderation_status
report.moderation_status = ModerationStatus(payload.status)
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
db.commit()
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
def _check_rate_limit(client: str) -> None:
now = datetime.now(timezone.utc)
recent = _submissions[client]
while recent and recent[0] < now - timedelta(minutes=10):
recent.popleft()
if len(recent) >= 5:
raise HTTPException(status_code=429, detail="too many submissions")
recent.append(now)
def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)