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
@@ -0,0 +1,26 @@
"""Moderation event journal."""
from alembic import op
import sqlalchemy as sa
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
moderation = sa.Enum("pending", "approved", "rejected", name="moderationstatus", create_type=False)
op.create_table(
"moderation_event",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("previous_status", moderation, nullable=False),
sa.Column("new_status", moderation, nullable=False),
sa.Column("moderator", sa.String(100), nullable=False),
sa.Column("reason", sa.Text()),
)
def downgrade() -> None:
op.drop_table("moderation_event")
+1
View File
@@ -3,6 +3,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
admin_token: str = "change-me-in-production"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+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)
+12
View File
@@ -109,3 +109,15 @@ class OfficialRecordImport(Base):
rows_created: Mapped[int] = mapped_column(default=0)
rows_updated: Mapped[int] = mapped_column(default=0)
error_summary: Mapped[str | None] = mapped_column(Text)
class ModerationEvent(Base):
__tablename__ = "moderation_event"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
previous_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus, name="moderationstatus", create_type=False))
new_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus, name="moderationstatus", create_type=False))
moderator: Mapped[str] = mapped_column(String(100))
reason: Mapped[str | None] = mapped_column(Text)
catch_report: Mapped[CatchReport] = relationship()
+57 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, field_validator
class FishOut(BaseModel):
@@ -98,3 +98,59 @@ class ImportRunOut(BaseModel):
rows_created: int
rows_updated: int
error_summary: str | None
class CatchReportCreate(BaseModel):
fish_slug: str
waterbody_slug: str
x: int = Field(ge=-10_000, le=10_000)
y: int = Field(ge=-10_000, le=10_000)
weight_g: int = Field(gt=0, le=3_000_000)
bait_name: str | None = Field(default=None, max_length=200)
fishing_method: str | None = Field(default=None, max_length=50)
rig_type: str | None = Field(default=None, max_length=100)
retrieve_method: str | None = Field(default=None, max_length=100)
retrieve_speed: int | None = Field(default=None, ge=0, le=100)
caught_at: datetime | None = None
player_name: str | None = Field(default=None, max_length=100)
source_url: str | None = Field(default=None, max_length=1000)
comment: str | None = Field(default=None, max_length=1000)
website: str = Field(default="", max_length=200)
@field_validator("fish_slug", "waterbody_slug")
@classmethod
def nonempty_slug(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("must not be empty")
return value
class CatchReportCreated(BaseModel):
id: UUID
moderation_status: str
class AdminCatchReportOut(BaseModel):
id: UUID
fish: str
waterbody: str
coordinates: str
weight_g: int
bait: str | None
player_name: str | None
reported_at: datetime
moderation_status: str
comment: str | None
class ModerationUpdate(BaseModel):
status: str
reason: str | None = Field(default=None, max_length=1000)
@field_validator("status")
@classmethod
def known_status(cls, value: str) -> str:
if value not in {"approved", "rejected", "pending"}:
raise ValueError("unknown moderation status")
return value
+19
View File
@@ -66,3 +66,22 @@ def test_records_list_is_empty_before_import() -> None:
response = client.get("/api/v1/records")
assert response.status_code == 200
assert response.json() == []
def test_user_report_requires_moderation_before_activity() -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
assert created.status_code == 201
assert created.json()["moderation_status"] == "pending"
report_id = created.json()["id"]
headers = {"Authorization": "Bearer change-me-in-production"}
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
assert pending.status_code == 200
assert any(item["id"] == report_id for item in pending.json())
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
assert approved.status_code == 200
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json()
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity)
def test_admin_requires_token() -> None:
assert client.get("/api/v1/admin/catch-reports").status_code == 401