Files
rf4-spotter/apps/api/app/activity.py
T

85 lines
3.7 KiB
Python

from __future__ import annotations
import math
from collections import Counter
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .models import CatchReport, ModerationStatus
from .schemas import ActivityOut
def activity_rows(
session: Session,
*,
hours: int,
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
now: datetime | None = None,
) -> list[ActivityOut]:
now = _aware(now or datetime.now(timezone.utc))
query = (
select(CatchReport)
.options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
joinedload(CatchReport.spot), joinedload(CatchReport.bait),
)
.where(
CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.deleted_at.is_(None),
CatchReport.spot_id.is_not(None),
CatchReport.reported_at >= now - timedelta(hours=hours),
)
)
reports = list(session.scalars(query))
if waterbody:
reports = [r for r in reports if r.waterbody.slug == waterbody]
if fish:
reports = [r for r in reports if r.fish.slug == fish]
if method:
reports = [r for r in reports if r.fishing_method == method]
groups: dict[tuple[object, object], list[CatchReport]] = {}
for report in reports:
groups.setdefault((report.spot_id, report.fish_id), []).append(report)
result: list[ActivityOut] = []
for items in groups.values():
first = items[0]
players = {r.player_name.strip().casefold() for r in items if r.player_name and r.player_name.strip()}
freshness = [math.exp(-max(0.0, (now - _aware(r.reported_at)).total_seconds()) / 3600 / 18) for r in items]
weighted = sum(value * r.source_confidence / 100 for value, r in zip(freshness, items))
trophies = sum(bool(r.fish.trophy_weight_g and r.weight_g >= r.fish.trophy_weight_g) for r in items)
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
average_confidence = sum(r.source_confidence for r in items) / len(items)
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
latest = max(_aware(r.caught_at or r.reported_at) for r in items)
baits = Counter(r.bait.name for r in items if r.bait)
freshness_text = _freshness_text(now - latest)
result.append(ActivityOut(
spot_id=first.spot.id, waterbody_slug=first.waterbody.slug,
waterbody=first.waterbody.name_ru, fish_slug=first.fish.slug,
fish=first.fish.name_ru, x=first.spot.x, y=first.spot.y,
best_bait=baits.most_common(1)[0][0] if baits else None,
catches=len(items), unique_players=len(players),
average_weight_g=round(sum(r.weight_g for r in items) / len(items)),
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
activity_score=activity, confidence_score=confidence,
explanation=f"{len(items)} свежих уловов от {len(players)} игроков. Последнее подтверждение {freshness_text}." + (" Данных мало." if len(items) < 3 else ""),
))
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def _freshness_text(delta: timedelta) -> str:
minutes = max(0, round(delta.total_seconds() / 60))
if minutes < 60:
return f"{minutes} мин. назад"
return f"{minutes // 60} ч. назад"