171 lines
9.1 KiB
Python
171 lines
9.1 KiB
Python
"""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
|