Initial commit

This commit is contained in:
Richard
2026-07-31 10:08:13 +02:00
parent 652943fb7e
commit a69f31649c
7192 changed files with 924319 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# Copy requirements and install via uv
COPY requirements.txt .
RUN uv pip install --system -r requirements.txt
# Copy backend application code
COPY . .
# Collect static files
RUN python manage.py collectstatic --noinput || true
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "shiftplan_backend.wsgi:application"]
View File
Binary file not shown.
View File
+6
View File
@@ -0,0 +1,6 @@
from django.contrib import admin
from .models import BrandingSetting
@admin.register(BrandingSetting)
class BrandingSettingAdmin(admin.ModelAdmin):
list_display = ['app_name', 'primary_color', 'secondary_color']
@@ -0,0 +1,25 @@
# Generated by Django 6.0.7 on 2026-07-31 07:21
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='BrandingSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('app_name', models.CharField(default='Veranstaltungsschichtplaner', max_length=200)),
('logo_url', models.CharField(blank=True, default='', max_length=500)),
('primary_color', models.CharField(default='#2563eb', max_length=50)),
('secondary_color', models.CharField(default='#4f46e5', max_length=50)),
('custom_banner_text', models.TextField(blank=True, default='')),
],
),
]
@@ -0,0 +1,48 @@
# Generated by Django 6.0.7 on 2026-07-31 07:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('branding', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='brandingsetting',
name='community_info_text',
field=models.TextField(default='Initiative e.V. Hausverein\nOffene Angebote, DIY-Kultur & engagierte Schichten.'),
),
migrations.AddField(
model_name='brandingsetting',
name='community_info_title',
field=models.CharField(default='📌 Verein & Infos', max_length=200),
),
migrations.AddField(
model_name='brandingsetting',
name='show_community_info_box',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='brandingsetting',
name='show_support_box',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='brandingsetting',
name='support_box_text',
field=models.TextField(default='Hilf mit als Helfer*in an der Bar, beim Essen kochen oder beim Auf- & Abbau.'),
),
migrations.AddField(
model_name='brandingsetting',
name='support_box_title',
field=models.CharField(default='❤️ Unterstützen', max_length=200),
),
migrations.AlterField(
model_name='brandingsetting',
name='primary_color',
field=models.CharField(default='#E05A47', max_length=50),
),
]
+26
View File
@@ -0,0 +1,26 @@
from django.db import models
class BrandingSetting(models.Model):
app_name = models.CharField(max_length=200, default="Veranstaltungsschichtplaner")
logo_url = models.CharField(max_length=500, blank=True, default="")
primary_color = models.CharField(max_length=50, default="#E05A47")
secondary_color = models.CharField(max_length=50, default="#4f46e5")
custom_banner_text = models.TextField(blank=True, default="")
# Home Page Community Info Box
show_community_info_box = models.BooleanField(default=True)
community_info_title = models.CharField(max_length=200, default="📌 Verein & Infos")
community_info_text = models.TextField(default="Initiative e.V. Hausverein\nOffene Angebote, DIY-Kultur & engagierte Schichten.")
# Home Page Support Box
show_support_box = models.BooleanField(default=True)
support_box_title = models.CharField(max_length=200, default="❤️ Unterstützen")
support_box_text = models.TextField(default="Hilf mit als Helfer*in an der Bar, beim Essen kochen oder beim Auf- & Abbau.")
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return f"Branding: {self.app_name}"
+7
View File
@@ -0,0 +1,7 @@
from rest_framework import serializers
from .models import BrandingSetting
class BrandingSettingSerializer(serializers.ModelSerializer):
class Meta:
model = BrandingSetting
fields = '__all__'
+6
View File
@@ -0,0 +1,6 @@
from django.urls import path
from .views import BrandingView
urlpatterns = [
path('', BrandingView.as_view(), name='branding-detail'),
]
+22
View File
@@ -0,0 +1,22 @@
from rest_framework import views, permissions, status
from rest_framework.response import Response
from .models import BrandingSetting
from .serializers import BrandingSettingSerializer
class BrandingView(views.APIView):
def get_permissions(self):
if self.request.method == 'GET':
return [permissions.AllowAny()]
return [permissions.IsAdminUser()]
def get(self, request):
setting = BrandingSetting.get_solo()
return Response(BrandingSettingSerializer(setting).data)
def post(self, request):
setting = BrandingSetting.get_solo()
serializer = BrandingSettingSerializer(setting, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
View File
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
from django.contrib import admin
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
@admin.register(Skill)
class SkillAdmin(admin.ModelAdmin):
list_display = ['name', 'color', 'created_at']
search_fields = ['name']
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ['title', 'start_date', 'end_date', 'location', 'created_by', 'created_at']
list_filter = ['start_date', 'end_date']
search_fields = ['title', 'description', 'location']
@admin.register(TaskArea)
class TaskAreaAdmin(admin.ModelAdmin):
list_display = ['name', 'event', 'order']
list_filter = ['event']
@admin.register(Shift)
class ShiftAdmin(admin.ModelAdmin):
list_display = ['title', 'task_area', 'start_time', 'end_time', 'max_participants']
list_filter = ['task_area__event', 'task_area']
@admin.register(ShiftSignup)
class ShiftSignupAdmin(admin.ModelAdmin):
list_display = ['shift', 'user', 'guest_name', 'created_at']
list_filter = ['shift__task_area__event', 'created_at']
@admin.register(EventTemplate)
class EventTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'created_by', 'created_at']
@@ -0,0 +1,160 @@
from django.core.management.base import BaseCommand
from apps.users.models import User, RegistrationDomainRule, RegistrationRestrictionSetting
from apps.events.models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
from apps.branding.models import BrandingSetting
class Command(BaseCommand):
help = "Populate database with initial demo data including Demo Admin & Demo User"
def handle(self, *args, **options):
# 1. Branding
branding = BrandingSetting.get_solo()
branding.app_name = "Veranstaltungsschichtplaner (Dev)"
branding.custom_banner_text = "💡 DEV INSTANZ: Demo Admin (admin / adminpassword) & Demo User (demouser / demouser123)"
branding.save()
# 2. Skills
skill_ersthelfer, _ = Skill.objects.get_or_create(
name="Ersthelfer",
defaults={'description': 'Gültiger Erste-Hilfe-Schein', 'color': '#ef4444'}
)
skill_bar, _ = Skill.objects.get_or_create(
name="Bar-Erfahrung",
defaults={'description': 'Erfahrung an der Zapfanlage', 'color': '#3b82f6'}
)
skill_fuehrerschein, _ = Skill.objects.get_or_create(
name="Führerschein B",
defaults={'description': 'PKW / Transporter fahren', 'color': '#10b981'}
)
# 3. Demo Admin User
admin_user, admin_created = User.objects.get_or_create(
username='admin',
defaults={
'email': 'admin@demo.local',
'display_name': 'Demo Admin',
'is_admin_user': True,
'is_staff': True,
'is_superuser': True
}
)
if admin_created:
admin_user.set_password('adminpassword')
admin_user.save()
self.stdout.write(self.style.SUCCESS("Demo Admin created: admin / adminpassword"))
else:
admin_user.set_password('adminpassword')
admin_user.is_admin_user = True
admin_user.is_staff = True
admin_user.is_superuser = True
admin_user.save()
# 4. Demo Regular User
demo_user, user_created = User.objects.get_or_create(
username='demouser',
defaults={
'email': 'user@demo.local',
'display_name': 'Demo User',
'is_admin_user': False
}
)
if user_created:
demo_user.set_password('demouser123')
demo_user.save()
demo_user.skills.add(skill_bar, skill_ersthelfer)
self.stdout.write(self.style.SUCCESS("Demo User created: demouser / demouser123"))
else:
demo_user.set_password('demouser123')
demo_user.save()
demo_user.skills.add(skill_bar, skill_ersthelfer)
# 5. Sample Event with Task Areas & Shifts
if not Event.objects.filter(title="Sommerfest 2026").exists():
event = Event.objects.create(
title="Sommerfest 2026",
description="Großes Vereins-Sommerfest mit Musik, Essen und Kinderprogramm",
location="Vereinsgelände, Festwiese",
start_date="2026-08-15",
end_date="2026-08-16",
created_by=admin_user
)
# Task Area: Tresendienst
ta_tresen = TaskArea.objects.create(
event=event, name="Tresendienst", description="Getränke- & Barverkauf", order=1
)
s1 = Shift.objects.create(
task_area=ta_tresen, title="Nachmittagsschicht", start_time="14:00", end_time="18:00", max_participants=3
)
s1.required_skills.add(skill_bar)
# Demo signup for demo_user
ShiftSignup.objects.create(shift=s1, user=demo_user)
s2 = Shift.objects.create(
task_area=ta_tresen, title="Abendschicht", start_time="18:00", end_time="23:00", max_participants=4
)
# Task Area: Essen kochen
ta_essen = TaskArea.objects.create(
event=event, name="Essen kochen & Stand", description="Grill & Salatausgabe", order=2
)
Shift.objects.create(
task_area=ta_essen, title="Vorbereitung & Grillen", start_time="11:30", end_time="16:00", max_participants=3
)
Shift.objects.create(
task_area=ta_essen, title="Abendschicht Essen", start_time="16:00", end_time="21:00", max_participants=3
)
# Task Area: Aufbau & Aufräumen
ta_aufbau = TaskArea.objects.create(
event=event, name="Aufbau & Aufräumen", description="Bänke, Pavillons & Technik", order=3
)
s_aufbau = Shift.objects.create(
task_area=ta_aufbau, title="Früher Aufbau", start_time="09:00", end_time="13:00", max_participants=5
)
s_aufbau.required_skills.add(skill_fuehrerschein)
Shift.objects.create(
task_area=ta_aufbau, title="Nacht-Aufräumen", start_time="23:00", end_time="01:00", max_participants=4
)
self.stdout.write(self.style.SUCCESS("Sample Event 'Sommerfest 2026' created."))
# 6. Sample Template
if not EventTemplate.objects.filter(name="Standard Vereinsfest Template").exists():
EventTemplate.objects.create(
name="Standard Vereinsfest Template",
description="Standard-Struktur mit Tresendienst, Essen kochen sowie Aufbau & Aufräumen",
template_data={
"task_areas": [
{
"name": "Tresendienst",
"description": "Getränkezapfen und Verkauf",
"order": 1,
"shifts": [
{"title": "Frühschicht", "start_time": "12:00", "end_time": "17:00", "max_participants": 2, "required_skill_names": ["Bar-Erfahrung"]},
{"title": "Spätschicht", "start_time": "17:00", "end_time": "22:00", "max_participants": 3}
]
},
{
"name": "Essen kochen",
"description": "Zubereitung & Verpflegung",
"order": 2,
"shifts": [
{"title": "Mittagessen", "start_time": "11:00", "end_time": "15:00", "max_participants": 3}
]
},
{
"name": "Aufbau & Aufräumen",
"description": "Aufbau und Abbau der Infrastruktur",
"order": 3,
"shifts": [
{"title": "Gesamtaufbau", "start_time": "08:00", "end_time": "12:00", "max_participants": 4}
]
}
]
},
created_by=admin_user
)
self.stdout.write(self.style.SUCCESS("Dev seed data populated with Demo Admin & Demo User!"))
@@ -0,0 +1,81 @@
# Generated by Django 6.0.7 on 2026-07-31 07:21
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Event',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True, default='')),
('location', models.CharField(blank=True, default='', max_length=200)),
('start_date', models.DateField()),
('end_date', models.DateField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='EventTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True, default='')),
('template_data', models.JSONField(help_text='JSON structure containing task areas and shifts')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Shift',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=150)),
('start_time', models.CharField(max_length=50)),
('end_time', models.CharField(max_length=50)),
('max_participants', models.PositiveIntegerField(default=1)),
],
),
migrations.CreateModel(
name='ShiftSignup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('guest_name', models.CharField(blank=True, max_length=150, null=True)),
('guest_session_key', models.CharField(blank=True, max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='Skill',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('description', models.TextField(blank=True, default='')),
('color', models.CharField(default='#3b82f6', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='TaskArea',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150)),
('description', models.TextField(blank=True, default='')),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['order', 'id'],
},
),
]
@@ -0,0 +1,53 @@
# Generated by Django 6.0.7 on 2026-07-31 07:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('events', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='event',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='eventtemplate',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_templates', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='shiftsignup',
name='shift',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='signups', to='events.shift'),
),
migrations.AddField(
model_name='shiftsignup',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shift_signups', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='shift',
name='required_skills',
field=models.ManyToManyField(blank=True, related_name='shifts', to='events.skill'),
),
migrations.AddField(
model_name='taskarea',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_areas', to='events.event'),
),
migrations.AddField(
model_name='shift',
name='task_area',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shifts', to='events.taskarea'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-07-31 07:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='event',
name='is_active',
field=models.BooleanField(default=True, help_text='Aktiv/Deaktiviert Status'),
),
]
+73
View File
@@ -0,0 +1,73 @@
from django.db import models
from django.conf import settings
class Skill(models.Model):
name = models.CharField(max_length=100, unique=True)
description = models.TextField(blank=True, default='')
color = models.CharField(max_length=20, default='#3b82f6')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Event(models.Model):
title = models.CharField(max_length=200)
description = models.TextField(blank=True, default='')
location = models.CharField(max_length=200, blank=True, default='')
start_date = models.DateField()
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')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.title} ({'Aktiv' if self.is_active else 'Deaktiviert'})"
class TaskArea(models.Model):
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='task_areas')
name = models.CharField(max_length=150) # e.g., Tresendienst, Essen kochen, Aufbau, Aufräumen
description = models.TextField(blank=True, default='')
order = models.IntegerField(default=0)
class Meta:
ordering = ['order', 'id']
def __str__(self):
return f"{self.event.title} - {self.name}"
class Shift(models.Model):
task_area = models.ForeignKey(TaskArea, on_delete=models.CASCADE, related_name='shifts')
title = models.CharField(max_length=150) # e.g., "Frühschicht", "14:00 - 18:00"
start_time = models.CharField(max_length=50) # e.g. "14:00" or ISO string
end_time = models.CharField(max_length=50) # e.g. "18:00" or ISO string
max_participants = models.PositiveIntegerField(default=1)
required_skills = models.ManyToManyField(Skill, blank=True, related_name='shifts')
def __str__(self):
return f"{self.task_area.name}: {self.title} ({self.start_time} - {self.end_time})"
class ShiftSignup(models.Model):
shift = models.ForeignKey(Shift, on_delete=models.CASCADE, related_name='signups')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name='shift_signups')
guest_name = models.CharField(max_length=150, null=True, blank=True)
guest_session_key = models.CharField(max_length=255, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
if self.user:
return f"{self.user.display_name or self.user.username} -> {self.shift.title}"
return f"Gast: {self.guest_name} -> {self.shift.title}"
class EventTemplate(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True, default='')
template_data = models.JSONField(help_text="JSON structure containing task areas and shifts")
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='event_templates')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
+90
View File
@@ -0,0 +1,90 @@
from rest_framework import serializers
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
class SkillSerializer(serializers.ModelSerializer):
class Meta:
model = Skill
fields = ['id', 'name', 'description', 'color', 'created_at']
class ShiftSignupSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
is_guest = serializers.SerializerMethodField()
class Meta:
model = ShiftSignup
fields = ['id', 'display_name', 'is_guest', 'created_at']
def get_is_guest(self, obj):
return obj.user is None
def get_display_name(self, obj):
request = self.context.get('request')
# PRIVACY RULE: Guests (unauthenticated users) MUST NOT see who signed up where
if request and not request.user.is_authenticated:
return "Belegt (Anonym)"
if obj.user:
return obj.user.get_display_name()
return obj.guest_name or "Gast"
class ShiftSerializer(serializers.ModelSerializer):
required_skills = SkillSerializer(many=True, read_only=True)
required_skill_ids = serializers.PrimaryKeyRelatedField(
queryset=Skill.objects.all(), many=True, write_only=True, required=False, source='required_skills'
)
signups = serializers.SerializerMethodField()
signups_count = serializers.SerializerMethodField()
is_full = serializers.SerializerMethodField()
class Meta:
model = Shift
fields = [
'id', 'task_area', 'title', 'start_time', 'end_time',
'max_participants', 'required_skills', 'required_skill_ids',
'signups', 'signups_count', 'is_full'
]
def get_signups(self, obj):
signups_qs = obj.signups.all()
serializer = ShiftSignupSerializer(signups_qs, many=True, context=self.context)
return serializer.data
def get_signups_count(self, obj):
return obj.signups.count()
def get_is_full(self, obj):
return obj.signups.count() >= obj.max_participants
class TaskAreaSerializer(serializers.ModelSerializer):
shifts = ShiftSerializer(many=True, read_only=True)
class Meta:
model = TaskArea
fields = ['id', 'event', 'name', 'description', 'order', 'shifts']
class EventSerializer(serializers.ModelSerializer):
task_areas = TaskAreaSerializer(many=True, read_only=True)
created_by_name = serializers.SerializerMethodField()
class Meta:
model = Event
fields = [
'id', 'title', 'description', 'location',
'start_date', 'end_date', 'is_active', 'created_by', 'created_by_name',
'task_areas', 'created_at', 'updated_at'
]
read_only_fields = ['created_by', 'created_at', 'updated_at']
def get_created_by_name(self, obj):
return obj.created_by.get_display_name() if obj.created_by else "Unbekannt"
class EventTemplateSerializer(serializers.ModelSerializer):
created_by_name = serializers.SerializerMethodField()
class Meta:
model = EventTemplate
fields = ['id', 'name', 'description', 'template_data', 'created_by', 'created_by_name', 'created_at']
read_only_fields = ['created_by', 'created_at']
def get_created_by_name(self, obj):
return obj.created_by.get_display_name() if obj.created_by else "System"
+113
View File
@@ -0,0 +1,113 @@
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 Skill, Event, TaskArea, Shift, ShiftSignup
User = get_user_model()
class EventAndShiftTests(TestCase):
def setUp(self):
self.client = APIClient()
self.skill = Skill.objects.create(name="Ersthelfer", color="#ef4444")
self.user = User.objects.create_user(
username="doctor_who", email="doctor@tardis.org", password="password123", display_name="Dr. Who"
)
self.event = Event.objects.create(title="Sommerfest", start_date="2026-08-10", end_date="2026-08-12")
self.ta = TaskArea.objects.create(event=self.event, name="Sanitätsdienst")
self.shift = Shift.objects.create(
task_area=self.ta, title="Hauptschicht", start_time="10:00", end_time="18:00", max_participants=2
)
self.shift.required_skills.add(self.skill)
def test_guest_cannot_signup_for_skill_required_shift(self):
res = self.client.post(f'/api/shifts/{self.shift.id}/signup/', {
'guest_name': 'Guest User',
'captcha_token': 'test-captcha-token'
})
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('Gäste können keine Fähigkeiten haben', res.data['error'])
def test_user_without_skill_blocked_and_with_skill_allowed(self):
self.client.force_authenticate(user=self.user)
# Attempt signup without skill
res_fail = self.client.post(f'/api/shifts/{self.shift.id}/signup/')
self.assertEqual(res_fail.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('Dir fehlen folgende erforderliche Fähigkeiten', res_fail.data['error'])
# Add skill to user
self.user.skills.add(self.skill)
# Attempt signup with skill
res_ok = self.client.post(f'/api/shifts/{self.shift.id}/signup/')
self.assertEqual(res_ok.status_code, status.HTTP_201_CREATED)
def test_guest_privacy_redaction_in_matrix(self):
# Add signup
self.user.skills.add(self.skill)
ShiftSignup.objects.create(shift=self.shift, user=self.user)
# Unauthenticated request (Guest)
anon_client = APIClient()
res_anon = anon_client.get(f'/api/events/{self.event.id}/matrix/')
self.assertEqual(res_anon.status_code, status.HTTP_200_OK)
signup_data_anon = res_anon.data['task_areas'][0]['shifts'][0]['signups'][0]
self.assertEqual(signup_data_anon['display_name'], 'Belegt (Anonym)')
# Authenticated request
self.client.force_authenticate(user=self.user)
res_auth = self.client.get(f'/api/events/{self.event.id}/matrix/')
self.assertEqual(res_auth.status_code, status.HTTP_200_OK)
signup_data_auth = res_auth.data['task_areas'][0]['shifts'][0]['signups'][0]
self.assertEqual(signup_data_auth['display_name'], 'Dr. Who')
def test_event_edit_and_deactivation_permissions(self):
admin = User.objects.create_user(
username="admin_user", email="admin@verein.de", password="password123", is_admin_user=True
)
other_user = User.objects.create_user(
username="other_user", email="other@verein.de", password="password123"
)
user_event = Event.objects.create(
title="User Event", start_date="2026-09-01", end_date="2026-09-02", created_by=self.user
)
# Other user cannot deactivate user_event
self.client.force_authenticate(user=other_user)
res_fail = self.client.patch(f'/api/events/{user_event.id}/', {'is_active': False})
self.assertEqual(res_fail.status_code, status.HTTP_403_FORBIDDEN)
# Creator (self.user) CAN deactivate their own event
self.client.force_authenticate(user=self.user)
res_creator_ok = self.client.patch(f'/api/events/{user_event.id}/', {'is_active': False})
self.assertEqual(res_creator_ok.status_code, status.HTTP_200_OK)
self.assertFalse(res_creator_ok.data['is_active'])
# Admin CAN manage and reactivate any event
self.client.force_authenticate(user=admin)
res_admin_ok = self.client.patch(f'/api/events/{user_event.id}/', {'is_active': True})
self.assertEqual(res_admin_ok.status_code, status.HTTP_200_OK)
self.assertTrue(res_admin_ok.data['is_active'])
def test_shift_and_participant_management_permissions(self):
creator = User.objects.create_user(username="creator", email="creator@verein.de", password="password123")
other_user = User.objects.create_user(username="other", email="other@verein.de", password="password123")
event = Event.objects.create(title="Creator Event", start_date="2026-09-01", end_date="2026-09-02", created_by=creator)
ta = TaskArea.objects.create(event=event, name="Bar")
shift = Shift.objects.create(task_area=ta, title="Abendschicht", start_time="19:00", end_time="23:00", max_participants=2)
signup = ShiftSignup.objects.create(shift=shift, user=other_user)
# Other user CANNOT remove creator's participant from creator's shift
third_user = User.objects.create_user(username="third", email="third@verein.de", password="password123")
self.client.force_authenticate(user=third_user)
res_fail = self.client.delete(f'/api/shifts/{shift.id}/signup/', {'signup_id': signup.id}, format='json')
self.assertEqual(res_fail.status_code, status.HTTP_404_NOT_FOUND)
# Creator CAN remove participant from shift on their own event
self.client.force_authenticate(user=creator)
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())
+19
View File
@@ -0,0 +1,19 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
SkillViewSet, EventViewSet, TaskAreaViewSet,
ShiftViewSet, ShiftSignupView, EventTemplateViewSet
)
router = DefaultRouter()
router.register('skills', SkillViewSet, basename='skill')
router.register('events', EventViewSet, basename='event')
router.register('task-areas', TaskAreaViewSet, basename='task-area')
router.register('shifts', ShiftViewSet, basename='shift')
router.register('templates', EventTemplateViewSet, basename='template')
urlpatterns = [
path('shifts/<int:shift_id>/signup/', ShiftSignupView.as_view(), name='shift-signup'),
path('shifts/<int:shift_id>/signup/<int:signup_id>/', ShiftSignupView.as_view(), name='shift-signup-detail'),
path('', include(router.urls)),
]
+382
View File
@@ -0,0 +1,382 @@
import json
import requests
from io import BytesIO
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from rest_framework import viewsets, permissions, status, views
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Q
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter, landscape
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
from .serializers import (
SkillSerializer, EventSerializer, TaskAreaSerializer,
ShiftSerializer, EventTemplateSerializer
)
class SkillViewSet(viewsets.ModelViewSet):
queryset = Skill.objects.all().order_by('name')
serializer_class = SkillSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
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
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
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
class EventViewSet(viewsets.ModelViewSet):
serializer_class = EventSerializer
permission_classes = [IsAdminOrEventCreator]
def get_queryset(self):
user = self.request.user
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(is_active=True).order_by('-start_date', '-id')
def perform_create(self, serializer):
serializer.save(created_by=self.request.user if self.request.user.is_authenticated else None)
@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)
@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', '')
# 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))
})
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):
event = self.get_object()
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer, pagesize=landscape(letter),
rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30
)
elements = []
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'DocTitle',
parent=styles['Heading1'],
fontSize=20,
leading=24,
textColor=colors.HexColor('#1e293b'),
spaceAfter=6
)
sub_style = ParagraphStyle(
'DocSub',
parent=styles['Normal'],
fontSize=10,
leading=12,
textColor=colors.HexColor('#64748b'),
spaceAfter=15
)
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])
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"
signups = shift.signups.all()
signups_list = []
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)
while len(signups_list) < shift.max_participants:
signups_list.append("[ Offen ]")
signups_str = "<br/>".join(signups_list)
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)
if len(table_data) == 1:
table_data.append([Paragraph("Keine Schichten angelegt", styles['Normal']), "", "", ""])
pdf_table = Table(table_data, colWidths=[150, 160, 160, 250])
pdf_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#0f172a')),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#cbd5e1')),
('PADDING', (0, 0), (-1, -1), 8),
]))
elements.append(pdf_table)
doc.build(elements)
pdf = buffer.getvalue()
buffer.close()
response = HttpResponse(pdf, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="schichtplan_{event.id}.pdf"'
return response
class TaskAreaViewSet(viewsets.ModelViewSet):
queryset = TaskArea.objects.all()
serializer_class = TaskAreaSerializer
permission_classes = [IsTaskAreaOwnerOrAdmin]
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)):
raise permissions.PermissionDenied("Keine Berechtigung, Bereiche für dieses Event hinzuzufügen.")
serializer.save()
class ShiftViewSet(viewsets.ModelViewSet):
queryset = Shift.objects.all()
serializer_class = ShiftSerializer
permission_classes = [IsShiftOwnerOrAdmin]
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)):
raise permissions.PermissionDenied("Keine Berechtigung, Schichten für dieses Event hinzuzufügen.")
serializer.save()
class ShiftSignupView(views.APIView):
permission_classes = [permissions.AllowAny]
def post(self, request, shift_id):
shift = get_object_or_404(Shift, pk=shift_id)
# 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():
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()
missing_skills = [s.name for s in required_skills if s not in user_skills]
if missing_skills:
missing_str = ", ".join(missing_skills)
return Response({
'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)
return Response({'message': 'Erfolgreich für Schicht eingetragen.', 'signup_id': signup.id}, status=status.HTTP_201_CREATED)
# Guest flow
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:
return Response({'error': 'Bitte löse das Captcha um dich einzutragen.'}, status=status.HTTP_400_BAD_REQUEST)
if 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.'
}, status=status.HTTP_400_BAD_REQUEST)
if not request.session.session_key:
request.session.create()
session_key = request.session.session_key
request.session['guest_name'] = guest_name
if ShiftSignup.objects.filter(shift=shift, guest_session_key=session_key).exists():
return Response({'error': 'Du bist mit deiner Gast-Sitzung bereits für diese Schicht eingetragen.'}, status=status.HTTP_400_BAD_REQUEST)
signup = ShiftSignup.objects.create(
shift=shift,
guest_name=guest_name,
guest_session_key=session_key
)
return Response({
'message': 'Als Gast erfolgreich eingetragen! Dein Name wurde in deiner Sitzung gespeichert.',
'signup_id': signup.id
}, status=status.HTTP_201_CREATED)
def delete(self, request, shift_id, signup_id=None):
shift = get_object_or_404(Shift, pk=shift_id)
if not signup_id:
signup_id = request.data.get('signup_id') or request.query_params.get('signup_id')
if not signup_id and request.body:
try:
body_data = json.loads(request.body.decode('utf-8'))
signup_id = body_data.get('signup_id')
except Exception:
pass
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)
if signup_id and is_manager:
signup = ShiftSignup.objects.filter(shift=shift, pk=signup_id).first()
if signup:
name = signup.user.get_display_name() if signup.user else (signup.guest_name or "Gast")
signup.delete()
return Response({'message': f'Eintragung von {name} wurde von der Schicht entfernt.'})
return Response({'error': 'Eintragung nicht gefunden.'}, status=status.HTTP_404_NOT_FOUND)
signup = ShiftSignup.objects.filter(shift=shift, user=user).first()
if signup:
signup.delete()
return Response({'message': 'Eintragung storniert.'})
return Response({'error': 'Keine passende eigene Eintragung gefunden.'}, status=status.HTTP_404_NOT_FOUND)
else:
session_key = request.session.session_key
if not session_key:
return Response({'error': 'Keine Gast-Sitzung gefunden.'}, status=status.HTTP_400_BAD_REQUEST)
signup = ShiftSignup.objects.filter(shift=shift, guest_session_key=session_key).first()
if signup:
signup.delete()
return Response({'message': 'Gast-Eintragung storniert.'})
return Response({'error': 'Keine passende Gast-Eintragung gefunden.'}, status=status.HTTP_404_NOT_FOUND)
class EventTemplateViewSet(viewsets.ModelViewSet):
queryset = EventTemplate.objects.all().order_by('-created_at')
serializer_class = EventTemplateSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
serializer.save(created_by=self.request.user if self.request.user.is_authenticated else None)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def instantiate(self, request, pk=None):
template = self.get_object()
title = request.data.get('title', f"Veranstaltung: {template.name}")
start_date = request.data.get('start_date')
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,
location=location,
start_date=start_date,
end_date=end_date,
created_by=request.user
)
template_task_areas = template.template_data.get('task_areas', [])
for ta_data in template_task_areas:
ta = TaskArea.objects.create(
event=event,
name=ta_data.get('name', 'Bereich'),
description=ta_data.get('description', ''),
order=ta_data.get('order', 0)
)
for shift_data in ta_data.get('shifts', []):
shift = Shift.objects.create(
task_area=ta,
title=shift_data.get('title', 'Schicht'),
start_time=shift_data.get('start_time', '18:00'),
end_time=shift_data.get('end_time', '22:00'),
max_participants=shift_data.get('max_participants', 1)
)
skill_names = shift_data.get('required_skill_names', [])
if skill_names:
matching_skills = Skill.objects.filter(name__in=skill_names)
shift.required_skills.set(matching_skills)
return Response(EventSerializer(event).data, status=status.HTTP_201_CREATED)
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import User, RegistrationDomainRule, RegistrationRestrictionSetting
@admin.register(User)
class UserAdmin(BaseUserAdmin):
fieldsets = BaseUserAdmin.fieldsets + (
('Zusatzinfos', {'fields': ('display_name', 'skills', 'is_admin_user')}),
)
list_display = ['username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser']
@admin.register(RegistrationDomainRule)
class RegistrationDomainRuleAdmin(admin.ModelAdmin):
list_display = ['domain', 'is_active', 'created_at']
list_filter = ['is_active']
search_fields = ['domain']
@admin.register(RegistrationRestrictionSetting)
class RegistrationRestrictionSettingAdmin(admin.ModelAdmin):
list_display = ['is_restriction_enabled']
@@ -0,0 +1,64 @@
# Generated by Django 6.0.7 on 2026-07-31 07:21
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('events', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='RegistrationDomainRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('domain', models.CharField(help_text='z. B. verein.de oder @beispiel.org', max_length=150, unique=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='RegistrationRestrictionSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_restriction_enabled', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True)),
('display_name', models.CharField(blank=True, default='', max_length=150)),
('is_admin_user', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('skills', models.ManyToManyField(blank=True, related_name='users', to='events.skill')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-07-31 07:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='registrationrestrictionsetting',
name='require_admin_approval',
field=models.BooleanField(default=False, help_text='Neue Registrierungen müssen von einem Admin freigeschaltet werden'),
),
migrations.AddField(
model_name='user',
name='is_approved',
field=models.BooleanField(default=True, help_text='Vom Admin freigeschaltet'),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 6.0.7 on 2026-07-31 07:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0003_event_is_active'),
('users', '0002_registrationrestrictionsetting_require_admin_approval_and_more'),
]
operations = [
migrations.CreateModel(
name='GuestClaimToken',
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)),
('guest_name', models.CharField(max_length=150)),
('is_claimed', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_claim_tokens', to=settings.AUTH_USER_MODEL)),
('signup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='claim_tokens', to='events.shiftsignup')),
],
),
]
+59
View File
@@ -0,0 +1,59 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.conf import settings
class User(AbstractUser):
email = models.EmailField(unique=True)
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")
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 ""
return f"{self.get_display_name()} ({self.email}){status_str}"
class RegistrationDomainRule(models.Model):
domain = models.CharField(max_length=150, unique=True, help_text="z. B. verein.de oder @beispiel.org")
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def clean_domain(self):
domain = self.domain.strip().lower()
if not domain.startswith('@'):
domain = '@' + domain
return domain
def save(self, *args, **kwargs):
self.domain = self.clean_domain()
super().save(*args, **kwargs)
def __str__(self):
return f"{self.domain} ({'Aktiv' if self.is_active else 'Inaktiv'})"
class RegistrationRestrictionSetting(models.Model):
is_restriction_enabled = models.BooleanField(default=False)
require_admin_approval = models.BooleanField(default=False, help_text="Neue Registrierungen müssen von einem Admin freigeschaltet werden")
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return f"Domain-Einschränkung: {self.is_restriction_enabled}, Admin-Freischaltung: {self.require_admin_approval}"
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')
guest_name = models.CharField(max_length=150)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='created_claim_tokens')
is_claimed = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Claim Token for {self.guest_name} ({self.token})"
+80
View File
@@ -0,0 +1,80 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import RegistrationDomainRule, RegistrationRestrictionSetting
from apps.events.models import Skill
User = get_user_model()
class SkillSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = Skill
fields = ['id', 'name', 'description', 'color']
class UserSerializer(serializers.ModelSerializer):
skills = SkillSimpleSerializer(many=True, read_only=True)
skill_ids = serializers.PrimaryKeyRelatedField(
queryset=Skill.objects.all(), many=True, write_only=True, required=False, source='skills'
)
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']
class AdminUserSerializer(serializers.ModelSerializer):
skills = SkillSimpleSerializer(many=True, read_only=True)
skill_ids = serializers.PrimaryKeyRelatedField(
queryset=Skill.objects.all(), many=True, write_only=True, required=False, source='skills'
)
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']
read_only_fields = ['id', 'date_joined']
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=6)
class Meta:
model = User
fields = ['username', 'email', 'password', 'display_name']
def validate_email(self, value):
email = value.strip().lower()
setting = RegistrationRestrictionSetting.get_solo()
if setting.is_restriction_enabled:
allowed_rules = RegistrationDomainRule.objects.filter(is_active=True)
if allowed_rules.exists():
email_domain = email.split('@')[-1] if '@' in email else ''
matched = False
for rule in allowed_rules:
clean_rule = rule.domain.lstrip('@').lower()
if email_domain == clean_rule or email_domain.endswith('.' + clean_rule):
matched = True
break
if not matched:
domains_list = ", ".join([r.domain for r in allowed_rules])
raise serializers.ValidationError(
f"Registrierungen sind derzeit nur für folgende E-Mail-Domains erlaubt: {domains_list}"
)
return email
def create(self, validated_data):
display_name = validated_data.get('display_name', '') or validated_data['username']
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data['email'],
password=validated_data['password'],
display_name=display_name
)
return user
class RegistrationDomainRuleSerializer(serializers.ModelSerializer):
class Meta:
model = RegistrationDomainRule
fields = ['id', 'domain', 'is_active', 'created_at']
class RegistrationRestrictionSettingSerializer(serializers.ModelSerializer):
class Meta:
model = RegistrationRestrictionSetting
fields = ['is_restriction_enabled', 'require_admin_approval']
+144
View File
@@ -0,0 +1,144 @@
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 apps.events.models import Event, TaskArea, Shift, ShiftSignup
User = get_user_model()
class UserRegistrationTests(TestCase):
def setUp(self):
self.client = APIClient()
def test_domain_restriction_enforcement(self):
# Enable domain restriction
setting = RegistrationRestrictionSetting.get_solo()
setting.is_restriction_enabled = True
setting.save()
# Add rule for verein.de
RegistrationDomainRule.objects.create(domain='verein.de', is_active=True)
# Attempt registration with non-allowed domain
res = self.client.post('/api/users/register/', {
'username': 'testuser',
'email': 'user@otherdomain.com',
'password': 'password123',
'display_name': 'Test User'
})
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('Registrierungen sind derzeit nur für folgende E-Mail-Domains erlaubt', str(res.data))
# Attempt registration with allowed domain
res_ok = self.client.post('/api/users/register/', {
'username': 'alloweduser',
'email': 'user@verein.de',
'password': 'password123',
'display_name': 'Allowed User'
})
self.assertEqual(res_ok.status_code, status.HTTP_201_CREATED)
self.assertIn('token', res_ok.data)
def test_guest_shift_claiming_on_registration(self):
# Create event, task area, shift
event = Event.objects.create(title="Test Event", start_date="2026-08-01", end_date="2026-08-02")
ta = TaskArea.objects.create(event=event, name="Tresendienst")
shift = Shift.objects.create(task_area=ta, title="Frühschicht", start_time="09:00", end_time="13:00")
# Guest signs up with guest_name="Max Mustermann"
session = self.client.session
session.save()
session_key = session.session_key
signup = ShiftSignup.objects.create(
shift=shift,
guest_name="Max Mustermann",
guest_session_key=session_key
)
# Now register user with display_name="Max Mustermann"
res = self.client.post('/api/users/register/', {
'username': 'max_mustermann',
'email': 'max@example.com',
'password': 'password123',
'display_name': 'Max Mustermann'
})
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
self.assertEqual(res.data['claimed_shifts_count'], 1)
signup.refresh_from_db()
self.assertIsNotNone(signup.user)
self.assertEqual(signup.user.username, 'max_mustermann')
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',
'email': 'pending@example.com',
'password': 'password123',
'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/', {
'username': 'pending_user',
'password': 'password123'
})
self.assertEqual(res_login_fail.status_code, status.HTTP_403_FORBIDDEN)
# Admin approves user
admin = User.objects.create_superuser(username='admin', email='admin@test.com', password='adminpassword')
self.client.force_authenticate(user=admin)
pending_user = User.objects.get(username='pending_user')
res_approve = self.client.post(f'/api/users/{pending_user.id}/approve/')
self.assertEqual(res_approve.status_code, status.HTTP_200_OK)
# Attempt login after approval (should succeed)
self.client.logout()
res_login_ok = self.client.post('/api/users/login/', {
'username': 'pending_user',
'password': 'password123'
})
self.assertEqual(res_login_ok.status_code, status.HTTP_200_OK)
self.assertIn('token', res_login_ok.data)
def test_guest_claim_token_and_registration_link(self):
admin = User.objects.create_user(username="admin_gen", email="admin_gen@test.com", password="password123", is_admin_user=True)
event = Event.objects.create(title="Claim Event", start_date="2026-09-01", end_date="2026-09-02")
ta = TaskArea.objects.create(event=event, name="Kasse")
shift = Shift.objects.create(task_area=ta, title="Spätschicht", start_time="18:00", end_time="22:00")
signup = ShiftSignup.objects.create(shift=shift, guest_name="Erika Muster")
# Admin generates claim link
self.client.force_authenticate(user=admin)
res_token = self.client.post(f'/api/users/signups/{signup.id}/generate-claim-link/')
self.assertEqual(res_token.status_code, status.HTTP_200_OK)
claim_token = res_token.data['token']
self.assertEqual(res_token.data['guest_name'], 'Erika Muster')
# Recipient registers using claim_token
self.client.logout()
res_reg = self.client.post('/api/users/register/', {
'username': 'erika_muster',
'email': 'erika@muster.de',
'password': 'password123',
'display_name': 'Erika Muster',
'claim_token': claim_token
})
self.assertEqual(res_reg.status_code, status.HTTP_201_CREATED)
signup.refresh_from_db()
self.assertIsNotNone(signup.user)
self.assertEqual(signup.user.username, 'erika_muster')
self.assertIsNone(signup.guest_name)
+24
View File
@@ -0,0 +1,24 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
RegisterView, LoginView, MeView,
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
PendingUsersView, ApproveUserView, AdminUserViewSet,
GenerateClaimLinkView, ClaimInfoView
)
router = DefaultRouter()
router.register('domain-rules', RegistrationDomainRuleViewSet, basename='domain-rule')
router.register('manage-users', AdminUserViewSet, basename='admin-user')
urlpatterns = [
path('register/', RegisterView.as_view(), name='register'),
path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'),
path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
path('pending/', PendingUsersView.as_view(), name='pending-users'),
path('<int:user_id>/approve/', ApproveUserView.as_view(), name='approve-user'),
path('signups/<int:signup_id>/generate-claim-link/', GenerateClaimLinkView.as_view(), name='generate-claim-link'),
path('claim-info/<uuid:token_uuid>/', ClaimInfoView.as_view(), name='claim-info'),
path('', include(router.urls)),
]
+231
View File
@@ -0,0 +1,231 @@
from rest_framework import views, viewsets, permissions, status
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 .serializers import (
UserSerializer, AdminUserSerializer, RegisterSerializer,
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer
)
from apps.events.models import ShiftSignup
User = get_user_model()
class AdminUserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('-date_joined')
serializer_class = AdminUserSerializer
permission_classes = [permissions.IsAdminUser]
class RegisterView(views.APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = RegisterSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
setting = RegistrationRestrictionSetting.get_solo()
claim_token_str = request.data.get('claim_token')
claimed_target_name = None
if claim_token_str:
try:
claim_obj = GuestClaimToken.objects.get(token=claim_token_str, is_claimed=False)
claim_obj.is_claimed = True
claim_obj.save()
# Assign target signup
target_signup = claim_obj.signup
if target_signup and not target_signup.user:
target_signup.user = user
target_signup.guest_name = None
target_signup.guest_session_key = None
target_signup.save()
claimed_target_name = claim_obj.guest_name
except Exception:
pass
# Reassign any guest shift signups to this newly created user!
session_key = request.session.session_key
display_name = user.get_display_name()
guest_signups = ShiftSignup.objects.filter(user=None)
assigned_count = 0
for signup in guest_signups:
matches_session = session_key and signup.guest_session_key == session_key
matches_display = signup.guest_name and signup.guest_name.strip().lower() == display_name.strip().lower()
matches_claimed_name = claimed_target_name and signup.guest_name and signup.guest_name.strip().lower() == claimed_target_name.strip().lower()
if matches_session or matches_display or matches_claimed_name:
signup.user = user
signup.guest_name = None
signup.guest_session_key = None
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)
token, _ = Token.objects.get_or_create(user=user)
user_data = UserSerializer(user).data
return Response({
'token': token.key,
'user': user_data,
'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!'
}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class GenerateClaimLinkView(views.APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, signup_id):
signup = get_object_or_404(ShiftSignup, pk=signup_id)
if signup.user:
return Response({'error': 'Diese Schicht ist bereits mit einem festen Benutzerkonto verknüpft.'}, status=status.HTTP_400_BAD_REQUEST)
guest_name = signup.guest_name or "Gast"
claim_obj, _ = GuestClaimToken.objects.get_or_create(
signup=signup,
created_by=request.user,
defaults={'guest_name': guest_name}
)
return Response({
'token': str(claim_obj.token),
'guest_name': guest_name,
'event_title': signup.shift.task_area.event.title,
'shift_title': signup.shift.title,
'created_by': request.user.get_display_name()
})
class ClaimInfoView(views.APIView):
permission_classes = [permissions.AllowAny]
def get(self, request, token_uuid):
claim_obj = get_object_or_404(GuestClaimToken, token=token_uuid)
return Response({
'token': str(claim_obj.token),
'guest_name': claim_obj.guest_name,
'is_claimed': claim_obj.is_claimed,
'event_title': claim_obj.signup.shift.task_area.event.title if claim_obj.signup else 'Veranstaltung',
'shift_title': claim_obj.signup.shift.title if claim_obj.signup else 'Schicht',
'created_by': claim_obj.created_by.get_display_name()
})
class LoginView(views.APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
username = request.data.get('username')
password = request.data.get('password')
if not username or not password:
return Response({'error': 'Benutzername und Passwort erforderlich.'}, status=status.HTTP_400_BAD_REQUEST)
user_obj = None
if '@' in username:
try:
user_obj = User.objects.get(email__iexact=username)
username = user_obj.username
except User.DoesNotExist:
pass
else:
try:
user_obj = User.objects.get(username__iexact=username)
except User.DoesNotExist:
pass
if user_obj:
if not user_obj.is_approved or not user_obj.is_active:
return Response({
'error': 'Dein Konto wurde noch nicht von einem Administrator freigeschaltet. Bitte gedulde dich.'
}, status=status.HTTP_403_FORBIDDEN)
user = authenticate(username=username, password=password)
if not user:
return Response({'error': 'Ungültige Anmeldedaten.'}, status=status.HTTP_401_UNAUTHORIZED)
token, _ = Token.objects.get_or_create(user=user)
return Response({
'token': token.key,
'user': UserSerializer(user).data
})
class MeView(views.APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
return Response(UserSerializer(request.user).data)
def patch(self, request):
serializer = UserSerializer(request.user, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationDomainRuleViewSet(viewsets.ModelViewSet):
queryset = RegistrationDomainRule.objects.all()
serializer_class = RegistrationDomainRuleSerializer
permission_classes = [permissions.IsAdminUser]
class RegistrationRestrictionSettingView(views.APIView):
def get_permissions(self):
if self.request.method == 'GET':
return [permissions.AllowAny()]
return [permissions.IsAdminUser()]
def get(self, request):
setting = RegistrationRestrictionSetting.get_solo()
active_domains = list(RegistrationDomainRule.objects.filter(is_active=True).values_list('domain', flat=True))
return Response({
'is_restriction_enabled': setting.is_restriction_enabled,
'require_admin_approval': setting.require_admin_approval,
'active_domains': active_domains
})
def post(self, request):
setting = RegistrationRestrictionSetting.get_solo()
if 'is_restriction_enabled' in request.data:
setting.is_restriction_enabled = request.data['is_restriction_enabled']
if 'require_admin_approval' in request.data:
setting.require_admin_approval = request.data['require_admin_approval']
setting.save()
return Response({
'is_restriction_enabled': setting.is_restriction_enabled,
'require_admin_approval': setting.require_admin_approval
})
class PendingUsersView(views.APIView):
permission_classes = [permissions.IsAdminUser]
def get(self, request):
pending_users = User.objects.filter(is_approved=False).order_by('-date_joined')
return Response(UserSerializer(pending_users, many=True).data)
class ApproveUserView(views.APIView):
permission_classes = [permissions.IsAdminUser]
def post(self, request, user_id):
user = get_object_or_404(User, pk=user_id)
user.is_approved = True
user.is_active = True
user.save()
return Response({'message': f'Nutzer {user.get_display_name()} wurde erfolgreich freigeschaltet.'})
def delete(self, request, user_id):
user = get_object_or_404(User, pk=user_id)
name = user.get_display_name()
user.delete()
return Response({'message': f'Registrierung von {name} wurde abgelehnt und gelöscht.'})
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shiftplan_backend.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+15
View File
@@ -0,0 +1,15 @@
asgiref==3.12.1
certifi==2026.7.22
charset-normalizer==3.4.9
django==6.0.7
django-cors-headers==4.9.0
djangorestframework==3.17.1
idna==3.18
pillow==12.3.0
pip==26.1.2
psycopg2-binary==2.9.12
python-dotenv==1.2.2
reportlab==5.0.0
requests==2.34.2
sqlparse==0.5.5
urllib3==2.7.0
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for shiftplan_backend project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shiftplan_backend.settings')
application = get_asgi_application()
+114
View File
@@ -0,0 +1,114 @@
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-key-shiftplan-change-me-in-production')
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third party
'rest_framework',
'rest_framework.authtoken',
'corsheaders',
# Local apps
'apps.users',
'apps.events',
'apps.branding',
]
AUTH_USER_MODEL = 'users.User'
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'shiftplan_backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'shiftplan_backend.wsgi.application'
# Database configuration: Postgres with SQLite fallback
DB_ENGINE = os.environ.get('POSTGRES_DB', None)
if DB_ENGINE:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('POSTGRES_DB', 'shiftplan_db'),
'USER': os.environ.get('POSTGRES_USER', 'shiftplan_user'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'shiftplan_password'),
'HOST': os.environ.get('POSTGRES_HOST', 'db'),
'PORT': os.environ.get('POSTGRES_PORT', '5432'),
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'de-de'
TIME_ZONE = 'Europe/Berlin'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
}
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True
+9
View File
@@ -0,0 +1,9 @@
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/users/', include('apps.users.urls')),
path('api/', include('apps.events.urls')),
path('api/branding/', include('apps.branding.urls')),
]
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for shiftplan_backend project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shiftplan_backend.settings')
application = get_wsgi_application()