99 lines
4.7 KiB
Python
99 lines
4.7 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 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))
|