Files
hockey_new/hockey_data/analytics.py
2026-09-27 00:47:30 +03:00

171 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Read-only comparisons and account-owned editorial drafts, without Agent calls."""
from __future__ import annotations
import hashlib
import math
from datetime import datetime
from threading import RLock
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import func, select, text
from .async_work import db_endpoint
from .auth_bridge import HockeyUser
from .khl_career import KhlCareerService, raw_object
from .models import AnalyticsDraft, Game, PlayerSeasonStatistic, Tournament
_DRAFT_LOCK = RLock()
DRAFT_LIMIT = 200
SEASON_KEYS = {
'gp': 'games', 'g': 'goals', 'a': 'assists', 'pts': 'points', 'pim': 'penalty_minutes',
'pm': 'plus_minus', 'ppg': 'power_play_goals', 'shg': 'short_handed_goals',
'gwg': 'game_winning_goals', 'hits': 'hits', 'bls': 'blocked_shots',
'sog_pct': 'shot_pct', 'fo_pct': 'faceoff_pct', 'w': 'wins', 'l': 'losses',
'ga': 'goals_against', 'sv': 'saves', 'sv_pct': 'save_pct',
'gaa': 'goals_against_average', 'so': 'shutouts',
}
def season_values(raw, goalie=False):
"""Read original cells: normalized defaults cannot distinguish missing from zero."""
result = {}
for key, field in {**SEASON_KEYS, 'sog': 'shots_against' if goalie else 'shots'}.items():
try:
value = float(str(raw.get(key, '')).replace(',', '.'))
except (ValueError, TypeError):
continue
if math.isfinite(value) and (value >= 0 or field == 'plus_minus'):
result[field] = int(value) if value.is_integer() else value
if 'points' not in result and 'goals' in result and 'assists' in result:
result['points'] = result['goals'] + result['assists']
result['available'] = 'games' in result
return result
def game_analytics(database, game_id, *, all_members=False, mode='all'):
payload = KhlCareerService(database).game_career(game_id, all_members=all_members, mode=mode)
if payload is None:
raise HTTPException(404, 'Матч не найден')
people = [p for side in ('home', 'away') for role in ('skaters', 'goalkeepers') for p in payload[side][role]]
with database.session() as session:
game = session.scalar(select(Game).where(Game.external_id == game_id))
if game is None:
raise HTTPException(404, 'Матч не найден')
tournament = session.scalar(select(Tournament).where(Tournament.external_id == game.tournament_external_id))
payload['season_label'] = (tournament.name_ru if tournament else '') or 'Выбранный турнир'
payload['season_label_en'] = (tournament.name_en if tournament else '') or 'Selected tournament'
payload['season_stage'] = tournament.season_part if tournament else ''
payload['game_date'] = game.game_date.isoformat() if game.game_date else ''
rows = list(session.scalars(select(PlayerSeasonStatistic).where(
PlayerSeasonStatistic.tournament_external_id == game.tournament_external_id,
PlayerSeasonStatistic.player_external_id.in_([p['id'] for p in people])))) if people else []
by_player = {}
for row in rows:
by_player.setdefault(row.player_external_id, []).append(row)
for p in people:
p['career_statistics'] = p.pop('season_statistics')
club = getattr(game, f"{p['side']}_team_external_id")
entry = getattr(game, f"{p['side']}_entry_external_id")
candidates = by_player.get(p['id'], [])
exact = [r for r in candidates if entry and r.team_entry_external_id == entry]
matching = exact or [r for r in candidates if club and r.club_external_id == club and (not entry or not r.team_entry_external_id)]
row = matching[0] if len(matching) == 1 else None
p['season_statistics'] = season_values(raw_object(row.raw_payload), p['role'] == 'goalkeeper') if row else {'available': False}
p['season_updated_at'] = row.synced_at.isoformat() + 'Z' if row and row.synced_at else ''
p['season_status'] = 'available' if p['season_statistics']['available'] else 'ambiguous' if len(matching) > 1 else 'unavailable'
return payload
class DraftPayload(BaseModel):
model_config = ConfigDict(extra='forbid')
expected_revision: int = Field(ge=0)
kind: Literal['comparison', 'hint', 'custom']
source_key: str = Field(default='', max_length=256)
title: str = Field(min_length=1, max_length=200)
text_ru: str = Field(default='', max_length=4000)
text_en: str = Field(default='', max_length=4000)
source: str = Field(default='', max_length=1000)
def draft_dict(row):
return {**{k: getattr(row, k) for k in ('kind', 'source_key', 'title', 'text_ru', 'text_en', 'source', 'revision')},
'id': row.draft_id, 'updated_at': row.updated_at.isoformat() + 'Z'}
def check_game(session, game_id):
if session.scalar(select(Game.id).where(Game.external_id == game_id)) is None:
raise HTTPException(404, 'Матч не найден')
def lock_drafts(session, user_id, game_id):
if session.get_bind().dialect.name == 'postgresql':
key = int.from_bytes(hashlib.sha256(f'analytics:{user_id}:{game_id}'.encode()).digest()[:8], 'big', signed=True)
session.execute(text('SELECT pg_advisory_xact_lock(:key)'), {'key': key})
def create_analytics_router(database, auth_dependency):
def private_response(response: Response):
response.headers['Cache-Control'] = 'private, no-store'
router = APIRouter(dependencies=[Depends(private_response)])
@router.get('/games/{external_id}/analytics')
@db_endpoint
def data(external_id: str, all_members: bool = False,
mode: str = Query('all', pattern='^(all|regular|playoff|hope)$'),
user: HockeyUser = Depends(auth_dependency)):
return game_analytics(database, external_id, all_members=all_members, mode=mode)
@router.get('/games/{external_id}/analytics/drafts')
@db_endpoint
def drafts(external_id: str, user: HockeyUser = Depends(auth_dependency)):
with database.session() as session:
check_game(session, external_id)
rows = session.scalars(select(AnalyticsDraft).where(
AnalyticsDraft.user_id == user.id, AnalyticsDraft.game_external_id == external_id)
.order_by(AnalyticsDraft.updated_at.desc(), AnalyticsDraft.draft_id).limit(DRAFT_LIMIT))
return {'items': [draft_dict(row) for row in rows], 'limit': DRAFT_LIMIT}
@router.put('/games/{external_id}/analytics/drafts/{draft_id}')
@db_endpoint
def save(external_id: str, draft_id: UUID, payload: DraftPayload, user: HockeyUser = Depends(auth_dependency)):
if not payload.title.strip() or not (payload.text_ru.strip() or payload.text_en.strip()):
raise HTTPException(422, 'Укажите название и текст хотя бы на одном языке')
with _DRAFT_LOCK, database.session() as session:
check_game(session, external_id)
lock_drafts(session, user.id, external_id)
row = session.get(AnalyticsDraft, (user.id, external_id, str(draft_id)))
if (row.revision if row else 0) != payload.expected_revision:
raise HTTPException(409, 'Запись изменена в другой вкладке. Обновите подборку и откройте сохранённую запись; ваш текст остался в редакторе.')
if row is None:
count = session.scalar(select(func.count()).select_from(AnalyticsDraft).where(
AnalyticsDraft.user_id == user.id, AnalyticsDraft.game_external_id == external_id))
if count >= DRAFT_LIMIT:
raise HTTPException(409, f'В подборке уже {DRAFT_LIMIT} записей. Удалите ненужные.')
row = AnalyticsDraft(user_id=user.id, game_external_id=external_id, draft_id=str(draft_id), revision=0)
session.add(row)
for key, value in payload.model_dump(exclude={'expected_revision'}).items():
setattr(row, key, value.strip())
row.revision += 1
row.updated_at = datetime.utcnow()
session.flush()
return draft_dict(row)
@router.delete('/games/{external_id}/analytics/drafts/{draft_id}')
@db_endpoint
def delete(external_id: str, draft_id: UUID, expected_revision: int = Query(..., ge=1),
user: HockeyUser = Depends(auth_dependency)):
with _DRAFT_LOCK, database.session() as session:
lock_drafts(session, user.id, external_id)
row = session.get(AnalyticsDraft, (user.id, external_id, str(draft_id)))
if row is None:
raise HTTPException(404, 'Запись не найдена')
if row.revision != expected_revision:
raise HTTPException(409, 'Запись изменена в другой вкладке. Обновите подборку.')
session.delete(row)
return {'ok': True}
return router