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]