feat: Add email verification, owner shift deletion, Coolify Docker compose setup, and fixes

This commit is contained in:
Richard
2026-07-31 10:16:22 +02:00
parent a69f31649c
commit 6ffcc58a4c
13 changed files with 458 additions and 334 deletions
@@ -0,0 +1,36 @@
# Generated by Django 6.0.7 on 2026-07-31 08:14
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_guestclaimtoken'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_email_verified',
field=models.BooleanField(default=False, help_text='E-Mail Bestätigungslink angeklickt'),
),
migrations.AlterField(
model_name='user',
name='is_approved',
field=models.BooleanField(default=False, help_text='Per Admin oder E-Mail-Bestätigung freigeschaltet'),
),
migrations.CreateModel(
name='EmailVerificationToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('is_used', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verification_tokens', to=settings.AUTH_USER_MODEL)),
],
),
]
+12 -2
View File
@@ -8,13 +8,14 @@ class User(AbstractUser):
display_name = models.CharField(max_length=150, blank=True, default='')
skills = models.ManyToManyField('events.Skill', blank=True, related_name='users')
is_admin_user = models.BooleanField(default=False)
is_approved = models.BooleanField(default=True, help_text="Vom Admin freigeschaltet")
is_approved = models.BooleanField(default=False, help_text="Per Admin oder E-Mail-Bestätigung freigeschaltet")
is_email_verified = models.BooleanField(default=False, help_text="E-Mail Bestätigungslink angeklickt")
def get_display_name(self):
return self.display_name or self.username or self.email
def __str__(self):
status_str = " (Ausstehend)" if not self.is_approved else ""
status_str = " (Ausstehend)" if not (self.is_approved or self.is_email_verified) else ""
return f"{self.get_display_name()} ({self.email}){status_str}"
class RegistrationDomainRule(models.Model):
@@ -47,6 +48,15 @@ class RegistrationRestrictionSetting(models.Model):
def __str__(self):
return f"Domain-Einschränkung: {self.is_restriction_enabled}, Admin-Freischaltung: {self.require_admin_approval}"
class EmailVerificationToken(models.Model):
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='verification_tokens')
is_used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Verification Token for {self.user.username} ({self.token})"
class GuestClaimToken(models.Model):
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
signup = models.ForeignKey('events.ShiftSignup', on_delete=models.CASCADE, related_name='claim_tokens')
+3 -3
View File
@@ -18,8 +18,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_active', 'skills', 'skill_ids']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'is_approved']
fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_email_verified', 'is_active', 'skills', 'skill_ids']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'is_approved', 'is_email_verified']
class AdminUserSerializer(serializers.ModelSerializer):
skills = SkillSimpleSerializer(many=True, read_only=True)
@@ -29,7 +29,7 @@ class AdminUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_active', 'skills', 'skill_ids', 'date_joined']
fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_email_verified', 'is_active', 'skills', 'skill_ids', 'date_joined']
read_only_fields = ['id', 'date_joined']
class RegisterSerializer(serializers.ModelSerializer):
+36 -9
View File
@@ -2,7 +2,7 @@ from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from .models import RegistrationDomainRule, RegistrationRestrictionSetting
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, EmailVerificationToken
from apps.events.models import Event, TaskArea, Shift, ShiftSignup
User = get_user_model()
@@ -38,7 +38,41 @@ class UserRegistrationTests(TestCase):
'display_name': 'Allowed User'
})
self.assertEqual(res_ok.status_code, status.HTTP_201_CREATED)
self.assertIn('token', res_ok.data)
self.assertTrue(res_ok.data.get('requires_verification'))
def test_email_verification_flow(self):
# Register new user
res_reg = self.client.post('/api/users/register/', {
'username': 'verify_user',
'email': 'verify@example.com',
'password': 'password123',
'display_name': 'Verify User'
})
self.assertEqual(res_reg.status_code, status.HTTP_201_CREATED)
self.assertTrue(res_reg.data.get('requires_verification'))
v_token_str = res_reg.data.get('verification_token')
# Login should be blocked prior to verification or admin approval
res_login_blocked = self.client.post('/api/users/login/', {
'username': 'verify_user',
'password': 'password123'
})
self.assertEqual(res_login_blocked.status_code, status.HTTP_403_FORBIDDEN)
# Verify email using verification token
res_verify = self.client.post('/api/users/verify-email/', {
'token': v_token_str
})
self.assertEqual(res_verify.status_code, status.HTTP_200_OK)
self.assertIn('token', res_verify.data)
# Login should now succeed
res_login_ok = self.client.post('/api/users/login/', {
'username': 'verify_user',
'password': 'password123'
})
self.assertEqual(res_login_ok.status_code, status.HTTP_200_OK)
self.assertIn('token', res_login_ok.data)
def test_guest_shift_claiming_on_registration(self):
# Create event, task area, shift
@@ -73,11 +107,6 @@ class UserRegistrationTests(TestCase):
self.assertIsNone(signup.guest_name)
def test_admin_approval_workflow(self):
# Enable admin approval requirement
setting = RegistrationRestrictionSetting.get_solo()
setting.require_admin_approval = True
setting.save()
# Register user
res_reg = self.client.post('/api/users/register/', {
'username': 'pending_user',
@@ -86,8 +115,6 @@ class UserRegistrationTests(TestCase):
'display_name': 'Pending User'
})
self.assertEqual(res_reg.status_code, status.HTTP_201_CREATED)
self.assertTrue(res_reg.data.get('requires_approval'))
self.assertNotIn('token', res_reg.data)
# Attempt login before approval (should be blocked)
res_login_fail = self.client.post('/api/users/login/', {
+2 -1
View File
@@ -1,7 +1,7 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
RegisterView, LoginView, MeView,
RegisterView, VerifyEmailView, LoginView, MeView,
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
PendingUsersView, ApproveUserView, AdminUserViewSet,
GenerateClaimLinkView, ClaimInfoView
@@ -13,6 +13,7 @@ router.register('manage-users', AdminUserViewSet, basename='admin-user')
urlpatterns = [
path('register/', RegisterView.as_view(), name='register'),
path('verify-email/', VerifyEmailView.as_view(), name='verify-email'),
path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'),
path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
+44 -18
View File
@@ -3,7 +3,7 @@ from rest_framework.response import Response
from rest_framework.authtoken.models import Token
from django.contrib.auth import authenticate, get_user_model
from django.shortcuts import get_object_or_404
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, EmailVerificationToken
from .serializers import (
UserSerializer, AdminUserSerializer, RegisterSerializer,
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer
@@ -24,6 +24,11 @@ class RegisterView(views.APIView):
serializer = RegisterSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
user.is_approved = False
user.is_email_verified = False
user.is_active = True
user.save()
setting = RegistrationRestrictionSetting.get_solo()
claim_token_str = request.data.get('claim_token')
@@ -65,27 +70,48 @@ class RegisterView(views.APIView):
signup.save()
assigned_count += 1
if setting.require_admin_approval:
user.is_approved = False
user.is_active = False
user.save()
return Response({
'requires_approval': True,
'claimed_shifts_count': assigned_count,
'message': 'Konto erfolgreich registriert! Ein Administrator muss dein Konto vor der ersten Anmeldung freischalten.'
}, status=status.HTTP_201_CREATED)
v_token = EmailVerificationToken.objects.create(user=user)
print("==================================================")
print(f"[E-MAIL BESTÄTIGUNG]: http://localhost:3000/?verify_email={v_token.token}")
print("==================================================")
token, _ = Token.objects.get_or_create(user=user)
user_data = UserSerializer(user).data
return Response({
'token': token.key,
'user': user_data,
'requires_verification': True,
'verification_token': str(v_token.token),
'claimed_shifts_count': assigned_count,
'message': f'Konto erfolgreich erstellt! {assigned_count} Gast-Schichten wurden deinem Konto zugewiesen.' if assigned_count > 0 else 'Konto erfolgreich erstellt!'
'message': 'Konto erfolgreich erstellt! Bitte bestätige deine E-Mail-Adresse über den Link in der Bestätigungs-E-Mail oder warte auf die Admin-Freischaltung.'
}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class VerifyEmailView(views.APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
token_str = request.data.get('token')
if not token_str:
return Response({'error': 'Token erforderlich.'}, status=status.HTTP_400_BAD_REQUEST)
v_token = EmailVerificationToken.objects.filter(token=token_str, is_used=False).first()
if not v_token:
return Response({'error': 'Ungültiger oder bereits verwendeter Bestätigungslink.'}, status=status.HTTP_400_BAD_REQUEST)
v_token.is_used = True
v_token.save()
user = v_token.user
user.is_email_verified = True
user.is_approved = True
user.is_active = True
user.save()
token, _ = Token.objects.get_or_create(user=user)
return Response({
'token': token.key,
'user': UserSerializer(user).data,
'message': f'E-Mail-Adresse für {user.get_display_name()} wurde erfolgreich bestätigt! Du bist jetzt angemeldet.'
}, status=status.HTTP_200_OK)
class GenerateClaimLinkView(views.APIView):
permission_classes = [permissions.IsAuthenticated]
@@ -147,9 +173,9 @@ class LoginView(views.APIView):
pass
if user_obj:
if not user_obj.is_approved or not user_obj.is_active:
if not (user_obj.is_approved or user_obj.is_email_verified or user_obj.is_superuser or user_obj.is_admin_user):
return Response({
'error': 'Dein Konto wurde noch nicht von einem Administrator freigeschaltet. Bitte gedulde dich.'
'error': 'Anmeldung nicht möglich: Deine E-Mail-Adresse wurde noch nicht bestätigt und dein Konto wurde noch nicht vom Admin freigeschaltet.'
}, status=status.HTTP_403_FORBIDDEN)
user = authenticate(username=username, password=password)
@@ -211,7 +237,7 @@ class PendingUsersView(views.APIView):
permission_classes = [permissions.IsAdminUser]
def get(self, request):
pending_users = User.objects.filter(is_approved=False).order_by('-date_joined')
pending_users = User.objects.filter(is_approved=False, is_email_verified=False, is_superuser=False).order_by('-date_joined')
return Response(UserSerializer(pending_users, many=True).data)
class ApproveUserView(views.APIView):