Feat: Implementierung des Spieler- und Eltern-Verifizierungsprozesses

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.
This commit is contained in:
Matthias Nagel 2025-11-23 16:34:25 +01:00
parent 5cc9b387b9
commit bc4b8a1e7f
12 changed files with 294 additions and 84 deletions

View File

@ -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

View File

@ -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',
),
]

View File

@ -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()

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Kontoaktivierung - Baseball Organisator</title>
</head>
<body>
<h2>Hallo,</h2>
<p>ein Konto wurde für Sie beim Baseball Organisator erstellt, da Sie als Elternteil für einen Spieler registriert wurden.</p>
<p>Bitte klicken Sie auf den folgenden Link, um Ihr Konto zu aktivieren. Sie werden aufgefordert, einen Benutzernamen zu wählen und ein Passwort festzulegen:</p>
<p><a href="{{ verification_url }}">Konto jetzt aktivieren</a></p>
<p>Ihr Verifizierungscode lautet: <strong>{{ verification_code }}</strong></p>
<p>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:<br>{{ verification_url }}</p>
<p>Vielen Dank,<br>Ihr Baseball Organisator Team</p>
</body>
</html>

View File

@ -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

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Willkommen beim Baseball Organisator</title>
</head>
<body>
<h2>Hallo {{ user.first_name }},</h2>
<p>willkommen beim Baseball Organisator!</p>
<p>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:</p>
<p><a href="{{ verification_url }}">Konto jetzt aktivieren</a></p>
<p>Dein Verifizierungscode lautet: <strong>{{ verification_code }}</strong></p>
<p>Wenn du den Link nicht klicken kannst, kopiere bitte die folgende URL und füge sie in die Adresszeile deines Browsers ein:<br>{{ verification_url }}</p>
<p>Vielen Dank,<br>Dein Baseball Organisator Team</p>
</body>
</html>

View File

@ -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

View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h2>Konto verifizieren</h2>
</div>
<div class="card-body">
<p>Bitte legen Sie Ihre Zugangsdaten fest.</p>
<form method="post">
{% csrf_token %}
{% if is_parent %}
<div class="mb-3">
<label for="{{ form.username.id_for_label }}" class="form-label">{{ form.username.label }}</label>
{{ form.username }}
{% for error in form.username.errors %}
<div class="alert alert-danger p-1 mt-1">{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<div class="mb-3">
<label for="{{ form.password.id_for_label }}" class="form-label">{{ form.password.label }}</label>
{{ form.password }}
{% for error in form.password.errors %}
<div class="alert alert-danger p-1 mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="{{ form.password_confirm.id_for_label }}" class="form-label">{{ form.password_confirm.label }}</label>
{{ form.password_confirm }}
{% for error in form.password_confirm.errors %}
<div class="alert alert-danger p-1 mt-1">{{ error }}</div>
{% endfor %}
</div>
{% for error in form.non_field_errors %}
<div class="alert alert-danger">{{ error }}</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Konto aktivieren</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -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/<uuid:verification_code>/', 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'),

74
accounts/utils.py Normal file
View File

@ -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)

View File

@ -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)
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})

View File

@ -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'