feat: Add event co-managers permissions and allow managers/admins to signup guests on skill-restricted shifts
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@@ -18,9 +18,19 @@ class Event(models.Model):
|
||||
end_date = models.DateField()
|
||||
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')
|
||||
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)
|
||||
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):
|
||||
return f"{self.title} ({'Aktiv' if self.is_active else 'Deaktiviert'})"
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
|
||||
from apps.users.serializers import UserSerializer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class SkillSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -65,12 +69,17 @@ class TaskAreaSerializer(serializers.ModelSerializer):
|
||||
class EventSerializer(serializers.ModelSerializer):
|
||||
task_areas = TaskAreaSerializer(many=True, read_only=True)
|
||||
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:
|
||||
model = Event
|
||||
fields = [
|
||||
'id', 'title', 'description', 'location',
|
||||
'start_date', 'end_date', 'is_active', 'created_by', 'created_by_name',
|
||||
'co_managers', 'co_manager_ids',
|
||||
'task_areas', 'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['created_by', 'created_at', 'updated_at']
|
||||
|
||||
@@ -111,3 +111,34 @@ class EventAndShiftTests(TestCase):
|
||||
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.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)
|
||||
|
||||
+126
-111
@@ -29,31 +29,19 @@ class IsAdminOrEventCreator(permissions.BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
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
|
||||
return obj.is_user_manager_or_admin(request.user)
|
||||
|
||||
class IsTaskAreaOwnerOrAdmin(permissions.BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
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
|
||||
return obj.event.is_user_manager_or_admin(request.user)
|
||||
|
||||
class IsShiftOwnerOrAdmin(permissions.BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
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
|
||||
return obj.task_area.event.is_user_manager_or_admin(request.user)
|
||||
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
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):
|
||||
return Event.objects.all().order_by('-start_date', '-id')
|
||||
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')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -73,110 +61,128 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=['get'])
|
||||
def matrix(self, request, pk=None):
|
||||
event = self.get_object()
|
||||
serializer = EventSerializer(event, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
task_areas = event.task_areas.prefetch_related('shifts__required_skills', 'shifts__signups__user').all()
|
||||
serializer = TaskAreaSerializer(task_areas, many=True, context={'request': request})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def save_as_template(self, request, pk=None):
|
||||
event = self.get_object()
|
||||
name = request.data.get('name', f"Vorlage von {event.title}")
|
||||
description = request.data.get('description', '')
|
||||
user_signups = []
|
||||
if request.user.is_authenticated:
|
||||
signups = ShiftSignup.objects.filter(shift__task_area__event=event, user=request.user)
|
||||
user_signups = list(signups.values_list('shift_id', flat=True))
|
||||
|
||||
# Build template structure
|
||||
template_task_areas = []
|
||||
for ta in event.task_areas.all():
|
||||
shifts_data = []
|
||||
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))
|
||||
return Response({
|
||||
'event': EventSerializer(event).data,
|
||||
'task_areas': serializer.data,
|
||||
'user_signups': user_signups
|
||||
})
|
||||
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'])
|
||||
def export_pdf(self, request, pk=None):
|
||||
def pdf(self, request, pk=None):
|
||||
event = self.get_object()
|
||||
task_areas = event.task_areas.prefetch_related('shifts__required_skills', 'shifts__signups__user').all()
|
||||
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=landscape(letter),
|
||||
rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30
|
||||
buffer,
|
||||
pagesize=landscape(letter),
|
||||
rightMargin=30,
|
||||
leftMargin=30,
|
||||
topMargin=30,
|
||||
bottomMargin=30
|
||||
)
|
||||
elements = []
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
title_style = ParagraphStyle(
|
||||
'DocTitle',
|
||||
'EventTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=20,
|
||||
leading=24,
|
||||
textColor=colors.HexColor('#1e293b'),
|
||||
fontSize=18,
|
||||
leading=22,
|
||||
textColor=colors.HexColor('#0f172a'),
|
||||
spaceAfter=6
|
||||
)
|
||||
sub_style = ParagraphStyle(
|
||||
'DocSub',
|
||||
|
||||
subtitle_style = ParagraphStyle(
|
||||
'EventSubtitle',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
leading=12,
|
||||
textColor=colors.HexColor('#64748b'),
|
||||
leading=14,
|
||||
textColor=colors.HexColor('#475569'),
|
||||
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))
|
||||
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
|
||||
table_data = []
|
||||
headers = ["Bereich", "Schicht & Zeit", "Benötigte Qualifikationen", "Eingetragene Personen"]
|
||||
table_data.append([Paragraph(f"<b>{h}</b>", styles['Normal']) for h in headers])
|
||||
date_str = f"{event.start_date.strftime('%d.%m.%Y')} - {event.end_date.strftime('%d.%m.%Y')}"
|
||||
loc_str = f" | Ort: {event.location}" if event.location else ""
|
||||
elements.append(Paragraph(f"Datum: {date_str}{loc_str}", subtitle_style))
|
||||
|
||||
for ta in event.task_areas.all():
|
||||
for shift in ta.shifts.all():
|
||||
skills_list = ", ".join([s.name for s in shift.required_skills.all()]) or "Keine"
|
||||
table_data = [[
|
||||
Paragraph("Aufgabenbereich", cell_title_style),
|
||||
Paragraph("Schicht", cell_title_style),
|
||||
Paragraph("Zeitfenster", cell_title_style),
|
||||
Paragraph("Erforderlich", cell_title_style),
|
||||
Paragraph("Eingetragene Personen / Plätze", cell_title_style)
|
||||
]]
|
||||
|
||||
for area in task_areas:
|
||||
shifts = area.shifts.all()
|
||||
if not shifts:
|
||||
table_data.append([
|
||||
Paragraph(area.name, cell_title_style),
|
||||
Paragraph("—", cell_text_style),
|
||||
Paragraph("—", cell_text_style),
|
||||
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"
|
||||
|
||||
signups = shift.signups.all()
|
||||
signups_list = []
|
||||
signup_names = []
|
||||
for su in signups:
|
||||
if request.user.is_authenticated:
|
||||
display = su.user.get_display_name() if su.user else (su.guest_name or "Gast")
|
||||
else:
|
||||
display = "Belegt (Anonym)"
|
||||
signups_list.append(display)
|
||||
if su.user:
|
||||
signup_names.append(su.user.get_display_name())
|
||||
elif su.guest_name:
|
||||
signup_names.append(f"{su.guest_name} (Gast)")
|
||||
|
||||
while len(signups_list) < shift.max_participants:
|
||||
signups_list.append("[ Offen ]")
|
||||
needed = shift.max_participants - len(signup_names)
|
||||
if needed > 0:
|
||||
signup_names.extend([f"<i>[ {needed} Platz frei ]</i>"] * 1)
|
||||
|
||||
signups_str = "<br/>".join(signups_list)
|
||||
participants_html = "<br/>".join(signup_names) or "<i>[ Frei ]</i>"
|
||||
|
||||
row = [
|
||||
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)
|
||||
date_prefix = f"{shift.date.strftime('%d.%m.')} " if shift.date else ""
|
||||
time_range = f"{date_prefix}{shift.start_time} - {shift.end_time} Uhr"
|
||||
|
||||
if len(table_data) == 1:
|
||||
table_data.append([Paragraph("Keine Schichten angelegt", styles['Normal']), "", "", ""])
|
||||
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)
|
||||
])
|
||||
|
||||
pdf_table = Table(table_data, colWidths=[150, 160, 160, 250])
|
||||
col_widths = [130, 110, 110, 110, 240]
|
||||
pdf_table = Table(table_data, colWidths=col_widths, repeatRows=1)
|
||||
pdf_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#0f172a')),
|
||||
@@ -203,9 +209,7 @@ class TaskAreaViewSet(viewsets.ModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
event = serializer.validated_data.get('event')
|
||||
user = self.request.user
|
||||
if not user.is_authenticated:
|
||||
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)):
|
||||
if not event or not event.is_user_manager_or_admin(user):
|
||||
raise permissions.PermissionDenied("Keine Berechtigung, Bereiche für dieses Event hinzuzufügen.")
|
||||
serializer.save()
|
||||
|
||||
@@ -217,9 +221,7 @@ class ShiftViewSet(viewsets.ModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
task_area = serializer.validated_data.get('task_area')
|
||||
user = self.request.user
|
||||
if not user.is_authenticated:
|
||||
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)):
|
||||
if not task_area or not task_area.event.is_user_manager_or_admin(user):
|
||||
raise permissions.PermissionDenied("Keine Berechtigung, Schichten für dieses Event hinzuzufügen.")
|
||||
serializer.save()
|
||||
|
||||
@@ -228,19 +230,36 @@ class ShiftSignupView(views.APIView):
|
||||
|
||||
def post(self, request, 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
|
||||
if shift.signups.count() >= shift.max_participants:
|
||||
return Response({'error': 'Diese Schicht ist bereits vollständig belegt.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Authenticated User flow
|
||||
if request.user.is_authenticated:
|
||||
if ShiftSignup.objects.filter(shift=shift, user=request.user).exists():
|
||||
guest_name = request.data.get('guest_name', '').strip()
|
||||
|
||||
# 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)
|
||||
|
||||
required_skills = shift.required_skills.all()
|
||||
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]
|
||||
if 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}'
|
||||
}, 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)
|
||||
|
||||
# Guest flow
|
||||
# Guest flow for visitors
|
||||
else:
|
||||
guest_name = request.data.get('guest_name', '').strip()
|
||||
captcha_token = request.data.get('captcha_token', '').strip()
|
||||
|
||||
if not guest_name:
|
||||
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)
|
||||
|
||||
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()])
|
||||
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.'
|
||||
@@ -282,7 +300,7 @@ class ShiftSignupView(views.APIView):
|
||||
guest_session_key=session_key
|
||||
)
|
||||
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
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -300,7 +318,7 @@ class ShiftSignupView(views.APIView):
|
||||
|
||||
if request.user.is_authenticated:
|
||||
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:
|
||||
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')
|
||||
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(
|
||||
title=title,
|
||||
description=template.description,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ from .views import (
|
||||
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
|
||||
PendingUsersView, ApproveUserView, AdminUserViewSet,
|
||||
GenerateClaimLinkView, ClaimInfoView,
|
||||
SMTPSettingView, TestSMTPEmailView
|
||||
SMTPSettingView, TestSMTPEmailView, UserListView
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -17,6 +17,7 @@ urlpatterns = [
|
||||
path('verify-email/', VerifyEmailView.as_view(), name='verify-email'),
|
||||
path('login/', LoginView.as_view(), name='login'),
|
||||
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('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
|
||||
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
|
||||
|
||||
@@ -388,3 +388,10 @@ class ApproveUserView(views.APIView):
|
||||
name = user.get_display_name()
|
||||
user.delete()
|
||||
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)
|
||||
|
||||
-272
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+272
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -11,8 +11,8 @@
|
||||
<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">
|
||||
<title>Schichtplaner — Veranstaltungsschichtpläne</title>
|
||||
<script type="module" crossorigin src="/assets/index-BTg0Pu2I.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DyS9UMYS.css">
|
||||
<script type="module" crossorigin src="/assets/index-KtQZvLw-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CGy_Z7Jy.css">
|
||||
</head>
|
||||
<body class="bg-paper text-main font-sans antialiased min-h-screen">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -278,7 +278,8 @@ export default function App() {
|
||||
description: eventPayload.description,
|
||||
location: eventPayload.location,
|
||||
start_date: eventPayload.start_date,
|
||||
end_date: eventPayload.end_date
|
||||
end_date: eventPayload.end_date,
|
||||
co_manager_ids: eventPayload.co_manager_ids || []
|
||||
})
|
||||
});
|
||||
} else {
|
||||
@@ -290,7 +291,8 @@ export default function App() {
|
||||
description: eventPayload.description,
|
||||
location: eventPayload.location,
|
||||
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;
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ShiftMatrixTable({
|
||||
<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>
|
||||
<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
|
||||
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"
|
||||
@@ -33,7 +33,8 @@ export default function ShiftMatrixTable({
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
|
||||
/* Hallmark · component: EventEditorPage · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
|
||||
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';
|
||||
|
||||
export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit }) {
|
||||
export default function EventEditorPage({ eventToEdit, skills, user, onBack, onSubmit }) {
|
||||
const [title, setTitle] = useState(eventToEdit?.title || '');
|
||||
const [description, setDescription] = useState(eventToEdit?.description || '');
|
||||
const [location, setLocation] = useState(eventToEdit?.location || '');
|
||||
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]);
|
||||
|
||||
// Co-managers state
|
||||
const [coManagerIds, setCoManagerIds] = useState(
|
||||
eventToEdit?.co_managers ? eventToEdit.co_managers.map(u => u.id) : []
|
||||
);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
|
||||
// Task areas state
|
||||
const [taskAreas, setTaskAreas] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
|
||||
if (eventToEdit && eventToEdit.task_areas && eventToEdit.task_areas.length > 0) {
|
||||
const formattedAreas = eventToEdit.task_areas.map(ta => ({
|
||||
id: ta.id,
|
||||
@@ -54,6 +62,13 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
||||
}
|
||||
}, [eventToEdit]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const data = await apiFetch('/users/all/');
|
||||
setAllUsers(data.results || data);
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const addTaskArea = () => {
|
||||
setTaskAreas([
|
||||
...taskAreas,
|
||||
@@ -100,13 +115,13 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
||||
setTaskAreas(updated);
|
||||
};
|
||||
|
||||
const toggleShiftSkill = (areaIndex, shiftIndex, skillId) => {
|
||||
const toggleSkillRequirement = (areaIndex, shiftIndex, skillId) => {
|
||||
const updated = [...taskAreas];
|
||||
const currentSkills = updated[areaIndex].shifts[shiftIndex].required_skill_ids || [];
|
||||
if (currentSkills.includes(skillId)) {
|
||||
updated[areaIndex].shifts[shiftIndex].required_skill_ids = currentSkills.filter(id => id !== skillId);
|
||||
const current = updated[areaIndex].shifts[shiftIndex].required_skill_ids || [];
|
||||
if (current.includes(skillId)) {
|
||||
updated[areaIndex].shifts[shiftIndex].required_skill_ids = current.filter(id => id !== skillId);
|
||||
} else {
|
||||
updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...currentSkills, skillId];
|
||||
updated[areaIndex].shifts[shiftIndex].required_skill_ids = [...current, skillId];
|
||||
}
|
||||
setTaskAreas(updated);
|
||||
};
|
||||
@@ -129,6 +144,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
||||
location,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
co_manager_ids: coManagerIds,
|
||||
task_areas: taskAreas
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -165,7 +181,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
||||
{eventToEdit ? 'Veranstaltung & Schichten Bearbeiten' : 'Neue Veranstaltung Erstellen'}
|
||||
</h2>
|
||||
<p className="text-xs text-muted font-mono mt-0.5">
|
||||
Konfiguriere Stammdaten, Aufgabenfelder, Schichtzeiten & Qualifikationen
|
||||
Konfiguriere Stammdaten, Co-Verwalter, Aufgabenfelder, Schichtzeiten & Qualifikationen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +199,7 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
||||
{/* Section 1: Stammdaten */}
|
||||
<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">
|
||||
1. Stammdaten der Veranstaltung
|
||||
1. Stammdaten & Verwalter der Veranstaltung
|
||||
</h3>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -347,31 +401,30 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Required Skills selection */}
|
||||
{/* Skill requirements selector */}
|
||||
{skills && skills.length > 0 && (
|
||||
<div className="pt-1.5 border-t border-grid text-[11px]">
|
||||
<span className="text-muted block mb-1">Erforderliche Qualifikationen:</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="pt-2 border-t border-grid flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted font-bold mr-1">Erforderlich:</span>
|
||||
{skills.map(sk => {
|
||||
const isSelected = (shift.required_skill_ids || []).includes(sk.id);
|
||||
const isReq = (shift.required_skill_ids || []).includes(sk.id);
|
||||
return (
|
||||
<button
|
||||
key={sk.id}
|
||||
type="button"
|
||||
onClick={() => toggleShiftSkill(aIdx, sIdx, sk.id)}
|
||||
onClick={() => toggleSkillRequirement(aIdx, sIdx, sk.id)}
|
||||
style={{
|
||||
backgroundColor: isSelected ? sk.color : `${sk.color}15`,
|
||||
borderColor: sk.color,
|
||||
color: isSelected ? '#ffffff' : sk.color
|
||||
backgroundColor: isReq ? `${sk.color}25` : 'transparent',
|
||||
borderColor: isReq ? sk.color : 'var(--color-border-grid)',
|
||||
color: isReq ? sk.color : 'var(--color-text-muted)'
|
||||
}}
|
||||
className="px-2 py-0.5 rounded-sm border text-[10px] font-bold transition"
|
||||
className="px-2 py-0.5 rounded-sm text-[10px] border font-bold transition flex items-center gap-1"
|
||||
>
|
||||
{sk.name} {isSelected ? '✓' : ''}
|
||||
<span>{sk.name}</span>
|
||||
{isReq && <CheckCircle2 className="w-3 h-3" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -380,71 +433,25 @@ export default function EventEditorPage({ eventToEdit, skills, onBack, onSubmit
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Controls */}
|
||||
<div className="hallmark-panel rounded-sm p-4 border border-grid flex flex-wrap items-center justify-between gap-3 font-mono text-xs">
|
||||
{/* Submit Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2 font-mono">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Bitte gib einen Titel an.');
|
||||
return;
|
||||
}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
{submitting ? 'Speichern...' : eventToEdit ? 'Änderungen Speichern' : 'Veranstaltung Jetzt Erstellen'}
|
||||
{submitting ? 'Speichern...' : 'Veranstaltung Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -136,7 +136,8 @@ export default function HomePage({
|
||||
{filteredEvents.map((evt) => {
|
||||
const taskAreaCount = evt.task_areas?.length || 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;
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user