feat: add external observation review queue
This commit is contained in:
@@ -8,11 +8,12 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
- этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника;
|
- этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника;
|
||||||
- этап 3 функционально завершён: форма, раздельные ошибки создания/скриншота с повторной загрузкой, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы;
|
- этап 3 функционально завершён: форма, раздельные ошибки создания/скриншота с повторной загрузкой, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы;
|
||||||
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
|
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
|
||||||
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — состояния карточек и сквозная проверка фильтров.
|
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров;
|
||||||
|
- RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации.
|
||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
Подробный план и актуальные чекбоксы находятся в [`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:
|
Один ограниченный снимок публичных карточек можно получить исследовательским 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 -
|
| docker compose exec -T api python -m app.cli stage-community-json --input -
|
||||||
```
|
```
|
||||||
|
|
||||||
Staging проверяет происхождение URL и диапазоны значений. Источники по умолчанию выключены; автоматического преобразования в одобренные уловы нет.
|
Staging проверяет происхождение URL и диапазоны значений. Источники по умолчанию выключены; автоматического преобразования в одобренные уловы нет. Ручная очередь доступна по адресу <http://localhost:4321/admin/external-sources>. Публикация разрешена только после сопоставления канонических рыбы и водоёма и при наличии координат и веса.
|
||||||
|
|
||||||
## Запуск через Docker
|
## Запуск через Docker
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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]
|
||||||
+79
-3
@@ -16,9 +16,10 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
from .activity import activity_rows
|
from .activity import activity_rows
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||||
from .importer import ImportSourceError, import_records, normalize
|
from .importer import ImportSourceError, import_records, normalize
|
||||||
from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
|
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
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"],
|
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"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
Db = Annotated[Session, Depends(get_session)]
|
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
|
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)
|
@app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201)
|
||||||
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated:
|
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated:
|
||||||
if payload.website:
|
if payload.website:
|
||||||
|
|||||||
+31
-1
@@ -4,7 +4,7 @@ import enum
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, time
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .database import Base
|
from .database import Base
|
||||||
@@ -166,4 +166,34 @@ class ExternalObservation(Base):
|
|||||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
status: Mapped[str] = mapped_column(String(30), default="staged", index=True)
|
status: Mapped[str] = mapped_column(String(30), default="staged", index=True)
|
||||||
payload: Mapped[dict] = mapped_column(JSON)
|
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()
|
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()
|
||||||
|
|||||||
@@ -161,3 +161,40 @@ class ModerationUpdate(BaseModel):
|
|||||||
if value not in {"approved", "rejected", "pending"}:
|
if value not in {"approved", "rejected", "pending"}:
|
||||||
raise ValueError("unknown moderation status")
|
raise ValueError("unknown moderation status")
|
||||||
return value
|
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
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.database import Base, get_session
|
from app.database import Base, get_session
|
||||||
|
from app.community_importer import stage_observations
|
||||||
from app.main import app
|
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)
|
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/catch-reports").status_code == 401
|
||||||
assert client.get("/api/v1/admin/imports").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.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:
|
def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
---
|
||||||
|
<Layout title="Внешние источники — RF4 Spotter">
|
||||||
|
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Staging</span><h1>Внешние<br/><em>наблюдения</em></h1></div><p>Сопоставьте названия с каноническим каталогом. Опубликовать можно только запись с координатами и весом.</p></section>
|
||||||
|
<section class="moderation-app" data-api-url={apiUrl}>
|
||||||
|
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button type="submit">Открыть очередь</button></form>
|
||||||
|
<p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
|
||||||
|
<div class="notice error" data-admin-error hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||||
|
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||||
|
const list = document.querySelector<HTMLElement>("[data-external-list]");
|
||||||
|
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||||
|
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
|
||||||
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
|
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
|
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
|
||||||
|
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : `Запрос завершился ошибкой ${response.status}.`); return response.json(); }
|
||||||
|
async function loadQueue() {
|
||||||
|
if (!root || !list) return; error?.setAttribute("hidden", ""); list.innerHTML = '<div class="state"><h2>Загружаем staging…</h2></div>';
|
||||||
|
[fishes, waters] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`)]);
|
||||||
|
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?limit=200`, {headers:{Authorization:`Bearer ${token}`}});
|
||||||
|
const pending = rows.filter((row: Record<string, unknown>) => !["published", "rejected"].includes(String(row.status)));
|
||||||
|
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
|
||||||
|
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><div><button type="button" data-map>Сопоставить</button><button type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button class="reject" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
||||||
|
}
|
||||||
|
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||||
|
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try { if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
|
||||||
|
</script>
|
||||||
|
</Layout>
|
||||||
+2
-2
@@ -68,14 +68,14 @@
|
|||||||
|
|
||||||
- [x] Провести аудит подключённых источников, потенциальных поставщиков и всех существующих парсеров; результат записан в `docs/data-source-audit.md`.
|
- [x] Провести аудит подключённых источников, потенциальных поставщиков и всех существующих парсеров; результат записан в `docs/data-source-audit.md`.
|
||||||
- [x] Проверить оба официальных HTML-парсера на актуальной странице и добавить общий контрактный тест эквивалентности.
|
- [x] Проверить оба официальных HTML-парсера на актуальной странице и добавить общий контрактный тест эквивалентности.
|
||||||
- [ ] Добавить `data_source` и языковые/версионные алиасы рыб, водоёмов и приманок до подключения второго автоматического источника.
|
- [x] Добавить `data_source` и алиасы рыб/водоёмов до подключения второго автоматического источника (миграции `0008`–`0009`; алиасы приманок уже нормализуются в `bait`).
|
||||||
- [ ] Вынести общий официальный DOM-парсер, устранив дублирование исследовательской и продуктивной реализации.
|
- [ ] Вынести общий официальный DOM-парсер, устранив дублирование исследовательской и продуктивной реализации.
|
||||||
- [ ] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
|
- [ ] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
|
||||||
- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
|
- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
|
||||||
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
|
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
|
||||||
- [ ] Зафиксировать сами подтверждения разрешений и согласованные лимиты/атрибуцию в репозитории или закрытой операционной документации.
|
- [ ] Зафиксировать сами подтверждения разрешений и согласованные лимиты/атрибуцию в репозитории или закрытой операционной документации.
|
||||||
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
||||||
- [ ] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report`.
|
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
|
||||||
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
||||||
|
|
||||||
## Этап 5 — пилот
|
## Этап 5 — пилот
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ Detail-страница дополнительно содержит ветер,
|
|||||||
## Перед продуктивным импортом
|
## Перед продуктивным импортом
|
||||||
|
|
||||||
1. Сохранить подтверждение разрешения и согласованные лимиты запросов.
|
1. Сохранить подтверждение разрешения и согласованные лимиты запросов.
|
||||||
2. Ввести алиасы рыб и водоёмов по внешним slug, а не только по названию.
|
2. Расширять добавленные алиасы рыб и водоёмов по мере ручной проверки новых значений.
|
||||||
3. Согласовать начальные уровни доверия для RF4DB и двух каналов RF4-STAT (в staging записаны консервативные значения 70/65/60, но источники выключены).
|
3. Согласовать начальные уровни доверия для RF4DB и двух каналов RF4-STAT (в staging записаны консервативные значения 70/65/60, но источники выключены).
|
||||||
4. Добавить ручную модерацию и явное преобразование staging-записи перед влиянием на индекс.
|
4. Проверить правила публикации на небольшой вручную подтверждённой выборке.
|
||||||
5. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки.
|
5. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки.
|
||||||
|
|
||||||
## Staging
|
## Staging
|
||||||
@@ -75,3 +75,5 @@ Detail-страница дополнительно содержит ветер,
|
|||||||
Загрузка принимает только известные источники, HTTPS-ссылки соответствующего домена и значения в допустимых диапазонах. Все записи получают статус `staged`; таблица не связана с расчётом активности. Тест доказывает путь `HTML → ExternalCatch → JSON → external_observation` без потери provenance.
|
Загрузка принимает только известные источники, 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`.
|
Контрольная загрузка в 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user