master #44

Merged
daniel merged 25 commits from master into database_update 2026-05-28 11:24:44 +02:00
36 changed files with 745 additions and 167 deletions

2
.gitignore vendored
View File

@ -18,3 +18,5 @@ env/
# 5. IDE / Editor Einstellungen
.vscode/
.idea/
data_trans.py

116
alembic.ini Normal file
View File

@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = mysql+pymysql://admin:PWM_556rtX@192.168.0.113:3306/league_db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -190,7 +190,7 @@ def get_player_statistic(player_id, system_id):
SELECT
sys.id AS gamesystem_id,
sys.name AS gamesystem_name,
sys.*,
sys.description,
stat.mmr,
stat.games_in_system,
stat.points,
@ -206,6 +206,7 @@ def get_player_statistic(player_id, system_id):
ON sys.id = stat.gamesystem_id AND stat.player_id = %s
WHERE sys.id = %s
"""
cursor.execute(query, (player_id, system_id))
row = cursor.fetchone()
@ -214,6 +215,33 @@ def get_player_statistic(player_id, system_id):
return row
def get_player_rank(player_id, system_id):
"""Holt den Rang eines Spielers in einem System basierend auf MMR (absteigend)."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
query = """
SELECT COUNT(*) + 1 AS rank
FROM player_game_statistic stat
WHERE stat.gamesystem_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
)
"""
cursor.execute(query, (system_id, player_id, system_id))
row = cursor.fetchone()
cursor.close()
connection.close()
if row and row['rank'] is not None:
return row['rank']
return None
def join_league(player_id, gamesystem_id):
connection = db_connection()
cursor = connection.cursor()
@ -407,19 +435,21 @@ def get_gamesystems_data():
return rows
def get_leaderboard(system_name):
"""Holt alle Spieler eines Systems sortiert nach MMR für die Rangliste."""
connection = db_connection()
cursor = connection.cursor(dictionary=True)
query = """
SELECT p.id, p.display_name, p.discord_name, stat.mmr
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
ORDER BY stat.mmr DESC
"""
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
ORDER BY stat.mmr DESC
"""
cursor.execute(query, (system_name,))
rows = cursor.fetchall()

View File

@ -2,10 +2,11 @@ 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/{systemname}', dark=True)
def gamesystem_statistic_page(systemname: str):
@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('/')
@ -14,112 +15,77 @@ def setup_routes():
gui_style.apply_design()
player_id = app.storage.user.get('db_id')
all_stats = data_api.get_player_statistics(player_id)
system_id = data_api.get_gamesystem_id_by_name(system_name)
print(player_id, system_id)
# Passendes System anhand des Namens (case-insensitive) herausfiltern
system_stat = next(
(s for s in all_stats if s["gamesystem_name"].lower() == systemname.lower()),
None
)
player_stats = data_api.get_player_statistic(player_id, 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}'))
if system_stat:
mmr = system_stat["mmr"] or 0
games = system_stat["games_in_system"] or 0
points = system_stat["points"] or 0
avv_points = system_stat.get("avv_points") or "-"
last_played_raw = system_stat.get("last_played")
last_played = str(last_played_raw)[:10] if last_played_raw else "-"
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')
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/{systemname}'))
# --- 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')
with ui.column().classes('w-full items-center justify-center mt-10'):
ui.label(f'Deine Statistik in {systemname}').classes('text-3xl justify-center font-bold text-normaltext')
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')
# --- BLOCK 1 (MMR & Rang | Rangliste) ---
leaderboard_data = data_api.get_leaderboard(systemname)
table_rows = []
my_rank = "-"
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)
for index, player in enumerate(leaderboard_data):
current_rank = index + 1
if player['id'] == player_id:
my_rank = current_rank
table_rows.append({
'rank': current_rank,
'trend': '',
'name': f"{player['display_name']} 'aka' {player['discord_name']}",
'mmr': player['mmr']
})
# --- 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"):
table_columns = [
{'name': 'rank', 'label': '#', 'field': 'rank', 'align': 'left'},
{'name': 'trend', 'label': 'Trend', 'field': 'trend', 'align': 'center'},
{'name': 'name', 'label': 'Spieler', 'field': 'name', 'align': 'left'},
{'name': 'mmr', 'label': 'MMR', 'field': 'mmr', 'align': 'left'},
]
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')
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(mmr)).classes('text-4xl font-bold text-accent')
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')
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(my_rank)).classes('text-4xl font-bold text-blue-100')
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.card().classes("w-full lg:col-span-2"):
ui.label("Liga Rangliste").classes("text-xl font-bold text-white mb-2")
ui.table(columns=table_columns, rows=table_rows, row_key='rank').classes('w-full bg-zinc-900 text-white')
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')
# --- 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"):
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')
with ui.card().classes("items-center justify-center text-center"):
ui.label("Spiele: ").classes('text-2xl font-bold')
ui.label(str(games)).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("Ø Punkte pro Spiel: ").classes('text-2xl font-bold')
ui.label(str(avv_points)).classes('text-4xl font-bold text-blue-100')
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("Win-Rate: ").classes('text-2xl font-bold')
ui.label("-").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("Letztes Spiel am: ").classes('text-2xl font-bold')
ui.label(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("-").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("-").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("-").classes('text-4xl font-bold text-blue-100')
else:
with ui.column().classes('w-full items-center justify-center mt-10'):
ui.label(f'Keine Statistik für "{systemname}" gefunden.').classes('text-red-500 text-xl mt-4')
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')

View File

@ -0,0 +1,24 @@
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)

View File

@ -21,7 +21,9 @@ def setup_routes(admin_discord_id):
ui.navigate.reload()
return
# -----------------------------------------
# ---------------------------
# --- NAVIGATIONSLEISTE (HEADER)
# ---------------------------
@ -31,19 +33,19 @@ def setup_routes(admin_discord_id):
# --- 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-15 h-15 rounded-full')
ui.label('Diceghost Liga').classes('text-2xl font-bold text-normaltext')
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')).props("round")
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-15 h-15 rounded-full')
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')
@ -57,10 +59,10 @@ def setup_routes(admin_discord_id):
# --- 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('text-xl font-bold text-normaltext')
ui.label(display_name).classes('md:text-3xl font-bold text-normaltext')
with ui.row().classes("items-center justify-between"):
ui.label("'aka'").classes('text-sm text-italic text-infotext')
ui.label(discord_name).classes('text-m text-bold text-infotext')
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!) ---
@ -201,19 +203,25 @@ def setup_routes(admin_discord_id):
sys_description = sys['description']
sys_card = ui.card().classes("h-60 w-full items-center justify-center transition-colors")
with sys_card:
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')
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
@ -225,11 +233,12 @@ def setup_routes(admin_discord_id):
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] # Wir holen uns seine Stats aus dem Wörterbuch
# Spieler IST in der Liga
stat = my_stats[sys_id]
sys_card.classes("cursor-pointer hover:bg-zinc-800")
sys_card.on('click', lambda e, name=sys_name: ui.navigate.to(f'/statistic/{name}'))
# 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")

View File

@ -33,19 +33,12 @@ def setup_routes():
raw_players = data_api.get_all_players_from_system(system_name)
my_id = app.storage.user.get('db_id')
def add_point():
p1_points.value += 1
def sub_point():
p1_points.value -= 1
with ui.row().classes("w-full items-center justify-between"):
p1_points = ui.slider(min=min_score, max=max_score, value=10).classes("w-35")
with ui.column().classes("items-center justify-between"):
# Punkte Up- Down- Buttons. und Textanzeige
ui.button(icon="expand_less", on_click=add_point)
ui.label().bind_text_from(p1_points, 'value').classes("text-lg text-normaltext")
ui.button(icon="expand_more", on_click=sub_point)
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 = {}
@ -56,36 +49,39 @@ def setup_routes():
opponent_select = ui.select(options=dropdown_options, label='Gegner auswählen').classes('w-full')
def add_point():
p2_points.value += 1
def sub_point():
p2_points.value -= 1
with ui.row().classes("w-full items-center justify-between"):
p2_points = ui.slider(min=min_score, max=max_score, value=10).classes("w-35")
with ui.column().classes("items-center justify-between"):
# Punkte Up- Down- Buttons. und Textanzeige
ui.button(icon="expand_less", on_click=add_point)
ui.label().bind_text_from(p2_points, 'value').classes("text-lg text-normaltext")
ui.button(icon="expand_more", on_click=sub_point)
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 und die MMR Berechnung triggern.
# 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
score_p1 = p1_points.value
score_p2 = p2_points.value
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)
# 4. Erfolgsmeldung und Berechnung
ui.notify("Match erfolgreich eingetragen!", color="green")
ui.navigate.to(f'/statistic/{system_name}')
# Buttons ganz unten in einer Reihe

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -0,0 +1,24 @@
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

@ -0,0 +1,41 @@
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')

18
main.py
View File

@ -1,11 +1,13 @@
import os
import schedule
from dotenv import load_dotenv
from nicegui import ui, app
from data import database
from gui import main_gui, match_gui, discord_login, league_statistic_gui, admin_gui, match_history_gui, imprint_gui
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
# 1. Lade die geheimen Variablen aus der .env Datei in den Speicher
load_dotenv()
@ -33,6 +35,18 @@ match_gui.setup_routes()
admin_gui.setup_routes()
match_history_gui.setup_routes()
imprint_gui.setup_routes()
league_statistic_visitor_gui.setup_routes()
# 4. Wir starten die NiceGUI App
# Timer der schedule regelmäßig prüft
@app.on_startup
def setup_scheduler():
import threading
from utils.season_scheduler import run_scheduler
thread = threading.Thread(target=run_scheduler, daemon=True)
thread.start()
# 4. Starten der App Runtime auf dem Server.
ui.run(title="Westside Diceghost Liga", port=9000, storage_secret="EIN_super-geheimes_Pa$$wort#!", favicon="gui/pictures/wsdg.png")

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

78
migrations/env.py Normal file
View File

@ -0,0 +1,78 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,16 @@
from sqlalchemy import create_engine, MetaData
DATABASE_URL = "mysql+pymysql://admin:PWM_556rtX@192.168.0.113:3306/league_db"
engine = create_engine(DATABASE_URL)
metadata = MetaData()
metadata.reflect(bind=engine)
print("Kopiere das hier in dein Alembic-Script:")
for table in metadata.tables.values():
print(f"\n# Tabellen-Definition für: {table.name}")
print(f"op.create_table('{table.name}',")
for column in table.columns:
col_type = str(column.type)
nullable = "True" if column.nullable else "False"
print(f" sa.Column('{column.name}', sa.{col_type.split('(')[0].upper()}(), nullable={nullable}),")
print(")")

28
migrations/script.py.mako Normal file
View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,129 @@
"""initial_setup
Revision ID: 47a1e41aa62f
Revises:
Create Date: 2026-05-28 08:32:26.064485
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '47a1e41aa62f'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Tabellen-Definition für: achievements
op.create_table('achievements',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.VARCHAR(), nullable=False),
sa.Column('description', sa.TEXT(), nullable=False),
sa.Column('icon', sa.TEXT(), nullable=True),
sa.Column('misc', sa.TEXT(), nullable=True),
)
# Tabellen-Definition für: gamesystems
op.create_table('gamesystems',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.VARCHAR(), nullable=False),
sa.Column('picture', sa.TEXT(), nullable=True),
sa.Column('description', sa.TEXT(), nullable=True),
sa.Column('active_players', sa.INTEGER(), nullable=True),
sa.Column('min_score', sa.INTEGER(), nullable=True),
sa.Column('max_score', sa.INTEGER(), nullable=True),
)
# Tabellen-Definition für: matches
op.create_table('matches',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('gamesystem_id', sa.INTEGER(), nullable=True),
sa.Column('player1_id', sa.INTEGER(), nullable=True),
sa.Column('score_player1', sa.INTEGER(), nullable=True),
sa.Column('player2_id', sa.INTEGER(), nullable=True),
sa.Column('score_player2', sa.INTEGER(), nullable=True),
sa.Column('played_at', sa.TIMESTAMP(), nullable=True),
sa.Column('player1_base_change', sa.INTEGER(), nullable=True),
sa.Column('player1_khorne', sa.INTEGER(), nullable=True),
sa.Column('player1_slaanesh', sa.INTEGER(), nullable=True),
sa.Column('player1_tzeentch', sa.INTEGER(), nullable=True),
sa.Column('player1_mmr_change', sa.INTEGER(), nullable=True),
sa.Column('player2_base_change', sa.INTEGER(), nullable=True),
sa.Column('player2_khorne', sa.INTEGER(), nullable=True),
sa.Column('player2_slaanesh', sa.INTEGER(), nullable=True),
sa.Column('player2_tzeentch', sa.INTEGER(), nullable=True),
sa.Column('player2_mmr_change', sa.INTEGER(), nullable=True),
sa.Column('player2_check', sa.INTEGER(), nullable=True),
sa.Column('match_is_counted', sa.INTEGER(), nullable=True),
sa.Column('rust_factor', sa.DOUBLE(), nullable=True),
sa.Column('point_inflation', sa.DOUBLE(), nullable=True),
sa.Column('elo_factor', sa.DOUBLE(), nullable=True),
)
# Tabellen-Definition für: players
op.create_table('players',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('discord_id', sa.VARCHAR(), nullable=True),
sa.Column('discord_name', sa.VARCHAR(), nullable=False),
sa.Column('discord_avatar_url', sa.TEXT(), nullable=True),
sa.Column('display_name', sa.VARCHAR(), nullable=True),
)
# Tabellen-Definition für: player_achievements
op.create_table('player_achievements',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('player_id', sa.INTEGER(), nullable=True),
sa.Column('achievement_id', sa.INTEGER(), nullable=True),
sa.Column('earned_at', sa.TIMESTAMP(), nullable=True),
sa.Column('earned_in', sa.INTEGER(), nullable=True),
)
# Tabellen-Definition für: player_game_statistic
op.create_table('player_game_statistic',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('player_id', sa.INTEGER(), nullable=True),
sa.Column('gamesystem_id', sa.INTEGER(), nullable=True),
sa.Column('mmr', sa.INTEGER(), nullable=True),
sa.Column('games_in_system', sa.INTEGER(), nullable=True),
sa.Column('points', sa.INTEGER(), nullable=True),
sa.Column('avv_points', sa.INTEGER(), nullable=True),
sa.Column('last_played', sa.TIMESTAMP(), nullable=True),
sa.Column('win_rate', sa.INTEGER(), nullable=True),
sa.Column('win_streak', sa.INTEGER(), nullable=True),
sa.Column('wins', sa.INTEGER(), nullable=True),
sa.Column('loss', sa.INTEGER(), nullable=True),
sa.Column('draws', sa.INTEGER(), nullable=True),
sa.Column('trend', sa.INTEGER(), nullable=True),
sa.Column('tyrann_id', sa.INTEGER(), nullable=True),
sa.Column('pushover_id', sa.INTEGER(), nullable=True),
sa.Column('nemesis_id', sa.INTEGER(), nullable=True),
sa.Column('season_id', sa.INTEGER(), nullable=False),
)
# Tabellen-Definition für: seasons
op.create_table('seasons',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('gamesystem_id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.VARCHAR(), nullable=False),
sa.Column('start_date', sa.DATE(), nullable=False),
sa.Column('end_date', sa.DATE(), nullable=True),
)
# Tabellen-Definition für: season_schedule
op.create_table('season_schedule',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('gamesystem_id', sa.INTEGER(), nullable=False),
sa.Column('start_month', sa.INTEGER(), nullable=False),
sa.Column('start_day', sa.INTEGER(), nullable=False),
)
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@ -1,4 +0,0 @@
nicegui
requests
python-dotenv
mariadb

View File

@ -1,15 +1,92 @@
#!/bin/bash
# 1. Erstellt die virtuelle Umgebung im Ordner ".venv" (falls sie nicht existiert)
# Sicherstellen dass das Script abbricht wenn ein Fehler passiert
# -e: Beendet das Script sofort wenn ein Befehl einen Fehlercode zurückgibt (Exit-Code != 0)
set -e
echo "✅ Starte einrichtung der Liga Umgebung. Updates und System Abhängigkeiten werden installiert ..."
# Prüfen ob der Liga-Service auf dem System installiert ist
# systemctl list-unit-files zeigt alle registrierten Service-Dateien
SERVICE_EXISTS=false
if systemctl list-unit-files --type service | grep -q "liga.service"; then
# Service existiert → sicher anhalten bevor Updates kommen
sudo systemctl stop liga
SERVICE_EXISTS=true
echo "✅ Liga Service wurde gestoppt."
else
# Service nicht installiert → kein Fehler, nur Info
echo " Liga Service nicht gefunden. Wird übersprungen."
fi
# 1. System updaten
# apt update: Lädt die aktuellen Paketlisten vom Server herunter
# apt upgrade -y: Installiert alle verfügbaren Updates, -y bestätigt automatisch
sudo apt update && sudo apt upgrade -y
# 2. System-Abhängigkeiten installieren
# -y: Automatisch mit "Ja" bestätigen
sudo apt install -y \
python3 \
python3-venv \
python3-full \
libmariadb3 \
libmariadb-dev \
alembic
echo "✅ Updates installiert. Überprüfe Softwarestand von Git ..."
# Git Pull: Holt die neuesten Änderungen vom Remote-Repository
# und merged sie in den lokalen Branch
git pull
echo "✅ Softwarestand von Git aktuell. Richte Umgebung ein ..."
# 3. Virtuelle Umgebung erstellen (falls noch nicht vorhanden)
# Wenn .venv schon existiert wird es einfach aktualisiert/überschrieben
python3 -m venv .venv
# 2. Aktiviert die virtuelle Umgebung für dieses Skript
# 4. Virtuelle Umgebung aktivieren
# WICHTIG: Da wir in einem Bash-Script sind, gilt die Aktivierung nur für dieses Script
source .venv/bin/activate
# 3. Aktualisiert das Installationsprogramm "pip" auf die neueste Version
echo "✅ Umgebung eingerichtet! Installiere Pakete ..."
# 5. pip aktualisieren
# pip ist der Paketmanager für Python sollte aktuell sein um Kompatibilitätsprobleme zu vermeiden
pip install --upgrade pip
# 4. Installiert alle Pakete aus deiner "Stückliste"
pip install -r requirements.txt
# 6. Python-Pakete installieren
pip install \
nicegui \
requests \
python-dotenv \
pymysql \
schedule \
sqlalchemy \
alembic
echo "✅ Pakete installiert. Starte Liga Service ..."
# Prüfen ob der Service vorher existierte und versuche ihn zu starten
if [ "$SERVICE_EXISTS" = true ]; then
# Service existiert bereits auf dem System → versuchen zu starten
# systemctl start gibt Exit-Code 0 bei Erfolg, != 0 bei Fehler
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"
exit 1
fi
else
# Service war vorher auch nicht installiert Info an den User
echo " Liga Service nicht installiert. Manuell starten oder Service erst einrichten."
fi
echo "Umgebung wurde erfolgreich eingerichtet!"

31
utils/season_scheduler.py Normal file
View File

@ -0,0 +1,31 @@
import schedule
import time
from datetime import date
from data import database
def check_new_seasons():
today = date.today()
conn = database.db_connection()
cursor = conn.cursor(dictionary=True)
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(f"[Season Check] {today} {len(triggers)} Treffer gefunden")
cursor.close()
conn.close()
def run_scheduler():
"""Läuft in einem Hintergrund-Thread."""
schedule.every().day.at("00:01").do(check_new_seasons)
while True:
schedule.run_pending()
time.sleep(60)