2.0.0-rc2: Achievements Refactored & Admin improved.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from database.job_store import JobStore
|
||||
from database.maintenance import (
|
||||
backup_storage_status,
|
||||
check_managed_databases,
|
||||
)
|
||||
from database.paths import BACKUP_ROOT
|
||||
from web.config import Config
|
||||
from web.database import query_db
|
||||
from web.services.integrity_service import IntegrityService
|
||||
from web.services.roster_version_service import RosterVersionService
|
||||
from web.services.team_context_service import TeamContextService
|
||||
|
||||
|
||||
class AdminService:
|
||||
DATABASE_LABELS = {
|
||||
'l2': 'L2 Facts',
|
||||
'l3': 'L3 Marts',
|
||||
'web': 'Web App',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_bytes(value):
|
||||
value = float(value or 0)
|
||||
for unit in ('B', 'KB', 'MB', 'GB'):
|
||||
if value < 1024 or unit == 'GB':
|
||||
return f'{value:.1f} {unit}'
|
||||
value /= 1024
|
||||
|
||||
@staticmethod
|
||||
def get_overview():
|
||||
integrity = IntegrityService.build_report()
|
||||
store = JobStore(Config.DB_WEB_PATH)
|
||||
latest_report = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT match_id, match_date, map_name, is_win,
|
||||
team_avg_rating, summary_text
|
||||
FROM dm_match_reports
|
||||
ORDER BY match_date DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
one=True,
|
||||
)
|
||||
latest_match = dict(latest_report) if latest_report else None
|
||||
if latest_match:
|
||||
latest_match['date_text'] = datetime.fromtimestamp(
|
||||
latest_match['match_date']
|
||||
).strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
counts = integrity['counts']
|
||||
return {
|
||||
'integrity': integrity,
|
||||
'jobs': store.list_jobs(8),
|
||||
'job_summary': store.get_summary(),
|
||||
'latest_match': latest_match,
|
||||
'roster_size': len(TeamContextService.get_active_roster_ids()),
|
||||
'roster_versions': len(RosterVersionService.list_versions()),
|
||||
'metrics': {
|
||||
'matches': counts.get('matches', 0),
|
||||
'roster_players': counts.get('active_roster', 0),
|
||||
'reports': counts.get('l3_match_reports', 0),
|
||||
'awards': counts.get('l3_awards', 0),
|
||||
'backup_size': AdminService._format_bytes(
|
||||
counts.get('backup_bytes', 0)
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_jobs(status=None, job_type=None, limit=50):
|
||||
store = JobStore(Config.DB_WEB_PATH)
|
||||
return {
|
||||
'jobs': store.list_jobs(
|
||||
limit=limit,
|
||||
status=status,
|
||||
job_type=job_type,
|
||||
),
|
||||
'summary': store.get_summary(),
|
||||
'status': status,
|
||||
'job_type': job_type,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_database_catalog(db_name):
|
||||
if db_name not in {'l2', 'l3', 'web'}:
|
||||
raise ValueError('Unknown database')
|
||||
rows = query_db(
|
||||
db_name,
|
||||
"""
|
||||
SELECT name, type
|
||||
FROM sqlite_master
|
||||
WHERE type IN ('table', 'view')
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY type, name
|
||||
""",
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_system_status():
|
||||
managed = check_managed_databases()
|
||||
web_status = {
|
||||
'path': Config.DB_WEB_PATH,
|
||||
'exists': Path(Config.DB_WEB_PATH).exists(),
|
||||
'size_bytes': (
|
||||
Path(Config.DB_WEB_PATH).stat().st_size
|
||||
if Path(Config.DB_WEB_PATH).exists() else 0
|
||||
),
|
||||
}
|
||||
databases = []
|
||||
for name, item in managed.items():
|
||||
entry = dict(item)
|
||||
entry['name'] = name.upper()
|
||||
entry['size_text'] = AdminService._format_bytes(
|
||||
entry['size_bytes']
|
||||
)
|
||||
databases.append(entry)
|
||||
web_status.update({
|
||||
'name': 'WEB',
|
||||
'quick_check': 'managed by app',
|
||||
'size_text': AdminService._format_bytes(web_status['size_bytes']),
|
||||
})
|
||||
databases.append(web_status)
|
||||
|
||||
backup_dirs = sorted(
|
||||
[path for path in BACKUP_ROOT.glob('*') if path.is_dir()],
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
backups = []
|
||||
for directory in backup_dirs:
|
||||
manifest_path = directory / 'manifest.json'
|
||||
manifest = {}
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
manifest = json.loads(
|
||||
manifest_path.read_text(encoding='utf-8')
|
||||
)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
manifest = {}
|
||||
size = sum(
|
||||
file.stat().st_size
|
||||
for file in directory.rglob('*')
|
||||
if file.is_file()
|
||||
)
|
||||
backups.append({
|
||||
'name': directory.name,
|
||||
'created_at': manifest.get('created_at'),
|
||||
'size_text': AdminService._format_bytes(size),
|
||||
'databases': manifest.get('databases', {}),
|
||||
})
|
||||
|
||||
return {
|
||||
'databases': databases,
|
||||
'backups': backups,
|
||||
'backup_summary': backup_storage_status(),
|
||||
'config': {
|
||||
'web_schema_version': Config.WEB_SCHEMA_VERSION,
|
||||
'sqlite_timeout': Config.SQLITE_TIMEOUT_SECONDS,
|
||||
'slow_query_threshold': Config.SLOW_QUERY_THRESHOLD_SECONDS,
|
||||
'max_upload_mb': Config.MAX_CONTENT_LENGTH / 1024 / 1024,
|
||||
'secret_key_configured': (
|
||||
Config.SECRET_KEY != 'yrtv-dev-only-change-me'
|
||||
),
|
||||
'admin_token_configured': (
|
||||
Config.ADMIN_TOKEN != 'yrtv-admin-dev'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
|
||||
from web.database import query_db
|
||||
|
||||
|
||||
class DiscoveryService:
|
||||
TONE_LABELS = {
|
||||
'positive': '高光',
|
||||
'negative': '低谷',
|
||||
'fun': '趣味',
|
||||
}
|
||||
MEDAL_LABELS = {
|
||||
'gold': '金牌',
|
||||
'silver': '银牌',
|
||||
'bronze': '铜牌',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _identity_map(steam_ids):
|
||||
steam_ids = sorted({str(value) for value in steam_ids if value})
|
||||
if not steam_ids:
|
||||
return {}
|
||||
placeholders = ','.join('?' for _ in steam_ids)
|
||||
rows = query_db(
|
||||
'l2',
|
||||
f"""
|
||||
SELECT steam_id_64, username, avatar_url
|
||||
FROM dim_players
|
||||
WHERE steam_id_64 IN ({placeholders})
|
||||
""",
|
||||
steam_ids,
|
||||
)
|
||||
return {str(row['steam_id_64']): dict(row) for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def get_insights(tone=None):
|
||||
args = []
|
||||
where = ''
|
||||
if tone in DiscoveryService.TONE_LABELS:
|
||||
where = 'WHERE tone = ?'
|
||||
args.append(tone)
|
||||
rows = query_db(
|
||||
'l3',
|
||||
f"""
|
||||
SELECT *
|
||||
FROM dm_discovery_insights
|
||||
{where}
|
||||
ORDER BY display_order, insight_key
|
||||
""",
|
||||
args,
|
||||
)
|
||||
identities = DiscoveryService._identity_map(
|
||||
[row['steam_id_64'] for row in rows]
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['identity'] = identities.get(str(item['steam_id_64']), {})
|
||||
item['tone_label'] = DiscoveryService.TONE_LABELS.get(
|
||||
item['tone'],
|
||||
item['tone'],
|
||||
)
|
||||
try:
|
||||
item['evidence'] = json.loads(item['evidence_json'] or '{}')
|
||||
except json.JSONDecodeError:
|
||||
item['evidence'] = {}
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_medals(dimension_type=None):
|
||||
args = []
|
||||
where = ''
|
||||
if dimension_type in {'map', 'elo'}:
|
||||
where = 'WHERE dimension_type = ?'
|
||||
args.append(dimension_type)
|
||||
rows = query_db(
|
||||
'l3',
|
||||
f"""
|
||||
SELECT *
|
||||
FROM dm_performance_medals
|
||||
{where}
|
||||
ORDER BY
|
||||
CASE dimension_type WHEN 'map' THEN 1 ELSE 2 END,
|
||||
dimension_key,
|
||||
medal_rank
|
||||
""",
|
||||
args,
|
||||
)
|
||||
identities = DiscoveryService._identity_map(
|
||||
[row['steam_id_64'] for row in rows]
|
||||
)
|
||||
grouped = []
|
||||
current_key = None
|
||||
current = None
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['identity'] = identities.get(str(item['steam_id_64']), {})
|
||||
item['medal_label'] = DiscoveryService.MEDAL_LABELS.get(
|
||||
item['medal_tier'],
|
||||
item['medal_tier'],
|
||||
)
|
||||
key = (item['dimension_type'], item['dimension_key'])
|
||||
if key != current_key:
|
||||
current = {
|
||||
'dimension_type': item['dimension_type'],
|
||||
'dimension_key': item['dimension_key'],
|
||||
'dimension_label': item['dimension_label'],
|
||||
'medals': [],
|
||||
}
|
||||
grouped.append(current)
|
||||
current_key = key
|
||||
current['medals'].append(item)
|
||||
return grouped
|
||||
|
||||
@staticmethod
|
||||
def get_medal_leaderboard():
|
||||
rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT
|
||||
steam_id_64,
|
||||
COUNT(*) AS medals,
|
||||
SUM(CASE WHEN medal_tier = 'gold' THEN 1 ELSE 0 END) AS gold,
|
||||
SUM(CASE WHEN medal_tier = 'silver' THEN 1 ELSE 0 END) AS silver,
|
||||
SUM(CASE WHEN medal_tier = 'bronze' THEN 1 ELSE 0 END) AS bronze
|
||||
FROM dm_performance_medals
|
||||
GROUP BY steam_id_64
|
||||
ORDER BY gold DESC, silver DESC, bronze DESC
|
||||
""",
|
||||
)
|
||||
identities = DiscoveryService._identity_map(
|
||||
[row['steam_id_64'] for row in rows]
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['identity'] = identities.get(str(item['steam_id_64']), {})
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_player_medals(steam_id):
|
||||
rows = query_db(
|
||||
'l3',
|
||||
"""
|
||||
SELECT *
|
||||
FROM dm_performance_medals
|
||||
WHERE steam_id_64 = ?
|
||||
ORDER BY medal_rank, dimension_type, dimension_key
|
||||
""",
|
||||
[steam_id],
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item['medal_label'] = DiscoveryService.MEDAL_LABELS.get(
|
||||
item['medal_tier'],
|
||||
item['medal_tier'],
|
||||
)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@@ -246,6 +246,7 @@ class IntegrityService:
|
||||
@staticmethod
|
||||
def _check_l3(db, roster_ids, checks, counts):
|
||||
required_tables = {
|
||||
'dm_discovery_insights',
|
||||
'dm_duo_stats',
|
||||
'dm_match_player_reports',
|
||||
'dm_match_reports',
|
||||
@@ -257,6 +258,7 @@ class IntegrityService:
|
||||
'dm_player_record_events',
|
||||
'dm_player_records',
|
||||
'dm_player_weapon_stats',
|
||||
'dm_performance_medals',
|
||||
'dm_lineup_stats',
|
||||
'dm_team_season_stats',
|
||||
}
|
||||
@@ -309,6 +311,12 @@ class IntegrityService:
|
||||
counts['l3_awards'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_player_awards'
|
||||
).fetchone()[0]
|
||||
counts['l3_discoveries'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_discovery_insights'
|
||||
).fetchone()[0]
|
||||
counts['l3_medals'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_performance_medals'
|
||||
).fetchone()[0]
|
||||
expected_matches = counts.get('matches', 0)
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
@@ -443,6 +451,8 @@ class IntegrityService:
|
||||
('l3_record_events', 'Record event mart'),
|
||||
('l3_seasons', 'Team season mart'),
|
||||
('l3_awards', 'Player award mart'),
|
||||
('l3_discoveries', 'Discovery insight mart'),
|
||||
('l3_medals', 'Map and ELO medal mart'),
|
||||
):
|
||||
value = counts[key]
|
||||
IntegrityService._check(
|
||||
@@ -453,6 +463,53 @@ class IntegrityService:
|
||||
value,
|
||||
)
|
||||
|
||||
discovery_tones = {
|
||||
row[0] for row in db.execute(
|
||||
'SELECT DISTINCT tone FROM dm_discovery_insights'
|
||||
)
|
||||
}
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Discovery tone coverage',
|
||||
(
|
||||
'pass'
|
||||
if discovery_tones == {'positive', 'negative', 'fun'}
|
||||
else 'fail'
|
||||
),
|
||||
f"tones: {', '.join(sorted(discovery_tones))}",
|
||||
len(discovery_tones),
|
||||
)
|
||||
|
||||
invalid_medals = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM dm_performance_medals
|
||||
WHERE matches < 5
|
||||
OR medal_rank NOT BETWEEN 1 AND 3
|
||||
"""
|
||||
).fetchone()[0]
|
||||
duplicate_medals = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM (
|
||||
SELECT dimension_type, dimension_key, medal_rank, COUNT(*) AS n
|
||||
FROM dm_performance_medals
|
||||
GROUP BY dimension_type, dimension_key, medal_rank
|
||||
HAVING n > 1
|
||||
)
|
||||
"""
|
||||
).fetchone()[0]
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Performance medal validity',
|
||||
'pass' if invalid_medals == 0 and duplicate_medals == 0 else 'fail',
|
||||
(
|
||||
f'{invalid_medals} invalid samples, '
|
||||
f'{duplicate_medals} duplicate ranks'
|
||||
),
|
||||
invalid_medals + duplicate_medals,
|
||||
)
|
||||
|
||||
if roster_ids:
|
||||
placeholders = ','.join('?' for _ in roster_ids)
|
||||
scope_sql = f"AND steam_id_64 IN ({placeholders})"
|
||||
|
||||
Reference in New Issue
Block a user