diff --git a/.env.example b/.env.example index f010c24..8657ced 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ DATABASE_URL=postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter PUBLIC_API_URL=http://localhost:8000 API_INTERNAL_URL=http://api:8000 +ADMIN_TOKEN=change-me-in-production diff --git a/README.md b/README.md index 41804c6..ebb59c1 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ docker compose up --build - справочники рыб, водоёмов и приманок; - Astro SSR-интерфейс с адаптивной главной и страницей точки; - объяснимые индексы активности и уверенности по формуле спецификации; -- состояния «нет данных» и «источник недоступен». +- состояния «нет данных» и «источник недоступен»; - идемпотентный импорт официальных записей с журналом запусков; -- публичная страница `/records` с источником и временем последнего импорта. +- публичная страница `/records` с источником и временем последнего импорта; +- форма `/report`, защищённые admin API и журнал модерации; +- honeypot и базовый лимит отправок. Все пользовательские ники и уловы в seed демонстрационные. @@ -87,6 +89,17 @@ docker compose --profile tools run --rm importer Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. Автоматическое расписание намеренно ещё не включено: сначала требуется согласовать допустимость регулярного опроса официального сайта. +## Пользовательские уловы и модерация + +Новая запись из `/report` получает статус `pending` и не участвует в активности до одобрения. Административные методы требуют заголовок `Authorization: Bearer $ADMIN_TOKEN`: + +```bash +curl -H "Authorization: Bearer change-me-in-production" \ + "http://localhost:8000/api/v1/admin/catch-reports?status=pending" +``` + +Перед внешним развёртыванием обязательно замените демонстрационный `ADMIN_TOKEN`. Скриншоты пока не принимаются: следующим шагом для них подключается MinIO/S3 с проверкой MIME, размера и очисткой EXIF. + ## Исследовательский парсер официальных рекордов ```bash diff --git a/apps/api/alembic/versions/0003_moderation_events.py b/apps/api/alembic/versions/0003_moderation_events.py new file mode 100644 index 0000000..a8caefe --- /dev/null +++ b/apps/api/alembic/versions/0003_moderation_events.py @@ -0,0 +1,26 @@ +"""Moderation event journal.""" +from alembic import op +import sqlalchemy as sa + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + moderation = sa.Enum("pending", "approved", "rejected", name="moderationstatus", create_type=False) + op.create_table( + "moderation_event", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("previous_status", moderation, nullable=False), + sa.Column("new_status", moderation, nullable=False), + sa.Column("moderator", sa.String(100), nullable=False), + sa.Column("reason", sa.Text()), + ) + + +def downgrade() -> None: + op.drop_table("moderation_event") diff --git a/apps/api/app/config.py b/apps/api/app/config.py index c705eb4..be59f6b 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -3,6 +3,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter" + admin_token: str = "change-me-in-production" model_config = SettingsConfigDict(env_file=".env", extra="ignore") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 00c0107..ee201f2 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -1,24 +1,27 @@ from __future__ import annotations -from collections import Counter +from collections import Counter, defaultdict, deque from datetime import datetime, timedelta, timezone from typing import Annotated, Literal from uuid import UUID -from fastapi import Depends, FastAPI, HTTPException, Query +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select from sqlalchemy.orm import Session, joinedload from .activity import activity_rows from .database import get_session -from .models import Bait, CatchReport, Fish, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody -from .schemas import ActivityOut, BaitOut, CatchOut, FishOut, ImportRunOut, OfficialRecordOut, SpotOut, WaterbodyOut +from .config import settings +from .importer import normalize +from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody +from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut app = FastAPI(title="RF4 Spotter API", version="0.1.0") app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:4321"], allow_methods=["GET"], allow_headers=["*"]) Db = Annotated[Session, Depends(get_session)] +_submissions: dict[str, deque[datetime]] = defaultdict(deque) @app.get("/health") @@ -104,5 +107,65 @@ def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecord return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit))) +@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: + raise HTTPException(status_code=400, detail="invalid submission") + _check_rate_limit(request.client.host if request.client else "unknown") + fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug)) + waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug)) + if fish is None or waterbody is None: + raise HTTPException(status_code=422, detail="unknown fish or waterbody") + spot = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == payload.x, Spot.y == payload.y)) + if spot is None: + spot = Spot(waterbody=waterbody, x=payload.x, y=payload.y) + db.add(spot) + bait = None + if payload.bait_name and payload.bait_name.strip(): + key = normalize(payload.bait_name) + bait = db.scalar(select(Bait).where(Bait.normalized_name == key)) + if bait is None: + bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown) + db.add(bait) + report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None) + db.add(report) + db.commit() + return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value) + + +def _admin(authorization: Annotated[str | None, Header()] = None) -> str: + if not authorization or authorization != f"Bearer {settings.admin_token}": + raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"}) + return "admin" + + +@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut]) +def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]: + reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status).order_by(CatchReport.reported_at).limit(limit))) + return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "—", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment")) for r in reports] + + +@app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated) +def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated: + report = db.get(CatchReport, report_id) + if report is None or report.source_type != SourceType.user: + raise HTTPException(status_code=404, detail="catch report not found") + previous = report.moderation_status + report.moderation_status = ModerationStatus(payload.status) + db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason)) + db.commit() + return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value) + + +def _check_rate_limit(client: str) -> None: + now = datetime.now(timezone.utc) + recent = _submissions[client] + while recent and recent[0] < now - timedelta(minutes=10): + recent.popleft() + if len(recent) >= 5: + raise HTTPException(status_code=429, detail="too many submissions") + recent.append(now) + + def _aware(value: datetime) -> datetime: return value if value.tzinfo else value.replace(tzinfo=timezone.utc) diff --git a/apps/api/app/models.py b/apps/api/app/models.py index e33a6a0..bae7b9d 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -109,3 +109,15 @@ class OfficialRecordImport(Base): rows_created: Mapped[int] = mapped_column(default=0) rows_updated: Mapped[int] = mapped_column(default=0) error_summary: Mapped[str | None] = mapped_column(Text) + + +class ModerationEvent(Base): + __tablename__ = "moderation_event" + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + previous_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus, name="moderationstatus", create_type=False)) + new_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus, name="moderationstatus", create_type=False)) + moderator: Mapped[str] = mapped_column(String(100)) + reason: Mapped[str | None] = mapped_column(Text) + catch_report: Mapped[CatchReport] = relationship() diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 1a39b64..4c46c52 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from uuid import UUID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, field_validator class FishOut(BaseModel): @@ -98,3 +98,59 @@ class ImportRunOut(BaseModel): rows_created: int rows_updated: int error_summary: str | None + + +class CatchReportCreate(BaseModel): + fish_slug: str + waterbody_slug: str + x: int = Field(ge=-10_000, le=10_000) + y: int = Field(ge=-10_000, le=10_000) + weight_g: int = Field(gt=0, le=3_000_000) + bait_name: str | None = Field(default=None, max_length=200) + fishing_method: str | None = Field(default=None, max_length=50) + rig_type: str | None = Field(default=None, max_length=100) + retrieve_method: str | None = Field(default=None, max_length=100) + retrieve_speed: int | None = Field(default=None, ge=0, le=100) + caught_at: datetime | None = None + player_name: str | None = Field(default=None, max_length=100) + source_url: str | None = Field(default=None, max_length=1000) + comment: str | None = Field(default=None, max_length=1000) + website: str = Field(default="", max_length=200) + + @field_validator("fish_slug", "waterbody_slug") + @classmethod + def nonempty_slug(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("must not be empty") + return value + + +class CatchReportCreated(BaseModel): + id: UUID + moderation_status: str + + +class AdminCatchReportOut(BaseModel): + id: UUID + fish: str + waterbody: str + coordinates: str + weight_g: int + bait: str | None + player_name: str | None + reported_at: datetime + moderation_status: str + comment: str | None + + +class ModerationUpdate(BaseModel): + status: str + reason: str | None = Field(default=None, max_length=1000) + + @field_validator("status") + @classmethod + def known_status(cls, value: str) -> str: + if value not in {"approved", "rejected", "pending"}: + raise ValueError("unknown moderation status") + return value diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index a7237f0..eb10487 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -66,3 +66,22 @@ def test_records_list_is_empty_before_import() -> None: response = client.get("/api/v1/records") assert response.status_code == 200 assert response.json() == [] + + +def test_user_report_requires_moderation_before_activity() -> None: + created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"}) + assert created.status_code == 201 + assert created.json()["moderation_status"] == "pending" + report_id = created.json()["id"] + headers = {"Authorization": "Bearer change-me-in-production"} + pending = client.get("/api/v1/admin/catch-reports", headers=headers) + assert pending.status_code == 200 + assert any(item["id"] == report_id for item in pending.json()) + approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"}) + assert approved.status_code == 200 + activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json() + assert any(item["x"] == 77 and item["catches"] == 1 for item in activity) + + +def test_admin_requires_token() -> None: + assert client.get("/api/v1/admin/catch-reports").status_code == 401 diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 7cbe4dc..20f4685 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -6,7 +6,7 @@ const { title = "RF4 Spotter" } = Astro.props;
Свежие точки без догадок
Свежие точки без догадок
До проверки модератором запись не влияет на публичный индекс клёва.