feat: preserve gear components through catch imports
This commit is contained in:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user