diff --git a/.gitignore b/.gitignore index 36b13f1..33cc4ec 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ cython_debug/ # PyPI configuration file .pypirc +.kdev4/ diff --git a/SchulSchachTV/__init__.py b/SchulSchachTV/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/SchulSchachTV/asgi.py b/SchulSchachTV/asgi.py new file mode 100644 index 0000000..1a720d2 --- /dev/null +++ b/SchulSchachTV/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for SchulSchachTV project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'SchulSchachTV.settings') + +application = get_asgi_application() diff --git a/SchulSchachTV/settings.py b/SchulSchachTV/settings.py new file mode 100644 index 0000000..2f909fb --- /dev/null +++ b/SchulSchachTV/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for SchulSchachTV project. + +Generated by 'django-admin startproject' using Django 4.2.30. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-yrn+^m7@(-q6l$##ed3hz2q@u7&#wmi$dqg+&y-6zr+)tnfgr3' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'SchulSchachTV.urls' + +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', + ], + }, + }, +] + +WSGI_APPLICATION = 'SchulSchachTV.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/SchulSchachTV/urls.py b/SchulSchachTV/urls.py new file mode 100644 index 0000000..ad5a2bd --- /dev/null +++ b/SchulSchachTV/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for SchulSchachTV project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/SchulSchachTV/wsgi.py b/SchulSchachTV/wsgi.py new file mode 100644 index 0000000..a4d3753 --- /dev/null +++ b/SchulSchachTV/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for SchulSchachTV project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'SchulSchachTV.settings') + +application = get_wsgi_application() diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..d64b748 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,61 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class User(AbstractUser): + """ + Custom User Model mit expliziter Rollenunterstützung. + Erweitert das Standard-Django-User-Modell um ein 'role'-Feld, + um Admins und Spieler klar zu trennen. + """ + + class Role(models.TextChoices): + ADMIN = 'admin', _('Administrator') + PLAYER = 'player', _('Spieler') + + role = models.CharField( + max_length=10, + choices=Role.choices, + default=Role.PLAYER, + verbose_name=_('Rolle') + ) + + def is_admin(self): + return self.role == self.Role.ADMIN + + +class Player(models.Model): + """ + Das reine Spielerprofil. + + Hinweis: Ein Spieler muss nicht unbedingt einen Account (Login) haben, + da wir auch per CSV importierte Gäste unterstützen. + Falls doch ein Account existiert, verknüpfen wir ihn hier 1-zu-1. + """ + user = models.OneToOneField( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='player_profile', + verbose_name=_('Benutzeraccount') + ) + first_name = models.CharField(_('Vorname'), max_length=100) + last_name = models.CharField(_('Nachname'), max_length=100) + club = models.CharField( + _('Verein/Klasse'), + max_length=150, + blank=True, + default='' + ) + + class Meta: + ordering = ['last_name', 'first_name'] + verbose_name = 'Spieler' + verbose_name_plural = 'Spieler' + + def __str__(self): + return f"{self.first_name} {self.last_name} ({self.club})" + + diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..612bfc0 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import Player + +class PlayerSerializer(serializers.ModelSerializer): + class Meta: + model = Player + fields = ['id', 'first_name', 'last_name', 'club'] + +class PlayerImportDataSerializer(serializers.Serializer): + """Validiert die Eingabe für den CSV-Import.""" + csv_data = serializers.CharField() # Der Raw-CSV Text (Base64 oder Plain) + tournament_id = serializers.IntegerField() # Die ID des Zieltourniers diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..4ab88ac --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import PlayerListView, PlayerImportView + +urlpatterns = [ + path('players/', PlayerListView.as_view(), name='player-list'), + path('players/import/', PlayerImportView.as_view(), name='player-import'), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..9ee23b3 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,91 @@ +import csv +import io +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from .models import Player, User +from tournaments.models import Tournament, Registration +from .serializers import PlayerSerializer, PlayerImportDataSerializer + +class PlayerListView(APIView): + """Listet alle Spieler auf.""" + + def get(self, request): + players = Player.objects.all() + serializer = PlayerSerializer(players, many=True) + return Response(serializer.data) + +class PlayerImportView(APIView): + """ + Importiert Spieler aus Moodle-CSV. + Erwartet ein POST mit JSON: { "csv_data": "...", "tournament_id": 1 } + """ + + def post(self, request): + serializer = PlayerImportDataSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + csv_string = serializer.validated_data['csv_data'] + tournament_id = serializer.validated_data['tournament_id'] + + # Ziel-Turnier finden + try: + tournament = Tournament.objects.get(id=tournament_id) + except Tournament.DoesNotExist: + return Response({"error": "Turnier nicht gefunden"}, status=404) + + created_count = 0 + existing_count = 0 + + # CSV parsen (Moodle nutzt Semikolon als Separator) + csv_file = io.StringIO(csv_string) + reader = csv.reader(csv_file, delimiter=';') + + next(reader, None) # Überspringe Header-Zeile + + for row in reader: + if len(row) < 5: + continue + + last_name = row[0].strip() + first_name = row[1].strip() + email = row[2].strip() + # city = row[3] (optional) + club = row[4].strip() # Die Moodle-Gruppe/Klasse als "Verein" + + if not first_name or not last_name: + continue + + # 1. Spieler anlegen oder aktualisieren + player, created = Player.objects.update_or_create( + defaults={'club': club}, + first_name=first_name, + last_name=last_name + ) + + if created: + created_count += 1 + else: + existing_count += 1 + + # 2. Spieler ins Turnier registrieren (Registration anlegen) + Registration.objects.get_or_create( + tournament=tournament, + player=player + ) + + # Optional: Falls ein Login-Wunsch besteht und keine User existiert: + if email and not User.objects.filter(username=email).exists(): + User.objects.create_user( + username=email, + email=email, + password='schach1234' # Default-Passwort für Moodle-Imports + ) + + return Response({ + "message": "Import abgeschlossen", + "created_players": created_count, + "updated_players": existing_count + }, status=201) + diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..4da7034 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +# --- Security & Core --- +SECRET_KEY = 'dein_geheimer_schluessel_fuer_dev' +DEBUG = True # Im Produktivbetrieb auf False setzen! +ALLOWED_HOSTS = ['*'] + +# --- App Configuration --- +INSTALLED_APPS = [ + # Standard Django + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # REST Framework & Tools + 'rest_framework', + 'django_filters', + + # Unsere eigenen Apps + 'accounts', + 'tournaments', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +# --- Database (SQLite) --- +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +# --- Custom User Model (WICHTIG: Vorerstes Migration setzen!) --- +AUTH_USER_MODEL = 'accounts.User' + +# --- REST Framework Settings --- +REST_FRAMEWORK = { + # Offene Sichtbarkeit (wie gewünscht) + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + # Pagination für Übersichten (z.B. Spielertabelle) + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, +} + +# --- Internationalization / Locale --- +LANGUAGE_CODE = 'de-de' +TIME_ZONE = 'Europe/Berlin' +USE_I18N = True +USE_TZ = True + +STATIC_URL = 'static/' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..dfbc2e3 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from django.urls import path, include +from rest_framework.authtoken.views import obtain_auth_token + +urlpatterns = [ + path('admin/', admin.site.urls), + + # API Endpunkte + path('api/v1/auth/', include([ + # Hier kommen später unsere Login/Register Viewsets hin + path('token/', obtain_auth_token, name='api-token'), + ])), + + path('api/v1/accounts/', include('accounts.urls')), + path('api/v1/tournaments/', include('tournaments.urls')), +] +o + diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..7d8d454 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'SchulSchachTV.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..82f7aed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Django>=6.0 +djangorestframework>=3.14.0 +django-filter>=23.0 # Für Filterung der Turniere/Spieler in der API +gunicorn>=21.2.0 # (Optional) Für Produktivumgebungen diff --git a/tournaments/models.py b/tournaments/models.py new file mode 100644 index 0000000..e4b955c --- /dev/null +++ b/tournaments/models.py @@ -0,0 +1,107 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Tournament(models.Model): + """ + Stellt ein komplettes Turnier dar. + Hier werden alle Runden und Spieler registriert. + """ + name = models.CharField(_('Turniername'), max_length=200) + date = models.DateField(_('Datum')) + description = models.TextField(_('Beschreibung'), blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-date'] + + def __str__(self): + return f"{self.name} ({self.date})" + + +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. + """ + tournament = models.ForeignKey( + Tournament, + on_delete=models.CASCADE, + related_name='registrations' + ) + player = models.ForeignKey( + 'Player', # String-Referenz vermeiden Import-Konflikte (Circular Dependency) + 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. + """ + tournament = models.ForeignKey( + Tournament, + on_delete=models.CASCADE, + related_name='rounds' + ) + round_number = models.PositiveIntegerField(_('Rundennummer')) + + class Meta: + unique_together = ('tournament', 'round_number') + ordering = ['round_number'] + + def __str__(self): + return f"{self.tournament.name} - Runde {self.round_number}" + + +class Match(models.Model): + """ + Einzelnes Spiel (Paarung) innerhalb einer Runde. + Hier wird festgehalten, wer als Weiß und Schwarz spielt und das Ergebnis. + """ + + class ResultStatus(models.TextChoices): + PENDING = 'pending', _('Ausstehend') + WHITE_WIN = 'white_win', _('Weiß gewinnt') + BLACK_WIN = 'black_win', _('Schwarz gewinnt') + DRAW = 'draw', _('Unentschieden') + + round_detail = models.ForeignKey( + Round, + on_delete=models.CASCADE, + related_name='matches' + ) + + player_white = models.ForeignKey( + 'Player', + on_delete=models.PROTECT, # Spiel darf nicht gelöscht werden wenn Spieler existiert (Datensicherung) + related_name='matches_as_white', + verbose_name=_('Weißer Spieler') + ) + player_black = models.ForeignKey( + 'Player', + on_delete=models.PROTECT, + related_name='matches_as_black', + verbose_name=_('Schwarzer Spieler') + ) + + result = models.CharField( + 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 new file mode 100644 index 0000000..e69de29 diff --git a/tournaments/urls.py b/tournaments/urls.py new file mode 100644 index 0000000..e39cb2c --- /dev/null +++ b/tournaments/urls.py @@ -0,0 +1,3 @@ +from django.urls import path + +urlpatterns = []