From c4f4deb24b859166d79343b5cc40e7e8cc961034 Mon Sep 17 00:00:00 2001 From: IK Date: Thu, 3 Sep 2026 18:47:31 +0700 Subject: [PATCH] feat: add external observation review queue --- README.md | 7 +- .../alembic/versions/0009_external_review.py | 45 ++++++ apps/api/app/community_review.py | 131 ++++++++++++++++++ apps/api/app/main.py | 82 ++++++++++- apps/api/app/models.py | 32 ++++- apps/api/app/schemas.py | 37 +++++ apps/api/tests/test_api.py | 65 ++++++++- .../src/pages/admin/external-sources.astro | 33 +++++ docs/ROADMAP.md | 4 +- docs/community-source-pilot.md | 6 +- 10 files changed, 429 insertions(+), 13 deletions(-) create mode 100644 apps/api/alembic/versions/0009_external_review.py create mode 100644 apps/api/app/community_review.py create mode 100644 apps/web/src/pages/admin/external-sources.astro diff --git a/README.md b/README.md index 1f11dee..7e6d4ea 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ RF4 Spotter — неофициальный сервис свежих точек - этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника; - этап 3 функционально завершён: форма, раздельные ошибки создания/скриншота с повторной загрузкой, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы; - для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`; -- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — состояния карточек и сквозная проверка фильтров. +- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров; +- RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации. Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). -Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md); эти данные пока разбираются в общий промежуточный контракт, но не импортируются в рабочую БД и не влияют на индекс. +Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md); данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором. Один ограниченный снимок публичных карточек можно получить исследовательским CLI: @@ -31,7 +32,7 @@ python -m rf4_research.community_cli rf4db --limit 25 \ | docker compose exec -T api python -m app.cli stage-community-json --input - ``` -Staging проверяет происхождение URL и диапазоны значений. Источники по умолчанию выключены; автоматического преобразования в одобренные уловы нет. +Staging проверяет происхождение URL и диапазоны значений. Источники по умолчанию выключены; автоматического преобразования в одобренные уловы нет. Ручная очередь доступна по адресу . Публикация разрешена только после сопоставления канонических рыбы и водоёма и при наличии координат и веса. ## Запуск через Docker diff --git a/apps/api/alembic/versions/0009_external_review.py b/apps/api/alembic/versions/0009_external_review.py new file mode 100644 index 0000000..6e03736 --- /dev/null +++ b/apps/api/alembic/versions/0009_external_review.py @@ -0,0 +1,45 @@ +"""Add canonical aliases and external-observation review state.""" +from alembic import op +import sqlalchemy as sa + +revision = "0009" +down_revision = "0008" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("external_observation", sa.Column("fish_id", sa.Uuid(), sa.ForeignKey("fish.id"))) + op.add_column("external_observation", sa.Column("waterbody_id", sa.Uuid(), sa.ForeignKey("waterbody.id"))) + op.add_column("external_observation", sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"))) + op.add_column("external_observation", sa.Column("review_note", sa.Text())) + op.add_column("external_observation", sa.Column("reviewed_at", sa.DateTime(timezone=True))) + op.create_unique_constraint("uq_external_observation_catch_report_id", "external_observation", ["catch_report_id"]) + op.create_table( + "external_entity_alias", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("source_system", sa.String(50), sa.ForeignKey("data_source.key"), nullable=False), + sa.Column("entity_type", sa.String(20), nullable=False), + sa.Column("external_id", sa.String(200), nullable=False), + sa.Column("external_name", sa.String(200), nullable=False), + sa.Column("fish_id", sa.Uuid(), sa.ForeignKey("fish.id")), + sa.Column("waterbody_id", sa.Uuid(), sa.ForeignKey("waterbody.id")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "(entity_type = 'fish' AND fish_id IS NOT NULL AND waterbody_id IS NULL) OR " + "(entity_type = 'waterbody' AND waterbody_id IS NOT NULL AND fish_id IS NULL)", + name="ck_external_entity_alias_target", + ), + sa.UniqueConstraint("source_system", "entity_type", "external_id"), + ) + op.create_index("ix_external_entity_alias_source_system", "external_entity_alias", ["source_system"]) + + +def downgrade() -> None: + op.drop_table("external_entity_alias") + op.drop_constraint("uq_external_observation_catch_report_id", "external_observation", type_="unique") + op.drop_column("external_observation", "reviewed_at") + op.drop_column("external_observation", "review_note") + op.drop_column("external_observation", "catch_report_id") + op.drop_column("external_observation", "waterbody_id") + op.drop_column("external_observation", "fish_id") diff --git a/apps/api/app/community_review.py b/apps/api/app/community_review.py new file mode 100644 index 0000000..bd0328f --- /dev/null +++ b/apps/api/app/community_review.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .importer import normalize +from .models import ( + Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, + Fish, ModerationStatus, SourceType, Spot, Waterbody, +) + + +class ExternalReviewError(ValueError): + pass + + +def map_observation( + session: Session, observation: ExternalObservation, fish: Fish, waterbody: Waterbody, + *, note: str | None = None, +) -> ExternalObservation: + if observation.status == "published": + raise ExternalReviewError("published observation cannot be remapped") + observation.fish = fish + observation.waterbody = waterbody + observation.review_note = note + observation.reviewed_at = datetime.now(timezone.utc) + observation.status = "ready" if _complete(observation) else "mapped" + _save_alias(session, observation, "fish", observation.fish_external_id or observation.fish_name, fish=fish) + _save_alias(session, observation, "waterbody", observation.waterbody_external_id or observation.waterbody_name, waterbody=waterbody) + session.commit() + return observation + + +def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation: + if observation.status == "published": + raise ExternalReviewError("published observation cannot be rejected") + observation.status = "rejected" + observation.review_note = reason + observation.reviewed_at = datetime.now(timezone.utc) + session.commit() + return observation + + +def publish_observation(session: Session, observation: ExternalObservation) -> CatchReport: + if observation.catch_report is not None: + return observation.catch_report + if observation.fish is None or observation.waterbody is None or not _complete(observation): + raise ExternalReviewError("fish, waterbody, coordinates and weight are required for publication") + spot = session.scalar(select(Spot).where( + Spot.waterbody_id == observation.waterbody.id, Spot.x == observation.x, Spot.y == observation.y, + )) + if spot is None: + spot = Spot(waterbody=observation.waterbody, x=observation.x, y=observation.y) + session.add(spot) + bait = _bait(session, observation.payload.get("bait")) + now = datetime.now(timezone.utc) + report = CatchReport( + fish=observation.fish, waterbody=observation.waterbody, spot=spot, bait=bait, + weight_g=observation.weight_g, + fishing_method=observation.payload.get("fishing_method"), + rig_type=observation.payload.get("rig_type"), + retrieve_method=observation.payload.get("retrieve_method"), + retrieve_speed=observation.payload.get("retrieve_speed"), + caught_at=observation.published_at, + reported_at=observation.published_at or observation.first_seen_at, + player_name=observation.payload.get("player_name"), + source_type=SourceType.manual_import, + source_url=observation.source_url, + source_external_id=_report_external_id(observation), + source_confidence=observation.source.default_confidence, + moderation_status=ModerationStatus.approved, + raw_payload={ + "provenance": { + "external_observation_id": str(observation.id), + "source_system": observation.source_system, + "source_external_id": observation.source_external_id, + }, + "original": observation.payload, + }, + ) + session.add(report) + session.flush() + observation.catch_report = report + observation.status = "published" + observation.reviewed_at = now + session.commit() + return report + + +def _complete(observation: ExternalObservation) -> bool: + return observation.x is not None and observation.y is not None and observation.weight_g is not None + + +def _save_alias( + session: Session, observation: ExternalObservation, entity_type: str, external_id: str, + *, fish: Fish | None = None, waterbody: Waterbody | None = None, +) -> None: + alias = session.scalar(select(ExternalEntityAlias).where( + ExternalEntityAlias.source_system == observation.source_system, + ExternalEntityAlias.entity_type == entity_type, + ExternalEntityAlias.external_id == external_id, + )) + if alias is None: + alias = ExternalEntityAlias( + source_system=observation.source_system, entity_type=entity_type, + external_id=external_id, external_name=observation.fish_name if fish else observation.waterbody_name, + ) + session.add(alias) + alias.fish = fish + alias.waterbody = waterbody + alias.updated_at = datetime.now(timezone.utc) + + +def _bait(session: Session, value: object) -> Bait | None: + name = str(value or "").strip() + if not name: + return None + key = normalize(name) + bait = session.scalar(select(Bait).where(Bait.normalized_name == key)) + if bait is None: + bait = Bait(name=name[:200], normalized_name=key[:200], kind=BaitKind.unknown) + session.add(bait) + return bait + + +def _report_external_id(observation: ExternalObservation) -> str: + raw = f"{observation.source_system}:{observation.source_external_id}".encode() + return "ext:" + hashlib.sha256(raw).hexdigest()[:60] diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 5495cd9..16172b6 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -16,9 +16,10 @@ from sqlalchemy.orm import Session, joinedload from .activity import activity_rows from .database import get_session from .config import settings +from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation from .importer import ImportSourceError, import_records, normalize -from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody -from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut +from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody +from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut from .storage import ScreenshotError, delete_screenshot, signed_screenshot_url, upload_screenshot @@ -26,7 +27,7 @@ app = FastAPI(title="RF4 Spotter API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"], - allow_methods=["GET", "PATCH", "DELETE"], + allow_methods=["GET", "POST", "PATCH", "DELETE"], allow_headers=["Authorization", "Content-Type"], ) Db = Annotated[Session, Depends(get_session)] @@ -147,6 +148,81 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc +def _external_out(item: ExternalObservation) -> ExternalObservationOut: + 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, + 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, + ) + + +@app.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut]) +def admin_external_observations( + db: Db, _: Annotated[str, Depends(_admin)], status: str | None = None, + source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), +) -> list[ExternalObservationOut]: + query = select(ExternalObservation).options( + joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody), + ) + if status: + query = query.where(ExternalObservation.status == status) + if source_system: + query = query.where(ExternalObservation.source_system == source_system) + items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc()).offset(offset).limit(limit)) + return [_external_out(item) for item in items] + + +@app.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut) +def admin_map_external_observation( + observation_id: UUID, payload: ExternalObservationMapping, db: Db, + _: Annotated[str, Depends(_admin)], +) -> ExternalObservationOut: + observation = db.get(ExternalObservation, observation_id) + fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug)) + waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug)) + if observation is None: + raise HTTPException(status_code=404, detail="external observation not found") + if fish is None or waterbody is None: + raise HTTPException(status_code=422, detail="unknown fish or waterbody") + try: + return _external_out(map_observation(db, observation, fish, waterbody, note=payload.note)) + except ExternalReviewError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@app.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished) +def admin_publish_external_observation( + observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)], +) -> ExternalObservationPublished: + observation = db.get(ExternalObservation, observation_id) + if observation is None: + raise HTTPException(status_code=404, detail="external observation not found") + try: + report = publish_observation(db, observation) + except ExternalReviewError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status) + + +@app.patch("/api/v1/admin/external-observations/{observation_id}/reject", response_model=ExternalObservationOut) +def admin_reject_external_observation( + observation_id: UUID, payload: ExternalObservationDecision, db: Db, + _: Annotated[str, Depends(_admin)], +) -> ExternalObservationOut: + observation = db.get(ExternalObservation, observation_id) + if observation is None: + raise HTTPException(status_code=404, detail="external observation not found") + try: + return _external_out(reject_observation(db, observation, reason=payload.reason)) + except ExternalReviewError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + @app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201) def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated: if payload.website: diff --git a/apps/api/app/models.py b/apps/api/app/models.py index 78a6e08..baee951 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -4,7 +4,7 @@ import enum import uuid from datetime import datetime, time -from sqlalchemy import JSON, DateTime, Enum, ForeignKey, Integer, String, Text, Time, UniqueConstraint +from sqlalchemy import JSON, CheckConstraint, DateTime, Enum, ForeignKey, Integer, String, Text, Time, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base @@ -166,4 +166,34 @@ class ExternalObservation(Base): last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) status: Mapped[str] = mapped_column(String(30), default="staged", index=True) payload: Mapped[dict] = mapped_column(JSON) + fish_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("fish.id")) + waterbody_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("waterbody.id")) + catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), unique=True) + review_note: Mapped[str | None] = mapped_column(Text) + reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) source: Mapped[DataSource] = relationship() + fish: Mapped[Fish | None] = relationship() + waterbody: Mapped[Waterbody | None] = relationship() + catch_report: Mapped[CatchReport | None] = relationship() + + +class ExternalEntityAlias(Base): + __tablename__ = "external_entity_alias" + __table_args__ = ( + UniqueConstraint("source_system", "entity_type", "external_id"), + CheckConstraint( + "(entity_type = 'fish' AND fish_id IS NOT NULL AND waterbody_id IS NULL) OR " + "(entity_type = 'waterbody' AND waterbody_id IS NOT NULL AND fish_id IS NULL)", + name="ck_external_entity_alias_target", + ), + ) + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + source_system: Mapped[str] = mapped_column(ForeignKey("data_source.key"), index=True) + entity_type: Mapped[str] = mapped_column(String(20)) + external_id: Mapped[str] = mapped_column(String(200)) + external_name: Mapped[str] = mapped_column(String(200)) + fish_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("fish.id")) + waterbody_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("waterbody.id")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + fish: Mapped[Fish | None] = relationship() + waterbody: Mapped[Waterbody | None] = relationship() diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 4532237..0381ed9 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -161,3 +161,40 @@ class ModerationUpdate(BaseModel): if value not in {"approved", "rejected", "pending"}: raise ValueError("unknown moderation status") return value + + +class ExternalObservationOut(BaseModel): + id: UUID + source_system: str + source_external_id: str + source_url: str + fish_name: str + fish_external_id: str | None + waterbody_name: str + waterbody_external_id: str | None + x: int | None + y: int | None + weight_g: int | None + published_at: datetime | None + last_seen_at: datetime + status: str + fish_slug: str | None + waterbody_slug: str | None + catch_report_id: UUID | None + review_note: str | None + + +class ExternalObservationMapping(BaseModel): + fish_slug: str + waterbody_slug: str + note: str | None = Field(default=None, max_length=1000) + + +class ExternalObservationDecision(BaseModel): + reason: str = Field(min_length=1, max_length=1000) + + +class ExternalObservationPublished(BaseModel): + observation_id: UUID + catch_report_id: UUID + status: str diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 2950d72..017dfdb 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -4,13 +4,14 @@ from datetime import datetime, timedelta, timezone from uuid import UUID from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base, get_session +from app.community_importer import stage_observations from app.main import app -from app.models import Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody +from app.models import Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) @@ -89,6 +90,66 @@ def test_admin_requires_token() -> None: assert client.get("/api/v1/admin/catch-reports").status_code == 401 assert client.get("/api/v1/admin/imports").status_code == 401 assert client.post("/api/v1/admin/imports/official-records").status_code == 401 + assert client.get("/api/v1/admin/external-observations").status_code == 401 + + +def test_external_observation_requires_mapping_and_complete_data_before_publication() -> None: + with Session(engine) as db: + stage_observations(db, [{ + "source_system": "rf4db", "source_external_id": "review-complete", + "source_url": "https://rf4db.com/catches/review-complete", "fish": "Pike external", + "fish_external_id": "fish-1", "waterbody": "Lake external", + "waterbody_external_id": "lake-1", "x": 31, "y": 41, "weight_g": 6200, + "bait": "Тестовая приманка", "player_name": "External Player", + }]) + observation_id = db.scalar(select(ExternalObservation.id).where( + ExternalObservation.source_external_id == "review-complete" + )) + headers = {"Authorization": "Bearer change-me-in-production"} + premature = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers) + assert premature.status_code == 409 + mapped = client.patch( + f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, + json={"fish_slug": "pike", "waterbody_slug": "test-lake", "note": "verified fixture"}, + ) + assert mapped.status_code == 200 + assert mapped.json()["status"] == "ready" + published = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers) + assert published.status_code == 200 + assert published.json()["status"] == "published" + repeated = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers) + assert repeated.json()["catch_report_id"] == published.json()["catch_report_id"] + with Session(engine) as db: + observation = db.get(ExternalObservation, observation_id) + report = db.get(CatchReport, observation.catch_report_id) + aliases = list(db.scalars(select(ExternalEntityAlias).where(ExternalEntityAlias.source_system == "rf4db"))) + assert report.moderation_status == ModerationStatus.approved + assert report.raw_payload["provenance"]["source_external_id"] == "review-complete" + assert {alias.entity_type for alias in aliases} == {"fish", "waterbody"} + + +def test_incomplete_external_observation_stays_out_of_public_data() -> None: + with Session(engine) as db: + stage_observations(db, [{ + "source_system": "rf4db", "source_external_id": "review-incomplete", + "source_url": "https://rf4db.com/catches/review-incomplete", "fish": "Pike external", + "waterbody": "Lake external", "x": 32, "y": 42, + }]) + observation_id = db.scalar(select(ExternalObservation.id).where( + ExternalObservation.source_external_id == "review-incomplete" + )) + headers = {"Authorization": "Bearer change-me-in-production"} + mapped = client.patch( + f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, + json={"fish_slug": "pike", "waterbody_slug": "test-lake"}, + ) + assert mapped.json()["status"] == "mapped" + assert client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers).status_code == 409 + rejected = client.patch( + f"/api/v1/admin/external-observations/{observation_id}/reject", headers=headers, + json={"reason": "weight is absent"}, + ) + assert rejected.json()["status"] == "rejected" def test_admin_can_start_and_list_official_import(monkeypatch) -> None: diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro new file mode 100644 index 0000000..f06865e --- /dev/null +++ b/apps/web/src/pages/admin/external-sources.astro @@ -0,0 +1,33 @@ +--- +import Layout from "../../layouts/Layout.astro"; +const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; +--- + +
ADMIN Staging

