feat: expose safe moderation provenance
This commit is contained in:
@@ -250,6 +250,8 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
|||||||
|
|
||||||
Для карточки с клавиатурным фокусом доступны подсказанные в интерфейсе быстрые клавиши одобрения, сопоставления и публикации. Они не срабатывают в полях ввода; отклонение и удаление требуют явного нажатия кнопки.
|
Для карточки с клавиатурным фокусом доступны подсказанные в интерфейсе быстрые клавиши одобрения, сопоставления и публикации. Они не срабатывают в полях ввода; отклонение и удаление требуют явного нажатия кнопки.
|
||||||
|
|
||||||
|
Административный API внешней очереди возвращает безопасный provenance: время первого и последнего обнаружения, время проверки, отсутствующие поля и только разрешённые скалярные поля исходной записи. Неизвестные ключи и вложенные служебные структуры в ответ не попадают.
|
||||||
|
|
||||||
## Эксплуатация production
|
## Эксплуатация production
|
||||||
|
|
||||||
- первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md);
|
- первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md);
|
||||||
|
|||||||
+15
-1
@@ -162,15 +162,29 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O
|
|||||||
|
|
||||||
|
|
||||||
def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
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(
|
return ExternalObservationOut(
|
||||||
id=item.id, source_system=item.source_system, source_external_id=item.source_external_id,
|
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,
|
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,
|
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,
|
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,
|
fish_slug=item.fish.slug if item.fish else None,
|
||||||
waterbody_slug=item.waterbody.slug if item.waterbody else None,
|
waterbody_slug=item.waterbody.slug if item.waterbody else None,
|
||||||
catch_report_id=item.catch_report_id, review_note=item.review_note,
|
catch_report_id=item.catch_report_id, review_note=item.review_note,
|
||||||
|
missing_fields=missing_fields, source_payload=allowed_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -226,12 +226,16 @@ class ExternalObservationOut(BaseModel):
|
|||||||
y: int | None
|
y: int | None
|
||||||
weight_g: int | None
|
weight_g: int | None
|
||||||
published_at: datetime | None
|
published_at: datetime | None
|
||||||
|
first_seen_at: datetime
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
|
reviewed_at: datetime | None
|
||||||
status: str
|
status: str
|
||||||
fish_slug: str | None
|
fish_slug: str | None
|
||||||
waterbody_slug: str | None
|
waterbody_slug: str | None
|
||||||
catch_report_id: UUID | None
|
catch_report_id: UUID | None
|
||||||
review_note: str | None
|
review_note: str | None
|
||||||
|
missing_fields: list[str]
|
||||||
|
source_payload: dict[str, str | int | float | bool | None]
|
||||||
|
|
||||||
|
|
||||||
class ExternalObservationMapping(BaseModel):
|
class ExternalObservationMapping(BaseModel):
|
||||||
|
|||||||
+52
-1
@@ -648,6 +648,11 @@
|
|||||||
],
|
],
|
||||||
"title": "Catch Report Id"
|
"title": "Catch Report Id"
|
||||||
},
|
},
|
||||||
|
"first_seen_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "First Seen At",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"fish_external_id": {
|
"fish_external_id": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -684,6 +689,13 @@
|
|||||||
"title": "Last Seen At",
|
"title": "Last Seen At",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"missing_fields": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": "Missing Fields",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"published_at": {
|
"published_at": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -707,10 +719,45 @@
|
|||||||
],
|
],
|
||||||
"title": "Review Note"
|
"title": "Review Note"
|
||||||
},
|
},
|
||||||
|
"reviewed_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Reviewed At"
|
||||||
|
},
|
||||||
"source_external_id": {
|
"source_external_id": {
|
||||||
"title": "Source External Id",
|
"title": "Source External Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"source_payload": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"title": "Source Payload",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"source_system": {
|
"source_system": {
|
||||||
"title": "Source System",
|
"title": "Source System",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -796,12 +843,16 @@
|
|||||||
"y",
|
"y",
|
||||||
"weight_g",
|
"weight_g",
|
||||||
"published_at",
|
"published_at",
|
||||||
|
"first_seen_at",
|
||||||
"last_seen_at",
|
"last_seen_at",
|
||||||
|
"reviewed_at",
|
||||||
"status",
|
"status",
|
||||||
"fish_slug",
|
"fish_slug",
|
||||||
"waterbody_slug",
|
"waterbody_slug",
|
||||||
"catch_report_id",
|
"catch_report_id",
|
||||||
"review_note"
|
"review_note",
|
||||||
|
"missing_fields",
|
||||||
|
"source_payload"
|
||||||
],
|
],
|
||||||
"title": "ExternalObservationOut",
|
"title": "ExternalObservationOut",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|||||||
@@ -342,6 +342,10 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
|
|||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers)
|
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())
|
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)
|
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())
|
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)
|
prioritized = client.get("/api/v1/admin/external-observations?status=review&order=risk", headers=headers)
|
||||||
|
|||||||
+1
-1
@@ -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] **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] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений.
|
||||||
- [x] **M03 · Эффективность очередей.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку по свежести или риску; проблемный порядок поднимает неполные и несопоставленные записи, а параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Безопасные горячие клавиши работают только внутри карточки с фокусом и отключены в полях ввода; отклонение и удаление намеренно оставлены только на кнопках.
|
- [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`, если запись уже изменена другим модератором.
|
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
|
||||||
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
|
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user