feat: manage external source lifecycle
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-13 16:26:34 +07:00
parent bb8040d6fb
commit bc3ceb5dfe
12 changed files with 157 additions and 6 deletions
+2
View File
@@ -34,6 +34,8 @@ Web Docker-образ устанавливает зависимости чере
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
Жизненный цикл первоисточника учитывается консервативно: изменённая опубликованная запись снимается с активности до повторной ручной проверки, а исчезнувшая — только после подтверждённого ответа `missing` во время разрешённого планового обращения. Временные ошибки и блокировка доступа не удаляют данные. Admin provenance показывает результат и время последней проверки; повторно появившаяся запись также требует подтверждения модератором.
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку. На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные 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` опубликована легенда цветов всех источников и статусов качества. Базовый 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` опубликована легенда цветов всех источников и статусов качества.
@@ -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")
+10
View File
@@ -68,6 +68,7 @@ def stage_observations(
"weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000), "weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000),
"published_at": _datetime(payload.get("published_at")), "published_at": _datetime(payload.get("published_at")),
"last_seen_at": fetched_at, "payload": payload, "last_seen_at": fetched_at, "payload": payload,
"source_check_status": "available", "source_checked_at": fetched_at,
} }
if observation is None: if observation is None:
observation = ExternalObservation( observation = ExternalObservation(
@@ -89,8 +90,17 @@ def stage_observations(
observation.fish = None observation.fish = None
observation.waterbody = None observation.waterbody = None
observation.review_note = "Source record changed; manual mapping and publication required" 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(): for key, value in values.items():
setattr(observation, key, value) 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 updated += 1
touched.append(observation) touched.append(observation)
session.commit() session.commit()
+2
View File
@@ -199,6 +199,8 @@ class ExternalObservation(Base):
review_note: Mapped[str | None] = mapped_column(Text) review_note: Mapped[str | None] = mapped_column(Text)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
moderation_version: Mapped[int] = mapped_column(default=0) 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() source: Mapped[DataSource] = relationship()
fish: Mapped[Fish | None] = relationship() fish: Mapped[Fish | None] = relationship()
waterbody: Mapped[Waterbody | None] = relationship() waterbody: Mapped[Waterbody | None] = relationship()
+2 -2
View File
@@ -156,13 +156,14 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut:
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, missing_fields=missing_fields, source_payload=allowed_payload,
moderation_version=item.moderation_version, 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]) @router.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
def admin_external_observations( def admin_external_observations(
db: Db, _: Annotated[str, Depends(_admin)], 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, source_system: str | None = None,
completeness: Literal["all", "complete", "incomplete"] = "all", completeness: Literal["all", "complete", "incomplete"] = "all",
order: Literal["newest", "oldest", "risk"] = "newest", 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() db.commit()
public_cache.invalidate() public_cache.invalidate()
return Response(status_code=204) return Response(status_code=204)
+1 -1
View File
@@ -40,7 +40,7 @@ def community_observations(
joinedload(ExternalObservation.source) joinedload(ExternalObservation.source)
).where( ).where(
ExternalObservation.catch_report_id.is_(None), ExternalObservation.catch_report_id.is_(None),
ExternalObservation.status != "rejected", ExternalObservation.status.not_in(["rejected", "withdrawn"]),
DataSource.enabled.is_(True), DataSource.enabled.is_(True),
) )
if waterbody: if waterbody:
+2
View File
@@ -239,6 +239,8 @@ class ExternalObservationOut(BaseModel):
missing_fields: list[str] missing_fields: list[str]
source_payload: dict[str, str | int | float | bool | None] source_payload: dict[str, str | int | float | bool | None]
moderation_version: int moderation_version: int
source_check_status: str | None
source_checked_at: datetime | None
class ExternalObservationMapping(BaseModel): class ExternalObservationMapping(BaseModel):
+38
View File
@@ -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
+27 -1
View File
@@ -820,6 +820,29 @@
], ],
"title": "Reviewed At" "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": { "source_external_id": {
"title": "Source External Id", "title": "Source External Id",
"type": "string" "type": "string"
@@ -942,7 +965,9 @@
"review_note", "review_note",
"missing_fields", "missing_fields",
"source_payload", "source_payload",
"moderation_version" "moderation_version",
"source_check_status",
"source_checked_at"
], ],
"title": "ExternalObservationOut", "title": "ExternalObservationOut",
"type": "object" "type": "object"
@@ -2109,6 +2134,7 @@
"ready", "ready",
"published", "published",
"rejected", "rejected",
"withdrawn",
"review" "review"
], ],
"type": "string" "type": "string"
+46
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from app.community_importer import CommunityImportError, stage_observations from app.community_importer import CommunityImportError, stage_observations
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases 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.database import Base
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody 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 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.moderation_status.value == "pending"
assert report.weight_g == 5000 assert report.weight_g == 5000
assert item.weight_g == 6000 assert item.weight_g == 6000
assert item.moderation_version == 1
assert item.reviewed_at is not None
stage_observations(db, [record() | {"weight_g": 6000}]) stage_observations(db, [record() | {"weight_g": 6000}])
assert item.status == "staged" assert item.status == "staged"
with pytest.raises(ExternalReviewError): 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 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: 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) source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
fish = Fish(slug="pike", name_ru="Щука") fish = Fish(slug="pike", name_ru="Щука")
@@ -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 missing = ((row.missing_fields as string[]) ?? []).map(key => missingLabels[key] ?? key);
const details = document.createElement("details"); const details = document.createElement("details");
details.className = "moderation-provenance"; details.className = "moderation-provenance";
details.innerHTML = `<summary>Происхождение и исходные поля</summary><dl><div><dt>Впервые замечено</dt><dd>${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Последний раз</dt><dd>${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Проверено</dt><dd>${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}</dd></div><div><dt>Не хватает</dt><dd>${missing.length ? esc(missing.join(", ")) : "ничего"}</dd></div>${payload.map(([key,value]) => `<div><dt>${esc(payloadLabels[key] ?? key)}</dt><dd>${esc(value)}</dd></div>`).join("")}</dl><p>Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.</p>`; const sourceCheckLabels: Record<string, string> = {available:"доступен",missing:"не найден",temporary_error:"временная ошибка",blocked:"доступ ограничен"};
details.innerHTML = `<summary>Происхождение и исходные поля</summary><dl><div><dt>Впервые замечено</dt><dd>${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Последний раз</dt><dd>${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Решение модератора</dt><dd>${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}</dd></div><div><dt>Проверка источника</dt><dd>${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"))}` : ""}</dd></div><div><dt>Не хватает</dt><dd>${missing.length ? esc(missing.join(", ")) : "ничего"}</dd></div>${payload.map(([key,value]) => `<div><dt>${esc(payloadLabels[key] ?? key)}</dt><dd>${esc(value)}</dd></div>`).join("")}</dl><p>Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.</p>`;
summary.append(details); summary.append(details);
}); });
} }
+1 -1
View File
@@ -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 и краткий отчёт. - [ ] **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. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник. - [ ] **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`; не создавать дополнительный сетевой цикл. - [ ] **Q03 · Целостность ссылок.** Проверять исходные ссылки только во время разрешённого планового обращения к площадке, разделяя `missing`, `temporary_error` и `blocked`; не создавать дополнительный сетевой цикл.
- [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…». - [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, а не отдельным блокером. - [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, а не отдельным блокером.