437 lines
17 KiB
Python
437 lines
17 KiB
Python
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)
|
|
l2_stats = {}
|
|
side_stats = {}
|
|
|
|
# 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
|
|
|
|
try:
|
|
matches = int(features.get("matches_played") or 0)
|
|
except Exception:
|
|
matches = 0
|
|
try:
|
|
total_rounds = int(features.get("total_rounds") or 0)
|
|
except Exception:
|
|
total_rounds = 0
|
|
|
|
def _f(key, default=0.0):
|
|
v = features.get(key)
|
|
if v is None:
|
|
return default
|
|
try:
|
|
return float(v)
|
|
except Exception:
|
|
return default
|
|
|
|
l2_stats = {
|
|
"matches": matches,
|
|
"total_rounds": total_rounds,
|
|
"c1": int(_f("tac_clutch_1v1_wins", 0)),
|
|
"att1": int(_f("tac_clutch_1v1_attempts", 0)),
|
|
"c2": int(_f("tac_clutch_1v2_wins", 0)),
|
|
"att2": int(_f("tac_clutch_1v2_attempts", 0)),
|
|
"c3": int(_f("tac_clutch_1v3_plus_wins", 0)),
|
|
"att3": int(_f("tac_clutch_1v3_plus_attempts", 0)),
|
|
"c4": 0,
|
|
"att4": 0,
|
|
"c5": 0,
|
|
"att5": 0,
|
|
"k2": int(round(_f("tac_avg_2k", 0) * max(matches, 0))),
|
|
"k3": int(round(_f("tac_avg_3k", 0) * max(matches, 0))),
|
|
"k4": int(round(_f("tac_avg_4k", 0) * max(matches, 0))),
|
|
"k5": int(round(_f("tac_avg_5k", 0) * max(matches, 0))),
|
|
"a2": 0,
|
|
"a3": 0,
|
|
"a4": 0,
|
|
"a5": 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)
|
|
|
|
@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: AIM, DEFENSE, UTILITY, CLUTCH, ECONOMY, PACE (6 Dimensions)
|
|
# 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 = {
|
|
'AIM': get_score('score_aim'),
|
|
'DEFENSE': get_score('score_defense'),
|
|
'UTILITY': get_score('score_utility'),
|
|
'CLUTCH': get_score('score_clutch'),
|
|
'ECONOMY': get_score('score_economy'),
|
|
'PACE': get_score('score_pace'),
|
|
'PISTOL': get_score('score_pistol'),
|
|
'STABILITY': get_score('score_stability')
|
|
}
|
|
|
|
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 = {
|
|
'AIM': float(f.get('score_aim') or 0.0),
|
|
'DEFENSE': float(f.get('score_defense') or 0.0),
|
|
'UTILITY': float(f.get('score_utility') or 0.0),
|
|
'CLUTCH': float(f.get('score_clutch') or 0.0),
|
|
'ECONOMY': float(f.get('score_economy') or 0.0),
|
|
'PACE': float(f.get('score_pace') or 0.0),
|
|
'PISTOL': float(f.get('score_pistol') or 0.0),
|
|
'STABILITY': float(f.get('score_stability') or 0.0)
|
|
}
|
|
|
|
# 2. Basic Stats for Table
|
|
rating_val = f.get('core_avg_rating2')
|
|
if rating_val is None:
|
|
rating_val = f.get('core_avg_rating')
|
|
if rating_val is None:
|
|
rating_val = f.get('basic_avg_rating')
|
|
basic = {
|
|
'rating': float(rating_val or 0),
|
|
'kd': float(f.get('core_avg_kd') or f.get('basic_avg_kd') or 0),
|
|
'adr': float(f.get('core_avg_adr') or f.get('basic_avg_adr') or 0),
|
|
'kast': float(f.get('core_avg_kast') or 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)
|