Build Dockerized MVP scaffold and records importer
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
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,
|
||||
) -> list[ActivityOut]:
|
||||
now = 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.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} ч. назад"
|
||||
Reference in New Issue
Block a user