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 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 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=1, le=100), min_players: int = Query(2, ge=1, le=100), ) -> 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), 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 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} item_ids = { component.tackle_item_id for report in unique_reports.values() for component in report.tackle_components if component.role == component_role and component.raw_value.strip() == value and component.tackle_item_id is not None } rig_ids = { component.rig_id for report in unique_reports.values() for component in report.tackle_components if component.role == component_role and component.raw_value.strip() == value and component.rig_id is not None } canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids else None canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids 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) last_seen = max(report.reported_at for report in unique_reports.values()) freshness_score = round(sum( exp(-_age_hours(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} независимых игрока." ), )) return sorted(result, key=lambda item: (item.status != "recommendation", -item.freshness_score, -item.catches, -item.unique_players, item.role, item.value))