Apply reference design to Astro and add screenshot storage

This commit is contained in:
ik
2026-09-03 07:53:53 +07:00
parent 6d536d0ade
commit c5242720b1
26 changed files with 253 additions and 59 deletions
+6
View File
@@ -4,6 +4,12 @@ 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"
s3_endpoint_url: str = "http://localhost:9000"
s3_public_endpoint_url: str = "http://localhost:9000"
s3_access_key: str = "rf4-local"
s3_secret_key: str = "rf4-local-secret"
s3_bucket: str = "catch-screenshots"
screenshot_max_bytes: int = 8 * 1024 * 1024
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+19 -2
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal
from uuid import UUID
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
@@ -16,6 +16,7 @@ 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
from .storage import ScreenshotError, signed_screenshot_url, upload_screenshot
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
@@ -133,6 +134,22 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) ->
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
@app.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) -> Response:
report = db.get(CatchReport, report_id)
if report is None or report.source_type != SourceType.user or report.moderation_status != ModerationStatus.pending:
raise HTTPException(status_code=404, detail="pending catch report not found")
if report.screenshot_key:
raise HTTPException(status_code=409, detail="screenshot already uploaded")
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
try:
report.screenshot_key = upload_screenshot(raw)
except ScreenshotError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
db.commit()
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"})
@@ -142,7 +159,7 @@ def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
@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]
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"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports]
@app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated)
+1
View File
@@ -91,6 +91,7 @@ class CatchReport(Base):
source_external_id: Mapped[str | None] = mapped_column(String(64), unique=True)
source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
screenshot_key: Mapped[str | None] = mapped_column(Text)
raw_payload: Mapped[dict | None] = mapped_column(JSON)
fish: Mapped[Fish] = relationship()
spot: Mapped[Spot | None] = relationship()
+1
View File
@@ -142,6 +142,7 @@ class AdminCatchReportOut(BaseModel):
reported_at: datetime
moderation_status: str
comment: str | None
screenshot_url: str | None
class ModerationUpdate(BaseModel):
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import io
import uuid
from functools import lru_cache
import boto3
from botocore.client import BaseClient
from botocore.exceptions import ClientError
from PIL import Image, UnidentifiedImageError
from .config import settings
class ScreenshotError(ValueError):
pass
ALLOWED_FORMATS = {"JPEG": ("jpg", "image/jpeg"), "PNG": ("png", "image/png"), "WEBP": ("webp", "image/webp")}
@lru_cache
def client() -> BaseClient:
return boto3.client("s3", endpoint_url=settings.s3_endpoint_url, aws_access_key_id=settings.s3_access_key, aws_secret_access_key=settings.s3_secret_key, region_name="us-east-1")
@lru_cache
def public_client() -> BaseClient:
return boto3.client("s3", endpoint_url=settings.s3_public_endpoint_url, aws_access_key_id=settings.s3_access_key, aws_secret_access_key=settings.s3_secret_key, region_name="us-east-1")
def prepare_image(raw: bytes) -> tuple[bytes, str, str]:
if not raw or len(raw) > settings.screenshot_max_bytes:
raise ScreenshotError("screenshot must be between 1 byte and 8 MB")
try:
with Image.open(io.BytesIO(raw)) as source:
source.verify()
with Image.open(io.BytesIO(raw)) as source:
fmt = (source.format or "").upper()
if fmt not in ALLOWED_FORMATS:
raise ScreenshotError("only JPEG, PNG and WebP images are accepted")
if source.width * source.height > 40_000_000:
raise ScreenshotError("image dimensions are too large")
extension, mime = ALLOWED_FORMATS[fmt]
clean = source.convert("RGB") if fmt in {"JPEG", "WEBP"} else source.convert("RGBA")
output = io.BytesIO()
clean.save(output, format=fmt, optimize=True)
return output.getvalue(), extension, mime
except (UnidentifiedImageError, OSError) as exc:
raise ScreenshotError("file is not a valid image") from exc
def upload_screenshot(raw: bytes) -> str:
body, extension, mime = prepare_image(raw)
key = f"reports/{uuid.uuid4()}.{extension}"
s3 = client()
try:
s3.head_bucket(Bucket=settings.s3_bucket)
except ClientError:
s3.create_bucket(Bucket=settings.s3_bucket)
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime)
return key
def signed_screenshot_url(key: str) -> str:
return public_client().generate_presigned_url("get_object", Params={"Bucket": settings.s3_bucket, "Key": key}, ExpiresIn=900)