Compare commits

...

4 Commits

Author SHA1 Message Date
xAndy dd3bf01529 add debug toolbar for query optimization work. not added to requirements.txt to keep prod slim
continuous-integration/drone/push Build is failing Details
2025-05-13 15:03:07 +02:00
xAndy 79e010c421 add tests for overlapping shifts 2025-05-13 13:24:03 +02:00
xAndy 8a194f3fc7 avoid overlapping shift registrations for helpers 2025-05-13 13:19:50 +02:00
xAndy 7e4ff4366b increase calendar url length, add import action in admin backend 2025-05-13 00:24:26 +02:00
10 changed files with 263 additions and 4 deletions

View File

@ -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 = (

View File

@ -13,7 +13,11 @@
<div class="notification">Diese Schicht wurde gelöscht.</div>
{% endif %}
{% if not can_register and not is_registered %}
{% if has_overlap %}
<div class="notification is-warning">Du hast bereits eine überlappende Schicht zu dieser Zeit: <a href="{% url 'shift' overlapping_shift.id %}">{{ overlapping_shift.room.name }} ({{ overlapping_shift.start_at }})</a></div>
{% else %}
<div class="notification">Diese Schicht ist bereits besetzt.</div>
{% endif %}
{% endif %}
<div class="content">
<p>

View File

@ -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))

View File

@ -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(

View File

@ -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,)

View File

@ -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),
),
]

View File

@ -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)

View File

@ -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()

View File

@ -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",
]

View File

@ -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)),
]