Apply reference design to Astro and add screenshot storage
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""Screenshot object key."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("catch_report", sa.Column("screenshot_key", sa.Text()))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("catch_report", "screenshot_key")
|
||||
@@ -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)
|
||||
@@ -1,9 +1,12 @@
|
||||
alembic==1.16.5
|
||||
beautifulsoup4==4.15.0
|
||||
boto3==1.40.35
|
||||
fastapi==0.116.1
|
||||
httpx==0.28.1
|
||||
psycopg[binary]==3.2.10
|
||||
pydantic-settings==2.10.1
|
||||
pillow==11.3.0
|
||||
python-multipart==0.0.20
|
||||
pytest==8.4.2
|
||||
sqlalchemy==2.0.43
|
||||
uvicorn[standard]==0.35.0
|
||||
|
||||
@@ -85,3 +85,12 @@ def test_user_report_requires_moderation_before_activity() -> None:
|
||||
|
||||
def test_admin_requires_token() -> None:
|
||||
assert client.get("/api/v1/admin/catch-reports").status_code == 401
|
||||
|
||||
|
||||
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
|
||||
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json()
|
||||
monkeypatch.setattr("app.main.upload_screenshot", lambda raw: "reports/test.jpg" if raw == b"image-bytes" else "unexpected")
|
||||
response = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
|
||||
assert response.status_code == 204
|
||||
duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
|
||||
assert duplicate.status_code == 409
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from app.storage import ScreenshotError, prepare_image
|
||||
|
||||
|
||||
def test_prepare_image_removes_metadata() -> None:
|
||||
source = Image.new("RGB", (32, 24), "#12383b")
|
||||
exif = Image.Exif()
|
||||
exif[0x010E] = "private note"
|
||||
raw = io.BytesIO()
|
||||
source.save(raw, format="JPEG", exif=exif)
|
||||
|
||||
cleaned, extension, mime = prepare_image(raw.getvalue())
|
||||
|
||||
with Image.open(io.BytesIO(cleaned)) as result:
|
||||
assert result.getexif() == {}
|
||||
assert result.size == (32, 24)
|
||||
assert (extension, mime) == ("jpg", "image/jpeg")
|
||||
|
||||
|
||||
def test_prepare_image_rejects_non_image() -> None:
|
||||
with pytest.raises(ScreenshotError, match="valid image"):
|
||||
prepare_image(b"not an image")
|
||||
Reference in New Issue
Block a user