feat: Add email verification, owner shift deletion, Coolify Docker compose setup, and fixes
This commit is contained in:
+36
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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/', {
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user