Build Dockerized MVP scaffold and records importer

This commit is contained in:
ik
2026-09-02 20:29:02 +07:00
parent a6f91a1329
commit d3a45248ef
39 changed files with 6638 additions and 13 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+30
View File
@@ -0,0 +1,30 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.database import Base
from app import models # noqa: F401
config = context.config
if config.config_file_name:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url")))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(url=config.get_main_option("sqlalchemy.url"), target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
run_migrations_offline() if context.is_offline_mode() else run_migrations_online()
+20
View File
@@ -0,0 +1,20 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+31
View File
@@ -0,0 +1,31 @@
"""Initial demo schema."""
from alembic import op
import sqlalchemy as sa
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bait_kind = sa.Enum("bait", "lure", "unknown", name="baitkind")
source_type = sa.Enum("official_record", "user", "manual_import", name="sourcetype")
moderation = sa.Enum("pending", "approved", "rejected", name="moderationstatus")
op.create_table("fish", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("slug", sa.String(100), unique=True, nullable=False), sa.Column("name_ru", sa.String(200), unique=True, nullable=False), sa.Column("trophy_weight_g", sa.Integer()))
op.create_table("waterbody", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("slug", sa.String(100), unique=True, nullable=False), sa.Column("name_ru", sa.String(200), unique=True, nullable=False), sa.Column("unlock_level", sa.Integer()))
op.create_table("bait", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("name", sa.String(200), nullable=False), sa.Column("normalized_name", sa.String(200), unique=True, nullable=False), sa.Column("kind", bait_kind, nullable=False))
op.create_table("spot", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("waterbody_id", sa.Uuid(), sa.ForeignKey("waterbody.id"), nullable=False), sa.Column("x", sa.Integer(), nullable=False), sa.Column("y", sa.Integer(), nullable=False), sa.Column("description", sa.Text()), sa.UniqueConstraint("waterbody_id", "x", "y"))
op.create_table("catch_report", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("fish_id", sa.Uuid(), sa.ForeignKey("fish.id"), nullable=False), sa.Column("spot_id", sa.Uuid(), sa.ForeignKey("spot.id")), sa.Column("waterbody_id", sa.Uuid(), sa.ForeignKey("waterbody.id"), nullable=False), sa.Column("bait_id", sa.Uuid(), sa.ForeignKey("bait.id")), sa.Column("weight_g", sa.Integer(), nullable=False), sa.Column("fishing_method", sa.String(50)), sa.Column("rig_type", sa.String(100)), sa.Column("retrieve_method", sa.String(100)), sa.Column("retrieve_speed", sa.Integer()), sa.Column("game_time", sa.Time()), sa.Column("caught_at", sa.DateTime(timezone=True)), sa.Column("reported_at", sa.DateTime(timezone=True), nullable=False), sa.Column("player_name", sa.String(100)), sa.Column("source_type", source_type, nullable=False), sa.Column("source_confidence", sa.Integer(), nullable=False), sa.Column("moderation_status", moderation, nullable=False))
op.create_index("ix_catch_report_activity", "catch_report", ["moderation_status", "reported_at"])
def downgrade() -> None:
op.drop_index("ix_catch_report_activity", table_name="catch_report")
op.drop_table("catch_report")
op.drop_table("spot")
op.drop_table("bait")
op.drop_table("waterbody")
op.drop_table("fish")
for name in ("moderationstatus", "sourcetype", "baitkind"):
sa.Enum(name=name).drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,37 @@
"""Official record import fields and run journal."""
from alembic import op
import sqlalchemy as sa
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
status = sa.Enum("running", "success", "partial", "failed", name="importstatus")
op.add_column("catch_report", sa.Column("source_url", sa.Text()))
op.add_column("catch_report", sa.Column("source_external_id", sa.String(64)))
op.add_column("catch_report", sa.Column("raw_payload", sa.JSON()))
op.create_unique_constraint("uq_catch_report_source_external_id", "catch_report", ["source_external_id"])
op.create_table(
"official_record_import",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.Column("status", status, nullable=False),
sa.Column("source_url", sa.Text(), nullable=False),
sa.Column("rows_seen", sa.Integer(), nullable=False),
sa.Column("rows_created", sa.Integer(), nullable=False),
sa.Column("rows_updated", sa.Integer(), nullable=False),
sa.Column("error_summary", sa.Text()),
)
def downgrade() -> None:
op.drop_table("official_record_import")
op.drop_constraint("uq_catch_report_source_external_id", "catch_report", type_="unique")
op.drop_column("catch_report", "raw_payload")
op.drop_column("catch_report", "source_external_id")
op.drop_column("catch_report", "source_url")
sa.Enum(name="importstatus").drop(op.get_bind(), checkfirst=True)
+1
View File
@@ -0,0 +1 @@
"""RF4 Spotter API."""
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import math
from collections import Counter
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .models import CatchReport, ModerationStatus
from .schemas import ActivityOut
def activity_rows(
session: Session,
*,
hours: int,
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
) -> list[ActivityOut]:
now = datetime.now(timezone.utc)
query = (
select(CatchReport)
.options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
joinedload(CatchReport.spot), joinedload(CatchReport.bait),
)
.where(
CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.spot_id.is_not(None),
CatchReport.reported_at >= now - timedelta(hours=hours),
)
)
reports = list(session.scalars(query))
if waterbody:
reports = [r for r in reports if r.waterbody.slug == waterbody]
if fish:
reports = [r for r in reports if r.fish.slug == fish]
if method:
reports = [r for r in reports if r.fishing_method == method]
groups: dict[tuple[object, object], list[CatchReport]] = {}
for report in reports:
groups.setdefault((report.spot_id, report.fish_id), []).append(report)
result: list[ActivityOut] = []
for items in groups.values():
first = items[0]
players = {r.player_name.strip().casefold() for r in items if r.player_name and r.player_name.strip()}
freshness = [math.exp(-max(0.0, (now - _aware(r.reported_at)).total_seconds()) / 3600 / 18) for r in items]
weighted = sum(value * r.source_confidence / 100 for value, r in zip(freshness, items))
trophies = sum(bool(r.fish.trophy_weight_g and r.weight_g >= r.fish.trophy_weight_g) for r in items)
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
average_confidence = sum(r.source_confidence for r in items) / len(items)
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
latest = max(_aware(r.caught_at or r.reported_at) for r in items)
baits = Counter(r.bait.name for r in items if r.bait)
freshness_text = _freshness_text(now - latest)
result.append(ActivityOut(
spot_id=first.spot.id, waterbody_slug=first.waterbody.slug,
waterbody=first.waterbody.name_ru, fish_slug=first.fish.slug,
fish=first.fish.name_ru, x=first.spot.x, y=first.spot.y,
best_bait=baits.most_common(1)[0][0] if baits else None,
catches=len(items), unique_players=len(players),
average_weight_g=round(sum(r.weight_g for r in items) / len(items)),
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
activity_score=activity, confidence_score=confidence,
explanation=f"{len(items)} свежих уловов от {len(players)} игроков. Последнее подтверждение {freshness_text}." + (" Данных мало." if len(items) < 3 else ""),
))
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def _freshness_text(delta: timedelta) -> str:
minutes = max(0, round(delta.total_seconds() / 60))
if minutes < 60:
return f"{minutes} мин. назад"
return f"{minutes // 60} ч. назад"
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import argparse
from .database import SessionLocal
from .importer import import_records
def main() -> int:
parser = argparse.ArgumentParser(prog="python -m app.cli")
sub = parser.add_subparsers(dest="command", required=True)
command = sub.add_parser("import-records")
command.add_argument("--url", default="https://rf4game.de/records/region/RU/")
command.add_argument("--region", default="RU")
command.add_argument("--category", default="records")
args = parser.parse_args()
with SessionLocal() as session:
run = import_records(session, url=args.url, region=args.region, category=args.category)
print(f"import {run.status.value}: seen={run.rows_seen} created={run.rows_created} updated={run.rows_updated}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -0,0 +1,9 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_session() -> Generator[Session, None, None]:
with SessionLocal() as session:
yield session
+7
View File
@@ -0,0 +1,7 @@
"""Compatibility imports; new code should use :mod:`app.database`."""
from .database import Base, SessionLocal, engine, get_session
get_db = get_session
__all__ = ["Base", "SessionLocal", "engine", "get_db", "get_session"]
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import hashlib
import re
import time as time_module
from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timezone
import httpx
from bs4 import BeautifulSoup, Tag
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import (
Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus,
OfficialRecordImport, SourceType, Waterbody,
)
USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
class ImportSourceError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class RawRecord:
region: str
category: str
player: str | None
fish: str
weight_g: int
waterbody: str
bait: str | None
record_date: date
def normalize(value: str) -> str:
return " ".join(value.replace("\xa0", " ").replace("", "-").replace("", "-").split()).casefold()
def slugify(value: str) -> str:
compact = re.sub(r"[^a-z0-9а-яё]+", "-", normalize(value), flags=re.IGNORECASE).strip("-")
return compact or hashlib.sha256(value.encode()).hexdigest()[:16]
def external_id(record: RawRecord) -> str:
parts = [record.region, record.category, record.player or "", record.fish, str(record.weight_g), record.waterbody, record.bait or "", record.record_date.isoformat()]
return hashlib.sha256("|".join(normalize(part) for part in parts).encode()).hexdigest()
def parse_weight(raw: str) -> int:
value = " ".join(raw.replace("\xa0", " ").split()).lower()
match = re.fullmatch(r"([\d .,']+)\s*(kg|g)", value)
if not match:
raise ImportSourceError(f"unsupported weight {raw!r}")
number, unit = match.groups()
number = number.replace(" ", "").replace("'", "").replace("", "")
if unit == "g":
return int(number.replace(".", "").replace(",", ""))
if "," in number and "." not in number:
number = number.replace(",", ".")
return round(float(number) * 1000)
def _text(node: Tag | None) -> str:
return node.get_text(" ", strip=True) if node else ""
def parse_html(html: str, *, region: str, category: str) -> list[RawRecord]:
soup = BeautifulSoup(html, "html.parser")
table = soup.select_one("div.records.flex_table")
if table is None:
raise ImportSourceError("records table not found")
header = table.select_one(":scope > .row.header")
expected = ["fish", "weight", "location", "bait", "gamername", "data"]
cells = header.find_all("div", recursive=False) if header else []
actual = [next((key for key in expected if key in cell.get("class", [])), "") for cell in cells]
if actual != expected:
raise ImportSourceError(f"record columns changed: {actual}")
records: list[RawRecord] = []
for group in table.select(":scope > .rows > .row > .records_subtable"):
group_header = group.select_one(":scope > .row.header")
if group_header is None:
continue
fish = _text(group_header.select_one(".fish .text"))
rows = [group_header, *group.select(":scope > .rows > .row")]
for row in rows:
bait_node = row.select_one(".bait_icon")
bait = bait_node.get("title", "").strip() if bait_node else ""
try:
record_date = datetime.strptime(_text(row.select_one(".data")), "%d.%m.%y").date()
except ValueError as exc:
raise ImportSourceError("record date format changed") from exc
records.append(RawRecord(region.upper(), category, _text(row.select_one(".gamername")) or None, fish, parse_weight(_text(row.select_one(".weight"))), _text(row.select_one(".location")), bait or None, record_date))
if not records:
raise ImportSourceError("records table is empty")
return records
def fetch_records(url: str, *, region: str, category: str) -> list[RawRecord]:
with httpx.Client(timeout=20, follow_redirects=True, headers={"User-Agent": USER_AGENT, "Accept": "text/html"}) as client:
for attempt in range(3):
try:
response = client.get(url)
response.raise_for_status()
if "text/html" not in response.headers.get("content-type", ""):
raise ImportSourceError("source did not return HTML")
return parse_html(response.text, region=region, category=category)
except (httpx.HTTPError, ImportSourceError):
if attempt == 2:
raise
time_module.sleep(2 ** attempt)
raise AssertionError("unreachable")
def import_records(session: Session, *, url: str, region: str, category: str, html: str | None = None) -> OfficialRecordImport:
run = OfficialRecordImport(started_at=datetime.now(timezone.utc), status=ImportStatus.running, source_url=url, rows_seen=0, rows_created=0, rows_updated=0)
session.add(run)
session.commit()
try:
records = parse_html(html, region=region, category=category) if html is not None else fetch_records(url, region=region, category=category)
run.rows_seen = len(records)
for raw in records:
key = external_id(raw)
report = session.scalar(select(CatchReport).where(CatchReport.source_external_id == key))
fish = _fish(session, raw.fish)
waterbody = _waterbody(session, raw.waterbody)
bait = _bait(session, raw.bait) if raw.bait else None
payload = asdict(raw) | {"record_date": raw.record_date.isoformat()}
caught = datetime.combine(raw.record_date, time(), tzinfo=timezone.utc)
if report is None:
session.add(CatchReport(fish=fish, waterbody=waterbody, bait=bait, spot=None, weight_g=raw.weight_g, caught_at=caught, reported_at=datetime.now(timezone.utc), player_name=raw.player, source_type=SourceType.official_record, source_url=url, source_external_id=key, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload=payload))
run.rows_created += 1
else:
report.raw_payload = payload
report.source_url = url
run.rows_updated += 1
run.status = ImportStatus.success
run.finished_at = datetime.now(timezone.utc)
session.commit()
return run
except Exception as exc:
session.rollback()
run = session.get(OfficialRecordImport, run.id)
run.status = ImportStatus.failed
run.finished_at = datetime.now(timezone.utc)
run.error_summary = str(exc)[:1000]
session.commit()
raise
def _fish(session: Session, name: str) -> Fish:
item = session.scalar(select(Fish).where(Fish.name_ru == name))
if item is None:
item = Fish(slug=_unique_slug(session, Fish, name), name_ru=name, trophy_weight_g=None)
session.add(item)
return item
def _waterbody(session: Session, name: str) -> Waterbody:
item = session.scalar(select(Waterbody).where(Waterbody.name_ru == name))
if item is None:
item = Waterbody(slug=_unique_slug(session, Waterbody, name), name_ru=name, unlock_level=None)
session.add(item)
return item
def _bait(session: Session, name: str) -> Bait:
normalized = normalize(name)
item = session.scalar(select(Bait).where(Bait.normalized_name == normalized))
if item is None:
item = Bait(name=name, normalized_name=normalized, kind=BaitKind.unknown)
session.add(item)
return item
def _unique_slug(session: Session, model: type[Fish] | type[Waterbody], name: str) -> str:
base = slugify(name)
candidate = base
index = 2
while session.scalar(select(model.id).where(model.slug == candidate)) is not None:
candidate = f"{base}-{index}"
index += 1
return candidate
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from collections import Counter
from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException, Query
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
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)]
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/v1/fishes", response_model=list[FishOut])
def fishes(db: Db) -> list[Fish]:
return list(db.scalars(select(Fish).order_by(Fish.name_ru)))
@app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut])
def waterbodies(db: Db) -> list[Waterbody]:
return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru)))
@app.get("/api/v1/baits", response_model=list[BaitOut])
def baits(db: Db) -> list[Bait]:
return list(db.scalars(select(Bait).order_by(Bait.name)))
@app.get("/api/v1/activity", response_model=list[ActivityOut])
def activity(
db: Db, hours: int = Query(24),
waterbody: str | None = None, fish: str | None = None,
method: str | None = None,
sort: Literal["activity", "confidence", "freshness"] = "activity",
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
) -> list[ActivityOut]:
if hours not in {6, 12, 24, 72}:
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
keys = {"activity": lambda r: r.activity_score, "confidence": lambda r: r.confidence_score, "freshness": lambda r: r.last_confirmed_at}
rows.sort(key=keys[sort], reverse=True)
return rows[offset:offset + limit]
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).where(Spot.id == spot_id))
if spot is None:
raise HTTPException(status_code=404, detail="spot not found")
return spot
@app.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
spot = _spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved)))
now = datetime.now(timezone.utc)
def count_since(delta: timedelta) -> int:
return sum(_aware(r.reported_at) >= now - delta for r in reports)
bait_counts = Counter(r.bait.name for r in reports if r.bait)
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)])
@app.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
_spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit)))
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
def records(
db: Db, fish: str | None = None, waterbody: str | None = None,
category: str | None = None, limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[OfficialRecordOut]:
query = select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.official_record)
if fish:
query = query.join(CatchReport.fish).where(Fish.slug == fish)
if waterbody:
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc()).offset(offset).limit(limit)))
if category:
items = [item for item in items if (item.raw_payload or {}).get("category") == category]
return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items]
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecordImport]:
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit)))
def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime, time
from sqlalchemy import JSON, DateTime, Enum, ForeignKey, Integer, String, Text, Time, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class SourceType(str, enum.Enum):
official_record = "official_record"
user = "user"
manual_import = "manual_import"
class ModerationStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class BaitKind(str, enum.Enum):
bait = "bait"
lure = "lure"
unknown = "unknown"
class ImportStatus(str, enum.Enum):
running = "running"
success = "success"
partial = "partial"
failed = "failed"
class Fish(Base):
__tablename__ = "fish"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
trophy_weight_g: Mapped[int | None]
class Waterbody(Base):
__tablename__ = "waterbody"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
unlock_level: Mapped[int | None]
class Bait(Base):
__tablename__ = "bait"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
class Spot(Base):
__tablename__ = "spot"
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
waterbody_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("waterbody.id"))
x: Mapped[int]
y: Mapped[int]
description: Mapped[str | None] = mapped_column(Text)
waterbody: Mapped[Waterbody] = relationship()
class CatchReport(Base):
__tablename__ = "catch_report"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
fish_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("fish.id"))
spot_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("spot.id"))
waterbody_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("waterbody.id"))
bait_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("bait.id"))
weight_g: Mapped[int]
fishing_method: Mapped[str | None] = mapped_column(String(50))
rig_type: Mapped[str | None] = mapped_column(String(100))
retrieve_method: Mapped[str | None] = mapped_column(String(100))
retrieve_speed: Mapped[int | None]
game_time: Mapped[time | None] = mapped_column(Time)
caught_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
player_name: Mapped[str | None] = mapped_column(String(100))
source_type: Mapped[SourceType] = mapped_column(Enum(SourceType))
source_url: Mapped[str | None] = mapped_column(Text)
source_external_id: Mapped[str | None] = mapped_column(String(64), unique=True)
source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
fish: Mapped[Fish] = relationship()
spot: Mapped[Spot | None] = relationship()
waterbody: Mapped[Waterbody] = relationship()
bait: Mapped[Bait | None] = relationship()
class OfficialRecordImport(Base):
__tablename__ = "official_record_import"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[ImportStatus] = mapped_column(Enum(ImportStatus))
source_url: Mapped[str] = mapped_column(Text)
rows_seen: Mapped[int] = mapped_column(default=0)
rows_created: Mapped[int] = mapped_column(default=0)
rows_updated: Mapped[int] = mapped_column(default=0)
error_summary: Mapped[str | None] = mapped_column(Text)
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class FishOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
slug: str
name_ru: str
trophy_weight_g: int | None
class WaterbodyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
slug: str
name_ru: str
unlock_level: int | None
class BaitOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
normalized_name: str
kind: str
class ActivityOut(BaseModel):
spot_id: UUID
waterbody_slug: str
waterbody: str
fish_slug: str
fish: str
x: int
y: int
best_bait: str | None
catches: int
unique_players: int
average_weight_g: int
max_weight_g: int
last_confirmed_at: datetime
activity_score: int
confidence_score: int
explanation: str
class CatchOut(BaseModel):
id: UUID
fish: str
weight_g: int
bait: str | None
player_name: str | None
caught_at: datetime | None
reported_at: datetime
retrieve_method: str | None
retrieve_speed: int | None
class SpotOut(BaseModel):
id: UUID
waterbody_slug: str
waterbody: str
x: int
y: int
description: str | None
catches_24h: int
catches_3d: int
catches_7d: int
top_baits: list[str]
class OfficialRecordOut(BaseModel):
id: UUID
fish: str
weight_g: int
waterbody: str
bait: str | None
player_name: str | None
record_date: datetime | None
category: str | None
region: str | None
source_url: str | None
class ImportRunOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
started_at: datetime
finished_at: datetime | None
status: str
source_url: str
rows_seen: int
rows_created: int
rows_updated: int
error_summary: str | None
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import select
from .database import SessionLocal
from .models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
IDS = {
"vyunok": UUID("10000000-0000-0000-0000-000000000001"),
"kuori": UUID("10000000-0000-0000-0000-000000000002"),
"pike": UUID("20000000-0000-0000-0000-000000000001"),
"trout": UUID("20000000-0000-0000-0000-000000000002"),
"spiker": UUID("30000000-0000-0000-0000-000000000001"),
"shad": UUID("30000000-0000-0000-0000-000000000002"),
"spot1": UUID("40000000-0000-0000-0000-000000000001"),
"spot2": UUID("40000000-0000-0000-0000-000000000002"),
}
def seed() -> None:
with SessionLocal.begin() as db:
if db.scalar(select(Fish.id).limit(1)) is not None:
return
vyunok = Waterbody(id=IDS["vyunok"], slug="vyunok", name_ru="Вьюнок", unlock_level=1)
kuori = Waterbody(id=IDS["kuori"], slug="kuori", name_ru="Куори", unlock_level=16)
pike = Fish(id=IDS["pike"], slug="pike", name_ru="Щука", trophy_weight_g=10_000)
trout = Fish(id=IDS["trout"], slug="lake-trout", name_ru="Озёрная форель", trophy_weight_g=10_000)
spiker = Bait(id=IDS["spiker"], name="Spiker #2 01-015", normalized_name="spiker #2 01-015", kind=BaitKind.lure)
shad = Bait(id=IDS["shad"], name="Salmon T1 Shad 12 005", normalized_name="salmon t1 shad 12 005", kind=BaitKind.lure)
spot1 = Spot(id=IDS["spot1"], waterbody=vyunok, x=110, y=103, description="Кромка травы у северного берега")
spot2 = Spot(id=IDS["spot2"], waterbody=kuori, x=85, y=92, description="Свальчик в глубину")
db.add_all([vyunok, kuori, pike, trout, spiker, shad, spot1, spot2])
now = datetime.now(timezone.utc)
for index in range(12):
db.add(CatchReport(
fish=pike, spot=spot1, waterbody=vyunok, bait=spiker,
weight_g=2_600 + index * 480, fishing_method="spinning",
retrieve_method="равномерная", retrieve_speed=22,
caught_at=now - timedelta(minutes=25 + index * 47),
reported_at=now - timedelta(minutes=20 + index * 47),
player_name=f"DemoPlayer{index % 7 + 1}", source_type=SourceType.manual_import,
source_confidence=80 + index % 3 * 5, moderation_status=ModerationStatus.approved,
))
for index in range(5):
db.add(CatchReport(
fish=trout, spot=spot2, waterbody=kuori, bait=shad,
weight_g=4_200 + index * 900, fishing_method="spinning",
retrieve_method="ступенчатая", retrieve_speed=18,
caught_at=now - timedelta(hours=2 + index * 4),
reported_at=now - timedelta(hours=2 + index * 4),
player_name=f"DemoAngler{index + 1}", source_type=SourceType.manual_import,
source_confidence=85, moderation_status=ModerationStatus.approved,
))
if __name__ == "__main__":
seed()
+9
View File
@@ -0,0 +1,9 @@
alembic==1.16.5
beautifulsoup4==4.15.0
fastapi==0.116.1
httpx==0.28.1
psycopg[binary]==3.2.10
pydantic-settings==2.10.1
pytest==8.4.2
sqlalchemy==2.0.43
uvicorn[standard]==0.35.0
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_session
from app.main import app
from app.models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
def override_session():
with Session(engine) as session:
yield session
app.dependency_overrides[get_session] = override_session
client = TestClient(app)
def setup_module() -> None:
with Session(engine) as db:
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1)
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
bait = Bait(name="Тестовая приманка", normalized_name="тестовая приманка", kind=BaitKind.lure)
spot = Spot(waterbody=waterbody, x=10, y=20, description="Тестовая точка")
db.add_all([waterbody, fish, bait, spot])
now = datetime.now(timezone.utc)
for index in range(3):
db.add(CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=3000 + index * 1000, fishing_method="spinning", reported_at=now - timedelta(hours=index), caught_at=now - timedelta(hours=index), player_name=f"Player {index}", source_type=SourceType.manual_import, source_confidence=90, moderation_status=ModerationStatus.approved))
db.commit()
def test_activity_filters_and_explains_score() -> None:
response = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24")
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["catches"] == 3
assert payload[0]["unique_players"] == 3
assert "3 свежих уловов" in payload[0]["explanation"]
def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?hours=13").status_code == 422
def test_spot_detail_and_catches() -> None:
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"]
detail = client.get(f"/api/v1/spots/{spot_id}")
catches = client.get(f"/api/v1/spots/{spot_id}/catches")
assert detail.status_code == 200
assert detail.json()["catches_24h"] == 3
assert catches.status_code == 200
assert len(catches.json()) == 3
def test_records_list_is_empty_before_import() -> None:
response = client.get("/api/v1/records")
assert response.status_code == 200
assert response.json() == []
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from pathlib import Path
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.database import Base
from app.importer import import_records, parse_html
from app.models import CatchReport, OfficialRecordImport, SourceType
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
def test_parser_and_import_are_idempotent() -> None:
html = FIXTURE.read_text(encoding="utf-8")
parsed = parse_html(html, region="RU", category="records")
assert len(parsed) == 2
assert parsed[1].weight_g == 2_519_264
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
first = import_records(db, url="fixture://records", region="RU", category="records", html=html)
second = import_records(db, url="fixture://records", region="RU", category="records", html=html)
assert (first.rows_created, first.rows_updated) == (2, 0)
assert (second.rows_created, second.rows_updated) == (0, 2)
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2