Files
rf4-spotter/apps/api/app/routers/analytics.py
T

105 lines
5.3 KiB
Python

from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from math import exp, log
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import and_, or_, 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()
FRESHNESS_HALF_LIFE_HOURS = 12.5
MAX_ANALYTICS_FACTS = 10_000
def _age_hours(value: datetime, now: datetime) -> float:
observed_at = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
return max(0.0, (now - observed_at).total_seconds() / 3600)
@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,
role: str | None = None,
hours: int = Query(72, ge=24, le=168),
min_samples: int = Query(3, ge=3, le=100),
min_players: int = Query(2, ge=2, le=100),
limit: int = Query(100, ge=1, le=500),
) -> list[TackleCombinationOut]:
if role is not None and role not in {"lure", "bait", "rig", "rod", "reel", "line", "hook", "float", "sinker"}:
raise HTTPException(status_code=422, detail="invalid tackle role")
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),
or_(
and_(CatchReport.caught_at.is_not(None), CatchReport.caught_at >= now - timedelta(hours=hours)),
and_(CatchReport.caught_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)
query = query.order_by(CatchReport.caught_at.desc(), CatchReport.reported_at.desc(), CatchReport.id.desc()).limit(MAX_ANALYTICS_FACTS)
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
for report in db.scalars(query):
for component in report.tackle_components:
if role and component.role != role:
continue
if component.raw_value.strip():
groups[(component.role, component.raw_value.strip())].append(report)
result = []
for (component_role, value), reports in groups.items():
unique_reports = {report.id: report for report in reports}
matching_components = [
component
for report in unique_reports.values()
for component in report.tackle_components
if component.role == component_role and component.raw_value.strip() == value
]
item_ids = {component.tackle_item_id for component in matching_components if component.tackle_item_id is not None}
rig_ids = {component.rig_id for component in matching_components if component.rig_id is not None}
has_unresolved = any(component.tackle_item_id is None and component.rig_id is None for component in matching_components)
canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids and not has_unresolved else None
canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids and not has_unresolved else None
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)
observed_times = [report.caught_at or report.reported_at for report in unique_reports.values()]
last_seen = max(observed_times)
freshness_score = round(sum(
exp(-_age_hours(report.caught_at or report.reported_at, now) * log(2) / FRESHNESS_HALF_LIFE_HOURS)
for report in unique_reports.values()
) / catches * 100)
enough = catches >= min_samples and unique_players >= min_players
result.append(TackleCombinationOut(
role=component_role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id,
catches=catches, unique_players=unique_players,
last_seen_at=last_seen, freshness_score=freshness_score,
status="recommendation" if enough else "insufficient_data",
explanation=(
"Достаточно независимых наблюдений для рекомендации."
if enough else
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока."
),
))
ranked = sorted(result, key=lambda item: (item.status != "recommendation", -item.freshness_score, -item.catches, -item.unique_players, item.role, item.value))
effective_limit = limit if isinstance(limit, int) else 100
return ranked[:effective_limit]