From 03163c1899d5a88b79bdd92a6d72c0a97ca31163 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 11 May 2025 00:51:13 +0200 Subject: [PATCH 01/12] docs: add SMS texts for various occasions --- texts.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 texts.md diff --git a/texts.md b/texts.md new file mode 100644 index 0000000..fc3e8a3 --- /dev/null +++ b/texts.md @@ -0,0 +1,26 @@ +# SMS-Texte + +## Schichten + +``` +Die Schichten für das Kontaktfestival sind online: https://helfen.kntkt.de +Ignoriere diese SMS, um keine weiteren von uns zu erhalten. +``` + +## Teamschichten + +``` +Ab sofort könnt ihr euch bei (Andi oder) mir melden, um den Link zu euren Teamschichten zu erhalten. Je früher, desto schneller habt ihr die Möglichkeit, die Schichten an eure Freundis weiterzuverteilen :D +``` + +## Abbauschichterinnerung + +``` +Deine Schicht beginnt um $UHRZEIT, bitte melde dich auf dem Gelände bei $TEAMMITGLIED https://helfen.kntkt.de/shift/$SHIFT_ID +``` + +## Helfifesteinladung + +``` +Danke für deine Hilfe auf dem Festival. Zur Belohnung schmeißen wir eine Grillparty mit allen Helfis: $fb +``` -- 2.40.1 From 7e4ff4366ba2ea45e405d5bd488901a4a60aac45 Mon Sep 17 00:00:00 2001 From: xAndy Date: Tue, 13 May 2025 00:24:26 +0200 Subject: [PATCH 02/12] increase calendar url length, add import action in admin backend --- shiftregister/importer/admin.py | 6 ++++++ .../migrations/0003_alter_calendar_url.py | 18 ++++++++++++++++++ shiftregister/importer/models.py | 9 ++++++++- shiftregister/importer/tasks.py | 3 +-- 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 shiftregister/importer/migrations/0003_alter_calendar_url.py diff --git a/shiftregister/importer/admin.py b/shiftregister/importer/admin.py index 01f1530..669a774 100644 --- a/shiftregister/importer/admin.py +++ b/shiftregister/importer/admin.py @@ -3,6 +3,12 @@ from django.contrib import admin from .models import Calendar +def update_calendar(modeladmin, request, queryset): + for calendar in queryset: + calendar.update() + + @admin.register(Calendar) class CalendarAdmin(admin.ModelAdmin): list_display = ("url", "needs_fallback", "has_errors") + actions = (update_calendar,) diff --git a/shiftregister/importer/migrations/0003_alter_calendar_url.py b/shiftregister/importer/migrations/0003_alter_calendar_url.py new file mode 100644 index 0000000..77f1e44 --- /dev/null +++ b/shiftregister/importer/migrations/0003_alter_calendar_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2025-05-12 22:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("importer", "0002_calendar_needs_fallback"), + ] + + operations = [ + migrations.AlterField( + model_name="calendar", + name="url", + field=models.URLField(max_length=1000, primary_key=True, serialize=False), + ), + ] diff --git a/shiftregister/importer/models.py b/shiftregister/importer/models.py index 2d09c90..8134d43 100644 --- a/shiftregister/importer/models.py +++ b/shiftregister/importer/models.py @@ -4,10 +4,17 @@ from shiftregister.app.models import * class Calendar(models.Model): - url = models.URLField(primary_key=True) + url = models.URLField(primary_key=True, max_length=1000) needs_fallback = models.BooleanField(default=False, editable=True) has_errors = models.BooleanField(default=False, editable=False) + def update(self): + # break circular import + from .importer import import_calendar + + self.has_errors = not import_calendar(self) + self.save() + class Event(Shift): uuid = models.UUIDField(primary_key=True, editable=False) diff --git a/shiftregister/importer/tasks.py b/shiftregister/importer/tasks.py index 948f5fb..32eec41 100644 --- a/shiftregister/importer/tasks.py +++ b/shiftregister/importer/tasks.py @@ -7,5 +7,4 @@ from .models import Calendar @shared_task def import_shifts(): for calendar in Calendar.objects.all(): - calendar.has_errors = not import_calendar(calendar) - calendar.save() + calendar.update() -- 2.40.1 From 8a194f3fc720532c22a86b0fde3f80fb225665d5 Mon Sep 17 00:00:00 2001 From: xAndy Date: Tue, 13 May 2025 12:39:37 +0200 Subject: [PATCH 03/12] avoid overlapping shift registrations for helpers --- shiftregister/app/models.py | 34 ++++++++++++++++++++++++++ shiftregister/app/templates/shift.html | 4 +++ shiftregister/app/views.py | 15 ++++++++++++ 3 files changed, 53 insertions(+) diff --git a/shiftregister/app/models.py b/shiftregister/app/models.py index 20f57a5..34bb420 100644 --- a/shiftregister/app/models.py +++ b/shiftregister/app/models.py @@ -97,6 +97,40 @@ class Helper(models.Model): token.send() return token + def has_overlapping_shift(self, shift): + new_shift_end = shift.start_at + shift.duration + + return ( + ShiftRegistration.objects.annotate( + shift_end=ExpressionWrapper( + F("shift__start_at") + F("shift__duration"), + output_field=models.DateTimeField(), + ) + ) + .filter( + helper=self, + shift__deleted=False, + state__in=[ + ShiftRegistration.RegState.REGISTERED, + ShiftRegistration.RegState.CHECKED_IN, + ], + ) + .filter( + # Case 1: Start time falls between new shift's start and end + Q( + shift__start_at__gte=shift.start_at, + shift__start_at__lt=new_shift_end, + ) + | + # Case 2: End time falls between new shift's start and end + Q(shift_end__gt=shift.start_at, shift_end__lte=new_shift_end) + | + # Case 3: Completely encompasses the new shift + Q(shift__start_at__lte=shift.start_at, shift_end__gte=new_shift_end) + ) + .first() + ) + # current or next shift def important_shift(self): ret = ( diff --git a/shiftregister/app/templates/shift.html b/shiftregister/app/templates/shift.html index f97ad15..9904c73 100644 --- a/shiftregister/app/templates/shift.html +++ b/shiftregister/app/templates/shift.html @@ -13,7 +13,11 @@
Diese Schicht wurde gelöscht.
{% endif %} {% if not can_register and not is_registered %} + {% if has_overlap %} +
Du hast bereits eine überlappende Schicht zu dieser Zeit: {{ overlapping_shift.room.name }} ({{ overlapping_shift.start_at }})
+ {% else %}
Diese Schicht ist bereits besetzt.
+ {% endif %} {% endif %}

