feat: Add recent performance stability stats (matches/days) to player profile
This commit is contained in:
431
web/routes/players.py
Normal file
431
web/routes/players.py
Normal file
@@ -0,0 +1,431 @@
|
||||
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('/<steam_id>', 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/<int:comment_id>/like', methods=['POST'])
|
||||
def like_comment(comment_id):
|
||||
WebService.like_comment(comment_id)
|
||||
return jsonify({'success': True})
|
||||
|
||||
@bp.route('/<steam_id>/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)
|
||||
Reference in New Issue
Block a user