Add audited user report deletion

This commit is contained in:
ik
2026-09-03 08:14:56 +07:00
parent 03a0ecb5f4
commit 0b3e6d4b2f
8 changed files with 78 additions and 10 deletions
@@ -0,0 +1,16 @@
"""Soft deletion and anonymization marker for user reports."""
from alembic import op
import sqlalchemy as sa
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("catch_report", sa.Column("deleted_at", sa.DateTime(timezone=True)))
def downgrade() -> None:
op.drop_column("catch_report", "deleted_at")
+26 -4
View File
@@ -17,14 +17,14 @@ from .config import settings
from .importer import ImportSourceError, import_records, 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
from .storage import ScreenshotError, delete_screenshot, signed_screenshot_url, upload_screenshot
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"],
allow_methods=["GET", "PATCH"],
allow_methods=["GET", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
Db = Annotated[Session, Depends(get_session)]
@@ -190,14 +190,14 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) ->
@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)))
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, CatchReport.deleted_at.is_(None)).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"), 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)
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:
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
raise HTTPException(status_code=404, detail="catch report not found")
previous = report.moderation_status
report.moderation_status = ModerationStatus(payload.status)
@@ -206,6 +206,28 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
@app.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)]) -> Response:
report = db.get(CatchReport, report_id)
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
raise HTTPException(status_code=404, detail="catch report not found")
previous = report.moderation_status
if report.screenshot_key:
try:
delete_screenshot(report.screenshot_key)
except Exception as exc:
raise HTTPException(status_code=502, detail="screenshot deletion failed") from exc
report.moderation_status = ModerationStatus.rejected
report.deleted_at = datetime.now(timezone.utc)
report.player_name = None
report.source_url = None
report.screenshot_key = None
report.raw_payload = None
db.add(ModerationEvent(catch_report=report, created_at=report.deleted_at, previous_status=previous, new_status=ModerationStatus.rejected, moderator=moderator, reason="user report deleted and anonymized"))
db.commit()
return Response(status_code=204)
def _check_rate_limit(client: str) -> None:
now = datetime.now(timezone.utc)
recent = _submissions[client]
+1
View File
@@ -92,6 +92,7 @@ class CatchReport(Base):
source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
screenshot_key: Mapped[str | None] = mapped_column(Text)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
fish: Mapped[Fish] = relationship()
spot: Mapped[Spot | None] = relationship()
+4
View File
@@ -64,3 +64,7 @@ def upload_screenshot(raw: bytes) -> str:
def signed_screenshot_url(key: str) -> str:
return public_client().generate_presigned_url("get_object", Params={"Bucket": settings.s3_bucket, "Key": key}, ExpiresIn=900)
def delete_screenshot(key: str) -> None:
client().delete_object(Bucket=settings.s3_bucket, Key=key)
+26 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
@@ -9,7 +10,7 @@ from sqlalchemy.pool import StaticPool
from app.database import Base, get_session
from app.main import app
from app.models import Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
from app.models import Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
@@ -122,3 +123,27 @@ def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
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
def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monkeypatch) -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 93, "y": 94, "weight_g": 4300, "player_name": "Private Player", "source_url": "https://example.test/private", "comment": "private comment"}).json()
with Session(engine) as db:
report = db.get(CatchReport, UUID(created["id"]))
report.screenshot_key = "reports/private.jpg"
db.commit()
deleted_keys: list[str] = []
monkeypatch.setattr("app.main.delete_screenshot", deleted_keys.append)
headers = {"Authorization": "Bearer change-me-in-production"}
response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers)
assert response.status_code == 204
assert deleted_keys == ["reports/private.jpg"]
with Session(engine) as db:
report = db.get(CatchReport, UUID(created["id"]))
assert report.deleted_at is not None
assert report.moderation_status == ModerationStatus.rejected
assert report.player_name is None and report.source_url is None
assert report.screenshot_key is None and report.raw_payload is None
event = db.query(ModerationEvent).filter_by(catch_report_id=report.id).order_by(ModerationEvent.created_at.desc()).first()
assert event is not None
assert event.reason == "user report deleted and anonymized"
assert client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers).status_code == 404