diff --git a/shiftregister/app/views.py b/shiftregister/app/views.py index 9f268d0..fd94fbb 100644 --- a/shiftregister/app/views.py +++ b/shiftregister/app/views.py @@ -211,6 +211,13 @@ def shift(request, shiftid): context["can_register"] = False if reg[0].can_cancel(): context["can_cancel"] = True + elif context["can_register"]: + # Check for overlapping shifts + overlapping_reg = helper.has_overlapping_shift(shift) + if overlapping_reg: + context["can_register"] = False + context["has_overlap"] = True + context["overlapping_shift"] = overlapping_reg.shift if request.method == "POST": if EmptyForm(request.POST).is_valid(): @@ -236,6 +243,14 @@ def shift(request, shiftid): ) return redirect("index") if context["can_register"]: + overlapping_reg = helper.has_overlapping_shift(shift) + if overlapping_reg: + messages.add_message( + request, + messages.ERROR, + "Du hast bereits eine überlappende Schicht zu dieser Zeit.", + ) + return redirect("shift", shiftid=shift.pk) s = ShiftRegistration(helper=helper, shift=shift) s.save() messages.add_message( -- 2.40.1 From 79e010c42129957e5327fc4fde0b08e1e2467418 Mon Sep 17 00:00:00 2001 From: xAndy Date: Tue, 13 May 2025 13:24:03 +0200 Subject: [PATCH 04/12] add tests for overlapping shifts --- shiftregister/app/tests.py | 141 ++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/shiftregister/app/tests.py b/shiftregister/app/tests.py index 7ce503c..b607e8b 100644 --- a/shiftregister/app/tests.py +++ b/shiftregister/app/tests.py @@ -1,3 +1,142 @@ from django.test import TestCase +from datetime import timedelta +from django.utils import timezone -# Create your tests here. +from .models import Helper, Room, Shift, ShiftRegistration + + +class ShiftOverlapTests(TestCase): + def setUp(self): + # Create a room + self.room = Room.objects.create( + name="Test Room", required_helpers=1, meeting_location="Test Location" + ) + + # Create a helper + self.helper = Helper.objects.create( + phone="+491234567890", name="Test Helper", number_validated=True + ) + + # Create a base shift for testing overlaps + self.base_shift = Shift.objects.create( + room=self.room, + start_at=timezone.now() + timedelta(hours=1), + duration=timedelta(hours=2), + required_helpers=1, + ) + + # Register the helper for the base shift + self.base_registration = ShiftRegistration.objects.create( + shift=self.base_shift, + helper=self.helper, + state=ShiftRegistration.RegState.REGISTERED, + ) + + def test_no_overlap_before(self): + """Test a shift that ends before the base shift starts""" + shift_before = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at - timedelta(hours=3), + duration=timedelta(hours=1), + required_helpers=1, + ) + self.assertIsNone(self.helper.has_overlapping_shift(shift_before)) + + def test_no_overlap_after(self): + """Test a shift that starts after the base shift ends""" + shift_after = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at + timedelta(hours=4), + duration=timedelta(hours=1), + required_helpers=1, + ) + self.assertIsNone(self.helper.has_overlapping_shift(shift_after)) + + def test_overlap_start(self): + """Test a shift that starts during the base shift""" + shift_overlap_start = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at + timedelta(hours=1), + duration=timedelta(hours=2), + required_helpers=1, + ) + self.assertIsNotNone(self.helper.has_overlapping_shift(shift_overlap_start)) + + def test_overlap_end(self): + """Test a shift that ends during the base shift""" + shift_overlap_end = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at - timedelta(hours=1), + duration=timedelta(hours=2), + required_helpers=1, + ) + self.assertIsNotNone(self.helper.has_overlapping_shift(shift_overlap_end)) + + def test_overlap_contained(self): + """Test a shift that is completely contained within the base shift""" + shift_contained = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at + timedelta(minutes=30), + duration=timedelta(hours=1), + required_helpers=1, + ) + self.assertIsNotNone(self.helper.has_overlapping_shift(shift_contained)) + + def test_overlap_contains(self): + """Test a shift that completely contains the base shift""" + shift_contains = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at - timedelta(hours=1), + duration=timedelta(hours=4), + required_helpers=1, + ) + self.assertIsNotNone(self.helper.has_overlapping_shift(shift_contains)) + + def test_exact_same_time(self): + """Test a shift that has exactly the same time as the base shift""" + shift_same_time = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at, + duration=self.base_shift.duration, + required_helpers=1, + ) + self.assertIsNotNone(self.helper.has_overlapping_shift(shift_same_time)) + + def test_deleted_shift_no_overlap(self): + """Test that deleted shifts are not considered for overlap""" + self.base_shift.deleted = True + self.base_shift.save() + + shift_same_time = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at, + duration=self.base_shift.duration, + required_helpers=1, + ) + self.assertIsNone(self.helper.has_overlapping_shift(shift_same_time)) + + def test_cancelled_registration_no_overlap(self): + """Test that cancelled registrations are not considered for overlap""" + self.base_registration.state = ShiftRegistration.RegState.CANCELED + self.base_registration.save() + + shift_same_time = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at, + duration=self.base_shift.duration, + required_helpers=1, + ) + self.assertIsNone(self.helper.has_overlapping_shift(shift_same_time)) + + def test_failed_registration_no_overlap(self): + """Test that failed registrations are not considered for overlap""" + self.base_registration.state = ShiftRegistration.RegState.FAILED + self.base_registration.save() + + shift_same_time = Shift.objects.create( + room=self.room, + start_at=self.base_shift.start_at, + duration=self.base_shift.duration, + required_helpers=1, + ) + self.assertIsNone(self.helper.has_overlapping_shift(shift_same_time)) -- 2.40.1 From dd3bf01529bde4b137ab730c903334baa5558d56 Mon Sep 17 00:00:00 2001 From: xAndy Date: Tue, 13 May 2025 15:03:07 +0200 Subject: [PATCH 05/12] add debug toolbar for query optimization work. not added to requirements.txt to keep prod slim --- shiftregister/settings.py | 29 +++++++++++++++++++++++++++++ shiftregister/urls.py | 8 ++++++++ 2 files changed, 37 insertions(+) diff --git a/shiftregister/settings.py b/shiftregister/settings.py index 79dbe4b..51841a0 100644 --- a/shiftregister/settings.py +++ b/shiftregister/settings.py @@ -61,6 +61,9 @@ THIRDPARTY_APPS = [ "phonenumber_field", ] +if DEBUG: + THIRDPARTY_APPS += ["debug_toolbar"] + LOCAL_APPS = [ "shiftregister.app", "shiftregister.core", @@ -87,6 +90,9 @@ MIDDLEWARE = [ "shiftregister.app.middleware.check_helper", ] +if DEBUG: + MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") + ROOT_URLCONF = "shiftregister.urls" LOGIN_URL = "/admin/login/" @@ -260,3 +266,26 @@ LOGGING = { }, }, } + +# Debug Toolbar settings +if DEBUG: + INTERNAL_IPS = ["127.0.0.1"] + DEBUG_TOOLBAR_CONFIG = { + "SHOW_TOOLBAR_CALLBACK": lambda request: True, # Always show toolbar in debug mode + "SHOW_TEMPLATE_CONTEXT": True, + "SQL_WARNING_THRESHOLD": 100, # ms + } + DEBUG_TOOLBAR_PANELS = [ + "debug_toolbar.panels.timer.TimerPanel", + "debug_toolbar.panels.settings.SettingsPanel", + "debug_toolbar.panels.headers.HeadersPanel", + "debug_toolbar.panels.request.RequestPanel", + "debug_toolbar.panels.sql.SQLPanel", + "debug_toolbar.panels.staticfiles.StaticFilesPanel", + "debug_toolbar.panels.templates.TemplatesPanel", + "debug_toolbar.panels.cache.CachePanel", + "debug_toolbar.panels.signals.SignalsPanel", + "debug_toolbar.panels.logging.LoggingPanel", + "debug_toolbar.panels.redirects.RedirectsPanel", + "debug_toolbar.panels.profiling.ProfilingPanel", + ] diff --git a/shiftregister/urls.py b/shiftregister/urls.py index 805610a..bb1ce13 100644 --- a/shiftregister/urls.py +++ b/shiftregister/urls.py @@ -16,6 +16,7 @@ Including another URLconf from django.contrib import admin from django.urls import include, path +from django.conf import settings urlpatterns = [ path("", include("shiftregister.metrics.urls")), @@ -28,3 +29,10 @@ urlpatterns = [ path("messages/", include("shiftregister.messaging.urls")), path("admin/", admin.site.urls), ] + +if settings.DEBUG: + import debug_toolbar + + urlpatterns += [ + path("__debug__/", include(debug_toolbar.urls)), + ] -- 2.40.1 From fd86c2fcc0b06dbac9d4030015d379e956e1460b Mon Sep 17 00:00:00 2001 From: xAndy Date: Wed, 14 May 2025 13:38:41 +0200 Subject: [PATCH 06/12] isort --- shiftregister/app/tests.py | 3 ++- shiftregister/urls.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/shiftregister/app/tests.py b/shiftregister/app/tests.py index b607e8b..4f50b91 100644 --- a/shiftregister/app/tests.py +++ b/shiftregister/app/tests.py @@ -1,5 +1,6 @@ -from django.test import TestCase from datetime import timedelta + +from django.test import TestCase from django.utils import timezone from .models import Helper, Room, Shift, ShiftRegistration diff --git a/shiftregister/urls.py b/shiftregister/urls.py index bb1ce13..860d84b 100644 --- a/shiftregister/urls.py +++ b/shiftregister/urls.py @@ -14,9 +14,9 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings from django.contrib import admin from django.urls import include, path -from django.conf import settings urlpatterns = [ path("", include("shiftregister.metrics.urls")), -- 2.40.1 From f55b653ccda9aefc7ef98ce4e436614e08cd30b8 Mon Sep 17 00:00:00 2001 From: xAndy Date: Wed, 14 May 2025 13:49:07 +0200 Subject: [PATCH 07/12] first shift trade draft. without pin for now --- shiftregister/fallback/admin.py | 1 + .../0009_fallbackassignment_traded_to.py | 25 ++++++++++ shiftregister/fallback/models.py | 22 ++++++--- .../templates/my_fallback_shifts.html | 29 +++++++++-- shiftregister/fallback/views.py | 49 +++++++++++++++---- .../signage/templates/team_dashboard.html | 13 ++++- .../team/templates/shift_detail.html | 6 ++- 7 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 shiftregister/fallback/migrations/0009_fallbackassignment_traded_to.py diff --git a/shiftregister/fallback/admin.py b/shiftregister/fallback/admin.py index bc59f6e..8c30c5b 100644 --- a/shiftregister/fallback/admin.py +++ b/shiftregister/fallback/admin.py @@ -25,6 +25,7 @@ class FallbackAssignmentInline(admin.TabularInline): model = FallbackAssignment ordering = ("shift__start_at",) readonly_fields = ("shift",) + fk_name = "team_member" @admin.register(TeamMember) diff --git a/shiftregister/fallback/migrations/0009_fallbackassignment_traded_to.py b/shiftregister/fallback/migrations/0009_fallbackassignment_traded_to.py new file mode 100644 index 0000000..5bd8b3d --- /dev/null +++ b/shiftregister/fallback/migrations/0009_fallbackassignment_traded_to.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.4 on 2025-05-14 11:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("fallback", "0008_alter_teammember_comment"), + ] + + operations = [ + migrations.AddField( + model_name="fallbackassignment", + name="traded_to", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="received_trades", + to="fallback.teammember", + ), + ), + ] diff --git a/shiftregister/fallback/models.py b/shiftregister/fallback/models.py index e1e050f..e6e69c7 100644 --- a/shiftregister/fallback/models.py +++ b/shiftregister/fallback/models.py @@ -21,18 +21,19 @@ class TeamMember(models.Model): id = models.IntegerField(default=generate_id, editable=False, primary_key=True) name = models.CharField(max_length=100) comment = models.CharField(max_length=100, blank=True, default="") - fallback_shifts = models.ManyToManyField(Shift, through="FallbackAssignment") + fallback_shifts = models.ManyToManyField( + Shift, through="FallbackAssignment", through_fields=("team_member", "shift") + ) def url(self): return "https://helfen.kntkt.de" + reverse( "my_fallback_shifts", - kwargs={ - "team_member_id": urlsafe_b64encode( - self.id.to_bytes(3, byteorder="big") - ).decode("utf-8") - }, + kwargs={"team_member_id": self.url_id()}, ) + def url_id(self): + return urlsafe_b64encode(self.id.to_bytes(3, byteorder="big")).decode("utf-8") + def assign_random_shifts(self): needs_fallback = Q(deleted=False, calendar__needs_fallback=True) @@ -199,6 +200,15 @@ class FallbackAssignment(models.Model): shift = models.ForeignKey(Shift, on_delete=models.CASCADE) team_member = models.ForeignKey(TeamMember, on_delete=models.CASCADE) was_full = models.BooleanField(default=False) + traded_to = models.ForeignKey( + TeamMember, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="received_trades", + ) def __str__(self): + if self.traded_to: + return f"{self.shift} {self.team_member.name} -> {self.traded_to.name}" return f"{self.shift} {self.team_member.name}" diff --git a/shiftregister/fallback/templates/my_fallback_shifts.html b/shiftregister/fallback/templates/my_fallback_shifts.html index d371307..29ad9d3 100644 --- a/shiftregister/fallback/templates/my_fallback_shifts.html +++ b/shiftregister/fallback/templates/my_fallback_shifts.html @@ -9,6 +9,22 @@