Внешние
наблюдения

Сопоставьте названия с каноническим каталогом. Опубликовать можно только запись с координатами и весом.

+
+ +

Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.

+
+
+ +
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4f1173d..f0db6e7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,14 +68,14 @@ - [x] Провести аудит подключённых источников, потенциальных поставщиков и всех существующих парсеров; результат записан в `docs/data-source-audit.md`. - [x] Проверить оба официальных HTML-парсера на актуальной странице и добавить общий контрактный тест эквивалентности. -- [ ] Добавить `data_source` и языковые/версионные алиасы рыб, водоёмов и приманок до подключения второго автоматического источника. +- [x] Добавить `data_source` и алиасы рыб/водоёмов до подключения второго автоматического источника (миграции `0008`–`0009`; алиасы приманок уже нормализуются в `bait`). - [ ] Вынести общий официальный DOM-парсер, устранив дублирование исследовательской и продуктивной реализации. - [ ] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории. - [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`. - [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT. - [ ] Зафиксировать сами подтверждения разрешений и согласованные лимиты/атрибуцию в репозитории или закрытой операционной документации. - [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест). -- [ ] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report`. +- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено). - [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений. ## Этап 5 — пилот diff --git a/docs/community-source-pilot.md b/docs/community-source-pilot.md index 829e1fe..13b8443 100644 --- a/docs/community-source-pilot.md +++ b/docs/community-source-pilot.md @@ -63,9 +63,9 @@ Detail-страница дополнительно содержит ветер, ## Перед продуктивным импортом 1. Сохранить подтверждение разрешения и согласованные лимиты запросов. -2. Ввести алиасы рыб и водоёмов по внешним slug, а не только по названию. +2. Расширять добавленные алиасы рыб и водоёмов по мере ручной проверки новых значений. 3. Согласовать начальные уровни доверия для RF4DB и двух каналов RF4-STAT (в staging записаны консервативные значения 70/65/60, но источники выключены). -4. Добавить ручную модерацию и явное преобразование staging-записи перед влиянием на индекс. +4. Проверить правила публикации на небольшой вручную подтверждённой выборке. 5. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки. ## Staging @@ -75,3 +75,5 @@ Detail-страница дополнительно содержит ветер, Загрузка принимает только известные источники, HTTPS-ссылки соответствующего домена и значения в допустимых диапазонах. Все записи получают статус `staged`; таблица не связана с расчётом активности. Тест доказывает путь `HTML → ExternalCatch → JSON → external_observation` без потери provenance. Контрольная загрузка в PostgreSQL создала 139 staging-записей: 23 `rf4db` с координатами, 100 `rf4stat-fishing` с весом и 16 `rf4stat-post` с весом. Немедленный повтор дал `created=0, updated=139`, подтвердив идемпотентность на реальной БД. Все три источника остались `enabled=false`. + +Миграция `0009` добавляет устойчивые алиасы внешних рыб и водоёмов, канонические ссылки и состояние ручной проверки. Страница `/admin/external-sources` позволяет сопоставить, отклонить или явно опубликовать наблюдение. Публикация создаёт одобренный `catch_report` с исходной ссылкой и полным provenance, но только если одновременно известны канонические сущности, координаты и вес. Поэтому текущие 139 неполных записей после миграции остались в staging.