From bea3b9ccbba7d4ea6e9b03c02708cbea554348c6 Mon Sep 17 00:00:00 2001 From: IK Date: Fri, 11 Sep 2026 07:40:58 +0700 Subject: [PATCH] fix: align recovery schema idempotency and record counts --- .../versions/20260910_recovery_columns.py | 28 +++++++++++++++++++ apps/api/app/main.py | 21 ++++++-------- apps/api/app/models.py | 2 ++ 3 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 apps/api/alembic/versions/20260910_recovery_columns.py diff --git a/apps/api/alembic/versions/20260910_recovery_columns.py b/apps/api/alembic/versions/20260910_recovery_columns.py new file mode 100644 index 0000000..34bda05 --- /dev/null +++ b/apps/api/alembic/versions/20260910_recovery_columns.py @@ -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") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index a01244a..c4a16a5 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -287,14 +287,9 @@ def records( query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody) if category: query = query.where(CatchReport.raw_payload["category"].as_string() == category) - # Count total before pagination - total = db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) or 0 - if fish: - 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 + # Count from the exact same filtered query (before pagination). + count_query = query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None) + total = db.scalar(count_query) or 0 items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit))) 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], @@ -480,10 +475,10 @@ def create_catch_report( if existing is not None: # Return 200 with idempotent flag — client can retry safely logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]}) - return JSONResponse( - status_code=200, - content={"id": "00000000-0000-0000-0000-000000000000", "moderation_status": "pending", "screenshot_upload_token": "", "idempotent": True}, - ) + report = existing.catch_report + if report is None: + 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]}) _check_rate_limit(request, db) fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug)) @@ -508,7 +503,7 @@ def create_catch_report( # Store idempotency key if provided if idempotency_key: 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() 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) diff --git a/apps/api/app/models.py b/apps/api/app/models.py index ac1ff38..dd0c9d0 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -137,7 +137,9 @@ class SubmissionAttempt(Base): id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) client_hash: Mapped[str] = mapped_column(String(64), 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) + catch_report: Mapped[CatchReport | None] = relationship() class DataSource(Base):