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
39 changed files with 1816 additions and 914 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*

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)

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."""