from __future__ import annotations import hashlib from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.orm import Session from .importer import normalize 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): pass def map_observation( session: Session, observation: ExternalObservation, fish: Fish, waterbody: Waterbody, *, note: str | None = None, ) -> ExternalObservation: if observation.status == "published": raise ExternalReviewError("published observation cannot be remapped") observation.fish = fish observation.waterbody = waterbody # A07: Explain the matching method in review_note match_method = [] if observation.fish_external_id: match_method.append(f"external_id={observation.fish_external_id}") elif observation.fish_name: match_method.append(f"name={observation.fish_name}") if observation.waterbody_external_id: match_method.append(f"wb_external_id={observation.waterbody_external_id}") elif observation.waterbody_name: match_method.append(f"wb_name={observation.waterbody_name}") method_explanation = f"matched via {', '.join(match_method)}" observation.review_note = f"{method_explanation}" + (f"; {note}" if note else "") observation.reviewed_at = datetime.now(timezone.utc) observation.status = "ready" if _complete(observation) else "mapped" _save_alias(session, observation, "fish", observation.fish_external_id or observation.fish_name, fish=fish) _save_alias(session, observation, "waterbody", observation.waterbody_external_id or observation.waterbody_name, waterbody=waterbody) session.commit() return observation def suggest_aliases(session: Session, observation: ExternalObservation) -> tuple[Fish | None, Waterbody | None]: fish_alias = session.scalar(select(ExternalEntityAlias).where( ExternalEntityAlias.source_system == observation.source_system, ExternalEntityAlias.entity_type == "fish", ExternalEntityAlias.external_id == (observation.fish_external_id or observation.fish_name), )) waterbody_alias = session.scalar(select(ExternalEntityAlias).where( ExternalEntityAlias.source_system == observation.source_system, ExternalEntityAlias.entity_type == "waterbody", ExternalEntityAlias.external_id == (observation.waterbody_external_id or observation.waterbody_name), )) return (fish_alias.fish if fish_alias else None, waterbody_alias.waterbody if waterbody_alias else None) def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation: if observation.status == "published": raise ExternalReviewError("published observation cannot be rejected") observation.status = "rejected" observation.review_note = reason observation.reviewed_at = datetime.now(timezone.utc) session.commit() return observation def publish_observation(session: Session, observation: ExternalObservation) -> CatchReport: if observation.catch_report is not None and observation.status == "published": return observation.catch_report if observation.status == "rejected": raise ExternalReviewError("rejected observation must be mapped again before publication") if observation.fish is None or observation.waterbody is None or not _complete(observation): raise ExternalReviewError("fish, waterbody, coordinates and weight are required for publication") spot = session.scalar(select(Spot).where( Spot.waterbody_id == observation.waterbody.id, Spot.x == observation.x, Spot.y == observation.y, )) if spot is None: spot = Spot(waterbody=observation.waterbody, x=observation.x, y=observation.y) session.add(spot) bait = _bait(session, observation.payload.get("bait")) now = datetime.now(timezone.utc) values = dict( fish=observation.fish, waterbody=observation.waterbody, spot=spot, bait=bait, weight_g=observation.weight_g, fishing_method=observation.payload.get("fishing_method"), rig_type=observation.payload.get("rig_type"), retrieve_method=observation.payload.get("retrieve_method"), retrieve_speed=observation.payload.get("retrieve_speed"), caught_at=None, reported_at=observation.published_at or observation.first_seen_at, player_name=observation.payload.get("player_name"), source_type=SourceType.manual_import, source_url=observation.source_url, source_external_id=_report_external_id(observation), source_confidence=observation.source.default_confidence, moderation_status=ModerationStatus.approved, raw_payload={ "provenance": { "external_observation_id": str(observation.id), "source_system": observation.source_system, "source_external_id": observation.source_external_id, "coordinate_raw": observation.coordinate_raw, "coordinate_precision": observation.coordinate_precision, }, "original": observation.payload, }, ) report = observation.catch_report if report is None: report = CatchReport(**values) else: for key, value in values.items(): 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 session.commit() return report def _complete(observation: ExternalObservation) -> bool: return observation.x is not None and observation.y is not None and observation.weight_g is not None def _save_alias( session: Session, observation: ExternalObservation, entity_type: str, external_id: str, *, fish: Fish | None = None, waterbody: Waterbody | None = None, ) -> None: alias = session.scalar(select(ExternalEntityAlias).where( ExternalEntityAlias.source_system == observation.source_system, ExternalEntityAlias.entity_type == entity_type, ExternalEntityAlias.external_id == external_id, )) if alias is None: alias = ExternalEntityAlias( source_system=observation.source_system, entity_type=entity_type, external_id=external_id, external_name=observation.fish_name if fish else observation.waterbody_name, ) session.add(alias) elif (fish is not None and alias.fish_id != fish.id) or (waterbody is not None and alias.waterbody_id != waterbody.id): raise ExternalReviewError(f"confirmed {entity_type} alias points to another entity") alias.fish = fish alias.waterbody = waterbody alias.updated_at = datetime.now(timezone.utc) def _bait(session: Session, value: object) -> Bait | None: name = str(value or "").strip() if not name: return None key = normalize(name) bait = session.scalar(select(Bait).where(Bait.normalized_name == key)) if bait is None: bait = Bait(name=name[:200], normalized_name=key[:200], kind=BaitKind.unknown) session.add(bait) return bait def _report_external_id(observation: ExternalObservation) -> str: raw = f"{observation.source_system}:{observation.source_external_id}".encode() return "ext:" + hashlib.sha256(raw).hexdigest()[:60]