Bug: review_note was empty or contained arbitrary text, not explaining how the observation was matched to fish/waterbody. Fix: review_note now includes the matching method: - 'matched via external_id=X' if fish_external_id was used - 'matched via name=X' if fish_name fallback was used - Same for waterbody (wb_external_id or wb_name) - Original note is appended after semicolon This provides transparency about how external observations were mapped, fulfilling the requirement that review_note explains the real matching method used. Verification: - 124/124 Python tests pass - Existing tests still pass (review_note is optional parameter) - New review_note format is machine-readable and human-friendly
167 lines
7.2 KiB
Python
167 lines
7.2 KiB
Python
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,
|
|
)
|
|
|
|
|
|
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,
|
|
},
|
|
"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()
|
|
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]
|