From bc3ceb5dfe65954154acc80981f82dbe43ecc443 Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 13 Sep 2026 16:26:34 +0700 Subject: [PATCH] feat: manage external source lifecycle --- README.md | 2 + .../alembic/versions/0016_source_lifecycle.py | 24 ++++++++++ apps/api/app/community_importer.py | 10 ++++ apps/api/app/models.py | 2 + apps/api/app/routers/admin.py | 4 +- apps/api/app/routers/public_data.py | 2 +- apps/api/app/schemas.py | 2 + apps/api/app/source_lifecycle.py | 38 +++++++++++++++ apps/api/openapi.json | 28 ++++++++++- apps/api/tests/test_community_importer.py | 46 +++++++++++++++++++ .../src/pages/admin/external-sources.astro | 3 +- docs/ROADMAP.md | 2 +- 12 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 apps/api/alembic/versions/0016_source_lifecycle.py create mode 100644 apps/api/app/source_lifecycle.py diff --git a/README.md b/README.md index 9e87987..770d92a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Web Docker-образ устанавливает зависимости чере RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. +Жизненный цикл первоисточника учитывается консервативно: изменённая опубликованная запись снимается с активности до повторной ручной проверки, а исчезнувшая — только после подтверждённого ответа `missing` во время разрешённого планового обращения. Временные ошибки и блокировка доступа не удаляют данные. Admin provenance показывает результат и время последней проверки; повторно появившаяся запись также требует подтверждения модератором. + На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку. Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены резкие favicon/app icons из SVG-мастера, отдельные полнофоновые maskable-иконки, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества. diff --git a/apps/api/alembic/versions/0016_source_lifecycle.py b/apps/api/alembic/versions/0016_source_lifecycle.py new file mode 100644 index 0000000..1fb407d --- /dev/null +++ b/apps/api/alembic/versions/0016_source_lifecycle.py @@ -0,0 +1,24 @@ +"""track source record lifecycle checks + +Revision ID: 0016 +Revises: 0015 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0016" +down_revision = "0015" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("external_observation", sa.Column("source_check_status", sa.String(length=30))) + op.add_column("external_observation", sa.Column("source_checked_at", sa.DateTime(timezone=True))) + + +def downgrade() -> None: + op.drop_column("external_observation", "source_checked_at") + op.drop_column("external_observation", "source_check_status") diff --git a/apps/api/app/community_importer.py b/apps/api/app/community_importer.py index 6873dc8..0501188 100644 --- a/apps/api/app/community_importer.py +++ b/apps/api/app/community_importer.py @@ -68,6 +68,7 @@ def stage_observations( "weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000), "published_at": _datetime(payload.get("published_at")), "last_seen_at": fetched_at, "payload": payload, + "source_check_status": "available", "source_checked_at": fetched_at, } if observation is None: observation = ExternalObservation( @@ -89,8 +90,17 @@ def stage_observations( observation.fish = None observation.waterbody = None observation.review_note = "Source record changed; manual mapping and publication required" + observation.reviewed_at = fetched_at + observation.moderation_version += 1 for key, value in values.items(): setattr(observation, key, value) + if observation.status == "withdrawn": + observation.status = "staged" + observation.fish = None + observation.waterbody = None + observation.review_note = "Source record reappeared; manual confirmation required" + observation.reviewed_at = fetched_at + observation.moderation_version += 1 updated += 1 touched.append(observation) session.commit() diff --git a/apps/api/app/models.py b/apps/api/app/models.py index e3482cd..974d78b 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -199,6 +199,8 @@ class ExternalObservation(Base): review_note: Mapped[str | None] = mapped_column(Text) reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) moderation_version: Mapped[int] = mapped_column(default=0) + source_check_status: Mapped[str | None] = mapped_column(String(30)) + source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) source: Mapped[DataSource] = relationship() fish: Mapped[Fish | None] = relationship() waterbody: Mapped[Waterbody | None] = relationship() diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py index 9f928b7..ecd4b9d 100644 --- a/apps/api/app/routers/admin.py +++ b/apps/api/app/routers/admin.py @@ -156,13 +156,14 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut: catch_report_id=item.catch_report_id, review_note=item.review_note, missing_fields=missing_fields, source_payload=allowed_payload, moderation_version=item.moderation_version, + source_check_status=item.source_check_status, source_checked_at=item.source_checked_at, ) @router.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut]) def admin_external_observations( db: Db, _: Annotated[str, Depends(_admin)], - status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None, + status: Literal["staged", "mapped", "ready", "published", "rejected", "withdrawn", "review"] | None = None, source_system: str | None = None, completeness: Literal["all", "complete", "incomplete"] = "all", order: Literal["newest", "oldest", "risk"] = "newest", @@ -328,4 +329,3 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad db.commit() public_cache.invalidate() return Response(status_code=204) - diff --git a/apps/api/app/routers/public_data.py b/apps/api/app/routers/public_data.py index 99bbfdf..0f95fb5 100644 --- a/apps/api/app/routers/public_data.py +++ b/apps/api/app/routers/public_data.py @@ -40,7 +40,7 @@ def community_observations( joinedload(ExternalObservation.source) ).where( ExternalObservation.catch_report_id.is_(None), - ExternalObservation.status != "rejected", + ExternalObservation.status.not_in(["rejected", "withdrawn"]), DataSource.enabled.is_(True), ) if waterbody: diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 5bfe881..35517e6 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -239,6 +239,8 @@ class ExternalObservationOut(BaseModel): missing_fields: list[str] source_payload: dict[str, str | int | float | bool | None] moderation_version: int + source_check_status: str | None + source_checked_at: datetime | None class ExternalObservationMapping(BaseModel): diff --git a/apps/api/app/source_lifecycle.py b/apps/api/app/source_lifecycle.py new file mode 100644 index 0000000..0d01d30 --- /dev/null +++ b/apps/api/app/source_lifecycle.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Literal + +from sqlalchemy.orm import Session + +from .models import ExternalObservation, ModerationStatus + + +SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"] + + +def record_source_check( + session: Session, + observation: ExternalObservation, + status: SourceCheckStatus, + *, + checked_at: datetime | None = None, +) -> ExternalObservation: + """Persist a check performed during an already scheduled source request. + + Only an authoritative 404/410-style ``missing`` result withdraws published + data. Transient errors and access blocks remain diagnostic and never remove + an observation from activity. + """ + current = checked_at or datetime.now(timezone.utc) + observation.source_check_status = status + observation.source_checked_at = current + if status == "missing" and observation.status != "withdrawn": + if observation.catch_report is not None: + observation.catch_report.moderation_status = ModerationStatus.pending + observation.status = "withdrawn" + observation.review_note = "Source record missing; withdrawn pending moderator review" + observation.reviewed_at = current + observation.moderation_version += 1 + session.commit() + return observation diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 78561d6..8e50799 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -820,6 +820,29 @@ ], "title": "Reviewed At" }, + "source_check_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Check Status" + }, + "source_checked_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Checked At" + }, "source_external_id": { "title": "Source External Id", "type": "string" @@ -942,7 +965,9 @@ "review_note", "missing_fields", "source_payload", - "moderation_version" + "moderation_version", + "source_check_status", + "source_checked_at" ], "title": "ExternalObservationOut", "type": "object" @@ -2109,6 +2134,7 @@ "ready", "published", "rejected", + "withdrawn", "review" ], "type": "string" diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py index 805f0d9..9cbfa5f 100644 --- a/apps/api/tests/test_community_importer.py +++ b/apps/api/tests/test_community_importer.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from app.community_importer import CommunityImportError, stage_observations from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases +from app.source_lifecycle import record_source_check 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 @@ -128,6 +129,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session) assert report.moderation_status.value == "pending" assert report.weight_g == 5000 assert item.weight_g == 6000 + assert item.moderation_version == 1 + assert item.reviewed_at is not None stage_observations(db, [record() | {"weight_g": 6000}]) assert item.status == "staged" with pytest.raises(ExternalReviewError): @@ -140,6 +143,49 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session) assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 +def test_missing_source_withdraws_published_record_until_manual_review(db: Session) -> None: + fish = Fish(slug="pike", name_ru="Щука") + water = Waterbody(slug="test-lake", name_ru="Тестовое озеро") + db.add_all([fish, water]) + db.commit() + seen = datetime(2026, 9, 13, 8, tzinfo=timezone.utc) + checked = datetime(2026, 9, 13, 9, tzinfo=timezone.utc) + stage_observations(db, [record() | {"weight_g": 5000}], fetched_at=seen) + item = db.scalar(select(ExternalObservation)) + assert item is not None and item.catch_report is not None + + record_source_check(db, item, "missing", checked_at=checked) + + assert item.status == "withdrawn" + assert item.source_check_status == "missing" + assert item.source_checked_at.replace(tzinfo=timezone.utc) == checked + assert item.catch_report.moderation_status.value == "pending" + assert item.moderation_version == 1 + + stage_observations(db, [record() | {"weight_g": 5000}], fetched_at=checked) + assert item.status == "staged" + assert item.source_check_status == "available" + assert item.catch_report.moderation_status.value == "pending" + assert "reappeared" in (item.review_note or "") + + +@pytest.mark.parametrize("status", ["temporary_error", "blocked"]) +def test_non_authoritative_source_failures_do_not_withdraw(db: Session, status: str) -> None: + fish = Fish(slug="pike", name_ru="Щука") + water = Waterbody(slug="test-lake", name_ru="Тестовое озеро") + db.add_all([fish, water]) + db.commit() + stage_observations(db, [record() | {"weight_g": 5000}]) + item = db.scalar(select(ExternalObservation)) + assert item is not None and item.catch_report is not None + + record_source_check(db, item, status) # type: ignore[arg-type] + + assert item.status == "published" + assert item.catch_report.moderation_status.value == "approved" + assert item.source_check_status == status + + def test_auto_publication_requires_enabled_source(db: Session) -> None: source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False) fish = Fish(slug="pike", name_ru="Щука") diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index 7824b2d..fd66a40 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -74,7 +74,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const missing = ((row.missing_fields as string[]) ?? []).map(key => missingLabels[key] ?? key); const details = document.createElement("details"); details.className = "moderation-provenance"; - details.innerHTML = `Происхождение и исходные поля
Впервые замечено
${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}
Последний раз
${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}
Проверено
${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}
Не хватает
${missing.length ? esc(missing.join(", ")) : "ничего"}
${payload.map(([key,value]) => `
${esc(payloadLabels[key] ?? key)}
${esc(value)}
`).join("")}

Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.

`; + const sourceCheckLabels: Record = {available:"доступен",missing:"не найден",temporary_error:"временная ошибка",blocked:"доступ ограничен"}; + details.innerHTML = `Происхождение и исходные поля
Впервые замечено
${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}
Последний раз
${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}
Решение модератора
${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}
Проверка источника
${row.source_check_status ? esc(sourceCheckLabels[String(row.source_check_status)] ?? row.source_check_status) : "ещё не выполнялась"}${row.source_checked_at ? ` · ${esc(new Date(String(row.source_checked_at)).toLocaleString("ru-RU"))}` : ""}
Не хватает
${missing.length ? esc(missing.join(", ")) : "ничего"}
${payload.map(([key,value]) => `
${esc(payloadLabels[key] ?? key)}
${esc(value)}
`).join("")}

Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.

`; summary.append(details); }); } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 835f9e3..68cff86 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -49,7 +49,7 @@ - [ ] **D07 · Визуальная и accessibility-приёмка.** Проверить light/dark/system на 320/390/768/1280 px для главной, каталогов, detail, records, report, status и всех admin-экранов; покрыть normal/hover/focus/disabled/error/loading/empty и длинные данные. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, корректную печать, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт. - [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник. -- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки. +- [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу. - [ ] **Q03 · Целостность ссылок.** Проверять исходные ссылки только во время разрешённого планового обращения к площадке, разделяя `missing`, `temporary_error` и `blocked`; не создавать дополнительный сетевой цикл. - [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…». - [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером.