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
+1 -1
View File
@@ -6,7 +6,7 @@ const { title = "RF4 Spotter" } = Astro.props;
<html lang="ru">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><title>{title}</title></head>
<body>
<header class="site-header"><a href="/" class="brand"><span>RF4</span> Spotter</a><nav><a href="/">Активность</a><a href="/records">Рекорды</a></nav><p>Свежие точки без догадок</p></header>
<header class="site-header"><a href="/" class="brand"><span>RF4</span> Spotter</a><nav><a href="/">Активность</a><a href="/records">Рекорды</a><a href="/report">Добавить улов</a></nav><p>Свежие точки без догадок</p></header>
<main><slot /></main>
<footer>Неофициальный проект. Данные демонстрационные.</footer>
</body>
+10
View File
@@ -0,0 +1,10 @@
import type { APIRoute } from "astro";
const base = import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export const POST: APIRoute = async ({ request, redirect }) => {
const form = await request.formData();
const text = (name: string) => String(form.get(name) || "").trim() || null;
const number = (name: string) => text(name) ? Number(text(name)) : null;
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
try { const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); return redirect(response.ok ? "/report?state=sent" : "/report?state=error", 303); }
catch { return redirect("/report?state=error", 303); }
};
+16
View File
@@ -0,0 +1,16 @@
---
import Layout from "../layouts/Layout.astro";
import { api, type DictionaryItem } from "../lib/api";
let fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
const state = Astro.url.searchParams.get("state");
---
<Layout title="Добавить улов — RF4 Spotter">
<section class="form-hero"><span class="eyebrow">Помогите другим игрокам</span><h1>Добавить улов</h1><p>До проверки модератором запись не влияет на публичный индекс клёва.</p></section>
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
{state === "error" && <div class="notice error">Не удалось отправить улов. Проверьте поля и повторите.</div>}
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report">
<div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" required /></label><label>Приманка<input name="bait_name" maxlength="200" /></label><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div>
<label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник необязателен. Не отправляйте личные данные. Скриншоты будут добавлены после подключения объектного хранилища.</p><button type="submit">Отправить на проверку</button>
</form>}
</Layout>
+1
View File
@@ -20,3 +20,4 @@ label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spa
@media(max-width:800px){.site-header p{display:none}.hero{grid-template-columns:1fr;padding-top:50px}.filters{position:static;grid-template-columns:1fr 1fr}.filters button{grid-column:1/-1}.grid,.detail-grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.spot-hero{padding:25px}.pin{display:none}.periods div{padding:18px 8px}.periods strong{font-size:24px}}
@media(max-width:480px){.site-header,main,footer{width:min(100% - 24px,1180px)}.filters{grid-template-columns:1fr}.card{padding:20px}.hero h1{font-size:40px}.periods span{font-size:10px}}
.records-hero{padding:70px 0 45px;display:flex;align-items:end;justify-content:space-between;gap:30px}.records-hero h1{font:700 clamp(42px,7vw,78px)/1 Unbounded;margin:15px 0 0;letter-spacing:-.05em}.records-hero h1 em{color:var(--accent);font-style:normal}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px;align-items:center;color:#c6d1cd}.source-status small{grid-column:2;color:var(--muted)}.status-dot{width:9px;height:9px;border-radius:50%;background:#66736e}.status-dot.success{background:var(--accent)}.status-dot.failed{background:#ff6f61}.record-filters{display:flex;gap:12px;padding:18px;border:1px solid var(--line);background:var(--panel);border-radius:14px}.record-filters label{flex:1}.record-filters input{display:block;width:100%;margin-top:7px;padding:12px;border:0;border-radius:8px;background:#14251f;color:var(--ink)}.record-table{border:1px solid var(--line);border-radius:14px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:16px 18px;border-bottom:1px solid var(--line);align-items:center}.record-row:last-child{border:0}.record-row span,.record-row time{font-size:13px;color:#aebdb7}.record-head{background:#14251f;text-transform:uppercase;letter-spacing:.07em}.record-head span{font-size:10px;color:var(--muted)}.official-note{color:var(--muted);font-size:12px;margin-top:20px}.official-note a{color:#bccf61}@media(max-width:800px){.site-header nav{gap:12px}.records-hero{display:block}.source-status{margin-top:30px}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}}
.form-hero{padding:65px 0 35px}.form-hero h1{font:700 clamp(40px,7vw,74px)/1 Unbounded;margin:14px 0;letter-spacing:-.05em}.form-hero p{color:var(--muted)}.report-form{padding:28px;border:1px solid var(--line);background:var(--panel);border-radius:18px}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}.report-form input,.report-form textarea{display:block;width:100%;margin-top:7px;padding:12px;border:1px solid #223a32;border-radius:8px;background:#14251f;color:var(--ink);font:14px Manrope}.report-form .wide{display:block;margin-top:18px}.privacy{font-size:12px;color:var(--muted);line-height:1.6}.honeypot{position:absolute;left:-9999px}.notice{padding:15px 18px;border-radius:10px;margin-bottom:18px}.notice.success{background:#1d3726;color:#d7f45b}.notice.error{background:#44201d;color:#ffafa6}@media(max-width:650px){.site-header nav a:first-child{display:none}.form-grid{grid-template-columns:1fr}.report-form{padding:20px}}