feat: display provenance and incomplete signals
This commit is contained in:
@@ -69,10 +69,22 @@ def activity_rows(
|
||||
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
||||
activity_score=activity, confidence_score=confidence,
|
||||
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
|
||||
sources=sorted({_source_system(item) for item in items}),
|
||||
))
|
||||
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
||||
|
||||
|
||||
def _source_system(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
if isinstance(provenance, dict) and provenance.get("source_system"):
|
||||
return str(provenance["source_system"])
|
||||
if report.source_type.value == "official_record":
|
||||
return "rf4-official"
|
||||
if report.source_type.value == "user":
|
||||
return "players"
|
||||
return "manual-import"
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
+46
-3
@@ -24,9 +24,9 @@ from .config import settings
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||
from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .models import Bait, BaitKind, CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -150,7 +150,50 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
||||
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
||||
_spot_or_404(db, spot_id)
|
||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
|
||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed, source_system=_report_source(r), source_url=r.source_url) for r in reports]
|
||||
|
||||
|
||||
def _report_source(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
if isinstance(provenance, dict) and provenance.get("source_system"):
|
||||
return str(provenance["source_system"])
|
||||
if report.source_type == SourceType.official_record:
|
||||
return "rf4-official"
|
||||
if report.source_type == SourceType.user:
|
||||
return "players"
|
||||
return "manual-import"
|
||||
|
||||
|
||||
@app.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
|
||||
def community_observations(
|
||||
db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0),
|
||||
) -> list[PublicObservationOut]:
|
||||
items = list(db.scalars(
|
||||
select(ExternalObservation)
|
||||
.join(ExternalObservation.source)
|
||||
.options(joinedload(ExternalObservation.source))
|
||||
.where(
|
||||
ExternalObservation.catch_report_id.is_(None),
|
||||
ExternalObservation.status != "rejected",
|
||||
DataSource.enabled.is_(True),
|
||||
)
|
||||
.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc())
|
||||
.offset(offset).limit(limit)
|
||||
))
|
||||
result: list[PublicObservationOut] = []
|
||||
for item in items:
|
||||
missing = []
|
||||
if item.x is None or item.y is None:
|
||||
missing.append("координаты")
|
||||
if item.weight_g is None:
|
||||
missing.append("вес")
|
||||
result.append(PublicObservationOut(
|
||||
id=item.id, source_system=item.source_system, source_name=item.source.name,
|
||||
source_url=item.source_url, fish_name=item.fish_name, waterbody_name=item.waterbody_name,
|
||||
x=item.x, y=item.y, weight_g=item.weight_g, last_seen_at=item.last_seen_at,
|
||||
missing_fields=missing, quality="incomplete" if missing else "unverified",
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
||||
|
||||
@@ -47,6 +47,7 @@ class ActivityOut(BaseModel):
|
||||
activity_score: int
|
||||
confidence_score: int
|
||||
explanation: str
|
||||
sources: list[str]
|
||||
|
||||
|
||||
class CatchOut(BaseModel):
|
||||
@@ -59,6 +60,8 @@ class CatchOut(BaseModel):
|
||||
reported_at: datetime
|
||||
retrieve_method: str | None
|
||||
retrieve_speed: int | None
|
||||
source_system: str
|
||||
source_url: str | None
|
||||
|
||||
|
||||
class SpotOut(BaseModel):
|
||||
@@ -85,6 +88,22 @@ class OfficialRecordOut(BaseModel):
|
||||
category: str | None
|
||||
region: str | None
|
||||
source_url: str | None
|
||||
source_system: str = "rf4-official"
|
||||
|
||||
|
||||
class PublicObservationOut(BaseModel):
|
||||
id: UUID
|
||||
source_system: str
|
||||
source_name: str
|
||||
source_url: str
|
||||
fish_name: str
|
||||
waterbody_name: str
|
||||
x: int | None
|
||||
y: int | None
|
||||
weight_g: int | None
|
||||
last_seen_at: datetime
|
||||
missing_fields: list[str]
|
||||
quality: str
|
||||
|
||||
|
||||
class ImportRunOut(BaseModel):
|
||||
|
||||
@@ -49,6 +49,7 @@ def test_activity_filters_and_explains_score() -> None:
|
||||
assert payload[0]["catches"] == 3
|
||||
assert payload[0]["unique_players"] == 3
|
||||
assert "3 свежих улова" in payload[0]["explanation"]
|
||||
assert payload[0]["sources"] == ["manual-import"]
|
||||
|
||||
|
||||
def test_invalid_period_is_rejected() -> None:
|
||||
@@ -81,6 +82,7 @@ def test_spot_detail_and_catches() -> None:
|
||||
assert detail.json()["catches_24h"] == 3
|
||||
assert catches.status_code == 200
|
||||
assert len(catches.json()) == 3
|
||||
assert catches.json()[0]["source_system"] == "manual-import"
|
||||
|
||||
|
||||
def test_records_list_is_empty_before_import() -> None:
|
||||
@@ -164,7 +166,7 @@ def test_external_observation_requires_mapping_and_complete_data_before_publicat
|
||||
assert {alias.entity_type for alias in aliases} == {"fish", "waterbody"}
|
||||
|
||||
|
||||
def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
def test_incomplete_external_observation_is_publicly_labelled_but_not_counted() -> None:
|
||||
with Session(engine) as db:
|
||||
stage_observations(db, [{
|
||||
"source_system": "rf4db", "source_external_id": "review-incomplete",
|
||||
@@ -174,6 +176,13 @@ def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
observation_id = db.scalar(select(ExternalObservation.id).where(
|
||||
ExternalObservation.source_external_id == "review-incomplete"
|
||||
))
|
||||
public = client.get("/api/v1/community-observations")
|
||||
assert public.status_code == 200
|
||||
signal = next(item for item in public.json() if item["id"] == str(observation_id))
|
||||
assert signal["source_system"] == "rf4db"
|
||||
assert signal["quality"] == "incomplete"
|
||||
assert signal["missing_fields"] == ["вес"]
|
||||
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json())
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
mapped = client.patch(
|
||||
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
||||
@@ -186,6 +195,7 @@ def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
json={"reason": "weight is absent"},
|
||||
)
|
||||
assert rejected.json()["status"] == "rejected"
|
||||
assert all(item["id"] != str(observation_id) for item in client.get("/api/v1/community-observations").json())
|
||||
|
||||
|
||||
def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user