"""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