From bc4b8a1e7f2f8b8e630fdc6e20d57e0f3f7f26f3 Mon Sep 17 00:00:00 2001 From: Matthias Nagel Date: Sun, 23 Nov 2025 16:34:25 +0100 Subject: [PATCH] Feat: Implementierung des Spieler- und Eltern-Verifizierungsprozesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fügt einen umfassenden Verifizierungsprozess für neu erstellte Spieler und zugeordnete Eltern hinzu. Dies ersetzt das frühere Einladungscode-System. Wesentliche Änderungen: - **`CustomUser` Modell:** Erweitert um `is_verified` (Standard `False`) und `verification_code` (UUID) Felder. `is_active` ist nun standardmäßig `False` bis zur Verifizierung. Das `InvitationCode`-Modell wurde entfernt. - **E-Mail-Utility (`accounts/utils.py`):** Eine neue Funktion `send_verification_email` sendet oder simuliert E-Mails (basierend auf `settings.MTP_EMAIL_SEND`). Simulierte E-Mails werden im `.mbox`-Format in `tmp_mails/` gespeichert. - **`settings.py`:** `DEFAULT_FROM_EMAIL` wurde hinzugefügt. - **`PlayerCreateView` (`accounts/views.py`):** - Generiert `verification_code` für neue Spieler und Eltern. - Setzt das Passwort für neue Benutzer auf unbrauchbar (`set_unusable_password`). - Löst den Versand von Verifizierungs-E-Mails aus. - **`verify_account` View (`accounts/views.py`):** - Eine neue View, die über einen Link in der E-Mail aufgerufen wird. - Ermöglicht Spielern, ein Passwort festzulegen. - Ermöglicht Eltern, einen eindeutigen Benutzernamen und ein Passwort festzulegen. - Setzt `is_active` und `is_verified` auf `True` und invalidiert den Verifizierungscode nach erfolgreicher Einrichtung. - Loggt den Benutzer nach erfolgreicher Verifizierung direkt ein und zeigt eine Erfolgsmeldung an. - Behebt ein Problem bei der Bestimmung von Eltern-Benutzern. - **Formulare (`accounts/forms.py`):** Neue `PlayerVerificationForm` und `ParentVerificationForm` für den Verifizierungsprozess. - **E-Mail-Templates:** Neue Text- und HTML-Templates für Spieler- und Eltern-Verifizierungs-E-Mails (`accounts/templates/accounts/email/`). - **Verifizierungs-Template:** Neues Template für die Verifizierungsseite (`accounts/templates/accounts/verify_account.html`). - **URLs (`accounts/urls.py`):** Entfernung der alten `invitation_code` und `register` URLs, Hinzufügung der neuen `verify_account` URL. - **Datenbankmigrationen:** Migrationen für die Änderungen am `CustomUser`-Modell erstellt und angewendet. - **Temporäres Verzeichnis:** `tmp_mails/` Verzeichnis für E-Mail-Simulation erstellt. --- accounts/forms.py | 34 +++--- ...d_customuser_verification_code_and_more.py | 31 ++++++ accounts/models.py | 23 ++-- .../accounts/email/parent_verification.html | 15 +++ .../accounts/email/parent_verification.txt | 14 +++ .../accounts/email/player_verification.html | 15 +++ .../accounts/email/player_verification.txt | 14 +++ .../templates/accounts/verify_account.html | 51 +++++++++ accounts/urls.py | 3 +- accounts/utils.py | 74 +++++++++++++ accounts/views.py | 101 +++++++++--------- baseball_organisator/settings.py | 3 + 12 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 accounts/migrations/0005_customuser_is_verified_customuser_verification_code_and_more.py create mode 100644 accounts/templates/accounts/email/parent_verification.html create mode 100644 accounts/templates/accounts/email/parent_verification.txt create mode 100644 accounts/templates/accounts/email/player_verification.html create mode 100644 accounts/templates/accounts/email/player_verification.txt create mode 100644 accounts/templates/accounts/verify_account.html create mode 100644 accounts/utils.py diff --git a/accounts/forms.py b/accounts/forms.py index 48d7111..c507203 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,18 +1,5 @@ from django import forms -from .models import InvitationCode, CustomUser - -class InvitationCodeForm(forms.Form): - code = forms.CharField(max_length=255, label="Einladungscode") - - def clean_code(self): - code = self.cleaned_data.get('code') - try: - invitation_code = InvitationCode.objects.get(code=code) - if not invitation_code.is_valid(): - raise forms.ValidationError("Dieser Einladungscode ist nicht mehr gültig.") - except InvitationCode.DoesNotExist: - raise forms.ValidationError("Ungültiger Einladungscode.") - return code +from .models import CustomUser class CustomUserCreationForm(forms.ModelForm): password = forms.CharField(widget=forms.PasswordInput) @@ -37,3 +24,22 @@ class PlayerCreationForm(forms.ModelForm): class Meta: model = CustomUser fields = ('username', 'first_name', 'last_name', 'email', 'birth_date', 'player_number', 'team') + +class PlayerVerificationForm(forms.Form): + password = forms.CharField(widget=forms.PasswordInput, label="Passwort") + password_confirm = forms.CharField(widget=forms.PasswordInput, label="Passwort bestätigen") + + def clean(self): + cleaned_data = super().clean() + if cleaned_data.get('password') != cleaned_data.get('password_confirm'): + raise forms.ValidationError("Die Passwörter stimmen nicht überein.") + return cleaned_data + +class ParentVerificationForm(PlayerVerificationForm): + username = forms.CharField(max_length=150, label="Benutzername") + + def clean_username(self): + username = self.cleaned_data.get('username') + if CustomUser.objects.filter(username=username).exists(): + raise forms.ValidationError("Dieser Benutzername ist bereits vergeben.") + return username diff --git a/accounts/migrations/0005_customuser_is_verified_customuser_verification_code_and_more.py b/accounts/migrations/0005_customuser_is_verified_customuser_verification_code_and_more.py new file mode 100644 index 0000000..b7418c8 --- /dev/null +++ b/accounts/migrations/0005_customuser_is_verified_customuser_verification_code_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.6 on 2025-11-23 15:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_customuser_parents'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='is_verified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='customuser', + name='verification_code', + field=models.UUIDField(blank=True, editable=False, null=True, unique=True), + ), + migrations.AlterField( + model_name='customuser', + name='is_active', + field=models.BooleanField(default=False), + ), + migrations.DeleteModel( + name='InvitationCode', + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 1d12871..8b9c39f 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,3 +1,4 @@ +import uuid from django.contrib.auth.models import AbstractUser from django.db import models from django.utils import timezone @@ -8,6 +9,11 @@ class CustomUser(AbstractUser): player_number = models.IntegerField(default=999) team = models.ForeignKey('clubs.Team', on_delete=models.SET_NULL, null=True, blank=True, related_name='players') parents = models.ManyToManyField('self', symmetrical=False, blank=True, related_name='children') + is_verified = models.BooleanField(default=False) + verification_code = models.UUIDField(null=True, blank=True, unique=True, editable=False) + + # New users are not active until they verify and set a password + is_active = models.BooleanField(default=False) @property def age(self): @@ -16,23 +22,6 @@ class CustomUser(AbstractUser): today = datetime.date.today() return today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day)) -class InvitationCode(models.Model): - code = models.CharField(max_length=255, unique=True) - user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - is_active = models.BooleanField(default=True) - - def is_valid(self): - if not self.is_active: - return False - two_weeks_ago = timezone.now() - datetime.timedelta(weeks=2) - if self.created_at < two_weeks_ago: - return False - return True - - def __str__(self): - return self.code - class AbsencePeriod(models.Model): user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='absence_periods') start_date = models.DateField() diff --git a/accounts/templates/accounts/email/parent_verification.html b/accounts/templates/accounts/email/parent_verification.html new file mode 100644 index 0000000..e956e4f --- /dev/null +++ b/accounts/templates/accounts/email/parent_verification.html @@ -0,0 +1,15 @@ + + + + Kontoaktivierung - Baseball Organisator + + +

Hallo,

+

ein Konto wurde für Sie beim Baseball Organisator erstellt, da Sie als Elternteil für einen Spieler registriert wurden.

+

Bitte klicken Sie auf den folgenden Link, um Ihr Konto zu aktivieren. Sie werden aufgefordert, einen Benutzernamen zu wählen und ein Passwort festzulegen:

+

Konto jetzt aktivieren

+

Ihr Verifizierungscode lautet: {{ verification_code }}

+

Wenn Sie den Link nicht klicken können, kopieren Sie bitte die folgende URL und fügen Sie sie in die Adresszeile Ihres Browsers ein:
{{ verification_url }}

+

Vielen Dank,
Ihr Baseball Organisator Team

+ + diff --git a/accounts/templates/accounts/email/parent_verification.txt b/accounts/templates/accounts/email/parent_verification.txt new file mode 100644 index 0000000..64088a3 --- /dev/null +++ b/accounts/templates/accounts/email/parent_verification.txt @@ -0,0 +1,14 @@ +Hallo, + +ein Konto wurde für Sie beim Baseball Organisator erstellt, da Sie als Elternteil für einen Spieler registriert wurden. + +Bitte klicken Sie auf den folgenden Link, um Ihr Konto zu aktivieren. Sie werden aufgefordert, einen Benutzernamen zu wählen und ein Passwort festzulegen: + +{{ verification_url }} + +Ihr Verifizierungscode lautet: {{ verification_code }} + +Wenn Sie den Link nicht klicken können, kopieren Sie ihn bitte und fügen Sie ihn in die Adresszeile Ihres Browsers ein. + +Vielen Dank, +Ihr Baseball Organisator Team diff --git a/accounts/templates/accounts/email/player_verification.html b/accounts/templates/accounts/email/player_verification.html new file mode 100644 index 0000000..d9047d1 --- /dev/null +++ b/accounts/templates/accounts/email/player_verification.html @@ -0,0 +1,15 @@ + + + + Willkommen beim Baseball Organisator + + +

Hallo {{ user.first_name }},

+

willkommen beim Baseball Organisator!

+

Ein Konto wurde für dich von deinem Coach erstellt. Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und dein Konto zu aktivieren:

+

Konto jetzt aktivieren

+

Dein Verifizierungscode lautet: {{ verification_code }}

+

Wenn du den Link nicht klicken kannst, kopiere bitte die folgende URL und füge sie in die Adresszeile deines Browsers ein:
{{ verification_url }}

+

Vielen Dank,
Dein Baseball Organisator Team

+ + diff --git a/accounts/templates/accounts/email/player_verification.txt b/accounts/templates/accounts/email/player_verification.txt new file mode 100644 index 0000000..9c71330 --- /dev/null +++ b/accounts/templates/accounts/email/player_verification.txt @@ -0,0 +1,14 @@ +Hallo {{ user.first_name }}, + +willkommen beim Baseball Organisator! + +Ein Konto wurde für dich von deinem Coach erstellt. Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und dein Konto zu aktivieren: + +{{ verification_url }} + +Dein Verifizierungscode lautet: {{ verification_code }} + +Wenn du den Link nicht klicken kannst, kopiere ihn bitte und füge ihn in die Adresszeile deines Browsers ein. + +Vielen Dank, +Dein Baseball Organisator Team diff --git a/accounts/templates/accounts/verify_account.html b/accounts/templates/accounts/verify_account.html new file mode 100644 index 0000000..d7a4683 --- /dev/null +++ b/accounts/templates/accounts/verify_account.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Konto verifizieren

+
+
+

Bitte legen Sie Ihre Zugangsdaten fest.

+
+ {% csrf_token %} + + {% if is_parent %} +
+ + {{ form.username }} + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} + +
+ + {{ form.password }} + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ + {{ form.password_confirm }} + {% for error in form.password_confirm.errors %} +
{{ error }}
+ {% endfor %} +
+ + {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} + + +
+
+
+
+
+{% endblock %} diff --git a/accounts/urls.py b/accounts/urls.py index f1dcba7..a000e99 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -3,8 +3,7 @@ from . import views from django.contrib.auth import views as auth_views urlpatterns = [ - path('invitation/', views.invitation_code_view, name='invitation_code'), - path('register/', views.register_view, name='register'), + path('verify//', views.verify_account, name='verify_account'), path('login/', views.MyLoginView.as_view(template_name='accounts/login.html'), name='login'), path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('profile/', views.edit_profile, name='edit_profile'), diff --git a/accounts/utils.py b/accounts/utils.py new file mode 100644 index 0000000..61631e1 --- /dev/null +++ b/accounts/utils.py @@ -0,0 +1,74 @@ +import os +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.urls import reverse +import uuid + +def send_verification_email(user, request, is_parent=False): + """ + Sends a verification email to a new user (player or parent). + """ + # Ensure user has a verification code + if not user.verification_code: + user.verification_code = uuid.uuid4() + user.save() + + # Build the verification URL + verification_path = reverse('verify_account', kwargs={'verification_code': str(user.verification_code)}) + verification_url = request.build_absolute_uri(verification_path) + + # Determine which template and subject to use + if is_parent: + subject = 'Verifizieren Sie Ihr Eltern-Konto für den Baseball Organisator' + template_prefix = 'accounts/email/parent_verification' + else: + subject = 'Willkommen beim Baseball Organisator! Bitte verifizieren Sie Ihr Konto.' + template_prefix = 'accounts/email/player_verification' + + context = { + 'user': user, + 'verification_url': verification_url, + 'verification_code': user.verification_code + } + + # Render email body from templates + email_body_txt = render_to_string(f'{template_prefix}.txt', context) + email_body_html = render_to_string(f'{template_prefix}.html', context) + + # Send or simulate email based on settings + if settings.MTP_EMAIL_SEND == 1: + send_mail( + subject=subject, + message=email_body_txt, + from_email=settings.DEFAULT_FROM_EMAIL, # Make sure this is set in settings.py + recipient_list=[user.email], + html_message=email_body_html, + fail_silently=False, + ) + else: + # Simulate email by saving to a file + mbox_content = f"""From: {settings.DEFAULT_FROM_EMAIL} +To: {user.email} +Subject: {subject} +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" + +{email_body_txt} + +--boundary +Content-Type: text/html; charset="utf-8" + +{email_body_html} + +--boundary-- +""" + # Ensure the tmp_mails directory exists + os.makedirs('tmp_mails', exist_ok=True) + # Save the email to a file + file_path = os.path.join('tmp_mails', f'{user.email}_{user.verification_code}.mbox') + with open(file_path, 'w') as f: + f.write(mbox_content) diff --git a/accounts/views.py b/accounts/views.py index b886f48..b4c00ed 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -3,53 +3,15 @@ from django.urls import reverse_lazy from django.views.generic.edit import CreateView from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.contrib.auth import views as auth_views +from django.contrib.auth import views as auth_views, login +from django.contrib import messages from django.db.models import Q from django.http import JsonResponse -from .forms import InvitationCodeForm, CustomUserCreationForm, CustomUserChangeForm, PlayerCreationForm -from .models import CustomUser, InvitationCode +from .forms import CustomUserCreationForm, CustomUserChangeForm, PlayerCreationForm, PlayerVerificationForm, ParentVerificationForm +from .models import CustomUser +from .utils import send_verification_email import uuid -def invitation_code_view(request): - if request.method == 'POST': - form = InvitationCodeForm(request.POST) - if form.is_valid(): - code = form.cleaned_data['code'] - request.session['invitation_code'] = code - return redirect('register') - else: - form = InvitationCodeForm() - return render(request, 'accounts/invitation_code.html', {'form': form}) - -def register_view(request): - invitation_code_str = request.session.get('invitation_code') - if not invitation_code_str: - return redirect('invitation_code') - - try: - invitation_code = InvitationCode.objects.get(code=invitation_code_str) - if not invitation_code.is_valid(): - # Handle invalid code, maybe redirect with a message - return redirect('invitation_code') - except InvitationCode.DoesNotExist: - return redirect('invitation_code') - - - if request.method == 'POST': - form = CustomUserCreationForm(request.POST) - if form.is_valid(): - user = form.save(commit=False) - user.set_password(form.cleaned_data['password']) - user.save() - invitation_code.is_active = False - invitation_code.user = user - invitation_code.save() - # Log the user in and redirect to the dashboard - return redirect('login') # Or wherever you want to redirect after registration - else: - form = CustomUserCreationForm() - return render(request, 'accounts/register.html', {'form': form}) - @login_required def edit_profile(request): if request.method == 'POST': @@ -74,11 +36,13 @@ class PlayerCreateView(LoginRequiredMixin, HeadCoachCheckMixin, CreateView): def form_valid(self, form): # Create player user player = form.save(commit=False) - player.is_active = False # Player can only login after using invitation code + player.is_active = False # Player is inactive until verified + player.set_unusable_password() # Password must be set via verification + player.verification_code = uuid.uuid4() player.save() - - # Create invitation code for player - InvitationCode.objects.create(code=str(uuid.uuid4()), user=player) + + # Send verification email to player + send_verification_email(player, self.request, is_parent=False) # Handle parents for i in ['1', '2']: @@ -96,7 +60,6 @@ class PlayerCreateView(LoginRequiredMixin, HeadCoachCheckMixin, CreateView): except CustomUser.DoesNotExist: form.add_error(f'parent{i}_search', 'User not found.') else: - # if no user is selected from the list, maybe the user typed an email/username directly try: parent_user = CustomUser.objects.get(Q(username=search_identifier) | Q(email=search_identifier)) player.parents.add(parent_user) @@ -106,8 +69,16 @@ class PlayerCreateView(LoginRequiredMixin, HeadCoachCheckMixin, CreateView): form.add_error(f'parent{i}_search', 'Multiple users found. Please be more specific.') elif new_email: - parent_user, created = CustomUser.objects.get_or_create(email=new_email, defaults={'username': new_email, 'is_active': False}) - InvitationCode.objects.create(code=str(uuid.uuid4()), user=parent_user) + parent_user, created = CustomUser.objects.get_or_create( + email=new_email, + defaults={'username': new_email, 'is_active': False} + ) + if created: + parent_user.set_unusable_password() + parent_user.verification_code = uuid.uuid4() + parent_user.save() + # Send verification email to new parent + send_verification_email(parent_user, self.request, is_parent=True) player.parents.add(parent_user) if form.errors: @@ -127,4 +98,32 @@ def user_search(request): results = [] for user in users: results.append(f"{user['last_name']}, {user['first_name']} ({user['username']})") - return JsonResponse(results, safe=False) \ No newline at end of file + return JsonResponse(results, safe=False) + +def verify_account(request, verification_code): + user = get_object_or_404(CustomUser, verification_code=verification_code, is_verified=False) + + # Determine if user is a parent (has no team) or player + is_parent = user.team is None + + FormClass = ParentVerificationForm if is_parent else PlayerVerificationForm + + if request.method == 'POST': + form = FormClass(request.POST) + if form.is_valid(): + user.set_password(form.cleaned_data['password']) + if is_parent: + user.username = form.cleaned_data['username'] + + user.is_active = True + user.is_verified = True + user.verification_code = None # Invalidate the code + user.save() + + login(request, user) # Log the user in + messages.success(request, 'Your account has been verified! You are now logged in.') + return redirect('dashboard') + else: + form = FormClass() + + return render(request, 'accounts/verify_account.html', {'form': form, 'is_parent': is_parent}) \ No newline at end of file diff --git a/baseball_organisator/settings.py b/baseball_organisator/settings.py index f8f8767..735c6a7 100644 --- a/baseball_organisator/settings.py +++ b/baseball_organisator/settings.py @@ -141,3 +141,6 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' AUTH_USER_MODEL = 'accounts.CustomUser' LOGIN_REDIRECT_URL = '/' + +MTP_EMAIL_SEND = 0 +DEFAULT_FROM_EMAIL = 'webmaster@localhost'