fix: align recovery schema idempotency and record counts
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
"""add recovery idempotency and import history columns
|
||||||
|
|
||||||
|
Revision ID: 20260910_recovery
|
||||||
|
Revises: 48094a7d1b92
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260910_recovery"
|
||||||
|
down_revision = "48094a7d1b92"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("submission_attempt", sa.Column("idempotency_key", sa.String(128), nullable=True))
|
||||||
|
op.add_column("submission_attempt", sa.Column("catch_report_id", sa.Uuid(), nullable=True))
|
||||||
|
op.create_foreign_key("fk_submission_attempt_report", "submission_attempt", "catch_report", ["catch_report_id"], ["id"])
|
||||||
|
op.create_index("ix_submission_attempt_idempotency_key", "submission_attempt", ["idempotency_key"], unique=True)
|
||||||
|
op.add_column("import_record_event", sa.Column("changes", sa.JSON(), nullable=True))
|
||||||
|
op.add_column("import_record_event", sa.Column("provenance", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("import_record_event", "provenance")
|
||||||
|
op.drop_column("import_record_event", "changes")
|
||||||
|
op.drop_index("ix_submission_attempt_idempotency_key", table_name="submission_attempt")
|
||||||
|
op.drop_constraint("fk_submission_attempt_report", "submission_attempt", type_="foreignkey")
|
||||||
|
op.drop_column("submission_attempt", "catch_report_id")
|
||||||
|
op.drop_column("submission_attempt", "idempotency_key")
|
||||||
+8
-13
@@ -287,14 +287,9 @@ def records(
|
|||||||
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
||||||
if category:
|
if category:
|
||||||
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
||||||
# Count total before pagination
|
# Count from the exact same filtered query (before pagination).
|
||||||
total = db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) or 0
|
count_query = query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
|
||||||
if fish:
|
total = db.scalar(count_query) or 0
|
||||||
total = db.scalar(select(func.count()).select_from(CatchReport).join(CatchReport.fish).where(Fish.slug == fish, CatchReport.source_type == SourceType.official_record)) or 0
|
|
||||||
if waterbody:
|
|
||||||
total = db.scalar(select(func.count()).select_from(CatchReport).join(CatchReport.waterbody).where(Waterbody.slug == waterbody, CatchReport.source_type == SourceType.official_record)) or 0
|
|
||||||
if category:
|
|
||||||
total = db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record, CatchReport.raw_payload["category"].as_string() == category)) or 0
|
|
||||||
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||||
return PaginatedOfficialRecordOut(
|
return PaginatedOfficialRecordOut(
|
||||||
items=[OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items],
|
items=[OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items],
|
||||||
@@ -480,10 +475,10 @@ def create_catch_report(
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
# Return 200 with idempotent flag — client can retry safely
|
# Return 200 with idempotent flag — client can retry safely
|
||||||
logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]})
|
logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]})
|
||||||
return JSONResponse(
|
report = existing.catch_report
|
||||||
status_code=200,
|
if report is None:
|
||||||
content={"id": "00000000-0000-0000-0000-000000000000", "moderation_status": "pending", "screenshot_upload_token": "", "idempotent": True},
|
raise HTTPException(status_code=409, detail="idempotency record is incomplete; retry with a new key")
|
||||||
)
|
return JSONResponse(status_code=200, content={"id": str(report.id), "moderation_status": report.moderation_status.value, "screenshot_upload_token": "", "idempotent": True})
|
||||||
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
|
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
|
||||||
_check_rate_limit(request, db)
|
_check_rate_limit(request, db)
|
||||||
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
||||||
@@ -508,7 +503,7 @@ def create_catch_report(
|
|||||||
# Store idempotency key if provided
|
# Store idempotency key if provided
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
|
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
|
||||||
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))
|
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report_id=report.id, created_at=datetime.now(timezone.utc)))
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("idempotency key stored", extra={"idempotency_key": idempotency_key[:8]})
|
logger.info("idempotency key stored", extra={"idempotency_key": idempotency_key[:8]})
|
||||||
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ class SubmissionAttempt(Base):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
client_hash: Mapped[str] = mapped_column(String(64), index=True)
|
client_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
idempotency_key: Mapped[str | None] = mapped_column(String(128), index=True)
|
idempotency_key: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||||
|
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
catch_report: Mapped[CatchReport | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
class DataSource(Base):
|
class DataSource(Base):
|
||||||
|
|||||||
Reference in New Issue
Block a user