feat: manage external source lifecycle
This commit is contained in:
@@ -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")
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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, а не отдельным блокером.
|
||||||
|
|||||||
Reference in New Issue
Block a user