Compare commits
11
Commits
c93c6adc7a
...
cd319a32c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd319a32c2 | ||
|
|
3886c4f167 | ||
|
|
8cf453f86b | ||
|
|
48cd217479 | ||
|
|
04ac25c818 | ||
|
|
976b386e6b | ||
|
|
bb6fc6cd53 | ||
|
|
1adeff5e94 | ||
|
|
9c44cfc08f | ||
|
|
b76da2edd0 | ||
|
|
30bfccd0e7 |
@@ -58,7 +58,7 @@ Production release отделяет Alembic от runtime: одноразовый
|
||||
|
||||
Тяжёлый production bootstrap вынесен в отдельный ручной/еженедельный CI workflow с 30-минутным timeout и сохраняемыми diagnostics; обычный push по-прежнему использует быстрый Compose E2E.
|
||||
|
||||
Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. Декомпозиция выполняется инкрементально: catalog, activity/spots, public data и submissions принадлежат отдельным `APIRouter`; проверка доверенных proxy и persistent rate limit отправки улова изолированы в `submission_security`.
|
||||
Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. Catalog, activity/spots, public data, submissions и admin API принадлежат отдельным `APIRouter`; `main.py` служит компактной точкой сборки приложения, а проверка доверенных proxy и persistent rate limit изолированы в `submission_security`.
|
||||
|
||||
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||
|
||||
|
||||
+5
-426
@@ -1,40 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time as time_module
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .admin_security import verify_admin
|
||||
from .config import settings
|
||||
from .dependencies import Db
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||
from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
from .routers.activity import router as activity_router
|
||||
from .routers.admin import router as admin_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, AdminModerationHistoryOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
from .storage import client as storage_client
|
||||
from .submission_security import check_rate_limit
|
||||
from .submission_security import is_trusted_proxy as _is_trusted_proxy
|
||||
|
||||
@@ -105,414 +89,9 @@ def ready(db: Db) -> JSONResponse:
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(activity_router)
|
||||
app.include_router(public_data_router)
|
||||
|
||||
|
||||
def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
return verify_admin(request, db, authorization, settings)
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/diagnostics")
|
||||
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
|
||||
report_counts = {status.value: count for status, count in db.execute(
|
||||
select(CatchReport.moderation_status, func.count()).group_by(CatchReport.moderation_status)
|
||||
)}
|
||||
observation_counts = {status: count for status, count in db.execute(
|
||||
select(ExternalObservation.status, func.count()).group_by(ExternalObservation.status)
|
||||
)}
|
||||
payload = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"build": {"version": settings.app_version, "revision": settings.app_revision, "environment": settings.deployment_environment},
|
||||
"counts": {
|
||||
"catch_reports": report_counts,
|
||||
"external_observations": observation_counts,
|
||||
"data_sources": db.scalar(select(func.count()).select_from(DataSource)) or 0,
|
||||
"enabled_data_sources": db.scalar(select(func.count()).select_from(DataSource).where(DataSource.enabled.is_(True))) or 0,
|
||||
"official_import_runs": db.scalar(select(func.count()).select_from(OfficialRecordImport)) or 0,
|
||||
"community_import_runs": db.scalar(select(func.count()).select_from(CommunityImportRun)) or 0,
|
||||
},
|
||||
}
|
||||
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/moderation-history-export")
|
||||
def admin_moderation_history_export(
|
||||
db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
limit: int = Query(1000, ge=1, le=5000),
|
||||
) -> JSONResponse:
|
||||
"""Return an anonymized, analysis-safe decision export."""
|
||||
events = admin_moderation_history(db, _, limit=limit, offset=0)
|
||||
payload = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"count": len(events),
|
||||
"events": [{
|
||||
"entity_type": event.entity_type,
|
||||
"decided_at": event.decided_at.isoformat(),
|
||||
"action": event.action,
|
||||
"requires_confirmation": event.requires_confirmation,
|
||||
} for event in events],
|
||||
}
|
||||
return JSONResponse(payload, headers={
|
||||
"Content-Disposition": "attachment; filename=rf4spotter-moderation-history.json",
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
||||
def admin_imports(
|
||||
db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> list[OfficialRecordImport]:
|
||||
query = select(OfficialRecordImport).order_by(
|
||||
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()
|
||||
).offset(offset).limit(limit)
|
||||
return list(db.scalars(query))
|
||||
|
||||
|
||||
@app.post("/api/v1/admin/imports/official-records", response_model=ImportRunOut, status_code=201)
|
||||
def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> OfficialRecordImport:
|
||||
try:
|
||||
return import_records(
|
||||
db,
|
||||
url=settings.official_records_url,
|
||||
region=settings.official_records_region,
|
||||
category=settings.official_records_category,
|
||||
)
|
||||
except ImportAlreadyRunning as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (ImportSourceError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
|
||||
|
||||
|
||||
def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
||||
allowed_payload = {
|
||||
key: value for key, value in (item.payload or {}).items()
|
||||
if key in {
|
||||
"bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed",
|
||||
"player_name", "published_at", "region", "category",
|
||||
} and (value is None or isinstance(value, (str, int, float, bool)))
|
||||
}
|
||||
missing_fields = []
|
||||
if item.x is None or item.y is None:
|
||||
missing_fields.append("coordinates")
|
||||
if item.weight_g is None:
|
||||
missing_fields.append("weight_g")
|
||||
return ExternalObservationOut(
|
||||
id=item.id, source_system=item.source_system, source_external_id=item.source_external_id,
|
||||
source_url=item.source_url, fish_name=item.fish_name, fish_external_id=item.fish_external_id,
|
||||
waterbody_name=item.waterbody_name, waterbody_external_id=item.waterbody_external_id,
|
||||
x=item.x, y=item.y, weight_g=item.weight_g, published_at=item.published_at,
|
||||
first_seen_at=item.first_seen_at, last_seen_at=item.last_seen_at, reviewed_at=item.reviewed_at,
|
||||
status=item.status,
|
||||
fish_slug=item.fish.slug if item.fish else None,
|
||||
waterbody_slug=item.waterbody.slug if item.waterbody else None,
|
||||
catch_report_id=item.catch_report_id, review_note=item.review_note,
|
||||
missing_fields=missing_fields, source_payload=allowed_payload,
|
||||
moderation_version=item.moderation_version,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
|
||||
def admin_external_observations(
|
||||
db: Db, _: Annotated[str, Depends(_admin)],
|
||||
status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None,
|
||||
source_system: str | None = None,
|
||||
completeness: Literal["all", "complete", "incomplete"] = "all",
|
||||
order: Literal["newest", "oldest", "risk"] = "newest",
|
||||
q: str | None = Query(None, max_length=100),
|
||||
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
||||
) -> list[ExternalObservationOut]:
|
||||
query = select(ExternalObservation).options(
|
||||
joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody),
|
||||
)
|
||||
if status == "review":
|
||||
query = query.where(ExternalObservation.status.in_(["staged", "mapped", "ready"]))
|
||||
elif status:
|
||||
query = query.where(ExternalObservation.status == status)
|
||||
if source_system:
|
||||
query = query.where(ExternalObservation.source_system == source_system)
|
||||
if completeness == "complete":
|
||||
query = query.where(
|
||||
ExternalObservation.x.is_not(None), ExternalObservation.y.is_not(None),
|
||||
ExternalObservation.weight_g.is_not(None),
|
||||
)
|
||||
elif completeness == "incomplete":
|
||||
query = query.where(or_(
|
||||
ExternalObservation.x.is_(None), ExternalObservation.y.is_(None),
|
||||
ExternalObservation.weight_g.is_(None),
|
||||
))
|
||||
if q and q.strip():
|
||||
term = q.strip()
|
||||
query = query.where(or_(
|
||||
ExternalObservation.fish_name.icontains(term, autoescape=True),
|
||||
ExternalObservation.waterbody_name.icontains(term, autoescape=True),
|
||||
))
|
||||
if order == "risk":
|
||||
incomplete = case(
|
||||
(or_(ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None)), 0),
|
||||
else_=1,
|
||||
)
|
||||
workflow = case(
|
||||
(ExternalObservation.status == "staged", 0),
|
||||
(ExternalObservation.status == "mapped", 1),
|
||||
else_=2,
|
||||
)
|
||||
ordering = (incomplete, workflow, ExternalObservation.last_seen_at.asc(), ExternalObservation.id.desc())
|
||||
else:
|
||||
direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc()
|
||||
ordering = (direction, ExternalObservation.id.desc())
|
||||
items = db.scalars(query.order_by(*ordering).offset(offset).limit(limit))
|
||||
return [_external_out(item) for item in items]
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/external-observations/{observation_id}/alias-suggestions", response_model=ExternalAliasSuggestionOut)
|
||||
def admin_external_alias_suggestions(
|
||||
observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalAliasSuggestionOut:
|
||||
observation = db.get(ExternalObservation, observation_id)
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
fish, waterbody = suggest_aliases(db, observation)
|
||||
return ExternalAliasSuggestionOut(
|
||||
fish_slug=fish.slug if fish else None,
|
||||
waterbody_slug=waterbody.slug if waterbody else None,
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut)
|
||||
def admin_map_external_observation(
|
||||
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalObservationOut:
|
||||
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
||||
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
if fish is None or waterbody is None:
|
||||
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
|
||||
if observation.moderation_version != payload.expected_version:
|
||||
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||
observation.moderation_version += 1
|
||||
try:
|
||||
return _external_out(map_observation(db, observation, fish, waterbody, note=payload.note))
|
||||
except ExternalReviewError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished)
|
||||
def admin_publish_external_observation(
|
||||
observation_id: UUID, payload: ExternalObservationAction, db: Db, _: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalObservationPublished:
|
||||
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
if observation.moderation_version != payload.expected_version:
|
||||
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||
observation.moderation_version += 1
|
||||
try:
|
||||
report = publish_observation(db, observation)
|
||||
except ExternalReviewError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
public_cache.invalidate()
|
||||
return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status)
|
||||
|
||||
|
||||
@app.patch("/api/v1/admin/external-observations/{observation_id}/reject", response_model=ExternalObservationOut)
|
||||
def admin_reject_external_observation(
|
||||
observation_id: UUID, payload: ExternalObservationDecision, db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalObservationOut:
|
||||
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
if observation.moderation_version != payload.expected_version:
|
||||
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||
observation.moderation_version += 1
|
||||
try:
|
||||
return _external_out(reject_observation(db, observation, reason=payload.reason))
|
||||
except ExternalReviewError as exc:
|
||||
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(
|
||||
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()
|
||||
# A05: Server-side idempotency — check BEFORE rate limit to avoid polluting table
|
||||
if idempotency_key:
|
||||
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
# Force refresh from database to see committed data from previous requests
|
||||
db.expire_all()
|
||||
existing = db.scalar(
|
||||
select(SubmissionAttempt).where(
|
||||
SubmissionAttempt.idempotency_key == key_hash,
|
||||
SubmissionAttempt.created_at >= cutoff,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
# Return 200 with idempotent flag — client can retry safely
|
||||
logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]})
|
||||
report = existing.catch_report
|
||||
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 report is None:
|
||||
raise HTTPException(status_code=409, detail="idempotency record is incomplete; retry with a new key")
|
||||
# Re-derive the one-time upload token from the idempotency key;
|
||||
# only its hash is persisted, so the secret is never stored.
|
||||
replay_token = hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(hashlib.sha256(replay_token.encode()).hexdigest(), 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={"id": str(report.id), "moderation_status": report.moderation_status.value, "screenshot_upload_token": replay_token, "idempotent": True})
|
||||
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
|
||||
_check_rate_limit(request, db)
|
||||
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 = None
|
||||
if payload.bait_name and payload.bait_name.strip():
|
||||
key = normalize(payload.bait_name)
|
||||
bait = db.scalar(select(Bait).where(Bait.normalized_name == key))
|
||||
if bait is None:
|
||||
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
|
||||
db.add(bait)
|
||||
upload_token = (hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
|
||||
if idempotency_key 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)
|
||||
# Store idempotency key if provided
|
||||
if idempotency_key:
|
||||
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
|
||||
# One transaction: a unique-key race must roll back the report too.
|
||||
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:
|
||||
# Another request won the same idempotency key race.
|
||||
db.rollback()
|
||||
winner = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))
|
||||
if winner and winner.catch_report:
|
||||
replay_token = hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
|
||||
return JSONResponse(status_code=200, content={"id": str(winner.catch_report.id), "moderation_status": winner.catch_report.moderation_status.value, "screenshot_upload_token": replay_token, "idempotent": 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)
|
||||
|
||||
|
||||
@submissions_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)
|
||||
|
||||
|
||||
app.include_router(admin_router)
|
||||
app.include_router(submissions_router)
|
||||
|
||||
|
||||
@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), offset: int = Query(0, ge=0)) -> 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, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).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, moderation_version=r.moderation_version) 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.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
|
||||
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")
|
||||
if report.moderation_version != payload.expected_version:
|
||||
raise HTTPException(status_code=409, detail="report changed; reload the queue")
|
||||
previous = report.moderation_status
|
||||
report.moderation_status = ModerationStatus(payload.status)
|
||||
report.moderation_version += 1
|
||||
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
|
||||
db.commit()
|
||||
public_cache.invalidate()
|
||||
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)], expected_version: int = Query(ge=0)) -> Response:
|
||||
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
|
||||
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")
|
||||
if report.moderation_version != expected_version:
|
||||
raise HTTPException(status_code=409, detail="report changed; reload the queue")
|
||||
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.moderation_version += 1
|
||||
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()
|
||||
public_cache.invalidate()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def _check_rate_limit(request: Request, db: Session) -> None:
|
||||
check_rate_limit(request, db, settings)
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from ..admin_security import verify_admin
|
||||
from ..community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
||||
from ..config import settings
|
||||
from ..dependencies import Db
|
||||
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
|
||||
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
|
||||
from ..public_cache import public_cache
|
||||
from ..schemas import AdminCatchReportOut, AdminModerationHistoryOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||
from ..storage import delete_screenshot, signed_screenshot_url
|
||||
from ..time_utils import aware
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
return verify_admin(request, db, authorization, settings)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/diagnostics")
|
||||
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
|
||||
report_counts = {status.value: count for status, count in db.execute(
|
||||
select(CatchReport.moderation_status, func.count()).group_by(CatchReport.moderation_status)
|
||||
)}
|
||||
observation_counts = {status: count for status, count in db.execute(
|
||||
select(ExternalObservation.status, func.count()).group_by(ExternalObservation.status)
|
||||
)}
|
||||
payload = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"build": {"version": settings.app_version, "revision": settings.app_revision, "environment": settings.deployment_environment},
|
||||
"counts": {
|
||||
"catch_reports": report_counts,
|
||||
"external_observations": observation_counts,
|
||||
"data_sources": db.scalar(select(func.count()).select_from(DataSource)) or 0,
|
||||
"enabled_data_sources": db.scalar(select(func.count()).select_from(DataSource).where(DataSource.enabled.is_(True))) or 0,
|
||||
"official_import_runs": db.scalar(select(func.count()).select_from(OfficialRecordImport)) or 0,
|
||||
"community_import_runs": db.scalar(select(func.count()).select_from(CommunityImportRun)) or 0,
|
||||
},
|
||||
}
|
||||
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
|
||||
|
||||
|
||||
@router.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]
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/moderation-history-export")
|
||||
def admin_moderation_history_export(
|
||||
db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
limit: int = Query(1000, ge=1, le=5000),
|
||||
) -> JSONResponse:
|
||||
"""Return an anonymized, analysis-safe decision export."""
|
||||
events = admin_moderation_history(db, _, limit=limit, offset=0)
|
||||
payload = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"count": len(events),
|
||||
"events": [{
|
||||
"entity_type": event.entity_type,
|
||||
"decided_at": event.decided_at.isoformat(),
|
||||
"action": event.action,
|
||||
"requires_confirmation": event.requires_confirmation,
|
||||
} for event in events],
|
||||
}
|
||||
return JSONResponse(payload, headers={
|
||||
"Content-Disposition": "attachment; filename=rf4spotter-moderation-history.json",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
||||
def admin_imports(
|
||||
db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> list[OfficialRecordImport]:
|
||||
query = select(OfficialRecordImport).order_by(
|
||||
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()
|
||||
).offset(offset).limit(limit)
|
||||
return list(db.scalars(query))
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/imports/official-records", response_model=ImportRunOut, status_code=201)
|
||||
def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> OfficialRecordImport:
|
||||
try:
|
||||
return import_records(
|
||||
db,
|
||||
url=settings.official_records_url,
|
||||
region=settings.official_records_region,
|
||||
category=settings.official_records_category,
|
||||
)
|
||||
except ImportAlreadyRunning as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (ImportSourceError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
|
||||
|
||||
|
||||
def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
||||
allowed_payload = {
|
||||
key: value for key, value in (item.payload or {}).items()
|
||||
if key in {
|
||||
"bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed",
|
||||
"player_name", "published_at", "region", "category",
|
||||
} and (value is None or isinstance(value, (str, int, float, bool)))
|
||||
}
|
||||
missing_fields = []
|
||||
if item.x is None or item.y is None:
|
||||
missing_fields.append("coordinates")
|
||||
if item.weight_g is None:
|
||||
missing_fields.append("weight_g")
|
||||
return ExternalObservationOut(
|
||||
id=item.id, source_system=item.source_system, source_external_id=item.source_external_id,
|
||||
source_url=item.source_url, fish_name=item.fish_name, fish_external_id=item.fish_external_id,
|
||||
waterbody_name=item.waterbody_name, waterbody_external_id=item.waterbody_external_id,
|
||||
x=item.x, y=item.y, weight_g=item.weight_g, published_at=item.published_at,
|
||||
first_seen_at=item.first_seen_at, last_seen_at=item.last_seen_at, reviewed_at=item.reviewed_at,
|
||||
status=item.status,
|
||||
fish_slug=item.fish.slug if item.fish else None,
|
||||
waterbody_slug=item.waterbody.slug if item.waterbody else None,
|
||||
catch_report_id=item.catch_report_id, review_note=item.review_note,
|
||||
missing_fields=missing_fields, source_payload=allowed_payload,
|
||||
moderation_version=item.moderation_version,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
|
||||
def admin_external_observations(
|
||||
db: Db, _: Annotated[str, Depends(_admin)],
|
||||
status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None,
|
||||
source_system: str | None = None,
|
||||
completeness: Literal["all", "complete", "incomplete"] = "all",
|
||||
order: Literal["newest", "oldest", "risk"] = "newest",
|
||||
q: str | None = Query(None, max_length=100),
|
||||
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
||||
) -> list[ExternalObservationOut]:
|
||||
query = select(ExternalObservation).options(
|
||||
joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody),
|
||||
)
|
||||
if status == "review":
|
||||
query = query.where(ExternalObservation.status.in_(["staged", "mapped", "ready"]))
|
||||
elif status:
|
||||
query = query.where(ExternalObservation.status == status)
|
||||
if source_system:
|
||||
query = query.where(ExternalObservation.source_system == source_system)
|
||||
if completeness == "complete":
|
||||
query = query.where(
|
||||
ExternalObservation.x.is_not(None), ExternalObservation.y.is_not(None),
|
||||
ExternalObservation.weight_g.is_not(None),
|
||||
)
|
||||
elif completeness == "incomplete":
|
||||
query = query.where(or_(
|
||||
ExternalObservation.x.is_(None), ExternalObservation.y.is_(None),
|
||||
ExternalObservation.weight_g.is_(None),
|
||||
))
|
||||
if q and q.strip():
|
||||
term = q.strip()
|
||||
query = query.where(or_(
|
||||
ExternalObservation.fish_name.icontains(term, autoescape=True),
|
||||
ExternalObservation.waterbody_name.icontains(term, autoescape=True),
|
||||
))
|
||||
if order == "risk":
|
||||
incomplete = case(
|
||||
(or_(ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None)), 0),
|
||||
else_=1,
|
||||
)
|
||||
workflow = case(
|
||||
(ExternalObservation.status == "staged", 0),
|
||||
(ExternalObservation.status == "mapped", 1),
|
||||
else_=2,
|
||||
)
|
||||
ordering = (incomplete, workflow, ExternalObservation.last_seen_at.asc(), ExternalObservation.id.desc())
|
||||
else:
|
||||
direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc()
|
||||
ordering = (direction, ExternalObservation.id.desc())
|
||||
items = db.scalars(query.order_by(*ordering).offset(offset).limit(limit))
|
||||
return [_external_out(item) for item in items]
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/external-observations/{observation_id}/alias-suggestions", response_model=ExternalAliasSuggestionOut)
|
||||
def admin_external_alias_suggestions(
|
||||
observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalAliasSuggestionOut:
|
||||
observation = db.get(ExternalObservation, observation_id)
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
fish, waterbody = suggest_aliases(db, observation)
|
||||
return ExternalAliasSuggestionOut(
|
||||
fish_slug=fish.slug if fish else None,
|
||||
waterbody_slug=waterbody.slug if waterbody else None,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut)
|
||||
def admin_map_external_observation(
|
||||
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalObservationOut:
|
||||
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
||||
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
if fish is None or waterbody is None:
|
||||
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
|
||||
if observation.moderation_version != payload.expected_version:
|
||||
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||
observation.moderation_version += 1
|
||||
try:
|
||||
return _external_out(map_observation(db, observation, fish, waterbody, note=payload.note))
|
||||
except ExternalReviewError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished)
|
||||
def admin_publish_external_observation(
|
||||
observation_id: UUID, payload: ExternalObservationAction, db: Db, _: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalObservationPublished:
|
||||
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
if observation.moderation_version != payload.expected_version:
|
||||
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||
observation.moderation_version += 1
|
||||
try:
|
||||
report = publish_observation(db, observation)
|
||||
except ExternalReviewError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
public_cache.invalidate()
|
||||
return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status)
|
||||
|
||||
|
||||
@router.patch("/api/v1/admin/external-observations/{observation_id}/reject", response_model=ExternalObservationOut)
|
||||
def admin_reject_external_observation(
|
||||
observation_id: UUID, payload: ExternalObservationDecision, db: Db,
|
||||
_: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalObservationOut:
|
||||
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
if observation.moderation_version != payload.expected_version:
|
||||
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
|
||||
observation.moderation_version += 1
|
||||
try:
|
||||
return _external_out(reject_observation(db, observation, reason=payload.reason))
|
||||
except ExternalReviewError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.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), offset: int = Query(0, ge=0)) -> 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, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).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, moderation_version=r.moderation_version) for r in reports]
|
||||
|
||||
|
||||
@router.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.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
|
||||
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")
|
||||
if report.moderation_version != payload.expected_version:
|
||||
raise HTTPException(status_code=409, detail="report changed; reload the queue")
|
||||
previous = report.moderation_status
|
||||
report.moderation_status = ModerationStatus(payload.status)
|
||||
report.moderation_version += 1
|
||||
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
|
||||
db.commit()
|
||||
public_cache.invalidate()
|
||||
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
||||
|
||||
|
||||
@router.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)], expected_version: int = Query(ge=0)) -> Response:
|
||||
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
|
||||
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")
|
||||
if report.moderation_version != expected_version:
|
||||
raise HTTPException(status_code=409, detail="report changed; reload the queue")
|
||||
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.moderation_version += 1
|
||||
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()
|
||||
public_cache.invalidate()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -396,7 +396,7 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||
db.refresh(run)
|
||||
return run
|
||||
|
||||
monkeypatch.setattr("app.main.import_records", fake_import)
|
||||
monkeypatch.setattr("app.routers.admin.import_records", fake_import)
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
started = client.post("/api/v1/admin/imports/official-records", headers=headers)
|
||||
assert started.status_code == 201
|
||||
@@ -407,14 +407,14 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||
def busy_import(*args, **kwargs):
|
||||
raise ImportAlreadyRunning("official import is already running")
|
||||
|
||||
monkeypatch.setattr("app.main.import_records", busy_import)
|
||||
monkeypatch.setattr("app.routers.admin.import_records", busy_import)
|
||||
conflict = client.post("/api/v1/admin/imports/official-records", headers=headers)
|
||||
assert conflict.status_code == 409
|
||||
|
||||
|
||||
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
|
||||
@@ -431,7 +431,7 @@ def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monke
|
||||
report.screenshot_key = "reports/private.jpg"
|
||||
db.commit()
|
||||
deleted_keys: list[str] = []
|
||||
monkeypatch.setattr("app.main.delete_screenshot", deleted_keys.append)
|
||||
monkeypatch.setattr("app.routers.admin.delete_screenshot", deleted_keys.append)
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}?expected_version=0", headers=headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
+212
-42
@@ -80,9 +80,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "022f6f1c3d263819df5aed549bfa64560c9c65509eaf771cc547895daefbbe6c",
|
||||
"local_path": "files/02/022f6f1c3d263819df5aed549bfa64560c9c65509eaf771cc547895daefbbe6c.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 6196,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T03:01:37.465029+00:00",
|
||||
"entity_key": "rf4map-bait-13",
|
||||
"reviewed_at": "2026-09-13T03:02:11.335096+00:00",
|
||||
"review_note": "Visual review: Banana Pop-Up 20 jar icon; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -93,9 +103,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "053590bb2a9cc3b2779150b7db5bc80cad2b202152b2fb58058a2edede6b76cc",
|
||||
"local_path": "files/05/053590bb2a9cc3b2779150b7db5bc80cad2b202152b2fb58058a2edede6b76cc.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 2840,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T03:32:07.357523+00:00",
|
||||
"entity_key": "rf4map-bait-21",
|
||||
"reviewed_at": "2026-09-13T03:32:34.735328+00:00",
|
||||
"review_note": "Visual review: Veikko 25g-004 spoon lure; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -106,9 +126,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "06d97e94c4cb18ef5ca7ed9b460d1daae26a9315298b9e9bf1c26c3c6d7a38d4",
|
||||
"local_path": "files/06/06d97e94c4cb18ef5ca7ed9b460d1daae26a9315298b9e9bf1c26c3c6d7a38d4.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 3189,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T04:06:38.572579+00:00",
|
||||
"entity_key": "rf4map-bait-122",
|
||||
"reviewed_at": "2026-09-13T04:07:07.899712+00:00",
|
||||
"review_note": "Visual review: Stor Fisk M25-600 #6 fish-shaped lure; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -119,9 +149,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "0773e82fe7a0a2841db9c95bdf95c5347feacdbb1c9637eada61e508c4ea82ba",
|
||||
"local_path": "files/07/0773e82fe7a0a2841db9c95bdf95c5347feacdbb1c9637eada61e508c4ea82ba.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 4409,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T04:53:04.221364+00:00",
|
||||
"entity_key": "rf4map-bait-71",
|
||||
"reviewed_at": "2026-09-13T04:53:34.296480+00:00",
|
||||
"review_note": "Visual review: Sweet Sunflower 25 bait mix jar; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -132,9 +172,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "08d15a88a75c8b6a9e8364c4a8155d035ac23bd64a20014d2961968eb2ba9745",
|
||||
"local_path": "files/08/08d15a88a75c8b6a9e8364c4a8155d035ac23bd64a20014d2961968eb2ba9745.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 4636,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T05:31:25.121364+00:00",
|
||||
"entity_key": "rf4map-bait-30",
|
||||
"reviewed_at": "2026-09-13T05:32:10.378720+00:00",
|
||||
"review_note": "Visual review: cheese cube bait icon; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -145,9 +195,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "09d1f6eb802a4bf4d003568541a4aeadebe95b1a0ff5fb1cdd971c7402d592b5",
|
||||
"local_path": "files/09/09d1f6eb802a4bf4d003568541a4aeadebe95b1a0ff5fb1cdd971c7402d592b5.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 2712,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T06:22:26.294564+00:00",
|
||||
"entity_key": "rf4map-bait-41",
|
||||
"reviewed_at": "2026-09-13T06:22:54.107170+00:00",
|
||||
"review_note": "Visual review: Nereis marine worm bait; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -158,9 +218,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "0b657cd2c946d0c1332eca0e3ea03989829aa42747cbd386565ed5c42c482f55",
|
||||
"local_path": "files/0b/0b657cd2c946d0c1332eca0e3ea03989829aa42747cbd386565ed5c42c482f55.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 2758,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T07:06:49.382477+00:00",
|
||||
"entity_key": "rf4map-bait-11",
|
||||
"reviewed_at": "2026-09-13T07:07:20.444622+00:00",
|
||||
"review_note": "Visual review: RealShrimp 6.5-03 soft shrimp lure; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -171,9 +241,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"sha256": "0df7bb5fb5b53890cfdf053c6119190e964fcf74c5435b52f521b1a4eb26564d",
|
||||
"local_path": "files/0d/0df7bb5fb5b53890cfdf053c6119190e964fcf74c5435b52f521b1a4eb26564d.png",
|
||||
"content_type": "image/png",
|
||||
"bytes": 6105,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"fetched_at": "2026-09-13T07:37:20.481934+00:00",
|
||||
"entity_key": "rf4map-bait-15",
|
||||
"reviewed_at": "2026-09-13T07:37:50.495510+00:00",
|
||||
"review_note": "Visual review: Spice Mix 20 bait-mix package; transparent PNG; RF4MAP catalog provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -4982,12 +5062,22 @@
|
||||
"entity_type": "reference",
|
||||
"label": "Zig-Rig Guide Unterschiedliche Wetterbedingungen führen zu Veränderungen der Wassertemperatur. Bei niedrigen Temperaturen können Karpfen in die sonnenerwärmten Oberflächenschichten des Gewässers aufsteigen. In solchen Fällen können Zig-Rig Montagen die besten Ergebnisse erzielen. Beim Zig-Rig wird a",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"https://rf4game.de/userguide/"
|
||||
]
|
||||
],
|
||||
"sha256": "b2af2a2edd79f56269b62b833a85b3532656ecfaa595cbe665f764a2b7773d7a",
|
||||
"local_path": "files/b2/b2af2a2edd79f56269b62b833a85b3532656ecfaa595cbe665f764a2b7773d7a.jpg",
|
||||
"content_type": "image/jpeg",
|
||||
"bytes": 53562,
|
||||
"width": 602,
|
||||
"height": 208,
|
||||
"fetched_at": "2026-09-13T03:01:39.882452+00:00",
|
||||
"entity_key": "rf4game-de-zig-rig-depth-diagram",
|
||||
"reviewed_at": "2026-09-13T03:02:11.506727+00:00",
|
||||
"review_note": "Visual review: Zig-Rig depth-position diagram; official RF4 German user guide provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4game.de/userguide/",
|
||||
@@ -4995,12 +5085,22 @@
|
||||
"entity_type": "reference",
|
||||
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"https://rf4game.de/userguide/"
|
||||
]
|
||||
],
|
||||
"sha256": "e1f3e67ac7bf654d4abb0a55688db9a6d25f49ea6f6bb20bc24066e71cd91e51",
|
||||
"local_path": "files/e1/e1f3e67ac7bf654d4abb0a55688db9a6d25f49ea6f6bb20bc24066e71cd91e51.jpg",
|
||||
"content_type": "image/jpeg",
|
||||
"bytes": 80511,
|
||||
"width": 552,
|
||||
"height": 378,
|
||||
"fetched_at": "2026-09-13T03:32:09.445288+00:00",
|
||||
"entity_key": "rf4game-de-seafishing-slope-diagram",
|
||||
"reviewed_at": "2026-09-13T03:32:34.909222+00:00",
|
||||
"review_note": "Visual review: underwater slope diagram for sea fishing; official RF4 German user guide provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4game.de/userguide/",
|
||||
@@ -5008,12 +5108,22 @@
|
||||
"entity_type": "reference",
|
||||
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"https://rf4game.de/userguide/"
|
||||
]
|
||||
],
|
||||
"sha256": "1ba3d3058c41456c14f692334e4c2a24a560318acfd3ee042bf951fb0d785c5f",
|
||||
"local_path": "files/1b/1ba3d3058c41456c14f692334e4c2a24a560318acfd3ee042bf951fb0d785c5f.jpg",
|
||||
"content_type": "image/jpeg",
|
||||
"bytes": 62165,
|
||||
"width": 573,
|
||||
"height": 407,
|
||||
"fetched_at": "2026-09-13T04:06:41.051053+00:00",
|
||||
"entity_key": "rf4game-de-sea-rig-retrieve-diagram",
|
||||
"reviewed_at": "2026-09-13T04:07:08.075151+00:00",
|
||||
"review_note": "Visual review: sea rig with sinker, bait and retrieve direction; official RF4 German user guide provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4game.de/userguide/",
|
||||
@@ -5021,12 +5131,22 @@
|
||||
"entity_type": "reference",
|
||||
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"https://rf4game.de/userguide/"
|
||||
]
|
||||
],
|
||||
"sha256": "6920f0ff951d9cd77581e87aa8418af33853aaee94334ac4687392576d0927a8",
|
||||
"local_path": "files/69/6920f0ff951d9cd77581e87aa8418af33853aaee94334ac4687392576d0927a8.jpg",
|
||||
"content_type": "image/jpeg",
|
||||
"bytes": 63946,
|
||||
"width": 585,
|
||||
"height": 285,
|
||||
"fetched_at": "2026-09-13T04:53:06.634014+00:00",
|
||||
"entity_key": "rf4game-de-sea-bank-diagram",
|
||||
"reviewed_at": "2026-09-13T04:53:34.462676+00:00",
|
||||
"review_note": "Visual review: underwater bank or plateau diagram; official RF4 German user guide provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4game.de/userguide/",
|
||||
@@ -5034,12 +5154,22 @@
|
||||
"entity_type": "reference",
|
||||
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"https://rf4game.de/userguide/"
|
||||
]
|
||||
],
|
||||
"sha256": "dcb8d053e165e7e229966163310016bfbee70a9d28817fda9b019c9df6981819",
|
||||
"local_path": "files/dc/dcb8d053e165e7e229966163310016bfbee70a9d28817fda9b019c9df6981819.jpg",
|
||||
"content_type": "image/jpeg",
|
||||
"bytes": 94279,
|
||||
"width": 1262,
|
||||
"height": 710,
|
||||
"fetched_at": "2026-09-13T05:31:27.525310+00:00",
|
||||
"entity_key": "rf4game-de-deepview-sonar-screen",
|
||||
"reviewed_at": "2026-09-13T05:32:10.559908+00:00",
|
||||
"review_note": "Visual review: in-game DeepView sonar and bathymetry screen; official RF4 German user guide provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4game.de/userguide/",
|
||||
@@ -5047,12 +5177,22 @@
|
||||
"entity_type": "reference",
|
||||
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"https://rf4game.de/userguide/"
|
||||
]
|
||||
],
|
||||
"sha256": "9ab56b5663337fdfbea4672620e4a0a2e8c100fcb1c40b8fd7be07ba3d68545e",
|
||||
"local_path": "files/9a/9ab56b5663337fdfbea4672620e4a0a2e8c100fcb1c40b8fd7be07ba3d68545e.jpg",
|
||||
"content_type": "image/jpeg",
|
||||
"bytes": 171608,
|
||||
"width": 964,
|
||||
"height": 1062,
|
||||
"fetched_at": "2026-09-13T06:22:28.460774+00:00",
|
||||
"entity_key": "rf4game-de-vertical-lure-retrieve-diagram",
|
||||
"reviewed_at": "2026-09-13T06:22:54.272995+00:00",
|
||||
"review_note": "Visual review: vertical sea-lure retrieve sequence; official RF4 German user guide provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4game.de/userguide/",
|
||||
@@ -5060,12 +5200,22 @@
|
||||
"entity_type": "reference",
|
||||
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"https://rf4game.de/userguide/"
|
||||
]
|
||||
],
|
||||
"sha256": "240227e2660c96f6b2f8aff09d52743543b7eb3b5d0868b63cb64d411d2d5400",
|
||||
"local_path": "files/24/240227e2660c96f6b2f8aff09d52743543b7eb3b5d0868b63cb64d411d2d5400.jpg",
|
||||
"content_type": "image/jpeg",
|
||||
"bytes": 120312,
|
||||
"width": 1005,
|
||||
"height": 496,
|
||||
"fetched_at": "2026-09-13T07:06:54.932255+00:00",
|
||||
"entity_key": "rf4game-de-bottom-hopping-retrieve-diagram",
|
||||
"reviewed_at": "2026-09-13T07:07:20.615834+00:00",
|
||||
"review_note": "Visual review: bottom-hopping lure retrieve sequence; official RF4 German user guide provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4game.de/userguide/",
|
||||
@@ -5073,12 +5223,22 @@
|
||||
"entity_type": "reference",
|
||||
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"https://rf4game.de/userguide/"
|
||||
]
|
||||
],
|
||||
"sha256": "40848bd084146070a11411f7c72699147aa1be2c2917d64d62ebe32dd2503cd3",
|
||||
"local_path": "files/40/40848bd084146070a11411f7c72699147aa1be2c2917d64d62ebe32dd2503cd3.jpg",
|
||||
"content_type": "image/jpeg",
|
||||
"bytes": 100732,
|
||||
"width": 814,
|
||||
"height": 452,
|
||||
"fetched_at": "2026-09-13T07:37:22.756704+00:00",
|
||||
"entity_key": "rf4game-de-pelagic-step-retrieve-diagram",
|
||||
"reviewed_at": "2026-09-13T07:37:50.666784+00:00",
|
||||
"review_note": "Visual review: stepped pelagic soft-lure retrieve sequence; official RF4 German user guide provenance"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4game.de/userguide/",
|
||||
@@ -5873,9 +6033,11 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "invalid",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_attempt_at": "2026-09-13T03:01:42.357538+00:00",
|
||||
"last_error": "expected image, got text/html"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -5886,9 +6048,11 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "invalid",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_attempt_at": "2026-09-13T03:32:12.317327+00:00",
|
||||
"last_error": "expected image, got text/html"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -5899,9 +6063,11 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "invalid",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_attempt_at": "2026-09-13T04:06:43.773724+00:00",
|
||||
"last_error": "expected image, got text/html"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -5912,9 +6078,11 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "invalid",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_attempt_at": "2026-09-13T04:53:09.593781+00:00",
|
||||
"last_error": "expected image, got text/html"
|
||||
},
|
||||
{
|
||||
"source_page": "https://rf4map.ru/fishes",
|
||||
@@ -5925,9 +6093,11 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "invalid",
|
||||
"first_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00"
|
||||
"last_seen_at": "2026-09-12T14:59:53.013377+00:00",
|
||||
"last_attempt_at": "2026-09-13T05:31:30.589945+00:00",
|
||||
"last_error": "expected image, got text/html"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@
|
||||
|
||||
Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog.
|
||||
|
||||
- [ ] **Q11 · Декомпозиция API — в работе.** После фиксации OpenAPI публичные catalog, activity/spots и records/community/status/import-history вынесены в отдельные `APIRouter`; submission endpoints уже принадлежат собственному router, а их security-слой изолирован в `submission_security`. URL, response models и generated contract сохранены. Далее: физически вынести submission handlers из `main.py`, затем admin.
|
||||
- [x] **Q11 · Декомпозиция API.** Catalog, activity/spots, records/community/status/import-history, submissions и весь admin API вынесены в отдельные `APIRouter`. `main.py` оставляет composition root, middleware, health/readiness и временные совместимые экспорты rate-limit для тестового контракта; URL и OpenAPI сохранены.
|
||||
- [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95.
|
||||
- [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics.
|
||||
- [ ] **Q14 · Полная CSP — origin-policy внедрена.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline event handlers, eval, wildcard и HTTP. Инвентаризация зафиксировала динамический JSON-LD, page scripts, scoped styles и CSS variables; из-за них `unsafe-inline` временно остаётся только для script/style элементов и style attributes. Далее вынести page scripts, решить nonce/hash JSON-LD и убрать исключения поэтапно с bootstrap-проверкой report/admin/OG/screenshots.
|
||||
|
||||
Reference in New Issue
Block a user