feat: preserve gear components through catch imports
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""preserve ordered gear evidence on catches
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0022"
|
||||
down_revision = "0021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"catch_tackle_component",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False),
|
||||
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
|
||||
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id")),
|
||||
sa.Column("role", sa.String(50), nullable=False),
|
||||
sa.Column("position", sa.Integer(), nullable=False),
|
||||
sa.Column("raw_value", sa.String(200), nullable=False),
|
||||
sa.Column("source_system", sa.String(50)),
|
||||
sa.Column("source_external_id", sa.String(200)),
|
||||
sa.Column("source_url", sa.Text()),
|
||||
sa.Column("raw_payload", sa.JSON()),
|
||||
sa.UniqueConstraint("catch_report_id", "position"),
|
||||
sa.CheckConstraint(
|
||||
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
|
||||
name="ck_catch_tackle_one_canonical_target",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("catch_tackle_component")
|
||||
@@ -11,6 +11,8 @@ from .models import (
|
||||
Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation,
|
||||
Fish, ModerationStatus, SourceType, Spot, Waterbody,
|
||||
)
|
||||
from .tackle_components import replace_tackle_components
|
||||
from rf4_research.gear_components import from_catch_fields
|
||||
|
||||
|
||||
class ExternalReviewError(ValueError):
|
||||
@@ -118,6 +120,17 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
|
||||
setattr(report, key, value)
|
||||
session.add(report)
|
||||
session.flush()
|
||||
replace_tackle_components(
|
||||
session,
|
||||
report,
|
||||
from_catch_fields(
|
||||
bait=observation.payload.get("bait"),
|
||||
rig_type=observation.payload.get("rig_type"),
|
||||
),
|
||||
source_system=observation.source_system,
|
||||
source_url=observation.source_url,
|
||||
raw_payload={"origin": "community_observation", "observation_id": str(observation.id)},
|
||||
)
|
||||
observation.catch_report = report
|
||||
observation.status = "published"
|
||||
observation.reviewed_at = now
|
||||
|
||||
@@ -16,6 +16,8 @@ from .models import (
|
||||
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
|
||||
OfficialRecordImport, SourceType, Waterbody,
|
||||
)
|
||||
from .tackle_components import replace_tackle_components
|
||||
from rf4_research.gear_components import from_catch_fields
|
||||
|
||||
|
||||
USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
|
||||
@@ -222,6 +224,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
|
||||
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
|
||||
))
|
||||
run.rows_updated += 1
|
||||
replace_tackle_components(
|
||||
session,
|
||||
report,
|
||||
from_catch_fields(bait=raw.bait, rig_type=None),
|
||||
source_system="rf4-official",
|
||||
source_url=url,
|
||||
raw_payload={"origin": "official_record", "source_external_id": key},
|
||||
)
|
||||
run.status = ImportStatus.success
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
|
||||
@@ -165,6 +165,34 @@ class CatchReport(Base):
|
||||
spot: Mapped[Spot | None] = relationship()
|
||||
waterbody: Mapped[Waterbody] = relationship()
|
||||
bait: Mapped[Bait | None] = relationship()
|
||||
tackle_components: Mapped[list["CatchTackleComponent"]] = relationship(back_populates="catch_report")
|
||||
|
||||
|
||||
class CatchTackleComponent(Base):
|
||||
"""Ordered gear evidence; unresolved raw values are valid and preserved."""
|
||||
|
||||
__tablename__ = "catch_tackle_component"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("catch_report_id", "position"),
|
||||
CheckConstraint(
|
||||
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
|
||||
name="ck_catch_tackle_one_canonical_target",
|
||||
),
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
|
||||
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
|
||||
rig_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rig.id"))
|
||||
role: Mapped[str] = mapped_column(String(50))
|
||||
position: Mapped[int] = mapped_column(Integer)
|
||||
raw_value: Mapped[str] = mapped_column(String(200))
|
||||
source_system: Mapped[str | None] = mapped_column(String(50))
|
||||
source_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||
source_url: Mapped[str | None] = mapped_column(Text)
|
||||
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||
catch_report: Mapped[CatchReport] = relationship(back_populates="tackle_components")
|
||||
tackle_item: Mapped[TackleItem | None] = relationship()
|
||||
rig: Mapped[Rig | None] = relationship()
|
||||
|
||||
|
||||
class OfficialRecordImport(Base):
|
||||
|
||||
@@ -21,6 +21,8 @@ from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, Source
|
||||
from ..schemas import CatchReportAccepted, CatchReportCreate
|
||||
from ..storage import ScreenshotError, upload_screenshot
|
||||
from ..submission_security import check_rate_limit
|
||||
from ..tackle_components import replace_tackle_components
|
||||
from rf4_research.gear_components import from_catch_fields
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("rf4.api.submissions")
|
||||
@@ -60,6 +62,14 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, id
|
||||
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)
|
||||
replace_tackle_components(
|
||||
db,
|
||||
report,
|
||||
from_catch_fields(bait=payload.bait_name, rig_type=payload.rig_type),
|
||||
source_system="user",
|
||||
source_url=payload.source_url,
|
||||
raw_payload={"origin": "user_submission"},
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from rf4_research.gear_components import GearComponentIdentity
|
||||
|
||||
from .models import CatchReport, CatchTackleComponent
|
||||
|
||||
|
||||
def replace_tackle_components(
|
||||
session: Session,
|
||||
report: CatchReport,
|
||||
components: Iterable[GearComponentIdentity],
|
||||
*,
|
||||
source_system: str | None,
|
||||
source_url: str | None = None,
|
||||
raw_payload: dict | None = None,
|
||||
) -> None:
|
||||
"""Replace the ordered evidence for a report while keeping imports idempotent."""
|
||||
session.flush()
|
||||
session.execute(
|
||||
delete(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == report.id)
|
||||
)
|
||||
session.add_all(
|
||||
CatchTackleComponent(
|
||||
catch_report_id=report.id,
|
||||
role=component.role,
|
||||
position=component.position,
|
||||
raw_value=component.raw_value,
|
||||
source_system=source_system,
|
||||
source_external_id=component.source_external_id,
|
||||
source_url=source_url,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
for component in components
|
||||
)
|
||||
@@ -12,7 +12,7 @@ from app.database import Base, get_session
|
||||
from app.community_importer import stage_observations
|
||||
from app.importer import ImportAlreadyRunning
|
||||
from app.main import app
|
||||
from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from app.models import Bait, BaitKind, CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from app.routers import admin as admin_router
|
||||
|
||||
|
||||
@@ -351,11 +351,14 @@ def test_records_pagination_returns_correct_total_and_offset() -> None:
|
||||
|
||||
|
||||
def test_user_report_requires_moderation_before_activity() -> None:
|
||||
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
|
||||
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "rig_type": "Спиннинг", "player_name": "Reporter"})
|
||||
assert created.status_code == 201
|
||||
assert created.headers["Cache-Control"] == "no-store"
|
||||
assert created.json()["moderation_status"] == "pending"
|
||||
report_id = created.json()["id"]
|
||||
with Session(engine) as db:
|
||||
components = db.scalars(select(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == UUID(report_id)).order_by(CatchTackleComponent.position)).all()
|
||||
assert [(component.role, component.raw_value) for component in components] == [("lure", "Новая приманка"), ("rig", "Спиннинг")]
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
|
||||
assert pending.status_code == 200
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import Base
|
||||
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Waterbody
|
||||
|
||||
|
||||
def test_catch_keeps_ordered_unresolved_gear_evidence() -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
waterbody = Waterbody(slug="lake", name_ru="Озеро")
|
||||
report = CatchReport(
|
||||
fish=fish, waterbody=waterbody, weight_g=1000,
|
||||
reported_at=datetime(2026, 9, 20, tzinfo=timezone.utc), source_type=SourceType.manual_import,
|
||||
source_confidence=50, moderation_status=ModerationStatus.pending,
|
||||
)
|
||||
report.tackle_components.extend([
|
||||
CatchTackleComponent(role="lure", position=0, raw_value="Spiker #2"),
|
||||
CatchTackleComponent(role="rig", position=1, raw_value="Method Popup"),
|
||||
])
|
||||
session.add(report)
|
||||
session.commit()
|
||||
saved = session.get(CatchReport, report.id)
|
||||
assert saved is not None
|
||||
assert [(row.position, row.role, row.raw_value) for row in saved.tackle_components] == [
|
||||
(0, "lure", "Spiker #2"), (1, "rig", "Method Popup"),
|
||||
]
|
||||
@@ -11,7 +11,7 @@ from app.community_importer import CommunityImportError, stage_observations, upd
|
||||
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
|
||||
from app.source_lifecycle import record_scheduled_source_check, record_source_check
|
||||
from app.database import Base
|
||||
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||
from app.models import CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
||||
|
||||
|
||||
@@ -239,6 +239,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
|
||||
assert updated.id == report_id
|
||||
assert updated.weight_g == 6000
|
||||
assert updated.moderation_status.value == "approved"
|
||||
components = db.scalars(select(CatchTackleComponent).order_by(CatchTackleComponent.position)).all()
|
||||
assert [(component.position, component.raw_value) for component in components] == [(0, "Приманка")]
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import Base
|
||||
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
|
||||
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType
|
||||
from app.models import CatchReport, CatchTackleComponent, ImportStatus, OfficialRecordImport, SourceType
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
|
||||
@@ -50,6 +50,7 @@ def test_parser_and_import_are_idempotent() -> None:
|
||||
# A12: Second import of identical data creates no events (no fields changed)
|
||||
assert (second.rows_created, second.rows_updated) == (0, 0)
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
|
||||
assert db.scalar(select(func.count()).select_from(CatchTackleComponent)) == 2
|
||||
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user