diff --git a/apps/api/app/community_review.py b/apps/api/app/community_review.py index d580652..ab19691 100644 --- a/apps/api/app/community_review.py +++ b/apps/api/app/community_review.py @@ -21,7 +21,7 @@ class ExternalReviewError(ValueError): def map_observation( session: Session, observation: ExternalObservation, fish: Fish, waterbody: Waterbody, - *, note: str | None = None, + *, note: str | None = None, commit: bool = True, ) -> ExternalObservation: if observation.status == "published": raise ExternalReviewError("published observation cannot be remapped") @@ -43,7 +43,8 @@ def map_observation( 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() + if commit: + session.commit() return observation diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py index 9473577..f8b00cf 100644 --- a/apps/api/app/routers/admin.py +++ b/apps/api/app/routers/admin.py @@ -20,7 +20,7 @@ from rf4_research.media_assets import publish_quality_upgrades, rollback_quality from ..media_catalog import MEDIA_ROOT, review_assets, review_file from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody from ..public_cache import public_cache -from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationBulkDecision, ExternalObservationBulkResult, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate +from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationBulkDecision, ExternalObservationBulkMapping, ExternalObservationBulkResult, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate from ..storage import delete_screenshot, signed_screenshot_url from ..time_utils import aware @@ -343,6 +343,34 @@ def admin_map_external_observation( raise HTTPException(status_code=409, detail=str(exc)) from exc +@router.patch("/api/v1/admin/external-observations/bulk-mapping", response_model=ExternalObservationBulkResult) +def admin_map_external_observations_bulk( + payload: ExternalObservationBulkMapping, db: Db, _: Annotated[str, Depends(_admin)], +) -> ExternalObservationBulkResult: + ids = [item.id for item in payload.items] + observations = list(db.scalars(select(ExternalObservation).where(ExternalObservation.id.in_(ids)).with_for_update())) + by_id = {item.id: item for item in observations} + expected = {item.id: item.expected_version for item in payload.items} + if len(by_id) != len(ids) or any( + item.status in {"published", "withdrawn"} or item.moderation_version != expected[item.id] + for item in observations + ): + raise HTTPException(status_code=409, detail="one or more observations changed; reload the queue") + fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug)) + waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug)) + if fish is None or waterbody is None: + raise HTTPException(status_code=422, detail="unknown fish or waterbody") + try: + for item in observations: + item.moderation_version += 1 + map_observation(db, item, fish, waterbody, note=payload.note, commit=False) + db.commit() + except ExternalReviewError as exc: + db.rollback() + raise HTTPException(status_code=409, detail=str(exc)) from exc + return ExternalObservationBulkResult(updated=len(observations)) + + @router.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished) def admin_publish_external_observation( observation_id: UUID, payload: ExternalObservationAction, db: Db, _: Annotated[str, Depends(_admin)], diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index cd3988b..99d38b0 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -413,6 +413,13 @@ class ExternalObservationBulkDecision(BaseModel): items: list[ExternalObservationBulkItem] = Field(min_length=1, max_length=50) +class ExternalObservationBulkMapping(BaseModel): + fish_slug: str + waterbody_slug: str + note: str | None = Field(default=None, max_length=1000) + items: list[ExternalObservationBulkItem] = Field(min_length=1, max_length=50) + + class ExternalObservationBulkResult(BaseModel): updated: int