feat: Add event co-managers permissions and allow managers/admins to signup guests on skill-restricted shifts

This commit is contained in:
Richard
2026-07-31 11:06:47 +02:00
parent 821e14fcab
commit e2b311b634
23 changed files with 589 additions and 485 deletions
@@ -0,0 +1,20 @@
# Generated by Django 6.0.7 on 2026-07-31 09:05
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0004_shift_date'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='event',
name='co_managers',
field=models.ManyToManyField(blank=True, help_text='Weitere Verwalter dieser Veranstaltung', related_name='managed_events', to=settings.AUTH_USER_MODEL),
),
]
+10
View File
@@ -18,9 +18,19 @@ class Event(models.Model):
end_date = models.DateField() end_date = models.DateField()
is_active = models.BooleanField(default=True, help_text="Aktiv/Deaktiviert Status") is_active = models.BooleanField(default=True, help_text="Aktiv/Deaktiviert Status")
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='created_events') created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='created_events')
co_managers = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name='managed_events', help_text="Weitere Verwalter dieser Veranstaltung")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def is_user_manager_or_admin(self, user):
if not user or not user.is_authenticated:
return False
if user.is_admin_user or user.is_staff or user.is_superuser:
return True
if self.created_by_id == user.id:
return True
return self.co_managers.filter(id=user.id).exists()
def __str__(self): def __str__(self):
return f"{self.title} ({'Aktiv' if self.is_active else 'Deaktiviert'})" return f"{self.title} ({'Aktiv' if self.is_active else 'Deaktiviert'})"
+9
View File
@@ -1,5 +1,9 @@
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
from apps.users.serializers import UserSerializer
User = get_user_model()
class SkillSerializer(serializers.ModelSerializer): class SkillSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@@ -65,12 +69,17 @@ class TaskAreaSerializer(serializers.ModelSerializer):
class EventSerializer(serializers.ModelSerializer): class EventSerializer(serializers.ModelSerializer):
task_areas = TaskAreaSerializer(many=True, read_only=True) task_areas = TaskAreaSerializer(many=True, read_only=True)
created_by_name = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField()
co_managers = UserSerializer(many=True, read_only=True)
co_manager_ids = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), many=True, write_only=True, required=False, source='co_managers'
)
class Meta: class Meta:
model = Event model = Event
fields = [ fields = [
'id', 'title', 'description', 'location', 'id', 'title', 'description', 'location',
'start_date', 'end_date', 'is_active', 'created_by', 'created_by_name', 'start_date', 'end_date', 'is_active', 'created_by', 'created_by_name',
'co_managers', 'co_manager_ids',
'task_areas', 'created_at', 'updated_at' 'task_areas', 'created_at', 'updated_at'
] ]
read_only_fields = ['created_by', 'created_at', 'updated_at'] read_only_fields = ['created_by', 'created_at', 'updated_at']
+31
View File
@@ -111,3 +111,34 @@ class EventAndShiftTests(TestCase):
res_ok = self.client.delete(f'/api/shifts/{shift.id}/signup/', {'signup_id': signup.id}, format='json') res_ok = self.client.delete(f'/api/shifts/{shift.id}/signup/', {'signup_id': signup.id}, format='json')
self.assertEqual(res_ok.status_code, status.HTTP_200_OK) self.assertEqual(res_ok.status_code, status.HTTP_200_OK)
self.assertFalse(ShiftSignup.objects.filter(pk=signup.id).exists()) self.assertFalse(ShiftSignup.objects.filter(pk=signup.id).exists())
def test_co_manager_permissions_and_guest_skill_bypass(self):
owner = User.objects.create_user(username="owner_user", email="owner@test.de", password="password123")
co_manager = User.objects.create_user(username="comanager", email="comanager@test.de", password="password123")
event = Event.objects.create(title="CoManaged Event", start_date="2026-10-01", end_date="2026-10-02", created_by=owner)
event.co_managers.add(co_manager)
ta = TaskArea.objects.create(event=event, name="Einlass")
shift = Shift.objects.create(task_area=ta, title="Spätschicht", start_time="20:00", end_time="24:00", max_participants=5)
shift.required_skills.add(self.skill)
# Co-manager can edit event
self.client.force_authenticate(user=co_manager)
res_edit = self.client.patch(f'/api/events/{event.id}/', {'location': 'Haupteingang'})
self.assertEqual(res_edit.status_code, status.HTTP_200_OK)
self.assertEqual(res_edit.data['location'], 'Haupteingang')
# Co-manager CAN register a guest on skill-required shift (skill check bypassed for manager!)
res_guest = self.client.post(f'/api/shifts/{shift.id}/signup/', {'guest_name': 'VIP Gast'}, format='json')
self.assertEqual(res_guest.status_code, status.HTTP_201_CREATED)
self.assertIn('ohne Qualifikationsprüfung', res_guest.data['message'])
# Check signup exists
signup_id = res_guest.data['signup_id']
signup = ShiftSignup.objects.get(pk=signup_id)
self.assertEqual(signup.guest_name, 'VIP Gast')
# Co-manager can delete any signup
res_del = self.client.delete(f'/api/shifts/{shift.id}/signup/', {'signup_id': signup_id}, format='json')
self.assertEqual(res_del.status_code, status.HTTP_200_OK)
+129 -114
View File
@@ -29,31 +29,19 @@ class IsAdminOrEventCreator(permissions.BasePermission):
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS: if request.method in permissions.SAFE_METHODS:
return True return True
if not request.user or not request.user.is_authenticated: return obj.is_user_manager_or_admin(request.user)
return False
if request.user.is_admin_user or request.user.is_staff or request.user.is_superuser:
return True
return obj.created_by == request.user
class IsTaskAreaOwnerOrAdmin(permissions.BasePermission): class IsTaskAreaOwnerOrAdmin(permissions.BasePermission):
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS: if request.method in permissions.SAFE_METHODS:
return True return True
if not request.user or not request.user.is_authenticated: return obj.event.is_user_manager_or_admin(request.user)
return False
if request.user.is_admin_user or request.user.is_staff or request.user.is_superuser:
return True
return obj.event.created_by == request.user
class IsShiftOwnerOrAdmin(permissions.BasePermission): class IsShiftOwnerOrAdmin(permissions.BasePermission):
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS: if request.method in permissions.SAFE_METHODS:
return True return True
if not request.user or not request.user.is_authenticated: return obj.task_area.event.is_user_manager_or_admin(request.user)
return False
if request.user.is_admin_user or request.user.is_staff or request.user.is_superuser:
return True
return obj.task_area.event.created_by == request.user
class EventViewSet(viewsets.ModelViewSet): class EventViewSet(viewsets.ModelViewSet):
serializer_class = EventSerializer serializer_class = EventSerializer
@@ -64,7 +52,7 @@ class EventViewSet(viewsets.ModelViewSet):
if user.is_authenticated and (user.is_admin_user or user.is_staff or user.is_superuser): if user.is_authenticated and (user.is_admin_user or user.is_staff or user.is_superuser):
return Event.objects.all().order_by('-start_date', '-id') return Event.objects.all().order_by('-start_date', '-id')
if user.is_authenticated: if user.is_authenticated:
return Event.objects.filter(Q(is_active=True) | Q(created_by=user)).order_by('-start_date', '-id') return Event.objects.filter(Q(is_active=True) | Q(created_by=user) | Q(co_managers=user)).distinct().order_by('-start_date', '-id')
return Event.objects.filter(is_active=True).order_by('-start_date', '-id') return Event.objects.filter(is_active=True).order_by('-start_date', '-id')
def perform_create(self, serializer): def perform_create(self, serializer):
@@ -73,110 +61,128 @@ class EventViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def matrix(self, request, pk=None): def matrix(self, request, pk=None):
event = self.get_object() event = self.get_object()
serializer = EventSerializer(event, context={'request': request}) task_areas = event.task_areas.prefetch_related('shifts__required_skills', 'shifts__signups__user').all()
return Response(serializer.data) serializer = TaskAreaSerializer(task_areas, many=True, context={'request': request})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) user_signups = []
def save_as_template(self, request, pk=None): if request.user.is_authenticated:
event = self.get_object() signups = ShiftSignup.objects.filter(shift__task_area__event=event, user=request.user)
name = request.data.get('name', f"Vorlage von {event.title}") user_signups = list(signups.values_list('shift_id', flat=True))
description = request.data.get('description', '')
# Build template structure return Response({
template_task_areas = [] 'event': EventSerializer(event).data,
for ta in event.task_areas.all(): 'task_areas': serializer.data,
shifts_data = [] 'user_signups': user_signups
for shift in ta.shifts.all(): })
shifts_data.append({
'title': shift.title,
'start_time': shift.start_time,
'end_time': shift.end_time,
'max_participants': shift.max_participants,
'required_skill_names': list(shift.required_skills.values_list('name', flat=True))
})
template_task_areas.append({
'name': ta.name,
'description': ta.description,
'order': ta.order,
'shifts': shifts_data
})
template = EventTemplate.objects.create(
name=name,
description=description,
template_data={'task_areas': template_task_areas},
created_by=request.user
)
return Response(EventTemplateSerializer(template).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def export_pdf(self, request, pk=None): def pdf(self, request, pk=None):
event = self.get_object() event = self.get_object()
task_areas = event.task_areas.prefetch_related('shifts__required_skills', 'shifts__signups__user').all()
buffer = BytesIO() buffer = BytesIO()
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
buffer, pagesize=landscape(letter), buffer,
rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30 pagesize=landscape(letter),
rightMargin=30,
leftMargin=30,
topMargin=30,
bottomMargin=30
) )
elements = [] elements = []
styles = getSampleStyleSheet() styles = getSampleStyleSheet()
title_style = ParagraphStyle( title_style = ParagraphStyle(
'DocTitle', 'EventTitle',
parent=styles['Heading1'], parent=styles['Heading1'],
fontSize=20, fontSize=18,
leading=24, leading=22,
textColor=colors.HexColor('#1e293b'), textColor=colors.HexColor('#0f172a'),
spaceAfter=6 spaceAfter=6
) )
sub_style = ParagraphStyle(
'DocSub', subtitle_style = ParagraphStyle(
'EventSubtitle',
parent=styles['Normal'], parent=styles['Normal'],
fontSize=10, fontSize=10,
leading=12, leading=14,
textColor=colors.HexColor('#64748b'), textColor=colors.HexColor('#475569'),
spaceAfter=15 spaceAfter=15
) )
cell_title_style = ParagraphStyle(
'CellTitle',
parent=styles['Normal'],
fontSize=9,
leading=11,
textColor=colors.HexColor('#0f172a'),
fontName='Helvetica-Bold'
)
cell_text_style = ParagraphStyle(
'CellText',
parent=styles['Normal'],
fontSize=8,
leading=10,
textColor=colors.HexColor('#334155')
)
elements.append(Paragraph(f"Schichtplan: {event.title}", title_style)) elements.append(Paragraph(f"Schichtplan: {event.title}", title_style))
loc_str = f" • Ort: {event.location}" if event.location else ""
elements.append(Paragraph(f"Zeitraum: {event.start_date} bis {event.end_date}{loc_str}", sub_style))
# Build Table Data date_str = f"{event.start_date.strftime('%d.%m.%Y')} - {event.end_date.strftime('%d.%m.%Y')}"
table_data = [] loc_str = f" | Ort: {event.location}" if event.location else ""
headers = ["Bereich", "Schicht & Zeit", "Benötigte Qualifikationen", "Eingetragene Personen"] elements.append(Paragraph(f"Datum: {date_str}{loc_str}", subtitle_style))
table_data.append([Paragraph(f"<b>{h}</b>", styles['Normal']) for h in headers])
for ta in event.task_areas.all(): table_data = [[
for shift in ta.shifts.all(): Paragraph("Aufgabenbereich", cell_title_style),
skills_list = ", ".join([s.name for s in shift.required_skills.all()]) or "Keine" Paragraph("Schicht", cell_title_style),
Paragraph("Zeitfenster", cell_title_style),
Paragraph("Erforderlich", cell_title_style),
Paragraph("Eingetragene Personen / Plätze", cell_title_style)
]]
signups = shift.signups.all() for area in task_areas:
signups_list = [] shifts = area.shifts.all()
for su in signups: if not shifts:
if request.user.is_authenticated: table_data.append([
display = su.user.get_display_name() if su.user else (su.guest_name or "Gast") Paragraph(area.name, cell_title_style),
else: Paragraph("", cell_text_style),
display = "Belegt (Anonym)" Paragraph("", cell_text_style),
signups_list.append(display) Paragraph("", cell_text_style),
Paragraph("Keine Schichten definiert", cell_text_style)
])
else:
for idx, shift in enumerate(shifts):
area_name = area.name if idx == 0 else ""
skills_str = ", ".join([s.name for s in shift.required_skills.all()]) or "Keine"
while len(signups_list) < shift.max_participants: signups = shift.signups.all()
signups_list.append("[ Offen ]") signup_names = []
for su in signups:
if su.user:
signup_names.append(su.user.get_display_name())
elif su.guest_name:
signup_names.append(f"{su.guest_name} (Gast)")
signups_str = "<br/>".join(signups_list) needed = shift.max_participants - len(signup_names)
if needed > 0:
signup_names.extend([f"<i>[ {needed} Platz frei ]</i>"] * 1)
row = [ participants_html = "<br/>".join(signup_names) or "<i>[ Frei ]</i>"
Paragraph(f"<b>{ta.name}</b>", styles['Normal']),
Paragraph(f"{shift.title}<br/><font color='#64748b'>{shift.start_time} - {shift.end_time}</font>", styles['Normal']),
Paragraph(skills_list, styles['Normal']),
Paragraph(signups_str, styles['Normal'])
]
table_data.append(row)
if len(table_data) == 1: date_prefix = f"{shift.date.strftime('%d.%m.')} " if shift.date else ""
table_data.append([Paragraph("Keine Schichten angelegt", styles['Normal']), "", "", ""]) time_range = f"{date_prefix}{shift.start_time} - {shift.end_time} Uhr"
pdf_table = Table(table_data, colWidths=[150, 160, 160, 250]) table_data.append([
Paragraph(area_name, cell_title_style),
Paragraph(shift.title, cell_text_style),
Paragraph(time_range, cell_text_style),
Paragraph(skills_str, cell_text_style),
Paragraph(participants_html, cell_text_style)
])
col_widths = [130, 110, 110, 110, 240]
pdf_table = Table(table_data, colWidths=col_widths, repeatRows=1)
pdf_table.setStyle(TableStyle([ pdf_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')), ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#0f172a')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#0f172a')),
@@ -203,9 +209,7 @@ class TaskAreaViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
event = serializer.validated_data.get('event') event = serializer.validated_data.get('event')
user = self.request.user user = self.request.user
if not user.is_authenticated: if not event or not event.is_user_manager_or_admin(user):
raise permissions.PermissionDenied("Nicht angemeldet.")
if not (user.is_admin_user or user.is_staff or user.is_superuser or (event and event.created_by == user)):
raise permissions.PermissionDenied("Keine Berechtigung, Bereiche für dieses Event hinzuzufügen.") raise permissions.PermissionDenied("Keine Berechtigung, Bereiche für dieses Event hinzuzufügen.")
serializer.save() serializer.save()
@@ -217,9 +221,7 @@ class ShiftViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
task_area = serializer.validated_data.get('task_area') task_area = serializer.validated_data.get('task_area')
user = self.request.user user = self.request.user
if not user.is_authenticated: if not task_area or not task_area.event.is_user_manager_or_admin(user):
raise permissions.PermissionDenied("Nicht angemeldet.")
if not (user.is_admin_user or user.is_staff or user.is_superuser or (task_area and task_area.event.created_by == user)):
raise permissions.PermissionDenied("Keine Berechtigung, Schichten für dieses Event hinzuzufügen.") raise permissions.PermissionDenied("Keine Berechtigung, Schichten für dieses Event hinzuzufügen.")
serializer.save() serializer.save()
@@ -228,19 +230,36 @@ class ShiftSignupView(views.APIView):
def post(self, request, shift_id): def post(self, request, shift_id):
shift = get_object_or_404(Shift, pk=shift_id) shift = get_object_or_404(Shift, pk=shift_id)
event = shift.task_area.event
user = request.user if request.user.is_authenticated else None
is_manager = event.is_user_manager_or_admin(user)
# Check capacity # Check capacity
if shift.signups.count() >= shift.max_participants: if shift.signups.count() >= shift.max_participants:
return Response({'error': 'Diese Schicht ist bereits vollständig belegt.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Diese Schicht ist bereits vollständig belegt.'}, status=status.HTTP_400_BAD_REQUEST)
# Authenticated User flow guest_name = request.data.get('guest_name', '').strip()
if request.user.is_authenticated:
if ShiftSignup.objects.filter(shift=shift, user=request.user).exists(): # Manager/Owner/Admin registering a guest manually -> Skill check bypassed!
if is_manager and guest_name:
signup = ShiftSignup.objects.create(
shift=shift,
guest_name=guest_name,
guest_session_key='added_by_manager'
)
return Response({
'message': f'Gast "{guest_name}" wurde von dir erfolgreich eingetragen (ohne Qualifikationsprüfung).',
'signup_id': signup.id
}, status=status.HTTP_201_CREATED)
# Authenticated User flow (self-signup)
if user and not guest_name:
if ShiftSignup.objects.filter(shift=shift, user=user).exists():
return Response({'error': 'Du bist für diese Schicht bereits eingetragen.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Du bist für diese Schicht bereits eingetragen.'}, status=status.HTTP_400_BAD_REQUEST)
required_skills = shift.required_skills.all() required_skills = shift.required_skills.all()
if required_skills.exists(): if required_skills.exists():
user_skills = request.user.skills.all() user_skills = user.skills.all()
missing_skills = [s.name for s in required_skills if s not in user_skills] missing_skills = [s.name for s in required_skills if s not in user_skills]
if missing_skills: if missing_skills:
missing_str = ", ".join(missing_skills) missing_str = ", ".join(missing_skills)
@@ -248,21 +267,20 @@ class ShiftSignupView(views.APIView):
'error': f'Dir fehlen folgende erforderliche Fähigkeiten für diese Schicht: {missing_str}' 'error': f'Dir fehlen folgende erforderliche Fähigkeiten für diese Schicht: {missing_str}'
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
signup = ShiftSignup.objects.create(shift=shift, user=request.user) signup = ShiftSignup.objects.create(shift=shift, user=user)
return Response({'message': 'Erfolgreich für Schicht eingetragen.', 'signup_id': signup.id}, status=status.HTTP_201_CREATED) return Response({'message': 'Erfolgreich für Schicht eingetragen.', 'signup_id': signup.id}, status=status.HTTP_201_CREATED)
# Guest flow # Guest flow for visitors
else: else:
guest_name = request.data.get('guest_name', '').strip()
captcha_token = request.data.get('captcha_token', '').strip()
if not guest_name: if not guest_name:
return Response({'error': 'Bitte gib einen Namen an.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Bitte gib einen Namen an.'}, status=status.HTTP_400_BAD_REQUEST)
if not captcha_token: captcha_token = request.data.get('captcha_token', '').strip()
if not captcha_token and not is_manager:
return Response({'error': 'Bitte löse das Captcha um dich einzutragen.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Bitte löse das Captcha um dich einzutragen.'}, status=status.HTTP_400_BAD_REQUEST)
if shift.required_skills.exists(): # If NOT a manager, enforce required skills check for guest signups
if not is_manager and shift.required_skills.exists():
required_names = ", ".join([s.name for s in shift.required_skills.all()]) required_names = ", ".join([s.name for s in shift.required_skills.all()])
return Response({ return Response({
'error': f'Gäste können keine Fähigkeiten haben. Diese Schicht erfordert: {required_names}. Bitte registriere ein Konto mit den passenden Fähigkeiten.' 'error': f'Gäste können keine Fähigkeiten haben. Diese Schicht erfordert: {required_names}. Bitte registriere ein Konto mit den passenden Fähigkeiten.'
@@ -282,7 +300,7 @@ class ShiftSignupView(views.APIView):
guest_session_key=session_key guest_session_key=session_key
) )
return Response({ return Response({
'message': 'Als Gast erfolgreich eingetragen! Dein Name wurde in deiner Sitzung gespeichert.', 'message': f'Gast "{guest_name}" erfolgreich eingetragen!',
'signup_id': signup.id 'signup_id': signup.id
}, status=status.HTTP_201_CREATED) }, status=status.HTTP_201_CREATED)
@@ -300,7 +318,7 @@ class ShiftSignupView(views.APIView):
if request.user.is_authenticated: if request.user.is_authenticated:
user = request.user user = request.user
is_manager = user.is_admin_user or user.is_staff or user.is_superuser or (shift.task_area.event.created_by == user) is_manager = shift.task_area.event.is_user_manager_or_admin(user)
if signup_id and is_manager: if signup_id and is_manager:
signup = ShiftSignup.objects.filter(shift=shift, pk=signup_id).first() signup = ShiftSignup.objects.filter(shift=shift, pk=signup_id).first()
@@ -344,9 +362,6 @@ class EventTemplateViewSet(viewsets.ModelViewSet):
end_date = request.data.get('end_date') end_date = request.data.get('end_date')
location = request.data.get('location', '') location = request.data.get('location', '')
if not start_date or not end_date:
return Response({'error': 'start_date und end_date sind erforderlich.'}, status=status.HTTP_400_BAD_REQUEST)
event = Event.objects.create( event = Event.objects.create(
title=title, title=title,
description=template.description, description=template.description,
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -5,7 +5,7 @@ from .views import (
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView, RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
PendingUsersView, ApproveUserView, AdminUserViewSet, PendingUsersView, ApproveUserView, AdminUserViewSet,
GenerateClaimLinkView, ClaimInfoView, GenerateClaimLinkView, ClaimInfoView,
SMTPSettingView, TestSMTPEmailView SMTPSettingView, TestSMTPEmailView, UserListView
) )
router = DefaultRouter() router = DefaultRouter()
@@ -17,6 +17,7 @@ urlpatterns = [
path('verify-email/', VerifyEmailView.as_view(), name='verify-email'), path('verify-email/', VerifyEmailView.as_view(), name='verify-email'),
path('login/', LoginView.as_view(), name='login'), path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'), path('me/', MeView.as_view(), name='me'),
path('all/', UserListView.as_view(), name='all-users'),
path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'), path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'), path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'), path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
+7
View File
@@ -388,3 +388,10 @@ class ApproveUserView(views.APIView):
name = user.get_display_name() name = user.get_display_name()
user.delete() user.delete()
return Response({'message': f'Registrierung von {name} wurde abgelehnt und gelöscht.'}) return Response({'message': f'Registrierung von {name} wurde abgelehnt und gelöscht.'})
class UserListView(views.APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
users = User.objects.filter(is_active=True).order_by('username')
return Response(UserSerializer(users, many=True).data)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<title>Schichtplaner — Veranstaltungsschichtpläne</title> <title>Schichtplaner — Veranstaltungsschichtpläne</title>
<script type="module" crossorigin src="/assets/index-BTg0Pu2I.js"></script> <script type="module" crossorigin src="/assets/index-KtQZvLw-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DyS9UMYS.css"> <link rel="stylesheet" crossorigin href="/assets/index-CGy_Z7Jy.css">
</head> </head>
<body class="bg-paper text-main font-sans antialiased min-h-screen"> <body class="bg-paper text-main font-sans antialiased min-h-screen">
<div id="root"></div> <div id="root"></div>
+4 -2
View File
@@ -278,7 +278,8 @@ export default function App() {
description: eventPayload.description, description: eventPayload.description,
location: eventPayload.location, location: eventPayload.location,
start_date: eventPayload.start_date, start_date: eventPayload.start_date,
end_date: eventPayload.end_date end_date: eventPayload.end_date,
co_manager_ids: eventPayload.co_manager_ids || []
}) })
}); });
} else { } else {
@@ -290,7 +291,8 @@ export default function App() {
description: eventPayload.description, description: eventPayload.description,
location: eventPayload.location, location: eventPayload.location,
start_date: eventPayload.start_date, start_date: eventPayload.start_date,
end_date: eventPayload.end_date end_date: eventPayload.end_date,
co_manager_ids: eventPayload.co_manager_ids || []
}) })
}); });
targetEventId = created.id; targetEventId = created.id;
+3 -2
View File
@@ -20,7 +20,7 @@ export default function ShiftMatrixTable({
<Clock className="w-8 h-8 text-muted mx-auto mb-3" /> <Clock className="w-8 h-8 text-muted mx-auto mb-3" />
<h3 className="font-serif text-xl font-bold text-main uppercase">Keine Aufgabenfelder vorhanden</h3> <h3 className="font-serif text-xl font-bold text-main uppercase">Keine Aufgabenfelder vorhanden</h3>
<p className="text-xs text-muted mt-1 mb-4">[ Event hat noch keine definierten Aufgabenfelder oder Schichten ]</p> <p className="text-xs text-muted mt-1 mb-4">[ Event hat noch keine definierten Aufgabenfelder oder Schichten ]</p>
{user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id) && onEditEvent && ( {user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id || (event?.co_managers && event.co_managers.some(cm => (typeof cm === 'object' ? cm.id === user.id : cm === user.id)))) && onEditEvent && (
<button <button
onClick={() => onEditEvent(event)} onClick={() => onEditEvent(event)}
className="px-4 py-2 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 font-bold text-xs inline-flex items-center gap-2" className="px-4 py-2 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 font-bold text-xs inline-flex items-center gap-2"
@@ -33,7 +33,8 @@ export default function ShiftMatrixTable({
} }
const isGuest = !user; const isGuest = !user;
const isManager = user && (user.is_admin_user || user.is_staff || user.is_superuser || event.created_by === user.id || event.created_by?.id === user.id); const isCoManager = user && event?.co_managers && event.co_managers.some(cm => (typeof cm === 'object' ? cm.id === user.id : cm === user.id));
const isManager = user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id || isCoManager);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
+97 -90
View File
@@ -1,22 +1,30 @@
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */ /* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
/* Hallmark · component: EventEditorPage · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */ /* Hallmark · component: EventEditorPage · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ArrowLeft, Calendar, Layers, Plus, Trash2, Edit3, CheckCircle2, AlertCircle } from 'lucide-react'; import { ArrowLeft, Calendar, Layers, Plus, Trash2, Edit3, CheckCircle2, AlertCircle, Users, UserCheck } from 'lucide-react';
import { apiFetch } from '../api/client'; import { apiFetch } from '../api/client';
export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit }) { export default function EventEditorPage({ eventToEdit, skills, user, onBack, onSubmit }) {
const [title, setTitle] = useState(eventToEdit?.title || ''); const [title, setTitle] = useState(eventToEdit?.title || '');
const [description, setDescription] = useState(eventToEdit?.description || ''); const [description, setDescription] = useState(eventToEdit?.description || '');
const [location, setLocation] = useState(eventToEdit?.location || ''); const [location, setLocation] = useState(eventToEdit?.location || '');
const [startDate, setStartDate] = useState(eventToEdit?.start_date || new Date().toISOString().split('T')[0]); const [startDate, setStartDate] = useState(eventToEdit?.start_date || new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState(eventToEdit?.end_date || new Date().toISOString().split('T')[0]); const [endDate, setEndDate] = useState(eventToEdit?.end_date || new Date().toISOString().split('T')[0]);
// Co-managers state
const [coManagerIds, setCoManagerIds] = useState(
eventToEdit?.co_managers ? eventToEdit.co_managers.map(u => u.id) : []
);
const [allUsers, setAllUsers] = useState([]);
// Task areas state // Task areas state
const [taskAreas, setTaskAreas] = useState([]); const [taskAreas, setTaskAreas] = useState([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
fetchUsers();
if (eventToEdit && eventToEdit.task_areas && eventToEdit.task_areas.length > 0) { if (eventToEdit && eventToEdit.task_areas && eventToEdit.task_areas.length > 0) {
const formattedAreas = eventToEdit.task_areas.map(ta => ({ const formattedAreas = eventToEdit.task_areas.map(ta => ({
id: ta.id, id: ta.id,
@@ -54,6 +62,13 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
} }
}, [eventToEdit]); }, [eventToEdit]);
const fetchUsers = async () => {
try {
const data = await apiFetch('/users/all/');
setAllUsers(data.results || data);
} catch (e) {}
};
const addTaskArea = () => { const addTaskArea = () => {
setTaskAreas([ setTaskAreas([
...taskAreas, ...taskAreas,
@@ -100,13 +115,13 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
setTaskAreas(updated); setTaskAreas(updated);
}; };
const toggleShiftSkill = (areaIndex, shiftIndex, skillId) => { const toggleSkillRequirement = (areaIndex, shiftIndex, skillId) => {
const updated = [...taskAreas]; const updated = [...taskAreas];
const currentSkills = updated[areaIndex].shifts[shiftIndex].required_skill_ids || []; const current = updated[areaIndex].shifts[shiftIndex].required_skill_ids || [];
if (currentSkills.includes(skillId)) { if (current.includes(skillId)) {
updated[areaIndex].shifts[shiftIndex].required_skill_ids = currentSkills.filter(id => id !== skillId); updated[areaIndex].shifts[shiftIndex].required_skill_ids = current.filter(id => id !== skillId);
} else { } else {
updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...currentSkills, skillId]; updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...current, skillId];
} }
setTaskAreas(updated); setTaskAreas(updated);
}; };
@@ -129,6 +144,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
location, location,
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
co_manager_ids: coManagerIds,
task_areas: taskAreas task_areas: taskAreas
}); });
} catch (err) { } catch (err) {
@@ -165,7 +181,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
{eventToEdit ? 'Veranstaltung & Schichten Bearbeiten' : 'Neue Veranstaltung Erstellen'} {eventToEdit ? 'Veranstaltung & Schichten Bearbeiten' : 'Neue Veranstaltung Erstellen'}
</h2> </h2>
<p className="text-xs text-muted font-mono mt-0.5"> <p className="text-xs text-muted font-mono mt-0.5">
Konfiguriere Stammdaten, Aufgabenfelder, Schichtzeiten & Qualifikationen Konfiguriere Stammdaten, Co-Verwalter, Aufgabenfelder, Schichtzeiten & Qualifikationen
</p> </p>
</div> </div>
</div> </div>
@@ -183,7 +199,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
{/* Section 1: Stammdaten */} {/* Section 1: Stammdaten */}
<div className="hallmark-panel rounded-sm p-6 border border-grid space-y-4 font-mono text-xs"> <div className="hallmark-panel rounded-sm p-6 border border-grid space-y-4 font-mono text-xs">
<h3 className="font-serif text-lg font-bold uppercase text-main border-b border-grid pb-2"> <h3 className="font-serif text-lg font-bold uppercase text-main border-b border-grid pb-2">
1. Stammdaten der Veranstaltung 1. Stammdaten & Verwalter der Veranstaltung
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -242,6 +258,44 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
className="w-full px-3.5 py-2 rounded-sm input-field text-xs font-sans" className="w-full px-3.5 py-2 rounded-sm input-field text-xs font-sans"
/> />
</div> </div>
{/* Co-Managers Selection */}
{allUsers.length > 0 && (
<div className="sm:col-span-2 pt-3 border-t border-grid space-y-2">
<label className="block text-main font-bold flex items-center gap-1.5">
<Users className="w-4 h-4 text-indigo-400" /> Co-Verwalter / Mit-Organisatoren (optional)
</label>
<p className="text-[11px] text-muted font-sans">
Wähle registrierte Benutzer aus, die dieses Event ebenfalls verwalten, bearbeiten und Gäste eintragen dürfen:
</p>
<div className="flex flex-wrap gap-2 pt-1">
{allUsers.map((u) => {
const isSelected = coManagerIds.includes(u.id);
return (
<button
key={u.id}
type="button"
onClick={() => {
if (isSelected) {
setCoManagerIds(coManagerIds.filter(id => id !== u.id));
} else {
setCoManagerIds([...coManagerIds, u.id]);
}
}}
className={`px-3 py-1.5 rounded-sm text-xs font-mono font-bold transition flex items-center gap-1.5 border ${
isSelected
? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/50 shadow-sm'
: 'bg-subtle text-muted border-grid hover:text-main'
}`}
>
<UserCheck className={`w-3.5 h-3.5 ${isSelected ? 'text-indigo-400' : 'opacity-40'}`} />
<span>{u.display_name || u.username}</span>
</button>
);
})}
</div>
</div>
)}
</div> </div>
</div> </div>
@@ -347,30 +401,29 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
</button> </button>
</div> </div>
{/* Required Skills selection */} {/* Skill requirements selector */}
{skills && skills.length > 0 && ( {skills && skills.length > 0 && (
<div className="pt-1.5 border-t border-grid text-[11px]"> <div className="pt-2 border-t border-grid flex flex-wrap items-center gap-1.5">
<span className="text-muted block mb-1">Erforderliche Qualifikationen:</span> <span className="text-[10px] text-muted font-bold mr-1">Erforderlich:</span>
<div className="flex flex-wrap gap-1.5"> {skills.map(sk => {
{skills.map(sk => { const isReq = (shift.required_skill_ids || []).includes(sk.id);
const isSelected = (shift.required_skill_ids || []).includes(sk.id); return (
return ( <button
<button key={sk.id}
key={sk.id} type="button"
type="button" onClick={() => toggleSkillRequirement(aIdx, sIdx, sk.id)}
onClick={() => toggleShiftSkill(aIdx, sIdx, sk.id)} style={{
style={{ backgroundColor: isReq ? `${sk.color}25` : 'transparent',
backgroundColor: isSelected ? sk.color : `${sk.color}15`, borderColor: isReq ? sk.color : 'var(--color-border-grid)',
borderColor: sk.color, color: isReq ? sk.color : 'var(--color-text-muted)'
color: isSelected ? '#ffffff' : sk.color }}
}} className="px-2 py-0.5 rounded-sm text-[10px] border font-bold transition flex items-center gap-1"
className="px-2 py-0.5 rounded-sm border text-[10px] font-bold transition" >
> <span>{sk.name}</span>
{sk.name} {isSelected ? '✓' : ''} {isReq && <CheckCircle2 className="w-3 h-3" />}
</button> </button>
); );
})} })}
</div>
</div> </div>
)} )}
</div> </div>
@@ -380,70 +433,24 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
))} ))}
</div> </div>
{/* Action Controls */} {/* Submit Actions */}
<div className="hallmark-panel rounded-sm p-4 border border-grid flex flex-wrap items-center justify-between gap-3 font-mono text-xs"> <div className="flex items-center justify-end gap-3 pt-2 font-mono">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
className="px-4 py-2 rounded-sm text-xs font-semibold text-muted hover:bg-surface-hover transition border border-grid" className="px-5 py-2.5 rounded-sm text-xs font-semibold text-muted hover:bg-surface-hover transition border border-grid"
> >
Abbrechen Abbrechen
</button> </button>
<button
<div className="flex items-center gap-2"> type="submit"
<button disabled={submitting}
type="button" style={{ backgroundColor: 'var(--brand-primary)' }}
disabled={submitting} className="px-6 py-2.5 rounded-sm text-xs font-bold text-white transition hover:brightness-110 shadow-sm flex items-center gap-2"
onClick={async () => { >
if (!title.trim()) { <CheckCircle2 className="w-4 h-4" />
setError('Bitte gib einen Titel an.'); {submitting ? 'Speichern...' : 'Veranstaltung Speichern'}
return; </button>
}
setSubmitting(true);
try {
await apiFetch('/templates/', {
method: 'POST',
body: JSON.stringify({
name: title,
description: description || `Vorlage mit ${taskAreas.length} Aufgabenbereichen`,
template_data: {
task_areas: taskAreas.map(ta => ({
name: ta.name,
description: ta.description,
shifts: (ta.shifts || []).map(s => ({
title: s.title,
start_time: s.start_time,
end_time: s.end_time,
max_participants: s.max_participants,
required_skill_ids: s.required_skill_ids
}))
}))
}
})
});
alert(`✅ Vorlage "${title}" inklusive aller voreingestellten Schichten erfolgreich gespeichert!`);
} catch (err) {
setError(err.message || 'Speichern der Vorlage fehlgeschlagen.');
} finally {
setSubmitting(false);
}
}}
className="px-4 py-2.5 rounded-sm text-xs font-mono font-bold bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 hover:bg-indigo-500/20 transition flex items-center gap-1.5"
title="Diese Konfiguration mit allen Schichten als Vorlage abspeichern"
>
<Layers className="w-4 h-4" /> ALS VORLAGE SPEICHERN
</button>
<button
type="submit"
disabled={submitting}
style={{ backgroundColor: 'var(--brand-primary)' }}
className="px-6 py-2.5 rounded-sm text-xs font-mono font-bold text-white transition shadow-sm hover:brightness-110 flex items-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
{submitting ? 'Speichern...' : eventToEdit ? 'Änderungen Speichern' : 'Veranstaltung Jetzt Erstellen'}
</button>
</div>
</div> </div>
</form> </form>
</div> </div>
+2 -1
View File
@@ -136,7 +136,8 @@ export default function HomePage({
{filteredEvents.map((evt) => { {filteredEvents.map((evt) => {
const taskAreaCount = evt.task_areas?.length || 0; const taskAreaCount = evt.task_areas?.length || 0;
const totalShifts = evt.task_areas?.reduce((acc, ta) => acc + (ta.shifts?.length || 0), 0) || 0; const totalShifts = evt.task_areas?.reduce((acc, ta) => acc + (ta.shifts?.length || 0), 0) || 0;
const isCreator = user && (evt.created_by === user.id || evt.created_by?.id === user.id); const isCoManager = user && evt.co_managers && evt.co_managers.some(cm => (typeof cm === 'object' ? cm.id === user.id : cm === user.id));
const isCreator = user && (evt.created_by === user.id || evt.created_by?.id === user.id || isCoManager);
const canManage = isUserAdmin || isCreator; const canManage = isUserAdmin || isCreator;
return ( return (