diff --git a/README.md b/README.md index f60b2a3..4e69b92 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,8 @@ curl -H "Authorization: Bearer change-me-in-production" \ Для карточки с клавиатурным фокусом доступны подсказанные в интерфейсе быстрые клавиши одобрения, сопоставления и публикации. Они не срабатывают в полях ввода; отклонение и удаление требуют явного нажатия кнопки. +Административный API внешней очереди возвращает безопасный provenance: время первого и последнего обнаружения, время проверки, отсутствующие поля и только разрешённые скалярные поля исходной записи. Неизвестные ключи и вложенные служебные структуры в ответ не попадают. + ## Эксплуатация production - первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md); diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 106ee61..093c376 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -162,15 +162,29 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O def _external_out(item: ExternalObservation) -> ExternalObservationOut: + allowed_payload = { + key: value for key, value in (item.payload or {}).items() + if key in { + "bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed", + "player_name", "published_at", "region", "category", + } and (value is None or isinstance(value, (str, int, float, bool))) + } + missing_fields = [] + if item.x is None or item.y is None: + missing_fields.append("coordinates") + if item.weight_g is None: + missing_fields.append("weight_g") return ExternalObservationOut( id=item.id, source_system=item.source_system, source_external_id=item.source_external_id, source_url=item.source_url, fish_name=item.fish_name, fish_external_id=item.fish_external_id, waterbody_name=item.waterbody_name, waterbody_external_id=item.waterbody_external_id, x=item.x, y=item.y, weight_g=item.weight_g, published_at=item.published_at, - last_seen_at=item.last_seen_at, status=item.status, + first_seen_at=item.first_seen_at, last_seen_at=item.last_seen_at, reviewed_at=item.reviewed_at, + status=item.status, fish_slug=item.fish.slug if item.fish else None, waterbody_slug=item.waterbody.slug if item.waterbody else None, catch_report_id=item.catch_report_id, review_note=item.review_note, + missing_fields=missing_fields, source_payload=allowed_payload, ) diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index a8b0917..1d164a5 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -226,12 +226,16 @@ class ExternalObservationOut(BaseModel): y: int | None weight_g: int | None published_at: datetime | None + first_seen_at: datetime last_seen_at: datetime + reviewed_at: datetime | None status: str fish_slug: str | None waterbody_slug: str | None catch_report_id: UUID | None review_note: str | None + missing_fields: list[str] + source_payload: dict[str, str | int | float | bool | None] class ExternalObservationMapping(BaseModel): diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 4212393..b312cca 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -648,6 +648,11 @@ ], "title": "Catch Report Id" }, + "first_seen_at": { + "format": "date-time", + "title": "First Seen At", + "type": "string" + }, "fish_external_id": { "anyOf": [ { @@ -684,6 +689,13 @@ "title": "Last Seen At", "type": "string" }, + "missing_fields": { + "items": { + "type": "string" + }, + "title": "Missing Fields", + "type": "array" + }, "published_at": { "anyOf": [ { @@ -707,10 +719,45 @@ ], "title": "Review Note" }, + "reviewed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reviewed At" + }, "source_external_id": { "title": "Source External Id", "type": "string" }, + "source_payload": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "title": "Source Payload", + "type": "object" + }, "source_system": { "title": "Source System", "type": "string" @@ -796,12 +843,16 @@ "y", "weight_g", "published_at", + "first_seen_at", "last_seen_at", + "reviewed_at", "status", "fish_slug", "waterbody_slug", "catch_report_id", - "review_note" + "review_note", + "missing_fields", + "source_payload" ], "title": "ExternalObservationOut", "type": "object" diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index afcc6f2..e273bf2 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -342,6 +342,10 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted() headers = {"Authorization": "Bearer change-me-in-production"} incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers) assert any(item["id"] == str(observation_id) for item in incomplete.json()) + provenance = next(item for item in incomplete.json() if item["id"] == str(observation_id)) + assert provenance["missing_fields"] == ["weight_g"] + assert provenance["first_seen_at"] and provenance["last_seen_at"] + assert set(provenance["source_payload"]) <= {"bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed", "player_name", "published_at", "region", "category"} complete = client.get("/api/v1/admin/external-observations?status=review&completeness=complete&q=Pike", headers=headers) assert all(item["id"] != str(observation_id) for item in complete.json()) prioritized = client.get("/api/v1/admin/external-observations?status=review&order=risk", headers=headers) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f3124f6..e74458e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,7 +65,7 @@ - [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage. - [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений. - [x] **M03 · Эффективность очередей.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку по свежести или риску; проблемный порядок поднимает неполные и несопоставленные записи, а параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Безопасные горячие клавиши работают только внутри карточки с фокусом и отключены в полях ввода; отклонение и удаление намеренно оставлены только на кнопках. -- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных. +- [ ] **M04 · Полный provenance и история решений — в работе.** Admin API отдаёт время первого/последнего обнаружения и проверки, явный список missing fields и allowlist безопасных скалярных полей исходной записи; неизвестные ключи, вложенные структуры и служебные данные не раскрываются. Далее: показать provenance перед публикацией, затем добавить read-only журнал решений и обезличенный экспорт. - [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором. - [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.