+ +
+

Schicht übernehmen

+
+ {% csrf_token %} +
+
+ +
+
+ +
+
+
+
+ {% if assignments %} {% if is_draw %}
Hallo {{ team_member.name }}, hier deine Teamschichten für das Festival:
@@ -26,6 +42,7 @@ Diese Schichtzuteilung wurde maschinell erstellt und ist auch ohne Unterschrift
 
+            
@@ -38,13 +55,19 @@ Diese Schichtzuteilung wurde maschinell erstellt und ist auch ohne Unterschrift
 {% for assignment in assignments %}
 {% with assignment.shift as shift %}
         
+            
-                    
+                    
 {% empty %}
                 
diff --git a/shiftregister/team/templates/shift_detail.html b/shiftregister/team/templates/shift_detail.html
index e73dfb3..71357bb 100644
--- a/shiftregister/team/templates/shift_detail.html
+++ b/shiftregister/team/templates/shift_detail.html
@@ -59,7 +59,11 @@
 {% for fallback in shift.event.fallbackassignment_set.all %}
     
- {{ fallback.team_member.name }} + {% if fallback.traded_to %} + {{ fallback.traded_to.name }} + {% else %} + {{ fallback.team_member.name }} + {% endif %}
{% endfor %} -- 2.40.1 From 6e45b1543bcb55892d72f2674788cb05d4e9f3af Mon Sep 17 00:00:00 2001 From: xAndy Date: Wed, 14 May 2025 14:17:08 +0200 Subject: [PATCH 08/12] add PINs for shift trades --- shiftregister/fallback/admin.py | 2 +- .../migrations/0010_teammember_pin.py | 22 +++++++ shiftregister/fallback/models.py | 7 +- .../templates/my_fallback_shifts.html | 16 ++++- shiftregister/fallback/views.py | 64 ++++++++++++------- 5 files changed, 86 insertions(+), 25 deletions(-) create mode 100644 shiftregister/fallback/migrations/0010_teammember_pin.py diff --git a/shiftregister/fallback/admin.py b/shiftregister/fallback/admin.py index 8c30c5b..8e90945 100644 --- a/shiftregister/fallback/admin.py +++ b/shiftregister/fallback/admin.py @@ -30,7 +30,7 @@ class FallbackAssignmentInline(admin.TabularInline): @admin.register(TeamMember) class TeamMemberAdmin(admin.ModelAdmin): - fields = ("id", "name", "comment", "url") + fields = ("id", "name", "comment", "url", "pin") readonly_fields = ("id", "url") list_display = ("name", "comment", "shift_count") ordering = ("name",) diff --git a/shiftregister/fallback/migrations/0010_teammember_pin.py b/shiftregister/fallback/migrations/0010_teammember_pin.py new file mode 100644 index 0000000..8fa900b --- /dev/null +++ b/shiftregister/fallback/migrations/0010_teammember_pin.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.4 on 2025-05-14 11:55 + +from django.db import migrations, models + +import shiftregister.fallback.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("fallback", "0009_fallbackassignment_traded_to"), + ] + + operations = [ + migrations.AddField( + model_name="teammember", + name="pin", + field=models.IntegerField( + default=shiftregister.fallback.models.generate_pin + ), + ), + ] diff --git a/shiftregister/fallback/models.py b/shiftregister/fallback/models.py index e6e69c7..fa5b7ce 100644 --- a/shiftregister/fallback/models.py +++ b/shiftregister/fallback/models.py @@ -2,7 +2,7 @@ import math import secrets from base64 import urlsafe_b64encode from datetime import datetime, time -from random import random +from random import randint, random import sentry_sdk from django.db.models import Count, Exists, ExpressionWrapper, Max, OuterRef, Sum @@ -17,10 +17,15 @@ def generate_id(): return int.from_bytes(secrets.token_bytes(3), byteorder="big") +def generate_pin(): + return randint(1000, 9999) + + class TeamMember(models.Model): id = models.IntegerField(default=generate_id, editable=False, primary_key=True) name = models.CharField(max_length=100) comment = models.CharField(max_length=100, blank=True, default="") + pin = models.IntegerField(default=generate_pin) fallback_shifts = models.ManyToManyField( Shift, through="FallbackAssignment", through_fields=("team_member", "shift") ) diff --git a/shiftregister/fallback/templates/my_fallback_shifts.html b/shiftregister/fallback/templates/my_fallback_shifts.html index 29ad9d3..dd3100f 100644 --- a/shiftregister/fallback/templates/my_fallback_shifts.html +++ b/shiftregister/fallback/templates/my_fallback_shifts.html @@ -16,12 +16,24 @@ {% csrf_token %}
- + {{ trade_form.assignment_id }} +
+
+ {{ trade_form.pin }}
+ {% if form.errors %} +
+ {% for field in trade_form %} + {% for error in field.errors %} +

