refactor: move submission endpoints to router
This commit is contained in:
@@ -364,8 +364,7 @@ def admin_reject_external_observation(
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@submissions_router.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
|
||||
def create_catch_report(
|
||||
def _legacy_create_catch_report(
|
||||
payload: CatchReportCreate, request: Request, db: Db,
|
||||
idempotency_key: Annotated[str | None, Header()] = None,
|
||||
) -> CatchReportAccepted:
|
||||
@@ -440,8 +439,7 @@ def create_catch_report(
|
||||
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
||||
|
||||
|
||||
@submissions_router.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
|
||||
def add_screenshot(
|
||||
def _legacy_add_screenshot(
|
||||
report_id: UUID, db: Db, screenshot: UploadFile = File(),
|
||||
upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None,
|
||||
) -> Response:
|
||||
|
||||
@@ -1,3 +1,115 @@
|
||||
from fastapi import APIRouter
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, Header, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..config import settings
|
||||
from ..dependencies import Db
|
||||
from ..importer import normalize
|
||||
from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from ..schemas import CatchReportAccepted, CatchReportCreate
|
||||
from ..storage import ScreenshotError, upload_screenshot
|
||||
from ..submission_security import check_rate_limit
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("rf4.api.submissions")
|
||||
|
||||
|
||||
@router.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
|
||||
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, idempotency_key: Annotated[str | None, Header()] = None) -> CatchReportAccepted:
|
||||
if payload.website:
|
||||
raise HTTPException(status_code=400, detail="invalid submission")
|
||||
payload_hash = hashlib.sha256(json.dumps(payload.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest() if idempotency_key else None
|
||||
if key_hash:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
db.expire_all()
|
||||
existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash, SubmissionAttempt.created_at >= cutoff))
|
||||
if existing is not None:
|
||||
logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]})
|
||||
if existing.payload_hash and not hmac.compare_digest(existing.payload_hash, payload_hash):
|
||||
raise HTTPException(status_code=409, detail="Idempotency-Key was already used with different payload")
|
||||
if existing.catch_report is None:
|
||||
raise HTTPException(status_code=409, detail="idempotency record is incomplete; retry with a new key")
|
||||
replay_token = _replay_token(key_hash)
|
||||
if not hmac.compare_digest(hashlib.sha256(replay_token.encode()).hexdigest(), existing.catch_report.screenshot_upload_token_hash or ""):
|
||||
raise HTTPException(status_code=409, detail="idempotency record token mismatch; retry with a new key")
|
||||
return JSONResponse(status_code=200, content=_accepted(existing.catch_report, replay_token, True))
|
||||
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
|
||||
check_rate_limit(request, db, settings)
|
||||
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 = _bait(db, payload.bait_name)
|
||||
upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32)
|
||||
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, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
|
||||
db.add(report)
|
||||
if key_hash:
|
||||
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
winner = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))
|
||||
if winner and winner.catch_report:
|
||||
return JSONResponse(status_code=200, content=_accepted(winner.catch_report, _replay_token(key_hash), True))
|
||||
raise
|
||||
logger.info("idempotency key stored", extra={"idempotency_key": idempotency_key[:8]})
|
||||
else:
|
||||
db.commit()
|
||||
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
||||
|
||||
|
||||
@router.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(), upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None) -> 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")
|
||||
supplied_hash = hashlib.sha256((upload_token or "").encode()).hexdigest()
|
||||
if not report.screenshot_upload_token_hash or not hmac.compare_digest(report.screenshot_upload_token_hash, supplied_hash):
|
||||
raise HTTPException(status_code=401, detail="invalid screenshot upload token")
|
||||
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, filename=screenshot.filename, content_type=screenshot.content_type)
|
||||
except ScreenshotError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
report.screenshot_upload_token_hash = None
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def _replay_token(key_hash: str) -> str:
|
||||
return hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _accepted(report: CatchReport, token: str, idempotent: bool) -> dict[str, object]:
|
||||
return {"id": str(report.id), "moderation_status": report.moderation_status.value, "screenshot_upload_token": token, "idempotent": idempotent}
|
||||
|
||||
|
||||
def _bait(db: Db, value: str | None) -> Bait | None:
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
key = normalize(value)
|
||||
bait = db.scalar(select(Bait).where(Bait.normalized_name == key))
|
||||
if bait is None:
|
||||
bait = Bait(name=value.strip(), normalized_name=key, kind=BaitKind.unknown)
|
||||
db.add(bait)
|
||||
return bait
|
||||
|
||||
@@ -414,7 +414,7 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||
|
||||
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, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected")
|
||||
monkeypatch.setattr("app.routers.submissions.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected")
|
||||
upload_url = f"/api/v1/catch-reports/{created['id']}/screenshot"
|
||||
assert client.post(upload_url, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
|
||||
assert client.post(upload_url, headers={"X-Upload-Token": "wrong"}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user