feat: add moderation decision history

This commit is contained in:
ik
2026-09-12 21:52:25 +07:00
parent 9681bdbd36
commit fcc26a5b34
6 changed files with 179 additions and 7 deletions
+30 -1
View File
@@ -31,8 +31,9 @@ from .routers.activity import router as activity_router
from .routers.catalog import router as catalog_router
from .routers.public_data import router as public_data_router
from .routers.submissions import router as submissions_router
from .time_utils import aware
from .public_cache import public_cache
from .schemas import ActivityOut, AdminCatchReportOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from .schemas import ActivityOut, AdminCatchReportOut, AdminModerationHistoryOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
from .submission_security import check_rate_limit
from .submission_security import is_trusted_proxy as _is_trusted_proxy
@@ -133,6 +134,34 @@ def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONRespons
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
@app.get("/api/v1/admin/moderation-history", response_model=list[AdminModerationHistoryOut])
def admin_moderation_history(
db: Db,
_: Annotated[str, Depends(_admin)],
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[AdminModerationHistoryOut]:
report_events = list(db.scalars(
select(ModerationEvent).order_by(ModerationEvent.created_at.desc()).limit(limit + offset)
))
external_events = list(db.scalars(
select(ExternalObservation).where(ExternalObservation.reviewed_at.is_not(None))
.order_by(ExternalObservation.reviewed_at.desc()).limit(limit + offset)
))
history = [AdminModerationHistoryOut(
entity_type="catch_report", entity_id=event.catch_report_id,
decided_at=event.created_at, action=event.new_status.value,
moderator=event.moderator, reason=event.reason,
) for event in report_events]
history.extend(AdminModerationHistoryOut(
entity_type="external_observation", entity_id=observation.id,
decided_at=observation.reviewed_at, action=observation.status,
moderator=None, reason=observation.review_note,
) for observation in external_events if observation.reviewed_at is not None)
history.sort(key=lambda event: aware(event.decided_at), reverse=True)
return history[offset:offset + limit]
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
def admin_imports(
db: Db,
+9
View File
@@ -247,6 +247,15 @@ class ExternalObservationMapping(BaseModel):
class ExternalAliasSuggestionOut(BaseModel):
fish_slug: str | None
waterbody_slug: str | None
class AdminModerationHistoryOut(BaseModel):
entity_type: str
entity_id: UUID
decided_at: datetime
action: str
moderator: str | None
reason: str | None
requires_confirmation: bool = True
+132 -5
View File
@@ -199,6 +199,65 @@
"title": "AdminCatchReportOut",
"type": "object"
},
"AdminModerationHistoryOut": {
"properties": {
"action": {
"title": "Action",
"type": "string"
},
"decided_at": {
"format": "date-time",
"title": "Decided At",
"type": "string"
},
"entity_id": {
"format": "uuid",
"title": "Entity Id",
"type": "string"
},
"entity_type": {
"title": "Entity Type",
"type": "string"
},
"moderator": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Moderator"
},
"reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reason"
},
"requires_confirmation": {
"default": true,
"title": "Requires Confirmation",
"type": "boolean"
}
},
"required": [
"entity_type",
"entity_id",
"decided_at",
"action",
"moderator",
"reason"
],
"title": "AdminModerationHistoryOut",
"type": "object"
},
"BaitOut": {
"properties": {
"id": {
@@ -565,11 +624,6 @@
],
"title": "Fish Slug"
},
"requires_confirmation": {
"default": true,
"title": "Requires Confirmation",
"type": "boolean"
},
"waterbody_slug": {
"anyOf": [
{
@@ -2509,6 +2563,79 @@
"summary": "Admin Start Official Import"
}
},
"/api/v1/admin/moderation-history": {
"get": {
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
"parameters": [
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 50,
"maximum": 200,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"minimum": 0,
"title": "Offset",
"type": "integer"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AdminModerationHistoryOut"
},
"title": "Response Admin Moderation History Api V1 Admin Moderation History Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Moderation History"
}
},
"/api/v1/baits": {
"get": {
"operationId": "baits_api_v1_baits_get",
+5
View File
@@ -158,6 +158,11 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
serialized = response.text.lower()
for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"):
assert forbidden not in serialized
assert client.get("/api/v1/admin/moderation-history").status_code == 401
history = client.get("/api/v1/admin/moderation-history", headers={"Authorization": "Bearer change-me-in-production"})
assert history.status_code == 200
for forbidden in ("player_name", "source_url", "raw_payload", "screenshot"):
assert forbidden not in history.text.lower()
def test_spot_detail_and_catches() -> None: