feat: suggest confirmed external aliases
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-07 18:57:09 +07:00
parent 214b328cd2
commit 8b2d7e2e1c
7 changed files with 67 additions and 4 deletions
+16
View File
@@ -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
View File
@@ -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,
+6
View File
@@ -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)
+1
View File
@@ -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:
+26
View File
@@ -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))