R09: Add PaginatedOfficialRecordOut schema and paginated /api/v1/records endpoint
This commit is contained in:
+15
-4
@@ -28,7 +28,7 @@ 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 .public_cache import public_cache
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PaginatedOfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -274,12 +274,12 @@ def source_status(db: Db) -> list[SourceStatusOut]:
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
||||
@app.get("/api/v1/records", response_model=PaginatedOfficialRecordOut)
|
||||
def records(
|
||||
db: Db, fish: str | None = None, waterbody: str | None = None,
|
||||
category: str | None = None, limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> list[OfficialRecordOut]:
|
||||
) -> PaginatedOfficialRecordOut:
|
||||
query = select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.official_record)
|
||||
if fish:
|
||||
query = query.join(CatchReport.fish).where(Fish.slug == fish)
|
||||
@@ -287,8 +287,19 @@ 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
|
||||
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||
return [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]
|
||||
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],
|
||||
total=total, limit=limit, offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
|
||||
@@ -98,6 +98,13 @@ class OfficialRecordOut(BaseModel):
|
||||
source_system: str = "rf4-official"
|
||||
|
||||
|
||||
class PaginatedOfficialRecordOut(BaseModel):
|
||||
items: list[OfficialRecordOut]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PublicObservationOut(BaseModel):
|
||||
id: UUID
|
||||
source_system: str
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy import create_engine, delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -177,7 +177,12 @@ def test_spot_detail_and_catches() -> None:
|
||||
def test_records_list_is_empty_before_import() -> None:
|
||||
response = client.get("/api/v1/records")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
payload = response.json()
|
||||
assert "items" in payload
|
||||
assert payload["total"] == 0
|
||||
assert payload["limit"] == 50
|
||||
assert payload["offset"] == 0
|
||||
assert payload["items"] == []
|
||||
|
||||
|
||||
def test_record_category_filter_is_applied_before_pagination() -> None:
|
||||
@@ -190,10 +195,73 @@ def test_record_category_filter_is_applied_before_pagination() -> None:
|
||||
CatchReport(fish=fish, waterbody=waterbody, weight_g=8000, caught_at=now - timedelta(days=1), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "wanted"}),
|
||||
])
|
||||
db.commit()
|
||||
response = client.get("/api/v1/records?category=wanted&limit=1")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["category"] == "wanted"
|
||||
try:
|
||||
response = client.get("/api/v1/records?category=wanted&limit=1")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total"] == 1 # only "wanted" matches
|
||||
assert payload["limit"] == 1
|
||||
assert payload["offset"] == 0
|
||||
assert len(payload["items"]) == 1
|
||||
assert payload["items"][0]["category"] == "wanted"
|
||||
finally:
|
||||
# Cleanup added records
|
||||
db.execute(delete(CatchReport).where(
|
||||
CatchReport.source_type == SourceType.official_record,
|
||||
CatchReport.raw_payload["category"].as_string().in_(["other", "wanted"]),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_records_pagination_returns_correct_total_and_offset() -> None:
|
||||
with Session(engine) as db:
|
||||
fish = db.scalar(select(Fish).where(Fish.slug == "pike"))
|
||||
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake"))
|
||||
now = datetime.now(timezone.utc)
|
||||
# Add exactly 5 official records with unique weights
|
||||
for index in range(5):
|
||||
db.add(CatchReport(fish=fish, waterbody=waterbody, weight_g=70000 + index * 100, caught_at=now - timedelta(days=index), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved))
|
||||
db.commit()
|
||||
try:
|
||||
# Page 1: limit=2, offset=0
|
||||
response1 = client.get("/api/v1/records?limit=2&offset=0")
|
||||
assert response1.status_code == 200
|
||||
p1 = response1.json()
|
||||
assert p1["total"] >= 5
|
||||
assert p1["limit"] == 2
|
||||
assert p1["offset"] == 0
|
||||
assert len(p1["items"]) == 2
|
||||
# Verify first item has our newest caught_at (index=0, weight=70000)
|
||||
assert p1["items"][0]["weight_g"] == 70000
|
||||
# Page 2: limit=2, offset=2
|
||||
response2 = client.get("/api/v1/records?limit=2&offset=2")
|
||||
assert response2.status_code == 200
|
||||
p2 = response2.json()
|
||||
assert p2["total"] == p1["total"] # total must be consistent
|
||||
assert p2["limit"] == 2
|
||||
assert p2["offset"] == 2
|
||||
assert len(p2["items"]) == 2
|
||||
# Page 3: limit=2, offset=4
|
||||
response3 = client.get("/api/v1/records?limit=2&offset=4")
|
||||
assert response3.status_code == 200
|
||||
p3 = response3.json()
|
||||
assert p3["total"] == p1["total"]
|
||||
assert p3["offset"] == 4
|
||||
# Last page should have remaining items
|
||||
assert len(p3["items"]) <= 2
|
||||
# Page 4: offset=total — past total, empty
|
||||
response4 = client.get(f"/api/v1/records?limit=2&offset={p1['total']}")
|
||||
assert response4.status_code == 200
|
||||
p4 = response4.json()
|
||||
assert p4["total"] == p1["total"]
|
||||
assert p4["items"] == []
|
||||
finally:
|
||||
# Cleanup added records
|
||||
db.execute(delete(CatchReport).where(
|
||||
CatchReport.source_type == SourceType.official_record,
|
||||
CatchReport.weight_g >= 70000,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_user_report_requires_moderation_before_activity() -> None:
|
||||
|
||||
Reference in New Issue
Block a user