From c62c593b1c7c95b190e7f10f88a9c7225eca9295 Mon Sep 17 00:00:00 2001 From: Martin Putzlocher Date: Tue, 28 Jul 2026 01:44:02 +0200 Subject: [PATCH] =?UTF-8?q?Datenmodell=20funktioniert=20und=20Verwaltung?= =?UTF-8?q?=20=C3=BCber=20Admin-Ansicht,=20service=20angelegt.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/migrations/0001_initial.py | 62 +++++++++++++++++ accounts/migrations/__init__.py | 0 clear_python_cache.sh | 4 ++ config/settings.py | 16 +++++ tournaments/admin.py | 7 +- tournaments/migrations/0001_initial.py | 65 ++++++++++++++++++ tournaments/migrations/__init__.py | 0 tournaments/models.py | 55 ++++++--------- tournaments/services.py | 93 ++++++++++++++++++++++++++ 9 files changed, 262 insertions(+), 40 deletions(-) create mode 100644 accounts/migrations/0001_initial.py create mode 100644 accounts/migrations/__init__.py create mode 100755 clear_python_cache.sh create mode 100644 tournaments/migrations/0001_initial.py create mode 100644 tournaments/migrations/__init__.py diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..134c1de --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 6.0.7 on 2026-07-27 22:56 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('role', models.CharField(choices=[('admin', 'Administrator'), ('player', 'Spieler')], default='player', max_length=10, verbose_name='Rolle')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Player', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100, verbose_name='Vorname')), + ('last_name', models.CharField(max_length=100, verbose_name='Nachname')), + ('club', models.CharField(blank=True, default='', max_length=150, verbose_name='Verein/Klasse')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='player_profile', to=settings.AUTH_USER_MODEL, verbose_name='Benutzeraccount')), + ], + options={ + 'verbose_name': 'Spieler', + 'verbose_name_plural': 'Spieler', + 'ordering': ['last_name', 'first_name'], + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clear_python_cache.sh b/clear_python_cache.sh new file mode 100755 index 0000000..9eba284 --- /dev/null +++ b/clear_python_cache.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Löscht alle compilierten Python-Zwischendateien im Projekt +find . -type d -name __pycache__ -exec rm -r {} + +find . -name "*.pyc" -delete diff --git a/config/settings.py b/config/settings.py index 4da7034..4459979 100644 --- a/config/settings.py +++ b/config/settings.py @@ -68,5 +68,21 @@ USE_TZ = True STATIC_URL = 'static/' +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/tournaments/admin.py b/tournaments/admin.py index 615f70d..4e6f054 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -4,9 +4,8 @@ from .models import Tournament, Registration, Round, Match class RegistrationInline(admin.TabularInline): model = Registration + fk_name = 'tournament' # 👈 Wichtig: Explizite Rückverknüpfung zum Elternmodell extra = 0 - # autocomplete_fields entfernt, um Startkonflikte zu vermeiden. - # Normale Select-Boxen sind für <= 50 Spieler besser! @admin.register(Tournament) class TournamentAdmin(admin.ModelAdmin): @@ -21,13 +20,12 @@ class TournamentAdmin(admin.ModelAdmin): class MatchInline(admin.TabularInline): model = Match + fk_name = 'round_detail' # 👈 Wichtig: Explizite Rückverknüpfung zur Runde extra = 0 - # autocomplete_fields hier ebenfalls entfernt @admin.register(Round) class RoundAdmin(admin.ModelAdmin): list_display = ('tournament', 'round_number') - list_filter = ('tournament',) inlines = [MatchInline] @@ -35,4 +33,3 @@ class RoundAdmin(admin.ModelAdmin): class MatchAdmin(admin.ModelAdmin): list_display = ('round_detail', 'player_white', 'player_black', 'result') list_filter = ('result', 'round_detail__tournament') - diff --git a/tournaments/migrations/0001_initial.py b/tournaments/migrations/0001_initial.py new file mode 100644 index 0000000..2293015 --- /dev/null +++ b/tournaments/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 6.0.7 on 2026-07-27 22:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Tournament', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Turniername')), + ('date', models.DateField(verbose_name='Datum')), + ('description', models.TextField(blank=True, default='', verbose_name='Beschreibung')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='Round', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('round_number', models.PositiveIntegerField(verbose_name='Rundennummer')), + ('tournament', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rounds', to='tournaments.tournament')), + ], + options={ + 'ordering': ['round_number'], + 'unique_together': {('tournament', 'round_number')}, + }, + ), + migrations.CreateModel( + name='Match', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('result', models.CharField(choices=[('pending', 'Ausstehend'), ('white_win', 'Weiß gewinnt'), ('black_win', 'Schwarz gewinnt'), ('draw', 'Unentschieden')], default='pending', max_length=10)), + ('player_black', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='matches_as_black', to='accounts.player', verbose_name='Schwarzer Spieler')), + ('player_white', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='matches_as_white', to='accounts.player', verbose_name='Weißer Spieler')), + ('round_detail', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='tournaments.round')), + ], + options={ + 'unique_together': {('round_detail', 'player_white', 'player_black')}, + }, + ), + migrations.CreateModel( + name='Registration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.player')), + ('tournament', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='tournaments.tournament')), + ], + options={ + 'unique_together': {('tournament', 'player')}, + }, + ), + ] diff --git a/tournaments/migrations/__init__.py b/tournaments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tournaments/models.py b/tournaments/models.py index e4b955c..4803576 100644 --- a/tournaments/models.py +++ b/tournaments/models.py @@ -3,10 +3,7 @@ from django.utils.translation import gettext_lazy as _ class Tournament(models.Model): - """ - Stellt ein komplettes Turnier dar. - Hier werden alle Runden und Spieler registriert. - """ + """Stellt ein komplettes Turnier dar.""" name = models.CharField(_('Turniername'), max_length=200) date = models.DateField(_('Datum')) description = models.TextField(_('Beschreibung'), blank=True, default='') @@ -20,34 +17,27 @@ class Tournament(models.Model): class Registration(models.Model): - """ - Verknüpfungstabelle zwischen einem Tournament und den daran teilnehmenden Playern. - Ermöglicht es, dass ein Spieler an mehreren Turnieren teilnehmen kann. - """ + """Verknüpfungstabelle zwischen Turnier und Spielern.""" tournament = models.ForeignKey( - Tournament, - on_delete=models.CASCADE, + Tournament, + on_delete=models.CASCADE, related_name='registrations' ) + # FIX: App-Label hinzugefügt ('accounts.Player') player = models.ForeignKey( - 'Player', # String-Referenz vermeiden Import-Konflikte (Circular Dependency) + 'accounts.Player', on_delete=models.CASCADE ) class Meta: unique_together = ('tournament', 'player') - verbose_name = 'Anmeldung' - verbose_name_plural = 'Anmeldungen' class Round(models.Model): - """ - Eine einzelne Runde im Turnier. - Jede Runde gehört eindeutig zu einem Tournament. - """ + """Eine einzelne Runde im Turnier.""" tournament = models.ForeignKey( - Tournament, - on_delete=models.CASCADE, + Tournament, + on_delete=models.CASCADE, related_name='rounds' ) round_number = models.PositiveIntegerField(_('Rundennummer')) @@ -61,10 +51,7 @@ class Round(models.Model): class Match(models.Model): - """ - Einzelnes Spiel (Paarung) innerhalb einer Runde. - Hier wird festgehalten, wer als Weiß und Schwarz spielt und das Ergebnis. - """ + """Einzelnes Spiel (Paarung) innerhalb einer Runde.""" class ResultStatus(models.TextChoices): PENDING = 'pending', _('Ausstehend') @@ -73,35 +60,33 @@ class Match(models.Model): DRAW = 'draw', _('Unentschieden') round_detail = models.ForeignKey( - Round, - on_delete=models.CASCADE, + Round, + on_delete=models.CASCADE, related_name='matches' ) - + + # FIX: App-Label explizit gesetzt ('accounts.Player') player_white = models.ForeignKey( - 'Player', - on_delete=models.PROTECT, # Spiel darf nicht gelöscht werden wenn Spieler existiert (Datensicherung) + 'accounts.Player', + on_delete=models.PROTECT, related_name='matches_as_white', verbose_name=_('Weißer Spieler') ) player_black = models.ForeignKey( - 'Player', + 'accounts.Player', on_delete=models.PROTECT, related_name='matches_as_black', verbose_name=_('Schwarzer Spieler') ) - + result = models.CharField( - max_length=10, - choices=ResultStatus.choices, + max_length=10, + choices=ResultStatus.choices, default=ResultStatus.PENDING ) class Meta: - # Verhindert technisch doppelte Einträge (gleiche Paarung in gleicher Runde) unique_together = ('round_detail', 'player_white', 'player_black') def __str__(self): return f"{self.player_white} vs {self.player_black} ({self.round_detail})" - - diff --git a/tournaments/services.py b/tournaments/services.py index e69de29..b5f5331 100644 --- a/tournaments/services.py +++ b/tournaments/services.py @@ -0,0 +1,93 @@ +from .models import Tournament, Match + + +class SwissSystemService: + """ + Zentrales Service für Turnierberechnungen im Schweizer System. + Verantwortlich für Ranglisten, Wertung und Paarungen. + """ + + @staticmethod + def get_standings(tournament_id): + """ + Berechnet die aktuelle Rangliste eines Turniers. + + Kriterien (in Reihenfolge): + 1. Anzahl gewonnener Punkte (Win=1, Draw=0.5) + 2. Buchholz-Wertung (Summe der Punktzahlen der Gegner) + """ + try: + tournament = Tournament.objects.get(id=tournament_id) + except Tournament.DoesNotExist: + return [] + + # Alle Spieler in diesem Turnier abrufen + players_dict = {} + + # 1. Basis-Daten für alle Spieler initialisieren + for reg in tournament.registrations.all(): + players_dict[reg.player.id] = { + 'id': reg.player.id, + 'name': f"{reg.player.first_name} {reg.player.last_name}", + 'club': reg.player.club, + 'points': 0.0, + 'matches_played': 0, + 'opponents_points': [] # Um Buchholz zu berechnen + } + + # 2. Alle abgeschlossenen Spiele durchgehen und Punkte zuteilen + completed_matches = Match.objects.filter( + round_detail__tournament=tournament, + result__in=[Match.ResultStatus.WHITE_WIN, Match.ResultStatus.BLACK_WIN, Match.ResultStatus.DRAW] + ) + + for match in completed_matches: + if match.result == Match.ResultStatus.DRAW: + p_white = players_dict.get(match.player_white.id) + p_black = players_dict.get(match.player_black.id) + + # Punkte addieren (0.5 für Unentschieden) + if p_white: + p_white['points'] += 0.5 + p_white['matches_played'] += 1 + if p_black: + p_black['points'] += 0.5 + p_black['matches_played'] += 1 + + elif match.result == Match.ResultStatus.WHITE_WIN: + p_white = players_dict.get(match.player_white.id) + if p_white: + p_white['points'] += 1.0 + p_white['matches_played'] += 1 + + elif match.result == Match.ResultStatus.BLACK_WIN: + p_black = players_dict.get(match.player_black.id) + if p_black: + p_black['points'] += 1.0 + p_black['matches_played'] += 1 + + # Buchholz-Hilfe: Merken, gegen wen gespielt wurde (ID des Gegners) + if match.player_white.id in players_dict: + players_dict[match.player_white.id]['opponents_points'].append(match.player_black.id) + if match.player_black.id in players_dict: + players_dict[match.player_black.id]['opponents_points'].append(match.player_white.id) + + # 3. Buchholz-Score berechnen + # (Summe der aktuellen Punkte aller Gegner) + for pid, data in players_dict.items(): + buchholz = 0.0 + for opp_id in data['opponents_points']: + if opp_id in players_dict: + buchholz += players_dict[opp_id]['points'] + + # Buchholz zum Datensatz hinzufügen (zum Anzeigen und Sortieren) + data['buchholz'] = round(buchholz, 2) + + # 4. In eine Liste verwandeln und sortieren + standings_list = list(players_dict.values()) + + # Sortierung: + # Primär: Punkte (absteigend), Sekundär: Buchholz (absteigend) + standings_list.sort(key=lambda x: (x['points'], x['buchholz']), reverse=True) + + return standings_list