feat: add tackle catalog and recommendation analytics
This commit is contained in:
@@ -14,6 +14,7 @@ from .dependencies import Db
|
||||
from .logging_config import configure_logging
|
||||
from .readiness import readiness_report
|
||||
from .routers.activity import router as activity_router
|
||||
from .routers.analytics import router as analytics_router
|
||||
from .routers.admin import router as admin_router
|
||||
from .routers.catalog import router as catalog_router
|
||||
from .routers.media import router as media_router
|
||||
@@ -90,6 +91,7 @@ def ready(db: Db) -> JSONResponse:
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(media_router)
|
||||
app.include_router(activity_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(public_data_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(submissions_router)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -40,6 +40,48 @@ class BaitOut(BaseModel):
|
||||
kind: str
|
||||
|
||||
|
||||
class TackleItemOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
name: str
|
||||
category: str
|
||||
subcategory: str | None
|
||||
brand: str | None
|
||||
family: str | None
|
||||
unlock_level: int | None
|
||||
source_system: str | None
|
||||
source_external_id: str | None
|
||||
source_url: str | None
|
||||
source_checked_at: datetime | None
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PaginatedTackleItemOut(BaseModel):
|
||||
items: list[TackleItemOut]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class RigComponentOut(BaseModel):
|
||||
id: UUID
|
||||
role: str
|
||||
position: int
|
||||
raw_value: str | None
|
||||
tackle_item_id: UUID | None
|
||||
|
||||
|
||||
class RigOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
source_system: str | None
|
||||
source_external_id: str | None
|
||||
source_url: str | None
|
||||
source_checked_at: datetime | None
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
components: list[RigComponentOut]
|
||||
|
||||
|
||||
class ActivityOut(BaseModel):
|
||||
spot_id: UUID
|
||||
waterbody_slug: str
|
||||
@@ -69,6 +111,16 @@ class PaginatedActivityOut(BaseModel):
|
||||
offset: int
|
||||
|
||||
|
||||
class TackleCombinationOut(BaseModel):
|
||||
role: str
|
||||
value: str
|
||||
catches: int
|
||||
unique_players: int
|
||||
last_seen_at: datetime
|
||||
status: str
|
||||
explanation: str
|
||||
|
||||
|
||||
class CatchOut(BaseModel):
|
||||
id: UUID
|
||||
fish: str
|
||||
@@ -81,6 +133,18 @@ class CatchOut(BaseModel):
|
||||
retrieve_speed: int | None
|
||||
source_system: str
|
||||
source_url: str | None
|
||||
tackle_components: list["CatchTackleComponentOut"]
|
||||
|
||||
|
||||
class CatchTackleComponentOut(BaseModel):
|
||||
id: UUID
|
||||
role: str
|
||||
position: int
|
||||
raw_value: str
|
||||
tackle_item_id: UUID | None
|
||||
rig_id: UUID | None
|
||||
source_system: str | None
|
||||
source_url: str | None
|
||||
|
||||
|
||||
class SpotOut(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user