feat: add tackle catalog and recommendation analytics

This commit is contained in:
ik
2026-09-20 18:26:53 +07:00
parent 4f0c2d23de
commit e94d247096
11 changed files with 350 additions and 11 deletions
+8 -3
View File
@@ -5,7 +5,7 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload
from ..activity import activity_rows
from ..config import settings
@@ -91,8 +91,13 @@ def _report_source(report: CatchReport) -> str:
@router.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, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url) for report in reports]
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait), selectinload(CatchReport.tackle_components)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url, tackle_components=[{
"id": component.id, "role": component.role, "position": component.position,
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
"rig_id": component.rig_id, "source_system": component.source_system,
"source_url": component.source_url,
} for component in sorted(report.tackle_components, key=lambda value: value.position)]) for report in reports]
@router.get("/api/v1/spots/{spot_id}/timeline")
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload, selectinload
from ..dependencies import Db
from ..models import CatchReport, Fish, ModerationStatus, Spot, Waterbody
from ..schemas import TackleCombinationOut
router = APIRouter()
@router.get("/api/v1/analytics/tackle", response_model=list[TackleCombinationOut])
def tackle_combinations(
db: Db,
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
hours: int = Query(72, ge=24, le=168),
min_samples: int = Query(3, ge=1, le=100),
min_players: int = Query(2, ge=1, le=100),
) -> list[TackleCombinationOut]:
now = datetime.now(timezone.utc)
query = select(CatchReport).options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
selectinload(CatchReport.tackle_components),
).where(
CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.deleted_at.is_(None),
CatchReport.reported_at >= now - timedelta(hours=hours),
)
if waterbody:
query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody)
if fish:
query = query.join(Fish, CatchReport.fish_id == Fish.id).where(Fish.slug == fish)
if method:
query = query.where(CatchReport.fishing_method == method)
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
for report in db.scalars(query):
for component in report.tackle_components:
if component.raw_value.strip():
groups[(component.role, component.raw_value.strip())].append(report)
result = []
for (role, value), reports in groups.items():
unique_reports = {report.id: report for report in reports}
players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()}
catches = len(unique_reports)
unique_players = len(players)
last_seen = max(report.reported_at for report in unique_reports.values())
enough = catches >= min_samples and unique_players >= min_players
result.append(TackleCombinationOut(
role=role, value=value, catches=catches, unique_players=unique_players,
last_seen_at=last_seen, status="recommendation" if enough else "insufficient_data",
explanation=(
"Достаточно независимых наблюдений для рекомендации."
if enough else
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока."
),
))
return sorted(result, key=lambda item: (item.status != "recommendation", -item.catches, -item.unique_players, item.role, item.value))
+69 -4
View File
@@ -1,9 +1,12 @@
from fastapi import APIRouter, Query
from sqlalchemy import select
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from ..dependencies import Db
from ..models import Bait, CatchReport, Fish, ModerationStatus, Spot, Waterbody
from ..schemas import BaitOut, FishOut, WaterbodyOut
from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody
from ..schemas import BaitOut, FishOut, PaginatedTackleItemOut, RigOut, TackleItemOut, WaterbodyOut
router = APIRouter()
@@ -23,6 +26,68 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
def _item_missing_fields(item: TackleItem) -> list[str]:
return [field for field, value in (
("subcategory", item.subcategory), ("brand", item.brand),
("family", item.family), ("unlock_level", item.unlock_level),
("source_url", item.source_url), ("source_checked_at", item.source_checked_at),
) if value is None]
@router.get("/api/v1/tackle/items", response_model=PaginatedTackleItemOut)
def tackle_items(
db: Db,
category: str | None = Query(None, pattern="^(bait|lure|rod|reel|line|hook|rig|float|sinker|other)$"),
brand: str | None = None,
family: str | None = None,
unlock_level: int | None = Query(None, ge=0),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> PaginatedTackleItemOut:
query = select(TackleItem)
if category:
query = query.where(TackleItem.category == category)
if brand:
query = query.where(TackleItem.brand == brand)
if family:
query = query.where(TackleItem.family == family)
if unlock_level is not None:
query = query.where(TackleItem.unlock_level == unlock_level)
total = db.scalar(query.with_only_columns(func.count(TackleItem.id), maintain_column_froms=True).order_by(None)) or 0
items = list(db.scalars(query.order_by(TackleItem.name, TackleItem.id).offset(offset).limit(limit)))
return PaginatedTackleItemOut(
items=[TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) for item in items],
total=total, limit=limit, offset=offset,
)
@router.get("/api/v1/tackle/items/{item_id}", response_model=TackleItemOut)
def tackle_item(item_id: UUID, db: Db) -> TackleItemOut:
item = db.get(TackleItem, item_id)
if item is None:
raise HTTPException(status_code=404, detail="tackle item not found")
return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)})
@router.get("/api/v1/tackle/rigs/{rig_id}", response_model=RigOut)
def rig_detail(rig_id: UUID, db: Db) -> RigOut:
rig = db.scalar(select(Rig).options(selectinload(Rig.components)).where(Rig.id == rig_id))
if rig is None:
raise HTTPException(status_code=404, detail="rig not found")
missing = [field for field, value in (
("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at),
) if value is None]
return RigOut(
id=rig.id, name=rig.name, source_system=rig.source_system,
source_external_id=rig.source_external_id, source_url=rig.source_url,
source_checked_at=rig.source_checked_at, missing_fields=missing,
components=[{
"id": component.id, "role": component.role, "position": component.position,
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
} for component in sorted(rig.components, key=lambda value: value.position)],
)
@router.get("/api/v1/public-spot-pages")
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)