Compare commits
4 Commits
03163c1899
...
dd3bf01529
Author | SHA1 | Date |
---|---|---|
![]() |
dd3bf01529 | |
![]() |
79e010c421 | |
![]() |
8a194f3fc7 | |
![]() |
7e4ff4366b |
|
@ -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 = (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,)
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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)),
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue