Compare commits

..

29 Commits

Author SHA1 Message Date
Daniel Nagel
8788839589 Fork Prepare 2026-07-22 13:12:05 +00:00
7ef6de6aea sdfgdrf 2026-07-12 22:20:08 +02:00
8d8b0decd1 Player Match history Seite 2026-07-12 22:20:06 +02:00
8cbe229c65 Merge branch 'dev_gui' of https://code.danielnagel.at/WestsideDiceghost/Liga-System into dev_gui 2026-07-06 22:15:16 +02:00
f278591268 Kommentare umbau auf Enlgish 2026-07-06 22:12:12 +02:00
66a5e61129 docs: Übersetze Kommentare im Script auf Englisch
Co-authored-by: aider (ollama_chat/qwen2.5-coder:14b) <aider@aider.chat>
2026-07-06 22:07:47 +02:00
e43cc64cff fix: Typenkonflikt in data/database.py behoben
Co-authored-by: aider (ollama_chat/qwen2.5-coder:14b) <aider@aider.chat>
2026-07-06 22:02:46 +02:00
f0f6de827d docs: Translate comments in main.py to English
Co-authored-by: aider (ollama_chat/qwen2.5-coder:14b) <aider@aider.chat>
2026-07-06 21:56:14 +02:00
ac90850a94 fix: Korrigiere Einrückungen und Kommentare in main.py 2026-07-06 21:56:06 +02:00
Daniel Nagel
86ab395fa8 strh 2026-07-03 09:08:02 +00:00
Daniel Nagel
0a45cb426f asdf 2026-07-01 12:15:21 +00:00
Daniel Nagel
c29c1f31d7 Stats werden mit Season Selector angezeigt. 2026-06-25 13:26:19 +00:00
Daniel Nagel
5b320bac56 System Season Stats angefangen. 2026-06-23 14:14:21 +00:00
Daniel Nagel
d2000dfe08 unconfirmed Games für Desktop und Mobile funktionieren. MatchForm für Mobile erstellt.
paar Bugs entfernt.
2026-06-23 07:26:52 +00:00
a5c327213e Confirm and delete Matches. 2026-06-22 22:24:53 +02:00
Daniel Nagel
fe518303c4 ... 2026-06-22 13:47:34 +00:00
Daniel Nagel
1cf43cd670 value Fehler in der MatchForm. fixxed. 2026-06-22 11:29:23 +00:00
3d735b928e Leichte Anpassungen. 2026-06-21 16:29:32 +02:00
b2323aabf5 kleine Änderungen 2026-06-20 22:01:56 +02:00
Daniel Nagel
ac8f3e4289 MatchForm Desktop fertig. Mobile fehtl noch. Login Prozess verfeinert. 2026-06-16 11:48:11 +00:00
Daniel Nagel
13ae6a24e8 Leaderboard und Player Card Desktop/Mobile verfeinert. 2026-06-12 09:27:17 +00:00
Daniel Nagel
356c2e944a Mobile/Desktop leaderboard und playercard. Reiter in der Stat seite sind mega geil! 2026-06-12 08:18:14 +00:00
Daniel Nagel
e4ad5c5063 Langsam wirds. Dynamische Reiter im Header zum Umschalten. Header angepasst. Zurück Button funktioniert. Buttons in der Mobile Version schalten automatisch weg wenn sie nicht gebraucht werden. 2026-06-11 14:11:16 +00:00
Daniel Nagel
2997a77d0e ufff ....
Templates modularisiert. ein paar __init__ files erstellt. Login Methodic neu gemacht für Zukünftige Auths. Desktop und Mobile unterscheidet sich. etc.

WIP!
2026-06-10 10:11:50 +00:00
c04a681bec Merge pull request 'dev_league_seasons' (#49) from dev_league_seasons into dev_gui
Reviewed-on: #49
2026-06-09 15:25:31 +02:00
Daniel Nagel
1b1031a5e7 data api function angepoasst. Besserer Namen.
Backend Seasons System funktioniert.
2026-06-09 13:23:00 +00:00
Daniel Nagel
e281160ec7 some print() lines deleted. 2026-06-09 07:41:03 +00:00
85e0b57833 Merge pull request 'master' (#48) from master into dev_league_seasons
Reviewed-on: #48
2026-05-31 12:03:25 +02:00
2d2f1ac9ab Merge pull request 'master' (#40) from master into dev_gui
Reviewed-on: #40
2026-05-27 15:40:14 +02:00
42 changed files with 1834 additions and 934 deletions

View File

@ -0,0 +1,4 @@
---
apply: always
---

3
.gitignore vendored
View File

@ -19,4 +19,5 @@ env/
.vscode/
.idea/
data_trans.py
data_trans.py
.aider*

View File

@ -1,3 +0,0 @@
Projekt und Repo werden nicht mehr geupdatet.
Werde das in ein Liga System für mehrere Clubs umwandeln. Das aktuelle System ist nur auf einen Club ausgelegt. Wer es als Grundlage verwenden will für was eigenes darf sich gerne bedienen.

0
auth/__init__.py Normal file
View File

View File

@ -1,3 +1,6 @@
import os
import requests
from data import data_api
import os
import urllib.parse
@ -5,16 +8,57 @@ import requests
from nicegui import ui, app
from data import data_api
def process_discord_login(code: str):
client_id = os.getenv("DISCORD_CLIENT_ID")
client_secret = os.getenv("DISCORD_CLIENT_SECRET")
app_url = os.getenv("APP_URL")
# Token austauschen
token_response = requests.post('https://discord.com/api/oauth2/token', data={
'client_id': client_id,
'client_secret': client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': f"{app_url}/login/discord"
})
if token_response.status_code != 200:
return None, "Fehler beim Token-Austausch."
access_token = token_response.json().get('access_token')
headers = {'Authorization': f"Bearer {access_token}"}
# Rollen/Server prüfen
guild_id = os.getenv("DISCORD_SERVER_ID")
member_response = requests.get(f'https://discord.com/api/users/@me/guilds/{guild_id}/member', headers=headers)
if member_response.status_code != 200:
return None, "Du bist nicht auf dem Diceghosts Server."
roles = member_response.json().get('roles', [])
if os.getenv("DISCORD_SERVER_DICEGHOST_ID") not in roles and os.getenv("DISCORD_SERVER_FRIEND_ID") not in roles:
return None, "Du hast nicht die benötigte Rolle."
# User Daten holen
user_json = requests.get('https://discord.com/api/users/@me', headers=headers).json()
discord_id = user_json['id']
avatar = f"https://cdn.discordapp.com/avatars/{discord_id}/{user_json['avatar']}.png" if user_json.get('avatar') else None
player = data_api.get_or_create_player(discord_id, user_json['username'], avatar)
return (player, discord_id, user_json['username'], avatar), None
def get_auth_url():
client_id = os.getenv("DISCORD_CLIENT_ID")
app_url = os.getenv("APP_URL")
redirect_uri = f"{app_url}/login/discord"
encoded_redirect_uri = urllib.parse.quote(redirect_uri, safe="")
# NEU: guilds.members.read erlaubt uns, die Rollen des Users in einem bestimmten Server abzufragen
# guilds.members.read erlaubt uns, die Rollen des Users in einem bestimmten Server abzufragen
return f"https://discord.com/api/oauth2/authorize?client_id={client_id}&redirect_uri={encoded_redirect_uri}&response_type=code&scope=identify%20guilds.members.read"
"""
def setup_login_routes():
client_id = os.getenv("DISCORD_CLIENT_ID")
client_secret = os.getenv("DISCORD_CLIENT_SECRET")
@ -103,3 +147,5 @@ def setup_login_routes():
ui.navigate.to('/')
else:
ui.label('Fehler beim Login!').classes('text-red-500')
"""

39
auth/providers/base.py Normal file
View File

@ -0,0 +1,39 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class UserInfo:
"""Genormte Nutzerdaten — egal ob von Discord, Google oder sonstwem.
Jeder Provider liefert dieses Format, damit der Rest der App
nicht wissen muss, woher die Daten kommen."""
provider: str # 'discord', 'google', ...
provider_user_id: str # eindeutige ID beim Provider
username: str # z. B. Discord-Username
display_name: str # Anzeigename in deiner App
avatar_url: Optional[str] = None
email: Optional[str] = None
raw: dict = field(default_factory=dict) # Original-Payload des Providers
class AuthProvider(ABC):
"""Abstrakte Basisklasse für einen OAuth-Provider (Strategy Pattern).
Das ist der 'Steckplatz'. Jeder konkrete Provider muss diese
drei Dinge liefern. Wie er das intern macht, ist ihm überlassen.
"""
@property
@abstractmethod
def name(self) -> str:
"""Kurzname des Providers, z. B. 'discord'."""
@abstractmethod
def get_auth_url(self) -> str:
"""Baut die URL, zu der der User weitergeleitet wird, um zu autorisieren."""
@abstractmethod
def process_callback(self, code: str) -> UserInfo:
"""Tauscht den Auth-Code gegen Tokens und holt die Nutzerdaten.
Gibt ein genormtes UserInfo zurück oder wirft einen Fehler."""

View File

@ -1,6 +1,6 @@
import random
from wood import logger
from pymysql.cursors import DictCursor
from data.database import db_connection
@ -9,7 +9,7 @@ from data.database import db_connection
# -----------------------------------------------------
def validate_user_session(db_id, discord_id):
"""Prüft, ob das Cookie noch zur aktuellen Datenbank passt."""
"""Checks if the cookie still matches the current database."""
connection = db_connection()
cursor = connection.cursor()
@ -23,7 +23,7 @@ def validate_user_session(db_id, discord_id):
return False
if str(result[0]) != str(discord_id):
logger.log(f"Player with false coockies logged in! {discord_id} doesnt belong to {db_id}")
logger.log(f"Player with false cookies logged in! {discord_id} does not belong to {db_id}")
return False
return True
@ -41,16 +41,16 @@ def change_display_name(player_id, new_name):
def generate_silly_name():
adjectives = ["Verwirrter", "Blinder", "Heulender", "Zorniger", "Chaos",
"Verzweifelter", "Schreiender", "Stolpernder", "Schwitzender"]
nouns = ["Grot", "Kultist", "Servitor", "Snotling", "Guardmen",
"Würfellecker", "Regelvergesser", "Meta-Chaser", "Klebschnüffler"]
adjectives = ["Confused", "Blind", "Howling", "Angry", "Chaos",
"Desperate", "Shouting", "Stumbling", "Sweating"]
nouns = ["Grot", "Cultist", "Servitor", "Snotling", "Guardmen",
"DiceLover", "RuleBreaker", "MetaChaser", "SlimeSniffer"]
return f"{random.choice(adjectives)} {random.choice(nouns)}"
def get_or_create_player(discord_id, discord_name, avatar_url):
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
cursor.execute(
"SELECT id, discord_name, display_name, discord_avatar_url FROM players WHERE discord_id = %s",
@ -92,13 +92,13 @@ def get_or_create_player(discord_id, discord_name, avatar_url):
# -----------------------------------------------------
# Player-Listen
# Player Lists
# -----------------------------------------------------
def get_all_players():
"""Alle Spieler Basisdaten."""
"""All players basic data."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
cursor.execute(
"SELECT id, display_name, discord_name, discord_avatar_url FROM players "
@ -113,7 +113,7 @@ def get_all_players():
def get_all_players_from_system(system_name):
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
query = """
SELECT DISTINCT
@ -153,7 +153,7 @@ def get_gamesystem_id_by_name(system_name):
def get_player_statistics(player_id):
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
query = """
SELECT
@ -182,71 +182,121 @@ def get_player_statistics(player_id):
return rows
def get_player_statistic(player_id, system_id):
connection = db_connection()
cursor = connection.cursor(dictionary=True)
query = """
SELECT
sys.id AS gamesystem_id,
sys.name AS gamesystem_name,
sys.description,
stat.mmr,
stat.games_in_system,
stat.points,
stat.avv_points,
stat.last_played,
stat.win_rate,
stat.trend,
stat.tyrann_id,
stat.pushover_id,
stat.nemesis_id
FROM gamesystems sys
LEFT JOIN player_game_statistic stat
ON sys.id = stat.gamesystem_id AND stat.player_id = %s
WHERE sys.id = %s
def get_player_statistic(player_id, system_id, season_id=None):
"""
- If season_id is provided: Data for this season.
- If season_id is None: Aggregated total statistics.
"""
connection = db_connection()
cursor = connection.cursor(cursor=DictCursor)
if season_id:
query = """
SELECT
sys.name AS gamesystem_name,
stat.mmr, stat.games_in_system, stat.points,
stat.avv_points, stat.last_played, stat.win_rate,
stat.trend, stat.tyrann_id, stat.pushover_id, stat.nemesis_id,
(
SELECT COUNT(*) + 1
FROM player_game_statistic rank_stat
WHERE rank_stat.gamesystem_id = %s
AND rank_stat.season_id = %s
AND rank_stat.games_in_system > 0
AND rank_stat.mmr > COALESCE(stat.mmr, 0)
) AS rank
FROM gamesystems sys
LEFT JOIN player_game_statistic stat
ON sys.id = stat.gamesystem_id
AND stat.player_id = %s
AND stat.season_id = %s
WHERE sys.id = %s
"""
cursor.execute(query, (system_id, season_id, player_id, season_id, system_id))
else:
query = """
SELECT
sys.name AS gamesystem_name,
SUM(stat.mmr) / COUNT(stat.season_id) AS mmr, -- Average MMR over seasons
SUM(stat.games_in_system) AS games_in_system,
SUM(stat.points) AS points,
SUM(stat.points) / SUM(stat.games_in_system) AS avv_points,
MAX(stat.last_played) AS last_played,
NULL AS rank
FROM gamesystems sys
LEFT JOIN player_game_statistic stat
ON sys.id = stat.gamesystem_id
AND stat.player_id = %s
WHERE sys.id = %s
GROUP BY sys.id
"""
cursor.execute(query, (player_id, system_id))
cursor.execute(query, (player_id, system_id))
row = cursor.fetchone()
cursor.close()
connection.close()
# Sicherstellen dass rank nie None ist wenn season_id gesetzt
if row and season_id and row.get('rank') is None:
row['rank'] = 1
return row
def get_player_rank(player_id, system_id):
"""Holt den Rang eines Spielers in einem System basierend auf MMR (absteigend)."""
def get_seasons_by_system(system_id):
"""Retrieves all seasons of a game system, sorted by number descending."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
query = """
SELECT id, name, season_number
FROM seasons
WHERE gamesystem_id = %s
ORDER BY season_number DESC
"""
cursor.execute(query, (system_id,))
seasons = cursor.fetchall()
cursor.close()
connection.close()
return seasons
def get_player_rank(player_id, system_id, season_id):
"""Retrieves the rank of a player in a system for a specific season."""
connection = db_connection()
cursor = connection.cursor(cursor=DictCursor)
query = """
SELECT COUNT(*) + 1 AS rank
FROM player_game_statistic stat
WHERE stat.gamesystem_id = %s
AND stat.season_id = %s
AND stat.games_in_system > 0
AND stat.mmr > (
SELECT stat2.mmr
FROM player_game_statistic stat2
WHERE stat2.player_id = %s
AND stat2.gamesystem_id = %s
AND stat2.season_id = %s
)
"""
cursor.execute(query, (system_id, player_id, system_id))
# Parameters: gamesystem, season, player, gamesystem, season
cursor.execute(query, (system_id, season_id, player_id, system_id, season_id))
row = cursor.fetchone()
cursor.close()
connection.close()
if row and row['rank'] is not None:
return row['rank']
return None
return row['rank'] if row and row['rank'] is not None else 1
def join_league(player_id, gamesystem_id):
try:
# 1. Die richtige Season für dieses System holen
# 1. Get the correct season for this system
season_id = get_current_season_id(gamesystem_id)
connection = db_connection()
@ -260,22 +310,22 @@ def join_league(player_id, gamesystem_id):
cursor.execute(query, (player_id, gamesystem_id, season_id))
connection.commit()
logger.log(f"Erfolg: Spieler {player_id} joined System {gamesystem_id} in Season {season_id}")
logger.log(f"Success: Player {player_id} joined System {gamesystem_id} in Season {season_id}")
except Exception as e:
logger.log(f"FEHLER beim Beitreten: {e}")
logger.log(f"ERROR joining league: {e}")
raise
finally:
# Hier ist ein kleiner Check, ob die connection überhaupt existiert
# Here is a small check if the connection actually exists
if 'connection' in locals() and connection:
cursor.close()
connection.close()
def get_current_season_id(gamesystem_id):
def get_current_season_id(system_id):
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
query = """
SELECT id FROM seasons
@ -283,7 +333,7 @@ def get_current_season_id(gamesystem_id):
ORDER BY start_date DESC
LIMIT 1
"""
cursor.execute(query, (gamesystem_id,))
cursor.execute(query, (system_id,))
result = cursor.fetchone()
cursor.close()
@ -292,18 +342,17 @@ def get_current_season_id(gamesystem_id):
if result:
return result['id']
else:
# Hier wissen wir jetzt genau, welches Spielsystem keine Saison hat
raise RuntimeError(f"Keine Saison für {gamesystem_id} gefunden!")
# Here we know exactly which game system has no season
raise RuntimeError(f"No season found for {gamesystem_id}!")
# -----------------------------------------------------
# Matches: Lesen
# Matches: Reading
# -----------------------------------------------------
def get_recent_matches_for_player(player_id):
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
query = """
SELECT
@ -335,36 +384,44 @@ def get_recent_matches_for_player(player_id):
def add_new_match(system_name, player1_id, player2_id, score_p1, score_p2):
connection = db_connection()
cursor = connection.cursor()
connection = None
try:
connection = db_connection()
cursor = connection.cursor()
cursor.execute("SELECT id FROM gamesystems WHERE name = %s", (system_name,))
sys_row = cursor.fetchone()
cursor.execute("SELECT id FROM gamesystems WHERE name = %s", (system_name,))
sys_row = cursor.fetchone()
if not sys_row:
cursor.close()
connection.close()
return None
if not sys_row:
raise ValueError(f"Game system '{system_name}' not found.")
sys_id = sys_row[0]
sys_id = sys_row[0]
query = """
INSERT INTO matches (gamesystem_id, player1_id, player2_id, score_player1, score_player2)
VALUES (%s, %s, %s, %s, %s)
"""
cursor.execute(query, (sys_id, player1_id, player2_id, score_p1, score_p2))
new_match_id = cursor.lastrowid
query = """
INSERT INTO matches (gamesystem_id, player1_id, player2_id, score_player1, score_player2)
VALUES (%s, %s, %s, %s, %s)
"""
cursor.execute(query, (sys_id, player1_id, player2_id, score_p1, score_p2))
new_match_id = cursor.lastrowid
logger.log(
f"{get_player_name(player1_id)}:({score_p1}) posted Match. "
f"System: {system_name}, {get_player_name(player2_id)}:({score_p2})"
)
logger.log(
f"{get_player_name(player1_id)}:({score_p1}) posted Match. "
f"System: {system_name}, {get_player_name(player2_id)}:({score_p2})"
)
connection.commit()
cursor.close()
connection.close()
connection.commit()
return new_match_id
except Exception as e:
if connection:
connection.rollback()
raise # Propagate the exception
finally:
if connection:
cursor.close()
connection.close()
return new_match_id
def save_calculated_match(calc_results: dict):
@ -382,7 +439,7 @@ def save_calculated_match(calc_results: dict):
)
row = cursor.fetchone()
if not row:
raise ValueError(f"Match ID {match_id} nicht in der Datenbank gefunden.")
raise ValueError(f"Match ID {match_id} not found in the database.")
player1_id, player2_id, gamesystem_id = row
@ -438,7 +495,7 @@ def save_calculated_match(calc_results: dict):
except Exception as e:
connection.rollback()
logger.log(f"KRITISCHER FEHLER beim Speichern des Matches: {e}")
logger.log(f"CRITICAL ERROR saving the match: {e}")
return False
finally:
@ -447,12 +504,12 @@ def save_calculated_match(calc_results: dict):
# -----------------------------------------------------
# Get Data Funktionen
# Get Data Functions
# -----------------------------------------------------
def get_gamesystem_data(system_id):
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
cursor.execute("SELECT * FROM gamesystems WHERE id = %s", (system_id,))
row = cursor.fetchone()
@ -464,7 +521,7 @@ def get_gamesystem_data(system_id):
def get_gamesystems_data():
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
cursor.execute("SELECT * FROM gamesystems")
rows = cursor.fetchall()
@ -476,19 +533,22 @@ def get_gamesystems_data():
def get_leaderboard(system_name):
"""Holt alle Spieler eines Systems sortiert nach MMR für die Rangliste."""
"""Retrieves all players of a system sorted by MMR for the leaderboard (current season only)."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
query = query = """
query = """
SELECT p.id, p.display_name, p.discord_name, p.discord_id, p.discord_avatar_url,
stat.mmr, stat.games_in_system, stat.wins, stat.loss, stat.avv_points
FROM players p
JOIN player_game_statistic stat ON p.id = stat.player_id
JOIN gamesystems sys ON stat.gamesystem_id = sys.id
WHERE sys.name = %s AND stat.games_in_system > 0
JOIN seasons s ON stat.season_id = s.id
WHERE sys.name = %s
AND s.end_date IS NULL
AND stat.games_in_system > 0
ORDER BY stat.mmr DESC
"""
"""
cursor.execute(query, (system_name,))
rows = cursor.fetchall()
@ -498,9 +558,9 @@ def get_leaderboard(system_name):
def get_match_by_id(match_id: int) -> dict | None:
"""Gibt alle Match-Daten inkl. Gamesystem-Name als Dict zurück."""
"""Returns all match data including game system name as a dictionary."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
try:
cursor.execute("""
@ -521,7 +581,7 @@ def get_match_by_id(match_id: int) -> dict | None:
return cursor.fetchone()
except Exception as e:
logger.log(f"Fehler beim Laden des Matches: {e}")
logger.log(f"Error loading the match: {e}")
return None
finally:
@ -530,9 +590,9 @@ def get_match_by_id(match_id: int) -> dict | None:
def get_player_name(player_id):
"""Gibt den Namen eines Spielers im Format 'Anzeigename (Discordname)' zurück."""
"""Returns the name of a player in the format 'Display Name (Discord Name)'."""
if player_id is None:
return "Unbekannter Spieler"
return "Unknown Player"
connection = db_connection()
cursor = connection.cursor()
@ -545,12 +605,12 @@ def get_player_name(player_id):
if row:
return f"{row[0]} ({row[1]})"
return "Gelöschter Spieler"
return "Deleted Player"
def get_system_name(sys_id):
def get_system_name_by_id(sys_id):
if sys_id is None:
return "Unbekanntes System"
return "Unknown System"
connection = db_connection()
cursor = connection.cursor()
@ -561,15 +621,15 @@ def get_system_name(sys_id):
cursor.close()
connection.close()
return row[0] if row else "Gelöschtes System"
return row[0] if row else "Deleted System"
def get_days_since_last_system_game(player_id, gamesystem_id):
"""Gibt zurück, wie viele Tage das letzte Spiel in einem bestimmten System her ist."""
"""Returns how many days the last game in a specific system was."""
connection = db_connection()
cursor = connection.cursor()
# MariaDB: DATEDIFF statt julianday()
# MariaDB: DATEDIFF instead of julianday()
query = """
SELECT DATEDIFF(NOW(), last_played)
FROM player_game_statistic
@ -587,33 +647,51 @@ def get_days_since_last_system_game(player_id, gamesystem_id):
# -----------------------------------------------------
# Matches Bestätigen, Löschen, Berechnen, ...
# Matches Confirming, Deleting, Calculating, ...
# -----------------------------------------------------
def get_unconfirmed_matches(player_id):
"""Holt alle offenen Matches, die der Spieler noch bestätigen muss."""
def get_open_matches(player_id):
"""Retrieves all open matches (player2_check = 0)."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
query = """
SELECT m.id AS match_id, m.score_player1, m.score_player2, m.played_at,
SELECT m.id AS match_id,
m.score_player1, m.score_player2,
m.played_at,
sys.name AS system_name,
p1.display_name AS p1_name
p1.display_name AS player1_name,
p2.display_name AS player2_name,
CASE WHEN m.player1_id = %s THEN 1 ELSE 0 END AS is_submitter
FROM matches m
JOIN gamesystems sys ON m.gamesystem_id = sys.id
JOIN players p1 ON m.player1_id = p1.id
WHERE m.player2_id = %s AND m.player2_check = 0
LEFT JOIN gamesystems sys ON m.gamesystem_id = sys.id
LEFT JOIN players p1 ON m.player1_id = p1.id
LEFT JOIN players p2 ON m.player2_id = p2.id
WHERE m.player2_check = 0
AND (m.player1_id = %s OR m.player2_id = %s)
"""
cursor.execute(query, (player_id,))
cursor.execute(query, (player_id, player_id, player_id))
rows = cursor.fetchall()
cursor.close()
connection.close()
return rows
submitted = []
to_confirm = []
for row in rows:
if row.pop('is_submitter'):
submitted.append(row)
else:
to_confirm.append(row)
return {"submitted": submitted, "to_confirm": to_confirm}
def delete_match(match_id, player_id):
"""Löscht ein Match anhand seiner ID komplett aus der Datenbank."""
"""Deletes a match completely from the database by its ID."""
connection = db_connection()
cursor = connection.cursor()
@ -630,7 +708,7 @@ def confirm_match(match_id):
cursor = connection.cursor()
cursor.execute("UPDATE matches SET player2_check = 1 WHERE id = %s", (match_id,))
logger.log(f"Match mit ID{match_id} von Player2 bestätigt.")
logger.log(f"Match with ID{match_id} confirmed by Player2.")
connection.commit()
cursor.close()
@ -638,7 +716,7 @@ def confirm_match(match_id):
def set_match_counted(match_id):
"""Setzt den Haken (1), dass das Match erfolgreich in die MMR eingeflossen ist."""
"""Sets the flag (1) that the match was successfully incorporated into the MMR."""
connection = db_connection()
cursor = connection.cursor()
@ -649,32 +727,11 @@ def set_match_counted(match_id):
connection.close()
def get_submitted_matches(player_id):
"""Holt alle offenen Matches, die der Spieler selbst eingetragen hat."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
query = """
SELECT m.id AS match_id, m.score_player1, m.score_player2, m.played_at,
sys.name AS system_name,
p2.display_name AS p2_name
FROM matches m
JOIN gamesystems sys ON m.gamesystem_id = sys.id
JOIN players p2 ON m.player2_id = p2.id
WHERE m.player1_id = %s AND m.player2_check = 0
"""
cursor.execute(query, (player_id,))
rows = cursor.fetchall()
cursor.close()
connection.close()
return rows
def get_match_history_log(player_id):
"""Holt ALLE Matches eines Spielers inkl. MMR-Änderungen und Faktoren."""
"""Retrieves ALL matches of a player including MMR changes and factors."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
cursor = connection.cursor(cursor=DictCursor)
query = """
SELECT m.played_at, sys.name AS gamesystem_name,

View File

@ -1,4 +1,4 @@
import mariadb
import pymysql
import os
from dotenv import load_dotenv
@ -11,18 +11,16 @@ def db_connection():
# Prefix für die Umgebungsvariablen wählen
prefix = "DB_" if is_prod else "DB_TEST_"
try:
connection = mariadb.connect(
connection = pymysql.connect(
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", 3306)),
user=os.getenv(f"{prefix}USER"),
password=os.getenv("DB_PASSWORD"),
password=os.getenv(f"DB_PASSWORD"),
database=os.getenv(f"{prefix}NAME")
)
return connection
if is_prod:
print("Verbindung mit Test DB")
except mariadb.Error as e:
return connection
except pymysql.Error as e:
raise RuntimeError(f"Fehler beim Verbinden mit {'Prod' if is_prod else 'Test'}-DB: {e}")

18
gui/__init__.py Normal file
View File

@ -0,0 +1,18 @@
# gui/__init__.py
import importlib
import pkgutil
import gui
from gui import _templates
from wood.logger import log as Log
def setup_all_routes():
"""Scannt automatisch alle Dateien in 'gui' und führt setup_routes() aus."""
# Liste aller Dateien in diesem Ordner (außer __init__.py selbst)
for loader, module_name, is_pkg in pkgutil.iter_modules(gui.__path__):
# Wir laden das Modul dynamisch
module = importlib.import_module(f'gui.{module_name}')
# Prüfen, ob das Modul eine Funktion 'setup_routes' hat
if hasattr(module, 'setup_routes'):
module.setup_routes()

View File

@ -0,0 +1,10 @@
from .header import DesktopHeader, MobileHeader
from .leaderboard import DesktopLeaderboard, MobileLeaderboard
from .player_card import DesktopPlayerCard, MobilePlayerCard
from .gamesystems import DesktopGamesystems, MobileGamesystems
from .match_form import DesktopMatchForm, MobileMatchForm
from .opengames import DesktopUncofirmedGames, MobileUncofirmedGames
from .stats_1 import DesktopStats_1, MobileStats_1
from .seasonselector import DesktopSeasonSelector, MobileSeasonSelector
from .player_match_history import DesktopMatchHistory, MobileMatchHistory
from .profile_menue import DesktopProfileMenu, MobileProfileMenu

View File

@ -0,0 +1,44 @@
from nicegui import ui, app
from gui.common import *
class Gamesystems:
def __init__(self, game_systems:dict):
self.game_systems = game_systems
self.build_content()
def build_content(self):
# Will be overwritte by Childs
pass
class MobileGamesystems(Gamesystems):
def build_content(self):
with ui.card().classes("w-full"):
for g in self.game_systems:
mobile_gamecard(g)
class DesktopGamesystems(Gamesystems):
def build_content(self):
with ui.card().classes("w-full"):
with ui.row().classes("w-full flex-wrap justify-center gap-4 p-4"):
for g in self.game_systems:
desktop_gamecard(g)
def desktop_gamecard(game):
with ui.link(target=f"/stats/{game['name']}").classes("no-underline"):
with ui.card().classes("w-120 h-60 items-center"):
ui.label(text=game["name"]).classes("text-3xl text-bold text-center")
ui.image(source=f"pictures/{game['picture']}").classes("w-80")
ui.label(game["description"]).classes("text-italic text-center")
def mobile_gamecard(game):
with ui.link(target=f"/stats/{game['name']}").classes("no-underline"):
with ui.card().classes("w-full items-center"):
ui.label(text=game["name"]).classes("text-lg text-bold text-center")
ui.image(source=f"pictures/{game['picture']}").classes("w-40")
ui.label(game["description"]).classes("text-italic text-center")

75
gui/_templates/header.py Normal file
View File

@ -0,0 +1,75 @@
from gui.common import *
class BaseHeader:
def __init__(self, title: str, request: Request, tab_names: list[str] | None = None):
self.request = request # Store centrally
self.is_logged_in = app.storage.user.get('authenticated', False)
self.tab_names = tab_names or []
self.tabs = None # set in build_content
with ui.header().classes('bg-[#15151B] items-center p-4 shadow-lg'):
self.build_content(title)
def logout(self):
app.storage.user.clear()
ui.navigate.to('/')
def build_content(self):
# Will be overwritten by Childs
raise NotImplementedError
class MobileHeader(BaseHeader):
def build_content(self, title):
# Make the main container a column
# So the title is at the top and the tabs take up the full width below
with ui.column().classes('w-full items-center gap-0'):
# Row for header elements (Logo/Back, Title, Profile)
with ui.row().classes('w-full items-center px-6'):
# 1. Left
if self.request.url.path != '/':
ui.button(icon='arrow_back', on_click=lambda: ui.navigate.to('/'))
else:
ui.image(source="/pictures/wsdg.png").classes("w-16")
# Spacer
ui.space()
# 3. Right
if self.request.url.path == '/':
ui.button(text="Profile", on_click=lambda: ui.notify("Menu"))
else:
ui.label(text=title).classes("flex-grow text-center text-bold text-xl text-white px-2")
# Tabs separately below the header block
if self.tab_names:
with ui.tabs().props('inline-label active-color=white indicator-color=white scrollable') \
.classes('text-white w-full') as self.tabs:
for name in self.tab_names:
ui.tab(name, label=name).classes('text-bold text-xs')
class DesktopHeader(BaseHeader):
def build_content(self, title):
# Header as a row with 'items-end', so everything slides down
with ui.row().classes('w-full items-end px-6 py-4 gap-6'):
# 1. Block: Logo & Back (Left)
with ui.column().classes('gap-6'):
ui.image(source="/pictures/wsdg.png").classes("w-30")
if self.request.url.path != '/':
ui.button(icon='arrow_back', text="back", on_click=lambda: ui.navigate.to('/')).classes('text-lg')
# 2. Block: Title & Tabs (Middle)
# 'flex-grow' makes this block take up the space in the middle
with ui.column().classes('flex-grow items-center gap-0'):
ui.label(text=title).classes("text-bold text-2xl text-white")
if self.tab_names:
with ui.tabs().props('inline-label active-color=white indicator-color=white') \
.classes('text-white') as self.tabs:
for name in self.tab_names:
ui.tab(name, label=name).classes('text-bold')
# 3. Block: Profile & Logout (Right)
with ui.row().classes('gap-6'):
templates.DesktopProfileMenu()

View File

@ -0,0 +1,54 @@
from gui.common import *
from gui._templates.player_card import PlayerCard, DesktopPlayerCard, MobilePlayerCard
class Leaderboard:
def __init__(self, system_name: str):
leaderboard_data = data_api.get_leaderboard(system_name)
self.build_content(leaderboard_data)
def build_content(self, leaderboard_data):
pass
class DesktopLeaderboard(Leaderboard):
def build_content(self, leaderboard_data):
with ui.column().classes('w-full gap-2') as self.container:
for index, player in enumerate(leaderboard_data):
with ui.row().classes('w-full items-center gap-10 flex-nowrap'):
ui.label(f'#{index + 1}').classes('text-2xl font-bold w-10 text-center shrink-0')
DesktopPlayerCard(
display_name=player['display_name'],
discord_name=player['discord_name'],
mmr=player['mmr'],
discord_avatar_url=player.get('discord_avatar_url'),
discord_id=player.get('discord_id'),
games_in_system=player.get('games_in_system', 0),
wins=player.get('wins', 0),
loss=player.get('loss', 0),
avv_points=player.get('avv_points', 0)
)
class MobileLeaderboard(Leaderboard):
def build_content(self, leaderboard_data):
with ui.column().classes('w-full gap-1') as self.container:
for index, player in enumerate(leaderboard_data):
# Row mit fester Breite für das Rang-Label, damit es nicht abschneidet
with ui.row().classes('w-full items-center gap-2 flex-nowrap'):
# w-10 statt w-6 gibt der zweistelligen Zahl genug Platz
ui.label(f'#{index + 1}').classes('font-bold w-8 text-center shrink-0')
# Flex-1 sorgt dafür, dass die Card den restlichen Platz einnimmt
with ui.column().classes('w-full'):
MobilePlayerCard(
display_name=player['display_name'],
discord_name=player['discord_name'],
mmr=player['mmr'],
discord_avatar_url=player.get('discord_avatar_url'),
discord_id=player.get('discord_id'),
games_in_system=player.get('games_in_system', 0),
wins=player.get('wins', 0),
loss=player.get('loss', 0),
avv_points=player.get('avv_points', 0)
)

View File

@ -0,0 +1,153 @@
from gui.common import *
class MatchForm:
def __init__(self, system_name: str, player1_id: int, active_players: dict, min_points: int, max_points: int):
self.system_name = system_name
self.player1_id = player1_id
self.active_players = active_players
self.min_points = min_points
self.max_points = max_points
self.build_content()
def build_content(self):
pass
def get_select_options(active_players, player1_id):
return {
p["player_id"]: f"{p['display_name']} aka {p['discord_name']}"
for p in active_players
if p["player_id"] != player1_id
}
class DesktopMatchForm(MatchForm):
def __init__(self, **kwargs): # packt alle Parameter in ein Dict
super().__init__(**kwargs) # entpackt und gibt sie an Parent
def build_content(self):
with ui.card().classes("w-full items-center"):
ui.label(f"Neues Spiel in {self.system_name} eintragen").classes("text-center w-full text-bold text-4xl")
ui.space()
with ui.row().classes('w-full items-center gap-2 flex-nowrap'):
with ui.column().classes("w-full items-center"):
ui.label("Deine Punkte:").classes("text-bold text-2xl")
score_p1 = ui.number(value=10, precision=0, min=self.min_points, max=self.max_points).classes("w-80")
score_p1_slider = ui.slider(min=self.min_points, max=self.max_points, value=10, step=1).classes("w-80")
score_p1.on("update:model-value", lambda: setattr(score_p1_slider, "value", score_p1.value))
score_p1_slider.on("update:model-value", lambda: setattr(score_p1, "value", score_p1_slider.value))
ui.select(options=[], label=" ").classes("w-80 opacity-0")
ui.space()
with ui.column().classes("w-full items-center"):
ui.label("Gegner:").classes("text-bold text-2xl")
player2_id = ui.select(
options=get_select_options(self.active_players, self.player1_id),
label="Gegner Name"
).classes("w-80")
score_p2 = ui.number(value=10, precision=0, min=self.min_points, max=self.max_points).classes("w-80")
score_p2_slider = ui.slider(min=self.min_points, max=self.max_points, value=10, step=1).classes("w-80")
score_p2.on("update:model-value", lambda: setattr(score_p2_slider, "value", score_p2.value))
score_p2_slider.on("update:model-value", lambda: setattr(score_p2, "value", score_p2_slider.value))
def submit():
# Validierung: Prüfen, ob ein Gegner gewählt wurde
if player2_id.value is None:
ui.notify("Bitte einen Gegner auswählen!", type="warning")
return
# DB-Eintrag
match_id = input_match_to_database(self.system_name, self.player1_id, player2_id.value, score_p1.value, score_p2.value)
if match_id:
ui.notify(f"Spiel erfolgreich gespeichert (ID: {match_id})", type="positive")
# Felder zurücksetzen
reset()
else:
ui.notify("Fehler beim Speichern in die Datenbank", type="negative")
def reset():
score_p1.value = 10
score_p2.value = 10
score_p1_slider.value = 10
score_p2_slider.value = 10
player2_id.value = None
ui.space()
with ui.row().classes('w-full justify-center gap-40'):
ui.button(text="Löschen", on_click=reset).classes("bg-red-500")
ui.button(text="Absenden", on_click=submit).props("color=positive")
class MobileMatchForm(MatchForm):
def __init__(self, **kwargs):
super().__init__(**kwargs)
def build_content(self):
with ui.card().classes("w-full items-center px-3 py-3"):
# Row 1: Title
ui.label("Spiel eintragen:").classes("text-center w-full font-bold text-lg mb-2")
# Row 2: "Deine Punkte:" + input
with ui.row().classes("w-full justify-center items-center gap-3"):
ui.label("Deine Punkte:").classes("font-bold text-sm w-32 text-right")
p1_pts = ui.number(value=10, precision=0, min=self.min_points, max=self.max_points).classes("w-24") \
.props('input-class="text-center" type="number"')
# Row 3: Opponent dropdown + pts input (aligned under row 2)
with ui.row().classes("w-full justify-center items-center gap-3"):
p2_id = ui.select(
options=get_select_options(self.active_players, self.player1_id),
label="Gegner"
).classes("w-32")
p2_pts = ui.number(value=10, precision=0, min=self.min_points, max=self.max_points).classes("w-24") \
.props('input-class="text-center" type="number"')
def submit():
if p2_id.value is None:
ui.notify("Bitte Gegner wählen!", type="warning")
return
match = input_match_to_database(
self.system_name,
self.player1_id,
p2_id.value,
p1_pts.value,
p2_pts.value
)
if match:
ui.notify("Spiel gespeichert!", type="positive")
reset()
else:
ui.notify("Fehler beim Speichern!", type="negative")
def reset():
p1_pts.value = 10
p2_pts.value = 10
p2_id.value = None
# Row 4: Buttons
with ui.row().classes("w-full justify-center gap-6 mt-2"):
ui.button("Reset", on_click=reset).classes("bg-red-500 text-sm")
ui.button("Absenden", on_click=submit).props("color=positive").classes("text-sm")
def input_match_to_database(system_name, player1_id, player2_id, score_p1, score_p2):
try:
match_id = data_api.add_new_match(system_name, player1_id, player2_id, score_p1, score_p2)
ui.notify(f"Spiel erfolgreich gespeichert (ID: {match_id})", type="positive")
return match_id
except ValueError as e:
# Logikfehler (z.B. System nicht gefunden)
ui.notify(f"Fehler: {e}", type="negative")
Log(f"Error:{e}")
except Exception as e:
# DB-Fehler, Verbindungsfehler, etc.
ui.notify(f"Datenbankfehler: {e}", type="negative")
Log(f"Error:{e}")

115
gui/_templates/opengames.py Normal file
View File

@ -0,0 +1,115 @@
from gui.common import *
from match_calculations.calc_match import calculate_match
class UnconfirmedGames():
def __init__(self, open_games :dict, player_id :int):
self.open_games = open_games
self.player_id = player_id
self.build_content()
def build_content(self):
pass
class DesktopUncofirmedGames(UnconfirmedGames):
# Bestätigungs für offene Spiele --- Der "Marian Balken !!!1!11!"
def build_content(self):
with ui.card().classes("w-full"):
ui.label(text="Deine Offenen Spiele:").classes("text-3xl text-bold")
for s in self.open_games["submitted"]:
with ui.card().classes('w-full bg-zinc-800 border border-gray-600 mb-6'):
with ui.row().classes("w-full items-center"):
with ui.column().classes("flex-1 items-center"):
ui.label(f"{s['system_name']}").classes("text-2xl text-bold")
ui.label(f"am {s['played_at']}").classes("text-sm text-bold")
with ui.column().classes("flex-1 items-center"):
ui.label(
f"Du ({s['score_player1']}) --vs.-- "
f"{s['player2_name']} ({s['score_player2']})"
).classes("text-2xl text-bold text-center")
ui.label("Dein Gegner muss das Spiel noch bestätigen.").classes("text-center")
with ui.column().classes("flex-1 items-center"):
ui.button(
icon='delete', color='negative',
on_click=lambda e, m_id=s['match_id']: delete_match(m_id, self.player_id)
).props('round')
for c in self.open_games["to_confirm"]:
with ui.card().classes('w-full bg-red-900/80 border-2 border-red-500 mb-6'):
with ui.row().classes("w-full items-center"):
with ui.column().classes("flex-1 items-center"):
ui.label(f"{s['system_name']}").classes("text-2xl text-bold")
ui.label(f"am {s['played_at']}").classes("text-sm text-bold")
with ui.column().classes("flex-1 items-center"):
ui.label(
f"Du ({s['score_player1']}) --vs.-- "
f"{s['player2_name']} ({s['score_player2']})"
).classes("text-2xl text-bold text-center")
ui.label("Wenn das Ergebnis stimmt, bestätige das Spiel damit es berechnet wird.").classes("text-center")
with ui.column().classes("flex-1 items-center"):
ui.button(
icon='thumb_up', color='positive',
on_click=lambda e, m_id=s['match_id']: confirm_match(m_id, self.player_id)
).props('round')
ui.button(
icon='delete', color='negative',
on_click=lambda e, m_id=s['match_id']: delete_match(m_id, self.player_id)
).props('round')
class MobileUncofirmedGames(UnconfirmedGames):
def build_content(self):
with ui.card().classes("w-full"):
ui.label(text="Deine Offenen Spiele:").classes("text-xl text-bold")
for s in self.open_games["submitted"]:
name = (s['system_name'][:18] + '') if len(s['system_name']) > 20 else s['system_name']
date = str(s['played_at'])[:10]
with ui.card().classes('w-full bg-zinc-800 border border-gray-600 mb-2 py-1'):
with ui.row().classes("w-full justify-between items-center"):
ui.label(name).classes("text-sm font-bold")
ui.label(date).classes("text-xs text-gray-400")
with ui.row().classes("w-full justify-between items-center"):
ui.label(
f"Du ({s['score_player1']}) vs. {s['player2_name']} ({s['score_player2']})"
).classes("text-xs font-bold flex-1 text-center")
ui.button(
icon='delete', color='negative',
on_click=lambda e, m_id=s['match_id']: delete_match(m_id, self.player_id)
).props('round dense')
for c in self.open_games["to_confirm"]:
name = (c['system_name'][:18] + '') if len(c['system_name']) > 20 else c['system_name']
date = str(c['played_at'])[:10]
with ui.card().classes('w-full bg-red-900/80 border-2 border-red-500 mb-2 py-1'):
with ui.row().classes("w-full justify-between items-center"):
ui.label(name).classes("text-sm font-bold")
ui.label(date).classes("text-xs text-gray-400")
with ui.row().classes("w-full justify-between items-center"):
ui.label(
f"Du ({c['score_player1']}) vs. {c['player2_name']} ({c['score_player2']})"
).classes("text-xs font-bold flex-1 text-center")
with ui.row().classes("gap-2 items-center"):
ui.button(
icon='delete', color='negative',
on_click=lambda e, m_id=c['match_id']: delete_match(m_id, self.player_id)
).props('round dense')
ui.button(
icon='thumb_up', color='positive',
on_click=lambda e, m_id=c['match_id']: confirm_match(m_id, self.player_id)
).props('round dense')
def confirm_match(match_id :int, player_id :int):
data_api.confirm_match(match_id)
calculate_match(match_id)
ui.navigate.reload() # Lädt die Seite neu, um die Karte zu aktualisieren
ui.notify("Spiel bestätigt. Berechnung wird durchgeführt.", color="positiv")
def delete_match(match_id :int, player_id :int):
data_api.delete_match(match_id, player_id)
ui.navigate.reload() # Lädt die Seite neu, um die Karte zu aktualisieren
ui.notify("Spiel abgelehnt und gelöscht!", color="warning")

View File

@ -0,0 +1,105 @@
from gui.common import *
class PlayerCard:
def __init__(self, display_name: str, discord_name: str, mmr: int,
discord_avatar_url: str = None, discord_id: str = None,
games_in_system: int = 0, wins: int = 0, loss: int = 0, avv_points: int = 0):
self.display_name = display_name
self.discord_name = discord_name
self.mmr = mmr
self.discord_avatar_url = discord_avatar_url
self.discord_id = discord_id
self.games_in_system = games_in_system
self.wins = wins
self.loss = loss
self.avv_points = avv_points
self.build_content()
def build_content(self):
pass
def _discord_content(self, avatar_url, display_name, discord_name, short_len=15):
short_name = display_name[:short_len] + '...' if len(display_name) > short_len else display_name
short_discord = discord_name[:short_len] + '...' if len(discord_name) > short_len else discord_name
return avatar_url, short_name, short_discord
class DesktopPlayerCard(PlayerCard):
def build_content(self):
avatar_url, short_name, short_discord = self._discord_content(
self.discord_avatar_url, self.display_name, self.discord_name, short_len=25
)
with ui.card().classes("shrink-0 grow-0 no-shadow bg-zinc-900 w-[750px] h-[190px] overflow-hidden p-4"):
# REIHE 1: Discord Card Links, Achievements Rechts
with ui.row().classes('w-full items-center justify-between flex-nowrap mb-4'):
if self.discord_id:
with ui.link(target=f'https://discord.com/users/{self.discord_id}', new_tab=True).classes('no-underline'):
self._discord_block(avatar_url, short_name, short_discord, desktop=True)
else:
self._discord_block(avatar_url, short_name, short_discord, desktop=True)
# Achievments
with ui.row().classes('items-center gap-3 shrink-0'):
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-15')
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-15')
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-15')
# REIHE 2: Stats
with ui.row().classes('w-full items-center gap-10'):
ui.label(f'{self.mmr} MMR').classes('font-bold text-lg')
ui.label(f'{self.games_in_system} Games').classes('font-bold text-lg')
ui.label(f'{self.wins} Wins').classes('font-bold text-lg')
ui.label(f'{self.loss} Losses').classes('font-bold text-lg')
ui.label(f'{self.avv_points} Ø pts per Game').classes('font-bold text-lg')
def _discord_block(self, avatar_url, short_name, short_discord, desktop=True):
with ui.card().classes("w-110 bg-indigo-950 no-shadow"):
with ui.row().classes('items-center gap-3 flex-nowrap w-full'):
if avatar_url:
ui.image(avatar_url).classes('w-20 h-20 rounded-full border-red-900 border-2 shrink-0')
with ui.column().classes('gap-0 min-w-0 flex-1'):
ui.label(short_name).classes('text-2xl font-bold whitespace-nowrap truncate')
ui.label(short_discord).classes('text-sm opacity-60 whitespace-nowrap truncate')
class MobilePlayerCard(PlayerCard):
def build_content(self):
avatar_url, short_name, short_discord = self._discord_content(
self.discord_avatar_url, self.display_name, self.discord_name, short_len=12
)
with ui.card().classes("w-full no-shadow bg-zinc-900 min-w-0 overflow-hidden"):
# Flex-Container für alles
with ui.row().classes('items-center gap-6 flex-nowrap w-full justify-between'):
# 2. DISCORD CARD
if self.discord_id:
# w-full hier entfernen, damit der Link nur so breit ist wie die Karte
with ui.link(target=f'https://discord.com/users/{self.discord_id}', new_tab=True).classes('no-underline'):
self._discord_block(avatar_url, short_name, short_discord)
else:
self._discord_block(avatar_url, short_name, short_discord)
# 1. ACHIEVEMENTS
with ui.column().classes('items-center gap-1 shrink-0'):
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-5')
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-5')
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-5')
# 3. Stats
with ui.row().classes('w-full justify-center gap-4 mt-2'):
ui.label(f'{self.mmr} MMR').classes('text-xs')
ui.label(f'{self.games_in_system} Spiele').classes('text-xs')
ui.label(f'{self.avv_points} Ø pts.').classes('text-xs')
def _discord_block(self, avatar_url, short_name, short_discord, desktop=False):
with ui.card().classes("w-fit min-w-[180px] bg-indigo-950 no-shadow"):
with ui.row().classes('items-center gap-3 min-w-0'):
if avatar_url:
ui.image(avatar_url).classes('w-9 h-9 rounded-full border-red-900 border-2 shrink-0')
with ui.column().classes('gap-0 min-w-0'):
ui.label(short_name).classes('text-sm font-bold whitespace-nowrap truncate')
ui.label(short_discord).classes('text-xs opacity-60 whitespace-nowrap truncate')

View File

@ -0,0 +1,246 @@
from gui.common import *
from data import data_api
# ─── Hilfsfunktion ─────────────────────────────────────────────
def fmt_signed(value, pending):
"""Formatiert einen Zahlenwert mit explizitem Vorzeichen.
- Bei pending=True: 'Ausstehend' (Match noch nicht gewertet)
- Positiv (+5.2): '+5.2' (Pluszeichen wird explizit dazu)
- Negativ (-3.1): '-3.1' (Minuszeichen ist schon im Wert)
- None: '0'
"""
if pending:
return 'Ausstehend'
if value is None:
return '0'
if value >= 0:
return f'+{value}'
return f'{value}'
# ─── StatRow: Eine einzelne Match-Card ─────────────────────────
class StatRow:
"""Eine einzelne Karten-Zeile für ein Match.
Wird mit einem Daten-Dictionary gefüttert und baut
entweder eine Desktop- oder Mobile-Ansicht.
"""
def __init__(self, data: dict, compact: bool = False):
"""
Args:
data: Dictionary mit den Match-Daten
compact: True = Mobile-Ansicht (weniger Details)
"""
self.data = data
self.compact = compact
self.build()
def build(self):
if self.compact:
self._build_mobile()
else:
self._build_desktop()
# --- Hilfsmethoden für Farben ---
def _color_for_value(self, value_str: str) -> str:
"""Gibt CSS-Klassen für MMR-Werte zurück (grün/rot/grau)."""
if value_str == 'Ausstehend':
return 'text-gray-400 italic'
if value_str.startswith('+'):
return 'text-green-500 font-bold'
if value_str.startswith('-'):
return 'text-red-500 font-bold'
return 'text-white'
def _result_color(self, result: str) -> str:
"""Gibt CSS-Klassen für Gewonnen/Verloren zurück."""
if result == 'Gewonnen':
return 'text-green-500 font-bold'
if result == 'Verloren':
return 'text-red-500 font-bold'
return 'text-yellow-500 font-bold'
# --- Desktop-Ansicht ---
def _build_desktop(self):
"""Desktop: Alle Felder nebeneinander in einer Card."""
with ui.card().classes('w-full bg-zinc-900 border border-zinc-700'):
with ui.row().classes('w-full items-center no-wrap'):
# Datum
ui.label(self.data['date']).classes('w-24 text-gray-300')
# Gegner Name (mit eigenem Score des Gegners)
ui.label(self.data['opponent']).classes('flex-grow text-white')
# Spielsystem
ui.label(self.data['system']).classes('w-28 text-gray-300')
# Ergebnis (Gewonnen / Verloren)
ui.label(self.data['result']).classes(
f'w-24 {self._result_color(self.data["result"])}'
)
# Elo Faktor
ui.label(self.data['elo']).classes(
f'w-20 text-right {self._color_for_value(self.data["elo"])}'
)
# Rost Faktor
ui.label(self.data['rust']).classes(
f'w-20 text-right {self._color_for_value(self.data["rust"])}'
)
# Basis MMR
ui.label(str(self.data['basis'])).classes(
'w-20 text-right text-gray-300'
)
# Khorne
ui.label(self.data['khorne']).classes(
f'w-16 text-right {self._color_for_value(self.data["khorne"])}'
)
# Tzeentch
ui.label(self.data['tzeentch']).classes(
f'w-20 text-right {self._color_for_value(self.data["tzeentch"])}'
)
# Slaanesh
ui.label(self.data['slaanesh']).classes(
f'w-20 text-right {self._color_for_value(self.data["slaanesh"])}'
)
# MMR Gesamt
ui.label(self.data['mmr']).classes(
f'w-24 text-right {self._color_for_value(self.data["mmr"])}'
)
# --- Mobile-Ansicht ---
def _build_mobile(self):
"""Mobile: Kompakte Ansicht, weniger Details."""
with ui.card().classes('w-full bg-zinc-900 border border-zinc-700'):
with ui.column().classes('w-full gap-1'):
# Zeile 1: Datum + Spielsystem + Ergebnis
with ui.row().classes('w-full items-center justify-between'):
ui.label(self.data['date']).classes('text-gray-400 text-sm')
ui.label(self.data['system']).classes('text-gray-400 text-sm')
ui.label(self.data['result']).classes(
f'text-sm {self._result_color(self.data["result"])}'
)
# Zeile 2: Gegner Name
ui.label(self.data['opponent']).classes('text-white text-sm')
# Zeile 3: MMR Gesamt (die wichtigste Zahl)
with ui.row().classes('items-center gap-2'):
ui.label('MMR:').classes('text-gray-400 text-sm')
ui.label(self.data['mmr']).classes(
f'text-sm {self._color_for_value(self.data["mmr"])}'
)
# Detail-Button (Platzhalter für später)
ui.button('Details', on_click=lambda: None).props('flat dense')
# ─── MatchHistory: Basisklasse ─────────────────────────────────
class MatchHistory:
"""Basisklasse — extrahiert die Match-Daten aus der DB."""
def __init__(self, player_id: int):
self.player_id = player_id
self.build_content()
def build_content(self):
pass
def _extract_match_data(self, match: dict, index: int) -> dict:
"""Extrahiert ein Match-Dictionary in ein sauberes Daten-Dict.
Diese Methode wird von Desktop und Mobile GEMEINSAM genutzt,
damit die Extraktions-Logik nicht doppelt geschrieben wird.
"""
is_player1 = match['player1_id'] == self.player_id
pending = match['match_is_counted'] == 0
# ── Je nachdem ob wir player1 oder player2 sind ──
if is_player1:
opponent = f"{match['p2_display']} aka {match['p2_discord']}"
my_score = match['score_player1']
opp_score = match['score_player2']
mmr_base = match["player1_base_change"]
mmr_change = match['player1_mmr_change']
khorne = match['player1_khorne']
tzeentch = match['player1_tzeentch']
slaanesh = match['player1_slaanesh']
else:
opponent = f"{match['p1_display']} aka {match['p1_discord']}"
my_score = match['score_player2']
opp_score = match['score_player1']
mmr_base = match["player2_base_change"]
mmr_change = match['player2_mmr_change']
khorne = match['player2_khorne']
tzeentch = match['player2_tzeentch']
slaanesh = match['player2_slaanesh']
# ── Ergebnis bestimmen ──
if my_score > opp_score:
result = "Gewonnen"
elif my_score < opp_score:
result = "Verloren"
else:
result = "Unentschieden"
elo_factor = match['elo_factor']
rust_factor = match['rust_factor']
# ── Sauberes Dictionary zurückgeben ──
return {
'id': index,
'date': str(match['played_at'])[:10],
'system': match['gamesystem_name'],
'score': f"{my_score}:{opp_score}",
'opponent': f"{opponent} ({opp_score})",
'result': result,
'basis': mmr_base,
'elo': fmt_signed(round(elo_factor, 2) if elo_factor is not None else None, pending),
'rust': fmt_signed(round(rust_factor, 2) if rust_factor is not None else None, pending),
'khorne': fmt_signed(khorne, pending),
'tzeentch': fmt_signed(tzeentch, pending),
'slaanesh': fmt_signed(slaanesh, pending),
'mmr': fmt_signed(mmr_change, pending),
'pending': pending,
}
# ─── DesktopMatchHistory ───────────────────────────────────────
class DesktopMatchHistory(MatchHistory):
def build_content(self):
raw_matches = data_api.get_match_history_log(self.player_id)
if not raw_matches:
ui.label("Keine Spiele gefunden.").classes("text-gray-400 italic")
return
with ui.column().classes('w-full gap-2'):
for i, match in enumerate(raw_matches):
data = self._extract_match_data(match, i)
StatRow(data, compact=False)
# ─── MobileMatchHistory ────────────────────────────────────────
class MobileMatchHistory(MatchHistory):
def build_content(self):
raw_matches = data_api.get_match_history_log(self.player_id)
if not raw_matches:
ui.label("Keine Spiele gefunden.").classes("text-gray-400 italic")
return
with ui.column().classes('w-full gap-2'):
for i, match in enumerate(raw_matches):
data = self._extract_match_data(match, i)
StatRow(data, compact=True)

View File

@ -0,0 +1,79 @@
from gui.common import *
from data import data_api
class ProfileMenu:
"""Base template for the profile popup menu in the header.
Shows the Discord avatar as a clickable button. On click a popup
menu (Quasar QMenu) opens where menu items can be added.
"""
def __init__(self, logout_callback=None):
# Daten aus dem Session-Cache lesen
self.discord_avatar_url = app.storage.user.get('discord_avatar_url')
self.discord_name = app.storage.user.get('discord_name')
self.discord_id = app.storage.user.get('discord_id')
self.is_logged_in = bool(app.storage.user.get('authenticated', False))
self.logout_callback = logout_callback
self.menu = None
self.build_content()
def build_content(self):
pass
def add_item(self, text, on_click=None, auto_close=True):
"""Fügt einen Menü-Eintrag nachträglich hinzu.
Kann nach der Konstruktion aufgerufen werden, um dynamisch
Buttons einzufügen."""
with self.menu:
ui.menu_item(text, on_click, auto_close=auto_close)
def add_separator(self):
"""Fügt eine Trennlinie zwischen Menü-Einträgen ein."""
with self.menu:
ui.separator()
class DesktopProfileMenu(ProfileMenu):
def build_content(self):
with ui.row().classes('items-center gap-3 shrink-0'):
# Optional: Name links vom Avatar (Desktop hat Platz)
if self.discord_name:
ui.label(self.discord_name).classes('text-sm opacity-80')
# Avatar als klickbarer Button -> Menu öffnet bei Klick
with ui.button().props('flat round dense'):
if self.discord_avatar_url:
ui.image(self.discord_avatar_url).classes('w-10 h-10 rounded-full')
else:
ui.icon('account_circle').classes('text-3xl')
# Popup-Menu (INNERHALB des Buttons -> verankert automatisch)
with ui.menu() as self.menu:
self._build_menu_items()
def _build_menu_items(self):
ui.menu_item('Profile', lambda: ui.navigate.to('/profile'))
ui.menu_item('Settings', lambda: ui.navigate.to('/settings'))
ui.separator()
if self.is_logged_in:
ui.menu_item('Logout', self.logout_callback)
class MobileProfileMenu(ProfileMenu):
def build_content(self):
with ui.button().props('flat round dense'):
if self.discord_avatar_url:
ui.image(self.discord_avatar_url).classes('w-8 h-8 rounded-full')
else:
ui.icon('account_circle').classes('text-2xl')
with ui.menu() as self.menu:
self._build_menu_items()
def _build_menu_items(self):
ui.menu_item('Profile', lambda: ui.navigate.to('/profile'))
ui.separator()
if self.is_logged_in:
ui.menu_item('Logout', self.logout_callback)

View File

@ -0,0 +1,61 @@
from gui.common import *
class SeasonSelector():
def __init__(self, seasons: dict, current_season: int, on_change=None): # ← on_change hinzufügen
self.seasons = seasons
self.current_season = current_season
self.on_change = on_change
self.build_content()
def build_content(self):
pass
class DesktopSeasonSelector(SeasonSelector):
def build_content(self):
seasons = sorted(self.seasons, key=lambda s: s['season_number'])
if not seasons:
return
self.season_index = len(seasons) - 1
self.seasons = seasons
with ui.card().classes("w-full"):
with ui.row().classes("w-full justify-center items-center gap-15"):
self.btn_prev = ui.button(text="<", on_click=lambda: self._navigate(-1))
self.season_label = (
ui.label(text="")
.classes("text-bold text-3xl text-center")
.style("min-width: 400px;") # ← feste Breite, anpassen nach Bedarf
)
self.btn_next = ui.button(text=">", on_click=lambda: self._navigate(1))
self._update_display()
def _navigate(self, direction: int):
self.season_index = max(0, min(len(self.seasons) - 1, self.season_index + direction))
self._update_display()
def _update_display(self):
current = self.seasons[self.season_index]
self.season_label.set_text(current['name'])
self.current_season_id = current['id']
self.btn_prev.set_enabled(self.season_index > 0)
self.btn_next.set_enabled(self.season_index < len(self.seasons) - 1)
if self.on_change: # ← das fehlt!
self.on_change(self.current_season_id)
class MobileSeasonSelector(SeasonSelector):
def build_content(self):
with ui.card().classes("W-full"):
pass

93
gui/_templates/stats_1.py Normal file
View File

@ -0,0 +1,93 @@
from gui.common import *
from data import data_api
class Stats_1():
def __init__(self, player_stats: dict):
self.player_stats = player_stats
self.build_content() # ← kein self als Argument!
def build_content(self):
pass
class DesktopStats_1(Stats_1):
def build_content(self):
if not self.player_stats:
ui.label("Keine Statistiken für diese Season.").classes("text-gray-400")
return
s = self.player_stats
with ui.card().classes("w-full"):
ui.label(f"{s['gamesystem_name']}").classes("text-2xl text-bold mb-4")
# CSS Grid: Rang links (row-span 2), Stats rechts in 2 Zeilen
with ui.element('div').style(
"display: grid;"
"grid-template-columns: 10rem 1fr;"
"grid-template-rows: 1fr 1fr;"
"gap: 1rem;"
"width: 100%;"
):
# --- Rang Card (links, überspannt beide Zeilen) ---
with ui.card().style(
"grid-column: 1;"
"grid-row: 1 / 3;"
"display: flex;"
"flex-direction: column;"
"align-items: center;"
"justify-content: center;"
):
ui.label(str(s['rank'])).classes("text-5xl text-bold")
ui.label("Rang").classes("text-gray-400 text-lg")
# --- Zeile 1 ---
with ui.element('div').style(
"grid-column: 2;"
"grid-row: 1;"
"display: flex;"
"gap: 1rem;"
):
with ui.card().classes("flex-1 items-center justify-center"):
ui.label(str(s['mmr'])).classes("text-3xl text-bold")
ui.label("MMR").classes("text-gray-400 text-sm")
with ui.card().classes("flex-1 items-center justify-center"):
ui.label(str(s['games_in_system'])).classes("text-3xl text-bold")
ui.label("Spiele").classes("text-gray-400 text-sm")
with ui.card().classes("flex-1 items-center justify-center"):
ui.label(str(s['points'])).classes("text-3xl text-bold")
ui.label("Punkte").classes("text-gray-400 text-sm")
# --- Zeile 2 ---
with ui.element('div').style(
"grid-column: 2;"
"grid-row: 2;"
"display: flex;"
"gap: 1rem;"
):
with ui.card().classes("flex-1 items-center justify-center"):
ui.label(str(s['avv_points'])).classes("text-3xl text-bold")
ui.label("Ø Punkte/Spiel").classes("text-gray-400 text-sm")
if 'win_rate' in s and s['win_rate'] is not None:
with ui.card().classes("flex-1 items-center justify-center"):
ui.label(f"{s['win_rate']}%").classes("text-3xl text-bold")
ui.label("Win Rate").classes("text-gray-400 text-sm")
if s.get('last_played'):
with ui.card().classes("flex-1 items-center justify-center"):
ui.label(s['last_played'].strftime("%d.%m.%Y")).classes("text-3xl text-bold")
ui.label("Zuletzt gespielt").classes("text-gray-400 text-sm")
class MobileStats_1(Stats_1):
def build_content(self):
pass

View File

@ -1,39 +0,0 @@
from nicegui import ui, app
from data import database, data_api
from gui import gui_style
from wood import logger
from gui import main_gui
def setup_routes():
@ui.page('/admin', dark=True)
def admin_page():
gui_style.apply_design()
with ui.header().classes('items-center justify-between bg-zinc-900 p-4 shadow-lg'):
ui.button("Zurück", on_click= lambda: ui.navigate.to("/") )
if app.storage.user.get('authenticated', False):
with ui.row().classes("w-full"):
ui.button(text= "test", on_click=lambda: data_api.create_random_dummy_match(2))
ui.button(icon="refresh", on_click= lambda: ui.navigate.to("/admin") )
with ui.card().classes("w-full"):
ui.label("System Audit Log").classes('text-2xl font-bold text-white mb-4')
# Daten abrufen
log_data = logger.get_full_log()
# Tabelle aufbauen
columns = [
{'name': 'time', 'label': 'Time', 'field': 'timestamp', 'align': 'left', 'sortable': True},
{'name': 'file', 'label': 'Datei', 'field': 'file', 'align': 'left'},
{'name': 'source', 'label': 'Quelle', 'field': 'source', 'align': 'left'},
{'name': 'details', 'label': 'Details', 'field': 'details', 'align': 'left'}
]
if len(log_data) > 0:
# Millisekunden vom Zeitstempel abschneiden [:19]
for row in log_data:
row['timestamp'] = str(row['timestamp'])[:19]
ui.table(columns=columns, rows=log_data, row_key='id').classes('w-full bg-zinc-900 text-gray-300')
else:
ui.label("Das Logbuch ist leer.").classes('text-gray-500 italic')

26
gui/base_page.py Normal file
View File

@ -0,0 +1,26 @@
# gui/base_page.py
from nicegui import ui, app
from starlette.requests import Request
def apply_design():
ui.add_css('body { background-color: #171721; }')
ui.dark_mode(True)
ui.colors(primary='#B80B0B', secondary='#FF3333', accent='#2078D4',
positive='#188C42', negative='#E31919', info='#939393', warning='#f59e0b')
def is_mobile(request: Request) -> bool:
user_agent = request.headers.get("user-agent", "").lower()
return any(k in user_agent for k in ["mobile", "android", "iphone", "ipod", "blackberry", "windows phone"])
def base_page(request: Request, title: str):
apply_design()
# Zentrale Login-Prüfung
if not app.storage.user.get('authenticated', False):
# Was wird angezeigt wenn ein User NICHT eingeloggt ist und versucht eine Seite aufzurufen.
apply_design()
ui.label('Access Denied. Please Login').classes('text-red-500')
ui.button('Login', on_click=lambda: ui.navigate.to('/login'))
return False
return True

10
gui/common.py Normal file
View File

@ -0,0 +1,10 @@
# gui/common.py
from gui.base_page import base_page, is_mobile
from gui import _templates as templates
from gui._templates import *
# Wenn du noch mehr hast, was jede Seite braucht, pack es hier rein:
from nicegui import ui, app
from starlette.requests import Request
from wood.logger import log as Log
from data import data_api

View File

@ -1,23 +0,0 @@
from nicegui import ui
# Funktion die jede GUI Seite am Anfang aufrufen kann. Dann haben alle die gleichen Farbeinstellungen. Und, wenn man was ändert,
# muss man es nur hier ändern!
def apply_design():
ui.add_css('body { background-color: #171721; }')
# 1. Dark Mode aktivieren
ui.dark_mode(True)
ui.colors(
primary='#B80B0B', # Hauptfarbe (z.B. für Standard-Buttons) Wenn keine Farbe angegeben wird, ist es diese Farbe
secondary='#FF3333', # Zweitfarbe
accent='#2078D4', # Akzentfarbe
positive='#188C42', # Farbe für Erfolg (Grün)
negative='#E31919', # Farbe für Fehler/Abbruch (Rot)
info='#939393', # Info-Farbe
warning='#f59e0b', # Warn-Farbe (Orange)
# Farben für Texte
normaltext="#F7F7F7",
accenttext="#2078D4",
infotext="#A8A8A8"
)

View File

@ -1,8 +1,7 @@
import json
from pathlib import Path
from nicegui import ui
from gui.common import *
from fastapi import Request
from gui import gui_style
# JSON laden - liegt laut Screenshot direkt in gui/
_JSON_PATH = Path(__file__).resolve().parent / 'imprint.json'
@ -32,7 +31,6 @@ def _get_locale_options() -> list[str]:
def setup_routes():
@ui.page('/info', dark=True)
def info_page(request: Request):
gui_style.apply_design()
lang = _get_locale(request)
locale_data = imprint_data['locales'][lang]

View File

@ -1,91 +1,97 @@
from nicegui import ui, app
from gui import gui_style
from gui.common import *
from data import data_api
from gui.info_text import info_system
from gui.templates.leaderboard import Leaderboard
def setup_routes():
@ui.page('/statistic/{system_name}', dark=True)
def gamesystem_statistic_page(system_name: str):
if not app.storage.user.get('authenticated', False):
ui.navigate.to('/')
@ui.page('/stats/{system_name}', dark=True)
def gamesystem_statistic_page(request: Request, system_name: str):
# 1. Wrapper aufrufen (Login-Check & Styling)
if not base_page(request, f"{system_name} Stats"):
return
gui_style.apply_design()
player_id = app.storage.user.get('db_id')
# 2. Daten laden (Das bleibt in der Seite)
player_id = app.storage.user.get('player_id')
system_id = data_api.get_gamesystem_id_by_name(system_name)
print(player_id, system_id)
player_stats = data_api.get_player_statistic(player_id, system_id)
active_players = data_api.get_all_players_from_system(system_name)
gamesystem_data = data_api.get_gamesystem_data(system_id)
# Header
with ui.header().classes('items-center justify-between bg-zinc-900 p-4 shadow-lg'):
ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to('/')).props("round")
ui.button("Spiel eintragen", on_click=lambda: ui.navigate.to(f'/add-match/{system_name}'))
seasons = data_api.get_seasons_by_system(system_id)
current_season = data_api.get_current_season_id(system_id)
player_stats = data_api.get_player_statistic(player_id, system_id, current_season)
# Die Tabs werden aus dieser Liste hier generiert mit den Namen die hier angegeben sind. Sie können auch abgefragt werden.
tab_names = ['Übersicht', 'Rangliste', "Spiel eintragen"]
with ui.column().classes('w-full items-center justify-center mt-10'):
ui.label(f'Deine Statistik in {system_name}').classes('text-3xl justify-center font-bold text-normaltext')
# 3. Desktop OR Mobile
def show_desktop():
if not base_page(request, f"{system_name} Stats"):
return
header = templates.DesktopHeader(f"Stats für {system_name}", tab_names=tab_names, request=request)
# --- BLOCK 1 (MMR & Rang | Rangliste) ---
with ui.element('div').classes("w-full grid grid-cols-1 lg:grid-cols-3 gap-4 mt-4"):
with ui.column().classes("w-full gap-4"):
with ui.card().classes("w-full items-center justify-center text-center"):
with ui.row().classes("w-full items-center text-center"):
ui.label("MMR Punkte: ").classes('justify-center text-2xl font-bold text-normaltext')
ui.space()
info_system.create_info_button("mmr_info")
ui.label(str(player_stats["mmr"])).classes('text-4xl font-bold text-accent')
# Content-Bereich: Tab-Panels
with ui.tab_panels(header.tabs, value=tab_names[0]).classes('w-full mt-4'):
for name in tab_names:
with ui.tab_panel(name):
match name:
case 'Übersicht':
stats_container = None
def on_season_change(season_id: int):
if stats_container is None: # ← Callback feuert zu früh → ignorieren
return
stats_container.clear()
with stats_container:
fresh_stats = data_api.get_player_statistic(player_id, system_id, season_id)
templates.DesktopStats_1(fresh_stats)
with ui.card().classes("w-full items-center justify-center text-center"):
with ui.row().classes("w-full items-center text-center"):
ui.label("Rang: ").classes('justify-center text-2xl font-bold text-normaltext')
ui.space()
info_system.create_info_button("rang_info")
ui.label(str(data_api.get_player_rank(player_id,system_id))).classes('text-4xl font-bold text-blue-100')
# 1. Season Selector (oben)
templates.DesktopSeasonSelector(
seasons,
current_season,
on_change=on_season_change
)
with ui.card().classes("w-full lg:col-span-2 max-h-[50vh] overflow-y-auto"):
ui.label("Liga Rangliste").classes("text-xl font-bold text-white mb-2 top-0 bg-zinc-900 z-10")
Leaderboard(system_name)
# 2. Stats Container (unten)
stats_container = ui.column().classes("w-full")
# --- BLOCK 2 (5 Karten) ---
with ui.card().classes("w-full"):
with ui.element('div').classes("w-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"):
# 3. Initial befüllen
with stats_container:
templates.DesktopStats_1(player_stats)
with ui.card().classes("items-center justify-center text-center"):
ui.label("Spiele: ").classes('text-2xl font-bold')
ui.label(str(player_stats["games_in_system"])).classes('text-4xl font-bold text-blue-100')
case 'Rangliste':
templates.DesktopLeaderboard(system_name)
case 'Spiel eintragen':
templates.DesktopMatchForm(
system_name= system_name,
player1_id= player_id,
active_players= active_players,
min_points= gamesystem_data["min_score"],
max_points= gamesystem_data["max_score"])
with ui.card().classes("items-center justify-center text-center"):
ui.label("Ø Punkte pro Spiel: ").classes('text-2xl font-bold')
ui.label(str(player_stats["avv_points"])).classes('text-4xl font-bold text-blue-100')
def show_mobile():
if not base_page(request, f"{system_name} Stats"):
return
header = templates.MobileHeader(f"Stats für {system_name}", tab_names=tab_names, request=request)
with ui.card().classes("items-center justify-center text-center"):
ui.label("Win-Rate: ").classes('text-2xl font-bold')
ui.label("-").classes('text-4xl font-bold text-blue-100')
with ui.tab_panels(header.tabs, value=tab_names[0]).classes('w-full mt-4'):
for name in tab_names:
with ui.tab_panel(name):
match name:
case 'Übersicht':
templates.DesktopSeasonSelector(seasons, current_season)
templates.MobileStats_1(player_stats)
case 'Rangliste':
templates.MobileLeaderboard(system_name)
case 'Spiel eintragen':
templates.MobileMatchForm(
system_name= system_name,
player1_id= player_id,
active_players= active_players,
min_points= gamesystem_data["min_score"],
max_points= gamesystem_data["max_score"])
with ui.card().classes("items-center justify-center text-center"):
ui.label("Letztes Spiel am: ").classes('text-2xl font-bold')
ui.label(str(player_stats["last_played"])).classes('text-4xl font-bold text-blue-100')
with ui.card().classes("items-center justify-center text-center"):
ui.label("Win-Streak: ").classes('text-2xl font-bold')
ui.label("-").classes('text-4xl font-bold text-blue-100')
# --- BLOCK 3 (3 Karten) ---
with ui.card().classes("w-full"):
with ui.element('div').classes("w-full grid grid-cols-1 md:grid-cols-3 lg:grid-cols-3 gap-4"):
with ui.card().classes("items-center justify-center text-center"):
ui.label("Dein Nemesis: ").classes('text-2xl font-bold')
ui.label(str(player_stats["nemesis_id"])).classes('text-4xl font-bold text-blue-100')
with ui.card().classes("items-center justify-center text-center"):
ui.label("Meisten Spiele mit: ").classes('text-2xl font-bold')
ui.label(str(player_stats["nemesis_id"])).classes('text-4xl font-bold text-blue-100')
with ui.card().classes("items-center justify-center text-center"):
ui.label("Dein 'Prügelknabe': ").classes('text-2xl font-bold')
ui.label(str(player_stats["pushover_id"])).classes('text-4xl font-bold text-blue-100')
# Weiche entscheiden
if is_mobile(request):
show_mobile()
else:
show_desktop()

View File

@ -1,24 +0,0 @@
from nicegui import ui, app
from gui import gui_style
from data import data_api
from gui.info_text import info_system
from gui.templates.leaderboard import Leaderboard
def setup_routes():
@ui.page('/statistic/visitor/{systemname}', dark=True)
def gamesystem_statistic_visitor_page(systemname: str):
if not app.storage.user.get('authenticated', False):
ui.navigate.to('/')
return
gui_style.apply_design()
with ui.header().classes('items-center justify-between bg-zinc-900 p-4 shadow-lg'):
ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to('/')).props("round")
with ui.column().classes('w-full items-center justify-center mt-10'):
ui.label(f'Die aktuelle Statistik in {systemname}').classes('text-3xl justify-center font-bold text-normaltext')
Leaderboard(systemname)

26
gui/login_gui.py Normal file
View File

@ -0,0 +1,26 @@
from gui.common import *
from auth import discord_auth # Für get_auth_url()
def setup_routes():
@ui.page('/login')
def login_page():
ui.label("Login Bereich").classes("text-h3")
ui.button("Login mit Discord", on_click=lambda: ui.navigate.to(discord_auth.get_auth_url()))
@ui.page('/login/discord')
async def discord_callback(code: str = None):
if not code:
ui.label("Kein Code erhalten.").classes("text-red-500")
return
user_info, error = discord_auth.process_discord_login(code)
if error:
ui.label(error).classes("text-red-500")
else:
player, d_id, d_name, avatar = user_info
app.storage.user['authenticated'] = True
app.storage.user["player_id"] = player["id"]
app.storage.user["display_name"] = player["display_name"]
ui.navigate.to('/')

View File

@ -1,300 +1,46 @@
from nicegui import ui, app
from data import database, data_api
from gui import discord_login, gui_style
from match_calculations import calc_match
from gui.info_text import info_system
# gui/league_statistic_gui.py
from gui.common import *
from data import data_api
def setup_routes(admin_discord_id):
def setup_routes():
@ui.page('/', dark=True)
def home_page():
gui_style.apply_design()
def main_page(request: Request):
# 1. Wrapper aufrufen (Login-Check & Styling)
if not base_page(request, title=""):
return
# --- SICHERHEITS-CHECK (Hassan, DER TÜRSTEHER) ---
if app.storage.user.get('authenticated', False):
db_id = app.storage.user.get('db_id')
discord_id = app.storage.user.get('discord_id')
# 2. Daten laden
player_id = app.storage.user.get('player_id')
game_systems = data_api.get_gamesystems_data()
open_games = data_api.get_open_matches(player_id)
# Fehlt die Discord-ID (altes Cookie) ODER sagt die Datenbank, dass da was nicht stimmt?
if not discord_id or not data_api.validate_user_session(db_id, discord_id):
app.storage.user.clear()
ui.notify("Deine Sitzung ist ungültig oder abgelaufen. Bitte neu einloggen!", color="negative")
ui.navigate.reload()
return
if not open_games["submitted"] and not open_games["to_confirm"]:
are_games_open = False
else:
are_games_open = True
# 3. Dektop OR Mobile
def show_desktop():
templates.DesktopHeader("",request=request)
if are_games_open:
templates.DesktopUncofirmedGames(open_games, player_id)
templates.DesktopGamesystems(game_systems)
ui.button('Spieler Statistik', on_click=lambda: ui.navigate.to("/player_match_history"))
# ---------------------------
# --- NAVIGATIONSLEISTE (HEADER)
# ---------------------------
def show_mobile():
templates.MobileHeader("",request=request)
if are_games_open:
templates.MobileUncofirmedGames(open_games, player_id)
templates.MobileGamesystems(game_systems)
ui.button('Spieler Statistik', on_click=lambda: ui.navigate.to("/player_match_history"))
with ui.header(fixed=False).classes('items-center justify-between bg-zinc-900 shadow-lg'):
# --- LINKE SEITE ---
# Vereinslogo und den Titel in einer eigenen Reihe (Reihe 1)
with ui.row().classes('items-center'):
ui.image("gui/pictures/wsdg.png").classes('w-12 h-12 md:w-25 md:h-25 rounded-full')
ui.label('Westside Diceghost Liga').classes('text-lg md:text-3xl font-bold text-normaltext')
# --- MITTE ---
if app.storage.user.get('authenticated', False):
discord_id = app.storage.user.get("discord_id")
if discord_id == admin_discord_id:
ui.button(icon="hardware", on_click=lambda: ui.navigate.to('/admin')).classes('w-2 h-2')
# --- RECHTE SEITE ---
if app.storage.user.get('authenticated', False):
with ui.row().classes('items-center gap-4'):
ui.image(app.storage.user.get('discord_avatar_url')).classes('w-10 h-10 md:w-25 md:h-25 rounded-full border-1 border-red-900')
discord_name = app.storage.user.get('discord_name')
display_name = app.storage.user.get('display_name')
player_id = app.storage.user.get('db_id')
# 1. kleine Funktion, die zwischen Text und Eingabe hin- und herschaltet
def toggle_edit_mode():
display_row.visible = not display_row.visible
edit_row.visible = not edit_row.visible
edit_button.visible = not edit_button.visible
# --- ANSICHT 1: Der normale Text mit Edit-Button ---
with ui.column().classes('items-center gap-0') as display_row:
with ui.column():
ui.label(display_name).classes('md:text-3xl font-bold text-normaltext')
with ui.row().classes("items-center justify-between"):
ui.label("'aka'").classes('md:text-lg text-xs text-italic text-infotext')
ui.label(discord_name).classes('text-sm md:text-xl text-bold text-infotext')
edit_button = ui.button(icon='edit', color='accent', on_click=toggle_edit_mode).props('round dense')
# --- ANSICHT 2: Das Eingabefeld (startet unsichtbar!) ---
with ui.row().classes('items-center gap-5') as edit_row:
edit_row.visible = False # Am Anfang verstecken
def save_new_name():
new_name = name_input.value
# Nur speichern, wenn ein Name drinsteht und er anders ist als vorher
if new_name and new_name != display_name:
print("save")
data_api.change_display_name(player_id, new_name) # Deine DB Funktion
app.storage.user['display_name'] = new_name
ui.notify('Name gespeichert!', color='positive')
ui.navigate.reload()
else:
# Wenn nichts geändert wurde, einfach wieder einklappen
toggle_edit_mode()
def generate_random_silly_name():
silly_name = data_api.generate_silly_name()
name_input.value=silly_name
name_input = ui.input('Neuer Name', value=display_name).on('keydown.enter', save_new_name)
ui.button(icon='save', color='positive', on_click=save_new_name).props('round dense')
ui.button(icon='casino', color="accent", on_click=generate_random_silly_name).props('round dense')
ui.button(icon='close', color='negative', on_click=toggle_edit_mode).props('round dense')
avatar = app.storage.user.get('avatar_url')
if avatar:
ui.image(avatar).classes('w-5 h-5 rounded-full border-2 border-red-500')
def logout():
app.storage.user.clear()
ui.navigate.to('/')
ui.button(icon="logout", on_click=logout).props('round dense size=lg')
else:
auth_url = discord_login.get_auth_url()
ui.button('Login with Discord', on_click=lambda: ui.navigate.to(auth_url))
# ---------------------------
# --- Match Bestätigung ---
# ---------------------------
# Bestätigungs für offene Spiele --- Der "Marian Balken !!!1!11!"
if app.storage.user.get('authenticated', False):
unconfirmed_matches = data_api.get_unconfirmed_matches(player_id)
if len(unconfirmed_matches) > 0:
# Eine auffällige, rote Karte über die volle Breite
with ui.card().classes('w-full bg-red-900/80 border-2 border-red-500 mb-6'):
ui.label(f"Aktion erforderlich: Du hast {len(unconfirmed_matches)} offen(e) Spiel(e)!").classes('text-2xl font-bold text-normaltext mb-2')
for match in unconfirmed_matches:
# Button Funktionen. Akzeptieren oder Rejecten. Der Reject Button löscht das Match aus der DB.
def reject_match(m_id):
data_api.delete_match(m_id, player_id)
ui.notify("Spiel abgelehnt und gelöscht!", color="warning")
ui.navigate.reload() # Lädt die Seite neu, um die Karte zu aktualisieren
def acccept_match(m_id):
ui.notify("Spiel akzeptiert. Wird Berechnet.")
ui.navigate.reload() # Lädt die Seite neu, um die Karte zu aktualisieren
data_api.confirm_match(m_id)
calc_match.calculate_match(m_id)
with ui.row().classes('w-full items-center justify-between bg-zinc-900 p-3 rounded shadow-inner'):
info_text = f"{match['system_name']} - {match["played_at"]} "
detail_text = f"{match['p1_name']} behauptet: {match['p1_name']} ({match['score_player1']}) vs. Du ({match['score_player2']})"
ui.label(info_text).classes('text-bold text-lg text-normaltext')
ui.label(detail_text).classes('text-bold text-normaltext')
# Die Buttons (Funktion machen wir im nächsten Schritt!)
with ui.row().classes('gap-2'):
# ABLEHNEN und Spiel löschen
ui.button(color="negative", icon="close", on_click=lambda e, m_id=match['match_id']: reject_match(m_id))
ui.space()
# BESTÄTIGEN und spiel berechnen lassen
ui.button(color="positive", icon="check", on_click=lambda e, m_id=match['match_id']: acccept_match(m_id))
ui.label("Bestätigen wenn die Angaben stimmen, ablehnen wenn sich ein Fehler eingeschlichen hat.")
# ---------------------------
# --- Selbst eingetragene, offene Spiele ---
# ---------------------------
submitted_matches = data_api.get_submitted_matches(player_id)
if len(submitted_matches) > 0:
# Eine etwas dezentere Karte (grau)
with ui.card().classes('w-full bg-zinc-800 border border-gray-600 mb-6'):
ui.label(f"Warten auf Gegner: Du hast {len(submitted_matches)} offene(s) Spiel(e).").classes('text-xl font-bold text-gray-300 mb-2')
for match in submitted_matches:
# Die Lösch-Funktion, die beim Klick ausgeführt wird
def retract_match(m_id):
data_api.delete_match(m_id, player_id)
ui.notify("Eingetragenes Spiel zurückgezogen!", color="warning")
ui.navigate.reload()
# Für jedes Match machen wir eine kleine Reihe
with ui.row().classes('w-full items-center justify-between bg-zinc-900 p-3 rounded shadow-inner'):
# Info-Text: Auf wen warten wir?
info_text = f"{match['system_name']} - {match["played_at"]} "
detail_text = f"Du ({match["score_player1"]}) vs. {match["p2_name"]}({match["score_player2"]})"
ui.label(info_text).classes('text-bold text-lg text-normaltext')
ui.label(detail_text).classes('text-bold text-normaltext')
ui.button(color="warning", icon="delete", on_click=lambda e, m_id=match['match_id']: retract_match(m_id))
ui.label("Dein Gegner muss das Match noch bestätigen. Wenn du einen Fehler gemacht hast, kannst du es löschen.")
# ---------------------------
# --- Spielsysteme ---
# ---------------------------
if app.storage.user.get('authenticated', False):
with ui.card().classes("w-full items-center"):
with ui.row():
ui.label(text="Meine Ligaplätze").classes("font-bold text-white text-xl text-normaltext")
info_system.create_info_button("league_info")
placements = data_api.get_player_statistics(player_id)
systems = data_api.get_gamesystems_data()
my_stats = { p['gamesystem_id']: p for p in placements }
def click_join_league(p_id, sys_id):
data_api.join_league(p_id, sys_id)
ui.navigate.to("/")
def toggle_visibility(row_a, row_b):
row_a.visible = not row_a.visible
row_b.visible = not row_b.visible
with ui.element('div').classes("w-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-center"):
for sys in systems:
sys_id = sys['id']
sys_name = sys['name']
sys_logo = sys["picture"]
sys_description = sys['description']
sys_card = ui.card().classes("h-60 w-full items-center justify-center transition-colors")
with sys_card:
# Wir erstellen einen Container für den "klickbaren" Teil
clickable_content = ui.column().classes("w-full h-full items-center")
with clickable_content:
ui.label(text=sys_name).classes('text-xl font-bold text-center text-normaltext')
if sys_logo:
ui.image(f"/pictures/{sys_logo}").classes("w-60")
if sys_description:
ui.label(text=sys['description']).classes('text-xs text-gray-400 text-center mt-2 text-infotext')
# Prüfen: Ist diese sys_id in den Stats des Spielers? UND hat er ein MMR?
if sys_id not in my_stats or my_stats[sys_id]['mmr'] is None:
ui.label(text="Du bist noch nicht in dieser Liga.").classes("text-red-500 font-bold")
clickable_content.classes("cursor-pointer hover:bg-zinc-800")
clickable_content.on('click', lambda e, name=sys_name: ui.navigate.to(f'/statistic/visitor/{name}'))
join_row = ui.row().classes('items-center gap-2')
confirm_row = ui.row().classes('items-center gap-2')
confirm_row.visible = False
with join_row:
ui.button("Beitreten", on_click=lambda e, r1=join_row, r2=confirm_row: toggle_visibility(r1, r2))
with confirm_row:
ui.button("Liga Beitreten", color="green", on_click=lambda e, p=player_id, s=sys_id: click_join_league(p, s))
ui.button(icon='cancel', color='red', on_click=lambda e, r1=join_row, r2=confirm_row: toggle_visibility(r1, r2)).props('round dense')
else:
# Spieler IST in der Liga
stat = my_stats[sys_id]
# JETZT machen wir nur den Container klickbar, nicht die ganze Karte!
clickable_content.classes("cursor-pointer hover:bg-zinc-800")
clickable_content.on('click', lambda e, name=sys_name: ui.navigate.to(f'/statistic/{name}'))
with ui.row().classes('items-center gap-4'):
ui.label(text=f"MMR: {stat['mmr']}").classes("text-lg font-bold text-accenttext")
ui.label(text=f"Spiele: {stat['games_in_system']}").classes("text-lg font-bold text-accenttext")
# ---------------------------
# Match Historie
# ---------------------------
with ui.card().classes("w-full items-center"):
ui.label(text= "Meine letzten Spiele").classes("font-bold text-normaltext text-xl")
# 1. Daten aus der DB holen ABER per [:5] hart auf die neuesten 5 Listen-Einträge abschneiden!
raw_matches = data_api.get_recent_matches_for_player(player_id)[:5]
# 2. Daten für die Tabelle aufbereiten (Bleibt exakt gleich wie bei dir)
table_rows = []
for match in raw_matches:
if match['player1_id'] == player_id:
opponent_name = f"{match['p2_display']} aka {match['p2_discord']}"
my_score = match['score_player1']
opp_score = match['score_player2']
else:
opponent_name = f"{match['p1_display']} aka {match['p1_discord']}"
my_score = match['score_player2']
opp_score = match['score_player1']
if my_score > opp_score:
result_text = "Gewonnen"
elif my_score < opp_score:
result_text = "Verloren"
else:
result_text = "Unentschieden"
date_clean = str(match['played_at'])[:10]
table_rows.append({
'date': date_clean,
'system': match['gamesystem_name'],
'opponent': opponent_name,
'result': f"{result_text} ({my_score} : {opp_score})"
})
table_columns = [
{'name': 'date', 'label': 'Gespielt am', 'field': 'date', 'align': 'left'},
{'name': 'system', 'label': 'System', 'field': 'system', 'align': 'left'},
{'name': 'opponent', 'label': 'Gegner', 'field': 'opponent', 'align': 'left'},
{'name': 'result', 'label': 'Ergebnis', 'field': 'result', 'align': 'left'}
]
# 4. Die Tabelle zeichnen und den NEUEN BUTTON hinzufügen
if len(table_rows) > 0:
ui.table(columns=table_columns, rows=table_rows, row_key='date').classes('w-full bg-zinc-900 text-white')
# NEU: Der Button, der zur großen Log-Seite führt
ui.button("Komplette Historie & Log anzeigen", icon="history", on_click=lambda: ui.navigate.to('/matchhistory')).classes('mt-4 bg-zinc-700 text-white hover:bg-zinc-600')
else:
ui.label("Noch keine Spiele absolviert.").classes("text-infotext italic")
with ui.footer(fixed=False).classes('items-center justify-between bg-zinc-900 shadow-lg'):
ui.button(icon="description", on_click=lambda: ui.navigate.to('/info'))
# Desktop oder Mobile?
if is_mobile(request):
show_mobile()
else:
show_desktop()

View File

@ -1,91 +0,0 @@
from nicegui import ui, app
from gui import gui_style
from data import data_api
from match_calculations import calc_match
from gui.info_text import info_system
def setup_routes():
# 1. Die {}-Klammern definieren eine dynamische Variable in der URL
@ui.page('/add-match/{system_name}', dark=True)
def match_form_page(system_name: str): # <-- Hier wird der Name des Spielsystems gefiltert.
gui_style.apply_design()
# --- SICHERHEITS-CHECK ---
if not app.storage.user.get('authenticated', False):
ui.label('Access Denied. Please log in first.').classes('text-red-500')
ui.button('Back to Home', on_click=lambda: ui.navigate.to('/'))
return
# --- Eingabeformular ---
with ui.card().classes('w-full max-w-md mx-auto items-center mt-10 p-6 shadow-xl'):
system_id = data_api.get_gamesystem_id_by_name(system_name)
system_data = data_api.get_gamesystem_data(system_id)
min_score = system_data["min_score"]
max_score = system_data["max_score"]
# Text-Center hinzugefügt, falls der Systemname sehr lang ist und auf dem Handy umbricht
ui.label(f'Neues Spiel für {system_name} eintragen').classes('text-2xl font-bold text-center mb-6')
with ui.card().classes('w-full max-w-md mx-auto items-center mt-10 p-6'):
ui.label("Meine Punkte:").classes('text-xl font-bold w-full text-left')
with ui.column().classes("w-full items-center gap-6"):
# 1. Daten aus der DB holen
raw_players = data_api.get_all_players_from_system(system_name)
my_id = app.storage.user.get('db_id')
with ui.column().classes("items-center justify-between"):
p1_points = ui.number(placeholder="Punkte eingeben",
min=system_data["min_score"],
max=system_data["max_score"],
precision=0
).classes("w-35").props("clearable")
with ui.card().classes('w-full max-w-md mx-auto items-center mt-10 p-6'):
dropdown_options = {}
for p in raw_players:
if p['player_id'] == my_id:
continue
dropdown_options[p['player_id']] = f"{p['display_name']} 'aka' {p['discord_name']}"
opponent_select = ui.select(options=dropdown_options, label='Gegner auswählen').classes('w-full')
with ui.column().classes("items-center justify-between"):
p2_points = ui.number(placeholder="Punkte eingeben",
min=system_data["min_score"],
max=system_data["max_score"],
precision=0
).classes("w-35").props("clearable")
# Das Match in die Datenbank eintragen lassen.
def input_match_to_database():
p2_id = opponent_select.value
score_p1 = p1_points.value
score_p2 = p2_points.value
# Check der Eingegebenen Daten.
# Fehler wenn kein Spieler oder keine Punkte eingegeben werden. Das Punkte Eingabefeld fängt mit min_score und max_score ab,
# ob für das Spielsystem legale Punkte eingetragen wurden.
if p2_id is None:
ui.notify("Bitte wähle zuerst einen Gegner aus!", color="red", position="top")
return
if score_p1 is None:
ui.notify("Ungültige Punkteeingabe Spieler 1!", color="red", position="top")
return
if score_p2 is None:
ui.notify("Ungültige Punkteeingabe Spieler 2!", color="red", position="top")
return
match_id = data_api.add_new_match(system_name, my_id, p2_id, score_p1, score_p2)
ui.navigate.to(f'/statistic/{system_name}')
# Buttons ganz unten in einer Reihe
with ui.row().classes("w-full items-center justify-between mt-8"):
ui.button(icon="close", on_click=lambda: ui.navigate.to(f'/statistic/{system_name}')).classes("w-10 h-8 rounded-full")
info_system.create_info_button("match_form_info")
ui.button(text="Absenden", color="positive", on_click=lambda: input_match_to_database())

View File

@ -1,125 +1,27 @@
from nicegui import ui, app
from gui.common import *
from data import data_api
from gui import gui_style
from gui.info_text.info_system import create_info_button
def setup_routes():
@ui.page('/matchhistory', dark=True)
def match_history_page():
gui_style.apply_design()
if not app.storage.user.get('authenticated', False):
ui.label('Bitte logge dich ein.').classes('text-red-500 text-2xl m-4')
@ui.page('/player_match_history', dark=True)
def player_match_history_page(request: Request):
# 1. Wrapper aufrufen (Login-Check & Styling)
if not base_page(request, "Match History"):
return
player_id = app.storage.user.get('db_id')
# 2. Daten laden
player_id = app.storage.user.get('player_id')
with ui.column().classes('w-full mx-auto p-4'):
# 3. Dektop OR Mobile
def show_desktop():
templates.DesktopHeader("Match History", request=request)
templates.DesktopMatchHistory(player_id)
with ui.row().classes('w-full items-center justify-between mb-6'):
ui.label("Komplette Match Historie").classes("text-3xl font-bold text-white")
create_info_button("mmr_calc")
ui.button("Zurück", icon="arrow_back", on_click=lambda: ui.navigate.to('/')).classes('bg-zinc-700 text-white')
def show_mobile():
templates.MobileHeader("Match History", request=request)
templates.MobileMatchHistory(player_id)
raw_matches = data_api.get_match_history_log(player_id)
table_rows = []
def fmt_signed(val, pending=False):
"""Formatiert einen Integer-Wert mit Vorzeichen oder gibt Sondertexte zurück."""
if pending:
return "Ausstehend"
if val is None:
return "0"
if val > 0:
return f"+{val}"
return str(val)
for i, match in enumerate(raw_matches):
is_player1 = match['player1_id'] == player_id
pending = match['match_is_counted'] == 0
if is_player1:
opponent = f"{match['p2_display']} aka {match['p2_discord']}"
my_score = match['score_player1']
opp_score = match['score_player2']
mmr_base = match["player1_base_change"]
mmr_change = match['player1_mmr_change']
khorne = match['player1_khorne']
tzeentch = match['player1_tzeentch']
slaanesh = match['player1_slaanesh']
else:
opponent = f"{match['p1_display']} aka {match['p1_discord']}"
my_score = match['score_player2']
opp_score = match['score_player1']
mmr_base = match["player2_base_change"]
mmr_change = match['player2_mmr_change']
khorne = match['player2_khorne']
tzeentch = match['player2_tzeentch']
slaanesh = match['player2_slaanesh']
if my_score > opp_score:
result = "Gewonnen"
elif my_score < opp_score:
result = "Verloren"
else:
result = "Unentschieden"
elo_factor = match['elo_factor']
rust_factor = match['rust_factor']
table_rows.append({
'id': i,
'date': str(match['played_at'])[:10],
'system': match['gamesystem_name'],
'score': str(my_score),
'opponent': (f"{opponent} ({opp_score})"),
"basis" : mmr_base,
'elo': fmt_signed(round(elo_factor, 2) if elo_factor is not None else None, pending),
'rust': fmt_signed(round(rust_factor, 2) if rust_factor is not None else None, pending),
'khorne': fmt_signed(khorne, pending),
'tzeentch': fmt_signed(tzeentch, pending),
'slaanesh': fmt_signed(slaanesh, pending),
'mmr': fmt_signed(mmr_change, pending),
})
columns = [
{'name': 'date', 'label': 'Datum', 'field': 'date', 'align': 'left'},
{'name': 'system', 'label': 'System', 'field': 'system', 'align': 'left'},
{'name': 'score', 'label': 'Punkte', 'field': 'score', 'align': 'center'},
{'name': 'opponent', 'label': 'Gegner (Pkt.)', 'field': 'opponent', 'align': 'left'},
{'name': 'elo', 'label': 'Elo Faktor', 'field': 'elo', 'align': 'right'},
{'name': 'basisMMR', 'label': 'Basis MMR', 'field': 'basis', 'align': 'right'},
{'name': 'rust', 'label': 'Rost Faktor', 'field': 'rust', 'align': 'right'},
{'name': 'khorne', 'label': 'Khorne', 'field': 'khorne', 'align': 'right'},
{'name': 'tzeentch', 'label': 'Tzeentch', 'field': 'tzeentch', 'align': 'right'},
{'name': 'slaanesh', 'label': 'Slaanesh', 'field': 'slaanesh', 'align': 'right'},
{'name': 'mmr', 'label': 'MMR GESAMT', 'field': 'mmr', 'align': 'right'},
]
# Shared slot template for colored signed values
colored_slot = '''
<q-td :props="props">
<span :class="{
'text-green-500 font-bold': props.row[props.col.field].startsWith('+'),
'text-red-500 font-bold': props.row[props.col.field].startsWith('-'),
'text-gray-400 italic': props.row[props.col.field] === 'Ausstehend'
}">
{{ props.row[props.col.field] }}
</span>
</q-td>
'''
if table_rows:
history_table = ui.table(
columns=columns,
rows=table_rows,
row_key='id'
).classes('w-full bg-zinc-900 text-white')
for col in ['elo', 'rust', 'khorne', 'tzeentch', 'slaanesh', 'mmr']:
history_table.add_slot(f'body-cell-{col}', colored_slot)
else:
ui.label("Keine Spiele gefunden.").classes("text-gray-400 italic")
# Desktop oder Mobile?
if is_mobile(request):
show_mobile()
else:
show_desktop()

8
gui/profile_gui.py Normal file
View File

@ -0,0 +1,8 @@
from gui.common import *
from data import data_api
def setup_routes():
@ui.page('/profile/{player_id}', dark=True)
def gamesystem_statistic_page(request: Request, player_id: int):
pass

View File

@ -1,24 +0,0 @@
from nicegui import ui
from data import data_api
from gui.templates.player_card import PlayerCard
class Leaderboard:
def __init__(self, system_name: str):
# Daten holen
leaderboard_data = data_api.get_leaderboard(system_name)
# Container
with ui.column().classes('w-full gap-2') as self.container:
for index, player in enumerate(leaderboard_data):
PlayerCard(
rank=index + 1,
display_name=player['display_name'],
discord_name=player['discord_name'],
mmr=player['mmr'],
discord_avatar_url=player.get('discord_avatar_url'),
discord_id=player.get('discord_id'),
games_in_system=player.get('games_in_system', 0),
wins=player.get('wins', 0),
loss=player.get('loss', 0),
avv_points=player.get('avv_points', 0)
)

View File

@ -1,41 +0,0 @@
from nicegui import ui
class PlayerCard:
def __init__(self, rank: int, display_name: str, discord_name: str, mmr: int,
discord_avatar_url: str = None, discord_id: str = None,
games_in_system: int = 0, wins: int = 0, loss: int = 0, avv_points: int = 0):
# Rang links, Card rechts
with ui.row().classes('w-full items-center gap-1 flex-nowrap'):
ui.label(f'#{rank}').classes('text-2xl font-bold w-10 text-center shrink-0')
# Player Card
with ui.card().classes("flex-1 no-shadow bg-zinc-900 min-w-0 overflow-hidden"):
# Discord Block - klickbar
if discord_id:
with ui.link(target=f'https://discord.com/users/{discord_id}', new_tab=True).classes('no-underline w-full'):
self._discord_content(discord_avatar_url, display_name, discord_name)
else:
self._discord_content(discord_avatar_url, display_name, discord_name)
# Stats und Achievments
with ui.row().classes('w-full items-center flex-nowrap'):
with ui.row().classes('flex-2 items-center gap-2 md:gap-3 md:justify-center shrink min-w-0'):
ui.label(f'{mmr} MMR').classes('font-bold md:text-lg')
with ui.row().classes('flex-1 justify-end items-center gap-1 md:gap-3 shrink-0'):
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-5 md:w-15')
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-5 md:w-15')
ui.image('gui/pictures/achievments/AchievementIcon_4_1Sieg.png').classes('w-5 md:w-15')
def _discord_content(self, avatar_url, display_name, discord_name):
short_name = display_name[:15] + '...' if len(display_name) > 15 else display_name
short_discord = discord_name[:15] + '...' if len(discord_name) > 15 else discord_name
with ui.card().classes("w-full md:w-90 bg-indigo-950 no-shadow"):
with ui.row().classes('items-center gap-3 min-w-0'):
if avatar_url:
ui.image(avatar_url).classes('w-10 h-10 md:w-20 md:h-20 rounded-full border-red-900 border-2 shrink-0')
with ui.column().classes('gap-0 min-w-0'):
ui.label(short_name).classes('text-sm md:text-2xl font-bold whitespace-nowrap truncate')
ui.label(short_discord).classes('text-xs md:text-sm opacity-60 whitespace-nowrap truncate')

35
main.py
View File

@ -1,43 +1,36 @@
import os
import schedule
import schedule
from dotenv import load_dotenv
from nicegui import ui, app
from gui import setup_all_routes
from data import database
from gui import main_gui, match_gui, discord_login, league_statistic_gui, admin_gui, match_history_gui, imprint_gui, league_statistic_visitor_gui
from wood import logger
from gui.info_text import info_system
from utils import season_scheduler
from starlette.requests import Request
from wood import logger
# 1. Lade die geheimen Variablen aus der .env Datei in den Speicher
# 1. Load the secret variables from the .env file into memory
load_dotenv()
# Festschreiben des Ordners für die Bilder (Darf hier bleiben!)
# Fixed directory for the pictures (must remain here!)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PICTURE_DIR = os.path.join(BASE_DIR, "gui", "pictures")
# Fester Pfad für den Webserver
# Fixed path for the web server
app.add_static_files('/pictures', PICTURE_DIR)
# 2. Variablen abrufen
# 2. Retrieve variables
client_id = os.getenv("DISCORD_CLIENT_ID")
client_secret = os.getenv("DISCORD_CLIENT_SECRET")
admin_discord_id = os.getenv("ADMIN")
url = os.getenv("APP_URL")
logger.setup_log_db()
# 3. Seitenrouten aufbauen
main_gui.setup_routes(admin_discord_id)
discord_login.setup_login_routes()
league_statistic_gui.setup_routes()
match_gui.setup_routes()
admin_gui.setup_routes()
match_history_gui.setup_routes()
imprint_gui.setup_routes()
league_statistic_visitor_gui.setup_routes()
# 3. Build page routes
setup_all_routes()
# Timer der schedule regelmäßig prüft
# Timer of schedule regularly checks
@app.on_startup
def setup_scheduler():
import threading
@ -46,7 +39,5 @@ def setup_scheduler():
thread = threading.Thread(target=run_scheduler, daemon=True)
thread.start()
# 4. Starten der App Runtime auf dem Server.
# 4. Start the App Runtime on the server.
ui.run(title="Westside Diceghost Liga", port=9000, storage_secret="EIN_super-geheimes_Pa$$wort#!", favicon="gui/pictures/wsdg.png")

View File

@ -3,7 +3,7 @@ from wood import logger
import sqlite3
def check_player_achievments(player_id):
def check_player_achievments(player_id :int, system_id :int):
# Schauen ob der Spieler sich nach dem Match (function wird aufgerufen NACHDEM ein Match berechnet wurde) sich ein Achievment verdient hat.
game_stats = data_api.get_player_statistic(player_id, system_id)

View File

@ -6,7 +6,7 @@ from wood import logger
from match_calculations.achievments import achievments
point_inflation = 1 # => entspricht % 1 == 100% 0.5 == 50%
point_inflation = 1 # => entspricht %
K_FACTOR = 40 # Die "Border" (Maximalpunkte) die ein Sieg gibt.
# Mach die DB abfrage für die Relevanten Daten. Von hier aus werden die "Aufgaben" und Daten dann an die kleineren Berechnungs Funktionen verteilt.
@ -79,23 +79,23 @@ def calculate_match (match_id):
"looser_id" : looser_id, # <-- String-Key für save_calculated_match
winner_id: { # <-- Variable als Key (z.B. 42: {...})
"base" : w_base,
"khorne" : w_khorne,
"slaanesh" : slaanesh,
"tzeentch" : tzeentch,
"total" : int(w_base * rust_factor + w_khorne + slaanesh + tzeentch)
"base" : w_base,
"khorne" : w_khorne,
"slaanesh" : slaanesh,
"tzeentch" : tzeentch,
"total" : int((w_base + w_khorne + slaanesh + tzeentch) * rust_factor),
},
looser_id: { # <-- Variable als Key (z.B. 7: {...})
"base" : -l_base,
"khorne" : l_khorne,
"slaanesh" : -slaanesh,
"tzeentch" : -tzeentch,
"total" : int(-l_base * rust_factor + l_khorne - slaanesh - tzeentch)
"base" : -l_base,
"khorne" : l_khorne,
"slaanesh" : -slaanesh,
"tzeentch" : -tzeentch,
"total" : int((-l_base + l_khorne - slaanesh - tzeentch) * rust_factor),
}
}
logger.log(f"...Winner: Base({w_base} × {rust_factor}) + Khorne({w_khorne}) + Slaanesh({slaanesh}) + Tzeentch({tzeentch}) = {total}")
logger.log(f"...Loser: Base(-{l_base} × {rust_factor}) + Khorne({l_khorne}) - Slaanesh({slaanesh}) - Tzeentch({tzeentch}) = {total}")
logger.log(f"Match{match_id}: Winner {data_api.get_player_name(winner_id)}: Base {w_base} + Khorne({w_khorne}) + Slaanesh({slaanesh}) + Tzeentch({tzeentch}) = {calc_results[winner_id]["total"]}")
logger.log(f"Match{match_id}: Looser {data_api.get_player_name(looser_id)}: -Base({l_base}) + Khorne({l_khorne}) - Slaanesh({slaanesh}) - Tzeentch({tzeentch}) = {calc_results[looser_id]["total"]}")
data_api.save_calculated_match(calc_results)
achievments.check_player_achievments(winner_id, system_id)
achievments.check_player_achievments(looser_id, system_id)

View File

@ -37,17 +37,18 @@ def calc_rust_factor(winner_id, looser_id, gamesystem_id):
w_days = data_api.get_days_since_last_system_game(winner_id, gamesystem_id)
l_days = data_api.get_days_since_last_system_game(looser_id, gamesystem_id)
# Der größere der beiden Werte wird verwendet.
# Der größeren der beiden Werte wird verwendet.
days_ago = max(w_days, l_days)
"""Berechnet den Dämpfungsfaktor basierend auf den vergangen Tagen."""
if days_ago <= 30:
return 1.0 # Volle Punkte
elif days_ago > 90:
return 0.4 # Maximal eingerostet (40% der Punkteänderung)
return 0.1 # Maximal eingerostet (nur 10% der Punkteänderung)
else:
# Lineare Rampe von 0.9 (bei Tag 31) runter auf 0.4 (bei Tag 90)
factor = 0.9 - 0.4 * ((days_ago - 30) / 60)
# Lineare Rampe von 0.8 (bei Tag 31) runter auf 0.1 (bei Tag 90)
# Formel: Startwert - (Differenz * Prozentualer Weg)
factor = 0.8 - 0.7 * ((days_ago - 30) / 60)
return round(factor, 2)

125
match_history_gui_OLD.py Normal file
View File

@ -0,0 +1,125 @@
from nicegui import ui, app
from data import data_api
from gui import gui_style
from gui.info_text.info_system import create_info_button
def setup_routes():
@ui.page('/matchhistory', dark=True)
def match_history_page():
gui_style.apply_design()
if not app.storage.user.get('authenticated', False):
ui.label('Bitte logge dich ein.').classes('text-red-500 text-2xl m-4')
return
player_id = app.storage.user.get('db_id')
with ui.column().classes('w-full mx-auto p-4'):
with ui.row().classes('w-full items-center justify-between mb-6'):
ui.label("Komplette Match Historie").classes("text-3xl font-bold text-white")
create_info_button("mmr_calc")
ui.button("Zurück", icon="arrow_back", on_click=lambda: ui.navigate.to('/')).classes('bg-zinc-700 text-white')
raw_matches = data_api.get_match_history_log(player_id)
table_rows = []
def fmt_signed(val, pending=False):
"""Formatiert einen Integer-Wert mit Vorzeichen oder gibt Sondertexte zurück."""
if pending:
return "Ausstehend"
if val is None:
return "0"
if val > 0:
return f"+{val}"
return str(val)
for i, match in enumerate(raw_matches):
is_player1 = match['player1_id'] == player_id
pending = match['match_is_counted'] == 0
if is_player1:
opponent = f"{match['p2_display']} aka {match['p2_discord']}"
my_score = match['score_player1']
opp_score = match['score_player2']
mmr_base = match["player1_base_change"]
mmr_change = match['player1_mmr_change']
khorne = match['player1_khorne']
tzeentch = match['player1_tzeentch']
slaanesh = match['player1_slaanesh']
else:
opponent = f"{match['p1_display']} aka {match['p1_discord']}"
my_score = match['score_player2']
opp_score = match['score_player1']
mmr_base = match["player2_base_change"]
mmr_change = match['player2_mmr_change']
khorne = match['player2_khorne']
tzeentch = match['player2_tzeentch']
slaanesh = match['player2_slaanesh']
if my_score > opp_score:
result = "Gewonnen"
elif my_score < opp_score:
result = "Verloren"
else:
result = "Unentschieden"
elo_factor = match['elo_factor']
rust_factor = match['rust_factor']
table_rows.append({
'id': i,
'date': str(match['played_at'])[:10],
'system': match['gamesystem_name'],
'score': str(my_score),
'opponent': (f"{opponent} ({opp_score})"),
"basis" : mmr_base,
'elo': fmt_signed(round(elo_factor, 2) if elo_factor is not None else None, pending),
'rust': fmt_signed(round(rust_factor, 2) if rust_factor is not None else None, pending),
'khorne': fmt_signed(khorne, pending),
'tzeentch': fmt_signed(tzeentch, pending),
'slaanesh': fmt_signed(slaanesh, pending),
'mmr': fmt_signed(mmr_change, pending),
})
columns = [
{'name': 'date', 'label': 'Datum', 'field': 'date', 'align': 'left'},
{'name': 'system', 'label': 'System', 'field': 'system', 'align': 'left'},
{'name': 'score', 'label': 'Punkte', 'field': 'score', 'align': 'center'},
{'name': 'opponent', 'label': 'Gegner (Pkt.)', 'field': 'opponent', 'align': 'left'},
{'name': 'elo', 'label': 'Elo Faktor', 'field': 'elo', 'align': 'right'},
{'name': 'basisMMR', 'label': 'Basis MMR', 'field': 'basis', 'align': 'right'},
{'name': 'rust', 'label': 'Rost Faktor', 'field': 'rust', 'align': 'right'},
{'name': 'khorne', 'label': 'Khorne', 'field': 'khorne', 'align': 'right'},
{'name': 'tzeentch', 'label': 'Tzeentch', 'field': 'tzeentch', 'align': 'right'},
{'name': 'slaanesh', 'label': 'Slaanesh', 'field': 'slaanesh', 'align': 'right'},
{'name': 'mmr', 'label': 'MMR GESAMT', 'field': 'mmr', 'align': 'right'},
]
# Shared slot template for colored signed values
colored_slot = '''
<q-td :props="props">
<span :class="{
'text-green-500 font-bold': props.row[props.col.field].startsWith('+'),
'text-red-500 font-bold': props.row[props.col.field].startsWith('-'),
'text-gray-400 italic': props.row[props.col.field] === 'Ausstehend'
}">
{{ props.row[props.col.field] }}
</span>
</q-td>
'''
if table_rows:
history_table = ui.table(
columns=columns,
rows=table_rows,
row_key='id'
).classes('w-full bg-zinc-900 text-white')
for col in ['elo', 'rust', 'khorne', 'tzeentch', 'slaanesh', 'mmr']:
history_table.add_slot(f'body-cell-{col}', colored_slot)
else:
ui.label("Keine Spiele gefunden.").classes("text-gray-400 italic")

56
requirements.txt Normal file
View File

@ -0,0 +1,56 @@
aiofiles==25.1.0
aiohappyeyeballs==2.6.2
aiohttp==3.14.1
aiosignal==1.4.0
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.14.0
attrs==26.1.0
bidict==0.23.1
certifi==2026.6.17
charset-normalizer==3.4.7
click==8.4.1
docutils==0.23
fastapi==0.138.0
frozenlist==1.8.0
h11==0.16.0
httpcore==1.0.9
httptools==0.8.0
httpx==0.28.1
idna==3.18
ifaddr==0.2.0
itsdangerous==2.2.0
Jinja2==3.1.6
lxml==6.1.1
lxml_html_clean==0.4.5
markdown2==2.5.5
MarkupSafe==3.0.3
multidict==6.7.1
nicegui==3.13.0
orjson==3.11.9
packaging==26.2
propcache==0.5.2
pydantic==2.13.4
pydantic_core==2.46.4
Pygments==2.20.0
PyMySQL==1.2.0
python-dotenv==1.2.2
python-engineio==4.13.2
python-multipart==0.0.32
python-socketio==5.16.3
PyYAML==6.0.3
requests==2.34.2
schedule==1.2.2
simple-websocket==1.1.0
starlette==1.3.1
tinycss2==1.5.1
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.7.0
uvicorn==0.49.0
uvloop==0.22.1
watchfiles==1.2.0
webencodings==0.5.1
websockets==16.0
wsproto==1.3.2
yarl==1.24.2

View File

@ -56,12 +56,7 @@ echo "✅ Umgebung eingerichtet! Installiere Pakete ..."
pip install --upgrade pip
# 6. Python-Pakete installieren
pip install \
nicegui \
requests \
python-dotenv \
pymysql \
schedule
pip install -r requirements.txt
echo "✅ Pakete installiert. Starte Liga Service ..."
@ -72,11 +67,6 @@ if [ "$SERVICE_EXISTS" = true ]; then
if sudo systemctl start liga; then
echo "✅ Liga Service erfolgreich gestartet. Alles bereit!"
else
# Service konnte nicht gestartet werden mögliche Ursachen:
# - Konfigurationsfehler in der .service Datei
# - Python-Fehler beim Start (Import-Error, Syntax-Error etc.)
# - Port bereits belegt
# - Fehlende Berechtigungen
echo "❌ FEHLER: Liga Service konnte nicht gestartet werden!"
echo " Prüfe mit: sudo systemctl status liga"
echo " Prüfe mit: sudo journalctl -u liga --no-pager -n 50"

View File

@ -1,26 +1,88 @@
import schedule
import time
from datetime import date
from data import database
from data import database, data_api
from wood import logger
def check_new_seasons():
print("Season mach brrrrr")
today = date.today()
conn = database.db_connection()
cursor = conn.cursor(dictionary=True)
# 1. Prüfen, ob für heute ein Start geplant ist
cursor.execute(
"SELECT id, gamesystem_id FROM season_schedule "
"WHERE start_month = %s AND start_day = %s",
(today.month, today.day)
)
triggers = cursor.fetchall()
print(triggers)
print(f"[Season Check] {today} {len(triggers)} Treffer gefunden")
for trigger in triggers:
gamesystem_id = trigger['gamesystem_id']
# 2. Prüfen, ob heute schon eine Season für das System erstellt wurde
cursor.execute(
"SELECT id FROM seasons WHERE gamesystem_id = %s AND start_date = %s",
(gamesystem_id, today)
)
if cursor.fetchone():
continue
# 3. Alte Season beenden (end_date = gestern)
cursor.execute(
"UPDATE seasons SET end_date = DATE_SUB(CURDATE(), INTERVAL 1 DAY) "
"WHERE gamesystem_id = %s AND end_date IS NULL",
(gamesystem_id,)
)
# 4. Neue Season anlegen
cursor.execute(
"SELECT MAX(season_number) as last_num FROM seasons WHERE gamesystem_id = %s",
(gamesystem_id,)
)
result = cursor.fetchone()
last_num = result['last_num'] if result['last_num'] else 0
new_season_number = last_num + 1
cursor.execute(
"INSERT INTO seasons (gamesystem_id, name, start_date, season_number) "
"VALUES (%s, %s, %s, %s)",
(gamesystem_id, f"{data_api.get_system_name_by_id(gamesystem_id)} - Season{new_season_number}", today, new_season_number)
)
new_season_id = cursor.lastrowid
logger.log(f"new Season for {data_api.get_system_name_by_id(gamesystem_id)}, Season ID:{new_season_id}")
# 5. AUTO-JOIN: Spieler der alten Season in die neue Season kopieren
cursor.execute(
"SELECT id FROM seasons WHERE gamesystem_id = %s AND id != %s ORDER BY id DESC LIMIT 1",
(gamesystem_id, new_season_id)
)
old_season = cursor.fetchone()
if old_season:
old_season_id = old_season['id']
# Der Auto-Join Befehl
# Wir nehmen player_id und gamesystem_id aus der alten Season
# und setzen die neue season_id ein.
cursor.execute("""
INSERT INTO player_game_statistic (player_id, gamesystem_id, season_id, wins, loss, draws, games_in_system)
SELECT player_id, gamesystem_id, %s, 0, 0, 0, 0
FROM player_game_statistic
WHERE season_id = %s
""", (new_season_id, old_season_id))
logger.log(f"Player copied from SeasonID {old_season_id} to SeasonID {new_season_id}")
conn.commit()
cursor.close()
conn.close()
def run_scheduler():
"""Läuft in einem Hintergrund-Thread."""