from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, current_app, session from web.services.stats_service import StatsService from web.services.feature_service import FeatureService from web.services.web_service import WebService from web.database import execute_db, query_db from web.config import Config from datetime import datetime import os from werkzeug.utils import secure_filename bp = Blueprint('players', __name__, url_prefix='/players') @bp.route('/') def index(): page = request.args.get('page', 1, type=int) search = request.args.get('search') # Default sort by 'matches' as requested sort_by = request.args.get('sort', 'matches') players, total = FeatureService.get_players_list(page, Config.ITEMS_PER_PAGE, sort_by, search) total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE return render_template('players/list.html', players=players, total=total, page=page, total_pages=total_pages, sort_by=sort_by) @bp.route('/', methods=['GET', 'POST']) def detail(steam_id): if request.method == 'POST': # Check if admin action if 'admin_action' in request.form and session.get('is_admin'): action = request.form.get('admin_action') if action == 'update_profile': notes = request.form.get('notes') # Handle Avatar Upload if 'avatar' in request.files: file = request.files['avatar'] if file and file.filename: try: # Use steam_id as filename to ensure uniqueness per player # Preserve extension ext = os.path.splitext(file.filename)[1].lower() if not ext: ext = '.jpg' filename = f"{steam_id}{ext}" upload_folder = os.path.join(current_app.root_path, 'static', 'avatars') os.makedirs(upload_folder, exist_ok=True) file_path = os.path.join(upload_folder, filename) file.save(file_path) # Generate URL (relative to web root) avatar_url = url_for('static', filename=f'avatars/{filename}') # Update L2 DB directly (Immediate effect) execute_db('l2', "UPDATE dim_players SET avatar_url = ? WHERE steam_id_64 = ?", [avatar_url, steam_id]) flash('Avatar updated successfully.', 'success') except Exception as e: print(f"Avatar upload error: {e}") flash('Error uploading avatar.', 'error') WebService.update_player_metadata(steam_id, notes=notes) flash('Profile updated.', 'success') elif action == 'add_tag': tag = request.form.get('tag') if tag: meta = WebService.get_player_metadata(steam_id) tags = meta.get('tags', []) if tag not in tags: tags.append(tag) WebService.update_player_metadata(steam_id, tags=tags) flash('Tag added.', 'success') elif action == 'remove_tag': tag = request.form.get('tag') if tag: meta = WebService.get_player_metadata(steam_id) tags = meta.get('tags', []) if tag in tags: tags.remove(tag) WebService.update_player_metadata(steam_id, tags=tags) flash('Tag removed.', 'success') return redirect(url_for('players.detail', steam_id=steam_id)) # Add Comment username = request.form.get('username', 'Anonymous') content = request.form.get('content') if content: WebService.add_comment(None, username, 'player', steam_id, content) flash('Comment added!', 'success') return redirect(url_for('players.detail', steam_id=steam_id)) player = StatsService.get_player_info(steam_id) if not player: return "Player not found", 404 features = FeatureService.get_player_features(steam_id) # --- New: Fetch Detailed Stats from L2 (Clutch, Multi-Kill, Multi-Assist) --- sql_l2 = """ SELECT SUM(p.clutch_1v1) as c1, SUM(p.clutch_1v2) as c2, SUM(p.clutch_1v3) as c3, SUM(p.clutch_1v4) as c4, SUM(p.clutch_1v5) as c5, SUM(a.attempt_1v1) as att1, SUM(a.attempt_1v2) as att2, SUM(a.attempt_1v3) as att3, SUM(a.attempt_1v4) as att4, SUM(a.attempt_1v5) as att5, SUM(p.kill_2) as k2, SUM(p.kill_3) as k3, SUM(p.kill_4) as k4, SUM(p.kill_5) as k5, SUM(p.many_assists_cnt2) as a2, SUM(p.many_assists_cnt3) as a3, SUM(p.many_assists_cnt4) as a4, SUM(p.many_assists_cnt5) as a5, COUNT(*) as matches, SUM(p.round_total) as total_rounds FROM fact_match_players p LEFT JOIN fact_match_clutch_attempts a ON p.match_id = a.match_id AND p.steam_id_64 = a.steam_id_64 WHERE p.steam_id_64 = ? """ l2_stats = query_db('l2', sql_l2, [steam_id], one=True) l2_stats = dict(l2_stats) if l2_stats else {} # Fetch T/CT splits for comparison # Note: We use SUM(clutch...) as Total Clutch Wins. We don't have attempts, so 'Win Rate' is effectively Wins/Rounds or just Wins count. # User asked for 'Win Rate', but without attempts data, we'll provide Rate per Round or just Count. # Let's provide Rate per Round for Multi-Kill/Assist, and maybe just Count for Clutch? # User said: "总残局胜率...分t和ct在下方加入对比". # Since we found clutch == end in DB, we treat it as Wins. We can't calc Win %. # We will display "Clutch Wins / Round" or just "Clutch Wins". sql_side = """ SELECT 'T' as side, SUM(clutch_1v1+clutch_1v2+clutch_1v3+clutch_1v4+clutch_1v5) as total_clutch, SUM(kill_2+kill_3+kill_4+kill_5) as total_multikill, SUM(many_assists_cnt2+many_assists_cnt3+many_assists_cnt4+many_assists_cnt5) as total_multiassist, SUM(round_total) as rounds FROM fact_match_players_t WHERE steam_id_64 = ? UNION ALL SELECT 'CT' as side, SUM(clutch_1v1+clutch_1v2+clutch_1v3+clutch_1v4+clutch_1v5) as total_clutch, SUM(kill_2+kill_3+kill_4+kill_5) as total_multikill, SUM(many_assists_cnt2+many_assists_cnt3+many_assists_cnt4+many_assists_cnt5) as total_multiassist, SUM(round_total) as rounds FROM fact_match_players_ct WHERE steam_id_64 = ? """ side_rows = query_db('l2', sql_side, [steam_id, steam_id]) side_stats = {row['side']: dict(row) for row in side_rows} if side_rows else {} # Ensure basic stats fallback if features missing or incomplete basic = StatsService.get_player_basic_stats(steam_id) from collections import defaultdict if not features: # Fallback to defaultdict with basic stats features = defaultdict(lambda: None) if basic: features.update({ 'basic_avg_rating': basic.get('rating', 0), 'basic_avg_kd': basic.get('kd', 0), 'basic_avg_kast': basic.get('kast', 0), 'basic_avg_adr': basic.get('adr', 0), }) else: # Convert to defaultdict to handle missing keys gracefully (e.g. newly added columns) # Use lambda: None so that Jinja can check 'if value is not none' features = defaultdict(lambda: None, dict(features)) # If features exist but ADR is missing (not in L3), try to patch it from basic if 'basic_avg_adr' not in features or features['basic_avg_adr'] is None: features['basic_avg_adr'] = basic.get('adr', 0) if basic else 0 comments = WebService.get_comments('player', steam_id) metadata = WebService.get_player_metadata(steam_id) # Roster Distribution Stats distribution = StatsService.get_roster_stats_distribution(steam_id) # History for table (L2 Source) - Fetch ALL for history table/chart history_asc = StatsService.get_player_trend(steam_id, limit=1000) history = history_asc[::-1] if history_asc else [] # Calculate Map Stats map_stats = {} for match in history: m_name = match['map_name'] if m_name not in map_stats: map_stats[m_name] = {'matches': 0, 'wins': 0, 'adr_sum': 0, 'rating_sum': 0} map_stats[m_name]['matches'] += 1 if match['is_win']: map_stats[m_name]['wins'] += 1 map_stats[m_name]['adr_sum'] += (match['adr'] or 0) map_stats[m_name]['rating_sum'] += (match['rating'] or 0) map_stats_list = [] for m_name, data in map_stats.items(): cnt = data['matches'] map_stats_list.append({ 'map_name': m_name, 'matches': cnt, 'win_rate': data['wins'] / cnt, 'adr': data['adr_sum'] / cnt, 'rating': data['rating_sum'] / cnt }) map_stats_list.sort(key=lambda x: x['matches'], reverse=True) # --- New: Recent Performance Stats --- recent_stats = StatsService.get_recent_performance_stats(steam_id) return render_template('players/profile.html', player=player, features=features, comments=comments, metadata=metadata, history=history, distribution=distribution, map_stats=map_stats_list, l2_stats=l2_stats, side_stats=side_stats, recent_stats=recent_stats) @bp.route('/comment//like', methods=['POST']) def like_comment(comment_id): WebService.like_comment(comment_id) return jsonify({'success': True}) @bp.route('//charts_data') def charts_data(steam_id): # ... (existing code) ... # Trend Data trends = StatsService.get_player_trend(steam_id, limit=1000) # Radar Data (Construct from features) features = FeatureService.get_player_features(steam_id) radar_data = {} radar_dist = FeatureService.get_roster_features_distribution(steam_id) if features: # Dimensions: STA, BAT, HPS, PTL, T/CT, UTIL # Use calculated scores (0-100 scale) # Helper to get score safely def get_score(key): val = features[key] if key in features.keys() else 0 return float(val) if val else 0 radar_data = { 'STA': get_score('score_sta'), 'BAT': get_score('score_bat'), 'HPS': get_score('score_hps'), 'PTL': get_score('score_ptl'), 'SIDE': get_score('score_tct'), 'UTIL': get_score('score_util'), 'ECO': get_score('score_eco'), 'PACE': get_score('score_pace') } trend_labels = [] trend_values = [] match_indices = [] for i, row in enumerate(trends): t = dict(row) # Convert sqlite3.Row to dict # Format: Match #Index (Map) # Use backend-provided match_index if available, or just index + 1 idx = t.get('match_index', i + 1) map_name = t.get('map_name', 'Unknown') trend_labels.append(f"#{idx} {map_name}") trend_values.append(t['rating']) return jsonify({ 'trend': {'labels': trend_labels, 'values': trend_values}, 'radar': radar_data, 'radar_dist': radar_dist }) # --- API for Comparison --- @bp.route('/api/search') def api_search(): query = request.args.get('q', '') if len(query) < 2: return jsonify([]) players, _ = FeatureService.get_players_list(page=1, per_page=10, search=query) # Return minimal data results = [{'steam_id': p['steam_id_64'], 'username': p['username'], 'avatar_url': p['avatar_url']} for p in players] return jsonify(results) @bp.route('/api/batch_stats') def api_batch_stats(): steam_ids = request.args.get('ids', '').split(',') stats = [] for sid in steam_ids: if not sid: continue f = FeatureService.get_player_features(sid) p = StatsService.get_player_info(sid) if f and p: # Convert sqlite3.Row to dict if necessary if hasattr(f, 'keys'): # It's a Row object or similar f = dict(f) # 1. Radar Scores (Normalized 0-100) # Use safe conversion with default 0 if None # Force 0.0 if value is 0 or None to ensure JSON compatibility radar = { 'STA': float(f.get('score_sta') or 0.0), 'BAT': float(f.get('score_bat') or 0.0), 'HPS': float(f.get('score_hps') or 0.0), 'PTL': float(f.get('score_ptl') or 0.0), 'SIDE': float(f.get('score_tct') or 0.0), 'UTIL': float(f.get('score_util') or 0.0) } # 2. Basic Stats for Table basic = { 'rating': float(f.get('basic_avg_rating') or 0), 'kd': float(f.get('basic_avg_kd') or 0), 'adr': float(f.get('basic_avg_adr') or 0), 'kast': float(f.get('basic_avg_kast') or 0), 'hs_rate': float(f.get('basic_headshot_rate') or 0), 'fk_rate': float(f.get('basic_first_kill_rate') or 0), 'matches': int(f.get('matches_played') or 0) } # 3. Side Stats side = { 'rating_t': float(f.get('side_rating_t') or 0), 'rating_ct': float(f.get('side_rating_ct') or 0), 'kd_t': float(f.get('side_kd_t') or 0), 'kd_ct': float(f.get('side_kd_ct') or 0), 'entry_t': float(f.get('side_entry_rate_t') or 0), 'entry_ct': float(f.get('side_entry_rate_ct') or 0), 'kast_t': float(f.get('side_kast_t') or 0), 'kast_ct': float(f.get('side_kast_ct') or 0), 'adr_t': float(f.get('side_adr_t') or 0), 'adr_ct': float(f.get('side_adr_ct') or 0) } # 4. Detailed Stats (Expanded for Data Center - Aligned with Profile) detailed = { # Row 1 'rating_t': float(f.get('side_rating_t') or 0), 'rating_ct': float(f.get('side_rating_ct') or 0), 'kd_t': float(f.get('side_kd_t') or 0), 'kd_ct': float(f.get('side_kd_ct') or 0), # Row 2 'win_rate_t': float(f.get('side_win_rate_t') or 0), 'win_rate_ct': float(f.get('side_win_rate_ct') or 0), 'first_kill_t': float(f.get('side_first_kill_rate_t') or 0), 'first_kill_ct': float(f.get('side_first_kill_rate_ct') or 0), # Row 3 'first_death_t': float(f.get('side_first_death_rate_t') or 0), 'first_death_ct': float(f.get('side_first_death_rate_ct') or 0), 'kast_t': float(f.get('side_kast_t') or 0), 'kast_ct': float(f.get('side_kast_ct') or 0), # Row 4 'rws_t': float(f.get('side_rws_t') or 0), 'rws_ct': float(f.get('side_rws_ct') or 0), 'multikill_t': float(f.get('side_multikill_rate_t') or 0), 'multikill_ct': float(f.get('side_multikill_rate_ct') or 0), # Row 5 'hs_t': float(f.get('side_headshot_rate_t') or 0), 'hs_ct': float(f.get('side_headshot_rate_ct') or 0), 'obj_t': float(f.get('side_obj_t') or 0), 'obj_ct': float(f.get('side_obj_ct') or 0) } stats.append({ 'username': p['username'], 'steam_id': sid, 'avatar_url': p['avatar_url'], 'radar': radar, 'basic': basic, 'side': side, 'detailed': detailed }) return jsonify(stats) @bp.route('/api/batch_map_stats') def api_batch_map_stats(): steam_ids = request.args.get('ids', '').split(',') steam_ids = [sid for sid in steam_ids if sid] if not steam_ids: return jsonify({}) # Query L2 for Map Stats grouped by Player and Map # We need to construct a query that can be executed via execute_db or query_db # Since StatsService usually handles this, we can write raw SQL here or delegate. # Raw SQL is easier for this specific aggregation. placeholders = ','.join('?' for _ in steam_ids) sql = f""" SELECT mp.steam_id_64, m.map_name, COUNT(*) as matches, SUM(CASE WHEN mp.is_win THEN 1 ELSE 0 END) as wins, AVG(mp.rating) as avg_rating, AVG(mp.kd_ratio) as avg_kd, AVG(mp.adr) as avg_adr FROM fact_match_players mp JOIN fact_matches m ON mp.match_id = m.match_id WHERE mp.steam_id_64 IN ({placeholders}) GROUP BY mp.steam_id_64, m.map_name ORDER BY matches DESC """ # We need to import query_db if not available in current scope (it is imported at top) from web.database import query_db rows = query_db('l2', sql, steam_ids) # Structure: {steam_id: [ {map: 'de_mirage', stats...}, ... ]} result = {} for r in rows: sid = r['steam_id_64'] if sid not in result: result[sid] = [] result[sid].append({ 'map_name': r['map_name'], 'matches': r['matches'], 'win_rate': (r['wins'] / r['matches']) if r['matches'] else 0, 'rating': r['avg_rating'], 'kd': r['avg_kd'], 'adr': r['avg_adr'] }) return jsonify(result)