{{ error }}

+ {% endfor %} + {% endfor %} +
+ {% endif %} @@ -31,6 +43,8 @@ {{ team_member.url }} +Deine PIN um Schichten zu übernehmen ist {{ team_member.pin }} + Deine Schichten werden in den nächsten Tagen weniger werden, wenn wir alle Schichten unter mehr Teammitgliedern verteilen. Du kannst unter dem Link immer nachschauen, welche Schichten du noch hast und welche schon von Helfis belegt sind. Bei Schichten mit mehreren Personen, bei denen nicht alle von Helfis belegt sind, koordiniere dich bitte mit den anderen Teammitgliedern, wer von euch die Schicht übernimmt. diff --git a/shiftregister/fallback/views.py b/shiftregister/fallback/views.py index 52ef2f7..5b91f06 100644 --- a/shiftregister/fallback/views.py +++ b/shiftregister/fallback/views.py @@ -1,5 +1,6 @@ from base64 import urlsafe_b64decode +from django import forms from django.contrib import messages from django.contrib.auth.decorators import login_required from django.db.models import Count, Q @@ -9,7 +10,14 @@ from django.urls import reverse from shiftregister.fallback.models import FallbackAssignment, TeamMember -# Create your views here. + +class TradeForm(forms.Form): + assignment_id = forms.IntegerField( + widget=forms.NumberInput(attrs={"class": "input", "placeholder": "Schicht-ID"}) + ) + pin = forms.IntegerField( + widget=forms.NumberInput(attrs={"class": "input", "placeholder": "Deine PIN"}) + ) def my_fallback_shifts(request, team_member_id): @@ -23,32 +31,43 @@ def my_fallback_shifts(request, team_member_id): team_member = get_object_or_404(TeamMember, pk=team_member_id) is_draw = False + trade_form = TradeForm( + request.POST + if request.method == "POST" and "take_shift" in request.POST + else None + ) + if request.method == "POST": if "draw_shifts" in request.POST: team_member.assign_random_shifts() is_draw = True - elif "take_shift" in request.POST: - assignment_id = request.POST.get("assignment_id") - try: - assignment = FallbackAssignment.objects.get(pk=assignment_id) - if assignment.team_member == team_member: - assignment.traded_to = None - messages.success(request, f"Schicht erfolgreich zurückgenommen") - elif assignment.traded_to == team_member: - assignment.traded_to = None - messages.success(request, f"Schicht erfolgreich zurückgegeben") - else: - assignment.traded_to = team_member - messages.success(request, f"Schicht erfolgreich übernommen") - assignment.save() - return redirect( - reverse( - "my_fallback_shifts", - kwargs={"team_member_id": team_member.url_id()}, + elif "take_shift" in request.POST and trade_form.is_valid(): + assignment_id = trade_form.cleaned_data["assignment_id"] + pin = trade_form.cleaned_data["pin"] + + if pin != team_member.pin: + messages.error(request, "Ungültige PIN") + else: + try: + assignment = FallbackAssignment.objects.get(pk=assignment_id) + if assignment.team_member == team_member: + assignment.traded_to = None + messages.success(request, f"Schicht erfolgreich zurückgenommen") + elif assignment.traded_to == team_member: + assignment.traded_to = None + messages.success(request, f"Schicht erfolgreich zurückgegeben") + else: + assignment.traded_to = team_member + messages.success(request, f"Schicht erfolgreich übernommen") + assignment.save() + return redirect( + reverse( + "my_fallback_shifts", + kwargs={"team_member_id": team_member.url_id()}, + ) ) - ) - except FallbackAssignment.DoesNotExist: - messages.error(request, "Ungültige Schicht-ID") + except FallbackAssignment.DoesNotExist: + messages.error(request, "Ungültige Schicht-ID") assignments = ( FallbackAssignment.objects.filter( @@ -63,6 +82,7 @@ def my_fallback_shifts(request, team_member_id): "team_member": team_member, "assignments": assignments, "is_draw": is_draw, + "trade_form": trade_form, } return render(request, "my_fallback_shifts.html", context) -- 2.40.1 From edcd18f91f606bec4661f97551d177e92f56ded7 Mon Sep 17 00:00:00 2001 From: xAndy Date: Wed, 14 May 2025 14:40:15 +0200 Subject: [PATCH 09/12] add trade tests --- shiftregister/fallback/tests.py | 172 +++++++++++++++++++++++++++++++- shiftregister/settings.py | 8 +- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/shiftregister/fallback/tests.py b/shiftregister/fallback/tests.py index 7ce503c..c242819 100644 --- a/shiftregister/fallback/tests.py +++ b/shiftregister/fallback/tests.py @@ -1,3 +1,171 @@ -from django.test import TestCase +import uuid +from datetime import timedelta -# Create your tests here. +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from shiftregister.fallback.models import FallbackAssignment, TeamMember +from shiftregister.importer.models import Calendar, Event, Room + + +class ShiftTradeTests(TestCase): + def setUp(self): + # Create test data + self.room = Room.objects.create(name="Test Room", required_helpers=2) + + self.event = Event.objects.create( + uuid=uuid.uuid4(), + calendar=Calendar.objects.create(url="https://example.com/calendar"), + start_at=timezone.now(), + duration=timedelta(hours=2), + room=self.room, + deleted=False, + ) + + self.team_member1 = TeamMember.objects.create(name="Team Member 1", pin=1111) + + self.team_member2 = TeamMember.objects.create(name="Team Member 2", pin=2222) + + self.team_member3 = TeamMember.objects.create(name="Team Member 3", pin=3333) + + self.assignment = FallbackAssignment.objects.create( + shift=self.event, team_member=self.team_member1, traded_to=self.team_member3 + ) + + self.client = Client() + + def test_invalid_pin_keeps_assignment_unchanged(self): + """Test that wrong PIN prevents any changes to the assignment""" + response = self.client.post( + reverse( + "my_fallback_shifts", + kwargs={"team_member_id": self.team_member2.url_id()}, + ), + {"take_shift": "true", "assignment_id": self.assignment.id, "pin": "9999"}, + ) + + self.assignment.refresh_from_db() + self.assertEqual(self.assignment.team_member, self.team_member1) + self.assertEqual(self.assignment.traded_to, self.team_member3) + + def test_take_shift_updates_traded_to(self): + """Test that taking a shift properly updates the traded_to field""" + response = self.client.post( + reverse( + "my_fallback_shifts", + kwargs={"team_member_id": self.team_member2.url_id()}, + ), + { + "take_shift": "true", + "assignment_id": self.assignment.id, + "pin": self.team_member2.pin, + }, + follow=True, + ) + + # Assignment should now be traded to team_member2 + self.assignment.refresh_from_db() + self.assertEqual(self.assignment.traded_to, self.team_member2) + self.assertEqual( + self.assignment.team_member, self.team_member1 + ) # Original owner unchanged + + def test_return_shift_clears_traded_to(self): + """Test that returning a shift clears the traded_to field""" + + # Then return it + response = self.client.post( + reverse( + "my_fallback_shifts", + kwargs={"team_member_id": self.team_member3.url_id()}, + ), + { + "take_shift": "true", + "assignment_id": self.assignment.id, + "pin": self.team_member3.pin, + }, + follow=True, + ) + + # Assignment should be returned (traded_to cleared) + self.assignment.refresh_from_db() + self.assertIsNone(self.assignment.traded_to) + self.assertEqual( + self.assignment.team_member, self.team_member1 + ) # Original owner unchanged + + def test_reclaim_shift_clears_traded_to(self): + """Test that original owner can reclaim their shift""" + + # Original owner reclaims it + response = self.client.post( + reverse( + "my_fallback_shifts", + kwargs={"team_member_id": self.team_member1.url_id()}, + ), + { + "take_shift": "true", + "assignment_id": self.assignment.id, + "pin": self.team_member1.pin, + }, + follow=True, + ) + + # Assignment should be reclaimed (traded_to cleared) + self.assignment.refresh_from_db() + self.assertIsNone(self.assignment.traded_to) + self.assertEqual(self.assignment.team_member, self.team_member1) + + def test_take_shift_from_other_trader(self): + """Test that a member can take over a shift that was already traded to someone else""" + # Verify initial state + self.assertEqual(self.assignment.team_member, self.team_member1) + self.assertEqual(self.assignment.traded_to, self.team_member3) + + # team_member2 takes the shift from team_member3 + response = self.client.post( + reverse( + "my_fallback_shifts", + kwargs={"team_member_id": self.team_member2.url_id()}, + ), + { + "take_shift": "true", + "assignment_id": self.assignment.id, + "pin": self.team_member2.pin, + }, + follow=True, + ) + + # Assignment should now be traded to team_member2 + self.assignment.refresh_from_db() + self.assertEqual( + self.assignment.team_member, self.team_member1 + ) # Original owner unchanged + self.assertEqual(self.assignment.traded_to, self.team_member2) # New trader + + def test_form_validation_prevents_changes(self): + """Test that invalid form data doesn't modify any assignments""" + initial_count = FallbackAssignment.objects.count() + initial_state = FallbackAssignment.objects.values_list( + "id", "team_member_id", "traded_to_id" + ) + + response = self.client.post( + reverse( + "my_fallback_shifts", + kwargs={"team_member_id": self.team_member1.url_id()}, + ), + { + "take_shift": "true", + "assignment_id": "not_a_number", + "pin": "also_not_a_number", + }, + ) + + # No assignments should be created or modified + self.assertEqual(FallbackAssignment.objects.count(), initial_count) + current_state = FallbackAssignment.objects.values_list( + "id", "team_member_id", "traded_to_id" + ) + self.assertEqual(list(initial_state), list(current_state)) diff --git a/shiftregister/settings.py b/shiftregister/settings.py index 51841a0..34e8083 100644 --- a/shiftregister/settings.py +++ b/shiftregister/settings.py @@ -10,6 +10,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.0/ref/settings/ """ +import sys from pathlib import Path import environ @@ -31,6 +32,9 @@ environ.Env.read_env(BASE_DIR / ".env") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env("ENVIRONMENT") == "development" +# Check if we're running tests +TESTING = "test" in sys.argv + # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = env( "SECRET_KEY", @@ -61,7 +65,7 @@ THIRDPARTY_APPS = [ "phonenumber_field", ] -if DEBUG: +if DEBUG and not TESTING: THIRDPARTY_APPS += ["debug_toolbar"] LOCAL_APPS = [ @@ -90,7 +94,7 @@ MIDDLEWARE = [ "shiftregister.app.middleware.check_helper", ] -if DEBUG: +if DEBUG and not TESTING: MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") ROOT_URLCONF = "shiftregister.urls" -- 2.40.1 From cacff14392a9832e6714548baec80a15b9add1fe Mon Sep 17 00:00:00 2001 From: xAndy Date: Wed, 14 May 2025 20:37:28 +0200 Subject: [PATCH 10/12] make pin a password field --- shiftregister/fallback/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiftregister/fallback/views.py b/shiftregister/fallback/views.py index 5b91f06..1916de5 100644 --- a/shiftregister/fallback/views.py +++ b/shiftregister/fallback/views.py @@ -16,7 +16,7 @@ class TradeForm(forms.Form): widget=forms.NumberInput(attrs={"class": "input", "placeholder": "Schicht-ID"}) ) pin = forms.IntegerField( - widget=forms.NumberInput(attrs={"class": "input", "placeholder": "Deine PIN"}) + widget=forms.PasswordInput(attrs={"class": "input", "placeholder": "Deine PIN"}) ) -- 2.40.1 From 5da9dbdbc40c6fbebfeccc9297e3f92dc589ff6f Mon Sep 17 00:00:00 2001 From: xAndy Date: Wed, 14 May 2025 21:21:03 +0200 Subject: [PATCH 11/12] add event_start setting to filter day navigation --- shiftregister/app/dynamic_preferences_registry.py | 8 ++++++++ shiftregister/app/views.py | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/shiftregister/app/dynamic_preferences_registry.py b/shiftregister/app/dynamic_preferences_registry.py index 3a34146..d8ac1b3 100644 --- a/shiftregister/app/dynamic_preferences_registry.py +++ b/shiftregister/app/dynamic_preferences_registry.py @@ -78,3 +78,11 @@ class FallbackQuota(types.FloatPreference): section = helper name = "fallback_quota" default = 0.7 + + +@global_preferences_registry.register +class EventStartAt(types.DateTimePreference): + section = helper + name = "event_start_at" + default = datetime.datetime.now() + help_text = "The start date and time of the event. Date navigation will only show days after this time." diff --git a/shiftregister/app/views.py b/shiftregister/app/views.py index fd94fbb..5195316 100644 --- a/shiftregister/app/views.py +++ b/shiftregister/app/views.py @@ -26,7 +26,12 @@ def index(request): days = cache.get("event_days") if not days: - days = Shift.objects.filter(deleted=False).datetimes("start_at", "day").all() + event_start = global_preferences["helper__event_start_at"] + days = ( + Shift.objects.filter(deleted=False, start_at__gte=event_start) + .datetimes("start_at", "day") + .all() + ) cache.set("event_days", days) context = { -- 2.40.1 From 6e34d8aceaac2443db1a96b97741d95aafd10cc4 Mon Sep 17 00:00:00 2001 From: xAndy Date: Thu, 15 May 2025 00:36:37 +0200 Subject: [PATCH 12/12] add event_end_at to provide full event range config --- shiftregister/app/dynamic_preferences_registry.py | 14 +++++++++++--- shiftregister/app/views.py | 9 ++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/shiftregister/app/dynamic_preferences_registry.py b/shiftregister/app/dynamic_preferences_registry.py index d8ac1b3..2bb7ee5 100644 --- a/shiftregister/app/dynamic_preferences_registry.py +++ b/shiftregister/app/dynamic_preferences_registry.py @@ -81,8 +81,16 @@ class FallbackQuota(types.FloatPreference): @global_preferences_registry.register -class EventStartAt(types.DateTimePreference): +class EventStartAt(types.DatePreference): section = helper name = "event_start_at" - default = datetime.datetime.now() - help_text = "The start date and time of the event. Date navigation will only show days after this time." + default = datetime.date(2024, 5, 24) + help_text = "The start date and time of the event. Date navigation will only show days between start and end time." + + +@global_preferences_registry.register +class EventEndAt(types.DatePreference): + section = helper + name = "event_end_at" + default = datetime.date(2025, 5, 28) + help_text = "The end date and time of the event. Date navigation will only show days between start and end time." diff --git a/shiftregister/app/views.py b/shiftregister/app/views.py index 5195316..0bca633 100644 --- a/shiftregister/app/views.py +++ b/shiftregister/app/views.py @@ -26,13 +26,16 @@ def index(request): days = cache.get("event_days") if not days: - event_start = global_preferences["helper__event_start_at"] + event_start_at = global_preferences["helper__event_start_at"] + event_end_at = global_preferences["helper__event_end_at"] days = ( - Shift.objects.filter(deleted=False, start_at__gte=event_start) + Shift.objects.filter( + deleted=False, start_at__gte=event_start_at, start_at__lte=event_end_at + ) .datetimes("start_at", "day") .all() ) - cache.set("event_days", days) + cache.set("event_days", days, 60 * 60) context = { "days": days, -- 2.40.1
Tausch-ID Wann Wie lange Wo{{ assignment.id }} {% if assignment.traded_to %}*{% endif %} {{ shift.start_at }} {{ shift.duration|duration }} {{ shift.room.name }} {{ shift.registration_count }}/{{ shift.required_helpers|default:shift.room.required_helpers }} -{% for assignment in shift.fallbackassignment_set.all %} - {{ assignment.team_member.name }}{% if not forloop.last %}, {% endif %} +{% for fa in shift.fallbackassignment_set.all %} + {% if fa.traded_to %} + {{ fa.traded_to.name }} + {% else %} + {{ fa.team_member.name }} + {% endif %} + {% if not forloop.last %}, {% endif %} {% endfor %} @@ -61,7 +84,7 @@ Diese Schichtzuteilung wurde maschinell erstellt und ist auch ohne Unterschrift {% if user.is_authenticated %}
{% csrf_token %} - +
{% else %} Noch keine Schichten zugewiesen, bitte wende dich an den Infopoint. diff --git a/shiftregister/fallback/views.py b/shiftregister/fallback/views.py index 4ce94eb..52ef2f7 100644 --- a/shiftregister/fallback/views.py +++ b/shiftregister/fallback/views.py @@ -1,11 +1,13 @@ from base64 import urlsafe_b64decode +from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.db.models import Count +from django.db.models import Count, Q from django.http import HttpResponse -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse -from shiftregister.fallback.models import TeamMember +from shiftregister.fallback.models import FallbackAssignment, TeamMember # Create your views here. @@ -22,15 +24,44 @@ def my_fallback_shifts(request, team_member_id): is_draw = False if request.method == "POST": - team_member.assign_random_shifts() - is_draw = True + if "draw_shifts" in request.POST: + team_member.assign_random_shifts() + is_draw = True + elif "take_shift" in request.POST: + assignment_id = request.POST.get("assignment_id") + try: + assignment = FallbackAssignment.objects.get(pk=assignment_id) + if assignment.team_member == team_member: + assignment.traded_to = None + messages.success(request, f"Schicht erfolgreich zurückgenommen") + elif assignment.traded_to == team_member: + assignment.traded_to = None + messages.success(request, f"Schicht erfolgreich zurückgegeben") + else: + assignment.traded_to = team_member + messages.success(request, f"Schicht erfolgreich übernommen") + assignment.save() + return redirect( + reverse( + "my_fallback_shifts", + kwargs={"team_member_id": team_member.url_id()}, + ) + ) + except FallbackAssignment.DoesNotExist: + messages.error(request, "Ungültige Schicht-ID") + + assignments = ( + FallbackAssignment.objects.filter( + Q(team_member=team_member, traded_to__isnull=True) + | Q(traded_to=team_member) + ) + .prefetch_related("shift", "traded_to", "team_member") + .order_by("shift__start_at") + ) context = { "team_member": team_member, - "assignments": team_member.fallbackassignment_set.order_by( - "shift__start_at" - ).all(), - # "shifts": team_member.fallback_shifts.order_by("start_at").all(), + "assignments": assignments, "is_draw": is_draw, } return render(request, "my_fallback_shifts.html", context) diff --git a/shiftregister/signage/templates/team_dashboard.html b/shiftregister/signage/templates/team_dashboard.html index 239884b..d4d325b 100644 --- a/shiftregister/signage/templates/team_dashboard.html +++ b/shiftregister/signage/templates/team_dashboard.html @@ -18,7 +18,18 @@
{{ shift.room.name }} {{ shift.start_at }}{% for fa in shift.fallbackassignment_set.all %}{% if not fa.was_full %}{{ fa.team_member.name }}{% if not forloop.last %}, {% endif %}{% endif %}{% endfor %} + {% for fa in shift.fallbackassignment_set.all %} + {% if not fa.was_full %} + {% if fa.traded_to %} + {{ fa.traded_to.name }} ({{ fa.id }}) + {% else %} + {{ fa.team_member.name }} ({{ fa.id }}) + {% endif %} + {% if not forloop.last %}, {% endif %} + {% endif %} + {% endfor %} +