feat: suggest confirmed external aliases
This commit is contained in:
@@ -34,6 +34,20 @@ def map_observation(
|
||||
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")
|
||||
@@ -109,6 +123,8 @@ def _save_alias(
|
||||
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)
|
||||
|
||||
+16
-2
@@ -21,13 +21,13 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from .activity import activity_rows
|
||||
from .database import get_session
|
||||
from .config import settings
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||
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, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -351,6 +351,20 @@ def admin_external_observations(
|
||||
return [_external_out(item) for item in items]
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/external-observations/{observation_id}/alias-suggestions", response_model=ExternalAliasSuggestionOut)
|
||||
def admin_external_alias_suggestions(
|
||||
observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalAliasSuggestionOut:
|
||||
observation = db.get(ExternalObservation, observation_id)
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
fish, waterbody = suggest_aliases(db, observation)
|
||||
return ExternalAliasSuggestionOut(
|
||||
fish_slug=fish.slug if fish else None,
|
||||
waterbody_slug=waterbody.slug if waterbody else None,
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut)
|
||||
def admin_map_external_observation(
|
||||
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
|
||||
|
||||
@@ -213,6 +213,12 @@ class ExternalObservationMapping(BaseModel):
|
||||
note: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ExternalAliasSuggestionOut(BaseModel):
|
||||
fish_slug: str | None
|
||||
waterbody_slug: str | None
|
||||
requires_confirmation: bool = True
|
||||
|
||||
|
||||
class ExternalObservationDecision(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ def test_list_pagination_and_filter_validation() -> None:
|
||||
assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422
|
||||
assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422
|
||||
assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422
|
||||
assert client.get("/api/v1/admin/external-observations/00000000-0000-0000-0000-000000000000/alias-suggestions").status_code == 401
|
||||
|
||||
|
||||
def test_public_source_status_hides_internal_details() -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.community_importer import CommunityImportError, stage_observations
|
||||
from app.community_review import ExternalReviewError, map_observation, suggest_aliases
|
||||
from app.database import Base
|
||||
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
||||
@@ -129,6 +130,31 @@ def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
||||
assert item is not None and item.status == "staged" and item.catch_report is None
|
||||
|
||||
|
||||
def test_confirmed_aliases_are_suggestions_and_cannot_be_retargeted(db: Session) -> None:
|
||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True)
|
||||
pike = Fish(slug="pike", name_ru="Щука")
|
||||
perch = Fish(slug="perch", name_ru="Окунь")
|
||||
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||
db.add_all([source, pike, perch, waterbody])
|
||||
db.commit()
|
||||
stage_observations(db, [record(external_id="first")])
|
||||
first = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "first"))
|
||||
assert first is not None
|
||||
map_observation(db, first, pike, waterbody)
|
||||
|
||||
stage_observations(db, [record(external_id="second")])
|
||||
second = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "second"))
|
||||
assert second is not None
|
||||
suggested_fish, suggested_waterbody = suggest_aliases(db, second)
|
||||
assert (suggested_fish.slug, suggested_waterbody.slug) == ("pike", "test-lake")
|
||||
|
||||
with pytest.raises(ExternalReviewError, match="confirmed fish alias"):
|
||||
map_observation(db, second, perch, waterbody)
|
||||
db.rollback()
|
||||
alias = db.scalar(select(ExternalEntityAlias).where(ExternalEntityAlias.entity_type == "fish"))
|
||||
assert alias is not None and alias.fish_id == pike.id
|
||||
|
||||
|
||||
def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
|
||||
parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8"))
|
||||
payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str))
|
||||
|
||||
Reference in New Issue
Block a user