diff --git a/README.md b/README.md index 2bac82f..f8214e3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ RF4 Spotter — неофициальный сервис свежих точек Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy; публичный адрес обратной связи ещё не задан. -RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. +RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Совпадающие рыба, водоём, координаты и вес объединяются визуально, при этом карточка сохраняет все уникальные ссылки на исходные наблюдения. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку. diff --git a/apps/api/app/community_review.py b/apps/api/app/community_review.py index bd0328f..c2ad479 100644 --- a/apps/api/app/community_review.py +++ b/apps/api/app/community_review.py @@ -34,6 +34,20 @@ def map_observation( return observation +def suggest_aliases(session: Session, observation: ExternalObservation) -> tuple[Fish | None, Waterbody | None]: + fish_alias = session.scalar(select(ExternalEntityAlias).where( + ExternalEntityAlias.source_system == observation.source_system, + ExternalEntityAlias.entity_type == "fish", + ExternalEntityAlias.external_id == (observation.fish_external_id or observation.fish_name), + )) + waterbody_alias = session.scalar(select(ExternalEntityAlias).where( + ExternalEntityAlias.source_system == observation.source_system, + ExternalEntityAlias.entity_type == "waterbody", + ExternalEntityAlias.external_id == (observation.waterbody_external_id or observation.waterbody_name), + )) + return (fish_alias.fish if fish_alias else None, waterbody_alias.waterbody if waterbody_alias else None) + + def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation: if observation.status == "published": raise ExternalReviewError("published observation cannot be rejected") @@ -109,6 +123,8 @@ def _save_alias( external_id=external_id, external_name=observation.fish_name if fish else observation.waterbody_name, ) session.add(alias) + elif (fish is not None and alias.fish_id != fish.id) or (waterbody is not None and alias.waterbody_id != waterbody.id): + raise ExternalReviewError(f"confirmed {entity_type} alias points to another entity") alias.fish = fish alias.waterbody = waterbody alias.updated_at = datetime.now(timezone.utc) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 064e0b0..569597d 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -21,13 +21,13 @@ 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 .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize from .logging_config import configure_logging from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .readiness import readiness_report from .public_cache import public_cache -from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut +from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot @@ -351,6 +351,20 @@ def admin_external_observations( return [_external_out(item) for item in items] +@app.get("/api/v1/admin/external-observations/{observation_id}/alias-suggestions", response_model=ExternalAliasSuggestionOut) +def admin_external_alias_suggestions( + observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)], +) -> ExternalAliasSuggestionOut: + observation = db.get(ExternalObservation, observation_id) + if observation is None: + raise HTTPException(status_code=404, detail="external observation not found") + fish, waterbody = suggest_aliases(db, observation) + return ExternalAliasSuggestionOut( + fish_slug=fish.slug if fish else None, + waterbody_slug=waterbody.slug if waterbody else None, + ) + + @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, diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 33414c8..a33c6c1 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -213,6 +213,12 @@ class ExternalObservationMapping(BaseModel): note: str | None = Field(default=None, max_length=1000) +class ExternalAliasSuggestionOut(BaseModel): + fish_slug: str | None + waterbody_slug: str | None + requires_confirmation: bool = True + + class ExternalObservationDecision(BaseModel): reason: str = Field(min_length=1, max_length=1000) diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 45785f0..ffada81 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -64,6 +64,7 @@ def test_list_pagination_and_filter_validation() -> None: assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422 assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422 assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422 + assert client.get("/api/v1/admin/external-observations/00000000-0000-0000-0000-000000000000/alias-suggestions").status_code == 401 def test_public_source_status_hides_internal_details() -> None: diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py index 92e8d37..ad1829c 100644 --- a/apps/api/tests/test_community_importer.py +++ b/apps/api/tests/test_community_importer.py @@ -8,6 +8,7 @@ from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session from app.community_importer import CommunityImportError, stage_observations +from app.community_review import ExternalReviewError, map_observation, suggest_aliases 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 @@ -129,6 +130,31 @@ def test_auto_publication_requires_enabled_source(db: Session) -> None: assert item is not None and item.status == "staged" and item.catch_report is None +def test_confirmed_aliases_are_suggestions_and_cannot_be_retargeted(db: Session) -> None: + source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True) + pike = Fish(slug="pike", name_ru="Щука") + perch = Fish(slug="perch", name_ru="Окунь") + waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро") + db.add_all([source, pike, perch, waterbody]) + db.commit() + stage_observations(db, [record(external_id="first")]) + first = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "first")) + assert first is not None + map_observation(db, first, pike, waterbody) + + stage_observations(db, [record(external_id="second")]) + second = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "second")) + assert second is not None + suggested_fish, suggested_waterbody = suggest_aliases(db, second) + assert (suggested_fish.slug, suggested_waterbody.slug) == ("pike", "test-lake") + + with pytest.raises(ExternalReviewError, match="confirmed fish alias"): + map_observation(db, second, perch, waterbody) + db.rollback() + alias = db.scalar(select(ExternalEntityAlias).where(ExternalEntityAlias.entity_type == "fish")) + assert alias is not None and alias.fish_id == pike.id + + def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None: parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8")) payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str)) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb9ebb6..53b5b92 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -102,7 +102,7 @@ - [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест). - [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено). - [ ] Найти разрешённый способ получать полные наблюдения с рыбой, водоёмом, координатами и весом из одного источника либо через подтверждённый общий ID; текущие 139 записей неполны и не публикуются. -- [ ] Добавить безопасные подсказки алиасов по уже подтверждённым сопоставлениям без автоматической публикации и тест конфликтующих алиасов. +- [x] Добавить защищённые подсказки алиасов по точным подтверждённым сопоставлениям без применения и запретить конфликтующую перезапись алиаса (7 сентября 2026). - [ ] Добавить управляемый повторный импорт staging с отчётом `created/updated/rejected`, лимитами запросов и наблюдаемым отказом при изменении DOM; регулярный запуск оставить выключенным до фиксации условий. - [ ] Определить процедуру повторной проверки опубликованного внешнего улова при изменении или удалении записи у источника. - [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.