2.0.0 Alpha: Data Refinery
This commit is contained in:
+111
-22
@@ -1,16 +1,17 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify
|
||||
from web.config import Config
|
||||
from web.auth import admin_required
|
||||
from web.database import query_db
|
||||
import os
|
||||
from web.services.etl_service import EtlService
|
||||
import hmac
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
token = request.form.get('token')
|
||||
if token == Config.ADMIN_TOKEN:
|
||||
token = request.form.get('token') or ''
|
||||
if hmac.compare_digest(token, Config.ADMIN_TOKEN):
|
||||
session['is_admin'] = True
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
@@ -27,19 +28,104 @@ def logout():
|
||||
def dashboard():
|
||||
return render_template('admin/dashboard.html')
|
||||
|
||||
from web.services.etl_service import EtlService
|
||||
@bp.route('/data-integrity')
|
||||
@admin_required
|
||||
def data_integrity():
|
||||
from web.services.integrity_service import IntegrityService
|
||||
|
||||
report = IntegrityService.build_report()
|
||||
if request.args.get('format') == 'json':
|
||||
return jsonify(report)
|
||||
return render_template('admin/data_integrity.html', report=report)
|
||||
|
||||
@bp.route('/trigger_etl', methods=['POST'])
|
||||
@admin_required
|
||||
def trigger_etl():
|
||||
script_name = request.form.get('script')
|
||||
allowed = ['L1A.py', 'L2_Builder.py', 'L3_Builder.py']
|
||||
if script_name not in allowed:
|
||||
return "Invalid script", 400
|
||||
|
||||
success, message = EtlService.run_script(script_name)
|
||||
status_code = 200 if success else 500
|
||||
return message, status_code
|
||||
from database.job_store import JobStore
|
||||
|
||||
store = JobStore(Config.DB_WEB_PATH)
|
||||
job_id = store.create_job(
|
||||
'manual_pipeline',
|
||||
created_by='admin',
|
||||
)
|
||||
try:
|
||||
pid = EtlService.start_pipeline(job_id)
|
||||
except Exception as exc:
|
||||
store.finish_job(job_id, False, str(exc), 0)
|
||||
return jsonify({'success': False, 'error': str(exc)}), 500
|
||||
return jsonify({'success': True, 'job_id': job_id, 'pid': pid}), 202
|
||||
|
||||
|
||||
@bp.route('/import-match', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def import_match():
|
||||
from database.job_store import JobStore
|
||||
from web.services.import_service import (
|
||||
DuplicateMatchError,
|
||||
ImportValidationError,
|
||||
MatchImportService,
|
||||
)
|
||||
|
||||
store = JobStore(Config.DB_WEB_PATH)
|
||||
if request.method == 'POST':
|
||||
upload = request.files.get('capture')
|
||||
if not upload or not upload.filename:
|
||||
flash('请选择 iframe_network.json 文件。', 'error')
|
||||
return redirect(url_for('admin.import_match'))
|
||||
|
||||
prepared = None
|
||||
try:
|
||||
prepared = MatchImportService.prepare_import(
|
||||
upload.read(),
|
||||
upload.filename,
|
||||
created_by='admin',
|
||||
replace=request.form.get('replace') == '1',
|
||||
)
|
||||
EtlService.start_pipeline(
|
||||
prepared['job_id'],
|
||||
match_id=prepared['match_id'],
|
||||
replace=prepared['replace'],
|
||||
)
|
||||
flash(
|
||||
f"比赛 {prepared['match_id']} 已进入导入队列。",
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for(
|
||||
'admin.import_match',
|
||||
job_id=prepared['job_id'],
|
||||
))
|
||||
except (DuplicateMatchError, ImportValidationError) as exc:
|
||||
flash(str(exc), 'warning')
|
||||
except Exception as exc:
|
||||
if prepared:
|
||||
store.finish_job(
|
||||
prepared['job_id'],
|
||||
False,
|
||||
f'Failed to start pipeline: {exc}',
|
||||
0,
|
||||
)
|
||||
flash(f'启动导入失败:{exc}', 'error')
|
||||
|
||||
selected_job = None
|
||||
selected_job_id = request.args.get('job_id', type=int)
|
||||
if selected_job_id:
|
||||
selected_job = store.get_job(selected_job_id)
|
||||
return render_template(
|
||||
'admin/import_match.html',
|
||||
jobs=store.list_jobs(30),
|
||||
selected_job=selected_job,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/api/jobs/<int:job_id>')
|
||||
@admin_required
|
||||
def api_job(job_id):
|
||||
from database.job_store import JobStore
|
||||
|
||||
job = JobStore(Config.DB_WEB_PATH).get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
return jsonify(job)
|
||||
|
||||
@bp.route('/sql', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
@@ -50,18 +136,21 @@ def sql_runner():
|
||||
db_name = "l2"
|
||||
|
||||
if request.method == 'POST':
|
||||
query = request.form.get('query')
|
||||
query = (request.form.get('query') or '').strip()
|
||||
db_name = request.form.get('db_name', 'l2')
|
||||
|
||||
# Safety check
|
||||
forbidden = ['DELETE', 'DROP', 'UPDATE', 'INSERT', 'ALTER', 'GRANT', 'REVOKE']
|
||||
if any(x in query.upper() for x in forbidden):
|
||||
error = "Only SELECT queries allowed in Web Runner."
|
||||
|
||||
statement = query.rstrip(';').strip()
|
||||
if db_name not in {'l2', 'l3', 'web'}:
|
||||
error = "Unknown database."
|
||||
elif not statement.upper().startswith('SELECT '):
|
||||
error = "Only SELECT queries are allowed."
|
||||
elif ';' in statement:
|
||||
error = "Only one SQL statement is allowed."
|
||||
else:
|
||||
try:
|
||||
# Enforce limit if not present
|
||||
if 'LIMIT' not in query.upper():
|
||||
query += " LIMIT 50"
|
||||
query = statement
|
||||
if 'LIMIT' not in statement.upper():
|
||||
query = f"{statement} LIMIT 50"
|
||||
|
||||
rows = query_db(db_name, query)
|
||||
if rows:
|
||||
|
||||
+10
-12
@@ -1,6 +1,5 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from web.services.stats_service import StatsService
|
||||
import time
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
@@ -18,18 +17,17 @@ def index():
|
||||
|
||||
return render_template('home/index.html', recent_matches=recent_matches, heatmap_data=heatmap_data, live_matches=live_matches)
|
||||
|
||||
from web.services.etl_service import EtlService
|
||||
|
||||
@bp.route('/parse_match', methods=['POST'])
|
||||
def parse_match():
|
||||
url = request.form.get('url')
|
||||
if not url or '5eplay.com' not in url:
|
||||
return jsonify({'success': False, 'message': 'Invalid 5EPlay URL'})
|
||||
|
||||
# Trigger L1A.py with URL argument
|
||||
success, msg = EtlService.run_script('L1A.py', args=[url])
|
||||
|
||||
if success:
|
||||
return jsonify({'success': True, 'message': 'Match parsing completed successfully!'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': f'Error: {msg}'})
|
||||
return jsonify({'success': False, 'message': 'Invalid 5EPlay URL'}), 400
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': (
|
||||
'URL downloader is not included in this repository. '
|
||||
'Place iframe_network.json under output_arena/<match_id>/ '
|
||||
'and run the L1/L2/L3 builders from Admin.'
|
||||
),
|
||||
}), 501
|
||||
|
||||
+3
-13
@@ -33,19 +33,9 @@ def detail(match_id):
|
||||
|
||||
rounds = StatsService.get_match_rounds(match_id)
|
||||
|
||||
# --- Roster Identification ---
|
||||
# Fetch active roster to identify "Our Team" players
|
||||
from web.services.web_service import WebService
|
||||
lineups = WebService.get_lineups()
|
||||
# Assume we use the first/active lineup
|
||||
active_roster_ids = []
|
||||
if lineups:
|
||||
try:
|
||||
active_roster_ids = json.loads(lineups[0]['player_ids_json'])
|
||||
except:
|
||||
pass
|
||||
|
||||
# Mark roster players (Ensure strict string comparison)
|
||||
from web.services.team_context_service import TeamContextService
|
||||
|
||||
active_roster_ids = TeamContextService.get_active_roster_ids()
|
||||
roster_set = set(str(uid) for uid in active_roster_ids)
|
||||
for p in players:
|
||||
p['is_in_roster'] = str(p['steam_id_64']) in roster_set
|
||||
|
||||
+36
-44
@@ -1,15 +1,16 @@
|
||||
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.player_profile_service import PlayerProfileService
|
||||
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
|
||||
import json
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
bp = Blueprint('players', __name__, url_prefix='/players')
|
||||
ALLOWED_AVATAR_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
@@ -41,7 +42,12 @@ def detail(steam_id):
|
||||
# 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'
|
||||
if (
|
||||
ext not in ALLOWED_AVATAR_EXTENSIONS
|
||||
or not (file.mimetype or '').startswith('image/')
|
||||
):
|
||||
flash('Avatar must be a JPG, PNG, or WebP image.', 'error')
|
||||
return redirect(url_for('players.detail', steam_id=steam_id))
|
||||
|
||||
filename = f"{steam_id}{ext}"
|
||||
upload_folder = os.path.join(current_app.root_path, 'static', 'avatars')
|
||||
@@ -177,33 +183,9 @@ def detail(steam_id):
|
||||
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)
|
||||
map_stats_list = PlayerProfileService.get_map_stats(steam_id)
|
||||
period_stats = PlayerProfileService.get_period_stats(steam_id)
|
||||
records = PlayerProfileService.get_records(steam_id)
|
||||
|
||||
return render_template('players/profile.html',
|
||||
player=player,
|
||||
@@ -213,6 +195,8 @@ def detail(steam_id):
|
||||
history=history,
|
||||
distribution=distribution,
|
||||
map_stats=map_stats_list,
|
||||
period_stats=period_stats,
|
||||
records=records,
|
||||
l2_stats=l2_stats,
|
||||
side_stats=side_stats)
|
||||
|
||||
@@ -223,9 +207,13 @@ def like_comment(comment_id):
|
||||
|
||||
@bp.route('/<steam_id>/charts_data')
|
||||
def charts_data(steam_id):
|
||||
# ... (existing code) ...
|
||||
# Trend Data
|
||||
trends = StatsService.get_player_trend(steam_id, limit=1000)
|
||||
period_key = request.args.get('period', 'last_20')
|
||||
if period_key not in PlayerProfileService.PERIOD_KEYS:
|
||||
period_key = 'last_20'
|
||||
trends = PlayerProfileService.get_period_history(steam_id, period_key)
|
||||
if not trends:
|
||||
trends = StatsService.get_player_trend(steam_id, limit=20)
|
||||
period_summary = PlayerProfileService.get_period(steam_id, period_key)
|
||||
|
||||
# Radar Data (Construct from features)
|
||||
features = FeatureService.get_player_features(steam_id)
|
||||
@@ -234,17 +222,11 @@ def charts_data(steam_id):
|
||||
|
||||
# Task 1: Strict Team Average Calculation
|
||||
team_avg_radar = None
|
||||
lineups = WebService.get_lineups()
|
||||
if lineups:
|
||||
target_lineup = None
|
||||
try:
|
||||
p_ids = [str(i) for i in json.loads(lineups[0].get("player_ids_json") or "[]")]
|
||||
if str(steam_id) in p_ids:
|
||||
target_lineup = p_ids
|
||||
except:
|
||||
target_lineup = None
|
||||
|
||||
if target_lineup:
|
||||
from web.services.team_context_service import TeamContextService
|
||||
|
||||
active_roster_ids = TeamContextService.get_active_roster_ids()
|
||||
target_lineup = active_roster_ids if str(steam_id) in active_roster_ids else None
|
||||
if target_lineup:
|
||||
# Calculate strict average for this lineup
|
||||
team_sums = {
|
||||
'score_aim': 0.0, 'score_defense': 0.0, 'score_utility': 0.0,
|
||||
@@ -303,9 +285,19 @@ def charts_data(steam_id):
|
||||
'trend': {'labels': trend_labels, 'values': trend_values},
|
||||
'radar': radar_data,
|
||||
'radar_dist': radar_dist,
|
||||
'team_avg_radar': team_avg_radar
|
||||
'team_avg_radar': team_avg_radar,
|
||||
'period': period_summary,
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/<steam_id>/period_stats')
|
||||
def period_stats(steam_id):
|
||||
period_key = request.args.get('period', 'last_20')
|
||||
period = PlayerProfileService.get_period(steam_id, period_key)
|
||||
if not period:
|
||||
return jsonify({'error': 'Unknown period or player'}), 404
|
||||
return jsonify(period)
|
||||
|
||||
# --- API for Comparison ---
|
||||
@bp.route('/api/search')
|
||||
def api_search():
|
||||
|
||||
+5
-7
@@ -67,14 +67,12 @@ def api_search():
|
||||
|
||||
@bp.route('/api/roster', methods=['GET', 'POST'])
|
||||
def api_roster():
|
||||
# Assume single team mode, always operating on ID=1 or the first lineup
|
||||
lineups = WebService.get_lineups()
|
||||
if not lineups:
|
||||
# Auto-create default team if none exists
|
||||
target_team = WebService.get_active_lineup()
|
||||
if not target_team:
|
||||
WebService.save_lineup("My Team", "Default Roster", [])
|
||||
lineups = WebService.get_lineups()
|
||||
|
||||
target_team = dict(lineups[0]) # Get the latest one
|
||||
target_team = WebService.get_active_lineup()
|
||||
|
||||
target_team = dict(target_team)
|
||||
|
||||
if request.method == 'POST':
|
||||
# Admin Check
|
||||
|
||||
Reference in New Issue
Block a user