Apply reference design to Astro and add screenshot storage
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -142,6 +142,7 @@ class AdminCatchReportOut(BaseModel):
|
||||
reported_at: datetime
|
||||
moderation_status: str
|
||||
comment: str | None
|
||||
screenshot_url: str | None
|
||||
|
||||
|
||||
class ModerationUpdate(BaseModel):
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user