Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions tapir/shifts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,28 @@ def clean(self):
return result


class ShiftTemplateEndDateForm(forms.ModelForm):
cancellation_reason = forms.CharField(label=_("Cancellation Reason"), required=True)

class Meta:
model = ShiftTemplate
fields = ["end_date"]
widgets = {"end_date": DateInputTapir()}

def __init__(self, shift_template=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["end_date"].required = True

def clean_end_date(self):
if (
self.cleaned_data["end_date"]
and self.instance.start_date is not None
and self.cleaned_data["end_date"] < self.instance.start_date
):
raise ValidationError(_("The end date must be later than the start date."))
return self.cleaned_data["end_date"]


class CreateShiftAccountEntryForm(forms.ModelForm):
class Meta:
model = ShiftAccountEntry
Expand Down
22 changes: 22 additions & 0 deletions tapir/shifts/migrations/0076_shifttemplate_end_date.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.2.15 on 2026-06-14 17:37

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("shifts", "0075_shiftwatch_watched_capabilities"),
]

operations = [
migrations.AddField(
model_name="shifttemplate",
name="end_date",
field=models.DateField(
blank=True,
help_text="All shifts after this date will be cancelled.",
null=True,
),
),
]
14 changes: 10 additions & 4 deletions tapir/shifts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ class ShiftTemplate(models.Model):
"This determines from which date shifts should be generated from this ABCD shift."
),
)
end_date = models.DateField(
blank=True,
null=True,
help_text=_("All shifts after this date will be cancelled."),
)
flexible_time = models.BooleanField(
verbose_name=_("Flexible time"),
help_text=_(
Expand Down Expand Up @@ -291,10 +296,11 @@ def update_future_generated_shifts_to_fit_this(self):
shift.update_to_fit_template()

def clean(self):
if self.start_time >= self.end_time:
raise ValidationError(
f"The shift must end after it starts. Given start time: {self.start_time}. Given end time: {self.end_time}"
)
if self.start_time and self.end_time:
if self.start_time >= self.end_time:
Comment on lines +299 to +300

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can merge the two ifs.

raise ValidationError(
f"The shift must end after it starts. Given start time: {self.start_time}. Given end time: {self.end_time}"
)


class RequiredCapabilitiesMixin:
Expand Down
9 changes: 7 additions & 2 deletions tapir/shifts/services/shift_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,13 @@ def create_shifts_for_group(
start_date_in_the_past_or_null = Q(start_date__lte=at_date) | Q(
start_date__isnull=True
)
shift_templates = ShiftTemplate.objects.filter(group=group).filter(
start_date_in_the_past_or_null
end_date_in_the_future_or_null = Q(end_date__gte=at_date) | Q(
end_date__isnull=True
)
shift_templates = (
ShiftTemplate.objects.filter(group=group)
.filter(start_date_in_the_past_or_null)
.filter(end_date_in_the_future_or_null)
)

if filter_shift_template_ids is not None:
Expand Down
15 changes: 10 additions & 5 deletions tapir/shifts/templates/shifts/shift_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,17 @@ <h5>#{{ forloop.counter }}</h5>
{% elif not slot.is_occupied %}
{% blocktranslate asvar self_register_tooltip %}You can only register
yourself
for a shift if:<br />
-You are not registered to another slot in that shift<br />
for a shift if:
<br />
-You are not registered to another slot in that shift
<br />
-You have the required qualification (if you want to get a
qualification, contact the member office)<br />
-The shift is in the future<br />
-The shift is not cancelled (holidays...)<br />
qualification, contact the member office)
<br />
-The shift is in the future
<br />
-The shift is not cancelled (holidays...)
<br />
{% endblocktranslate %}
{% autoescape off %}
<span {% if not slot.can_self_register and not perms.shifts.manage %} data-bs-toggle="tooltip" data-bs-html="true" title="{{ self_register_tooltip }}"{% endif %}>
Expand Down
5 changes: 5 additions & 0 deletions tapir/shifts/templates/shifts/shift_template_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ <h5 class="card-header d-flex justify-content-between align-items-center">
<span class="material-icons button-icon">edit</span>
{% translate "Edit" %}
</a>
<a class="{% tapir_button_link_to_action %}"
href="{% url 'shifts:shift_template_set_end_date' object.pk %}">
<span class="material-icons button-icon">event_busy</span>
{% translate 'Set end date' %}
</a>
</div>
{% endif %}
</h5>
Expand Down
29 changes: 29 additions & 0 deletions tapir/shifts/templates/shifts/shift_template_set_end_date.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% extends "shifts/base.html" %}
{% load django_bootstrap5 %}
{% load static %}
{% load i18n %}
{% load core %}
{% block title %}
{% translate 'Set end date for' %}: {{ shift_template.get_display_name }}
{% endblock title %}
{% block content %}
<div class="card">
<h5 class="card-header">{% translate 'Set end date for' %}: {{ shift_template.get_display_name }}</h5>
<div class="card-body">
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="alert alert-warning " role="alert">
<strong>{% translate 'Warning' %}</strong>
{% translate 'All shifts generated from this ABCD-shift after the selected date will be cancelled. Registered members will be notified.' %}
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="{% tapir_button_action %}">
<span class="material-icons">event_busy</span>
{% translate 'Set end date and cancel shifts' %}
</button>
</div>
</form>
</div>
</div>
{% endblock content %}
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,22 @@ def test_createShiftsForGroup_withoutHolidayCancellation_doesntCancelsHolidays(
)
self.assertFalse(shift.cancelled)
self.assertIsNone(shift.cancelled_reason)

def test_createShiftForGroup_endDate_noShiftsCreatedPastEndDate(self):
group_a = ShiftTemplateGroup.objects.create(name="A")

template_with_future_end_date = ShiftTemplateFactory.create(
group=group_a, end_date=datetime.date(2025, 11, 20)
)

# should be created
ShiftGenerator.create_shifts_for_group(
at_date=datetime.date(2025, 11, 10), group=group_a
)

# should not be created
ShiftGenerator.create_shifts_for_group(
at_date=datetime.date(2025, 11, 24), group=group_a
)

Comment on lines +124 to +133

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make 2 test cases for this, or at least assert between the creations.

self.assertEqual(1, template_with_future_end_date.generated_shifts.count())
120 changes: 120 additions & 0 deletions tapir/shifts/tests/test_shifttemplateenddateview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import datetime

from django.contrib.messages import get_messages
from django.urls import reverse
from django.utils import timezone
from django_extensions.jobs import weekly

from tapir.accounts.tests.factories.factories import TapirUserFactory
from tapir.shifts.tests.factories import ShiftTemplateFactory
from tapir.shifts.tests.utils import register_user_to_shift_template
from tapir.utils.tests_utils import TapirFactoryTestBase


class TestShiftTemplateEndView(TapirFactoryTestBase):

def setUp(self):
super().setUp()
self.shift_template = ShiftTemplateFactory.create(
start_date=timezone.now().date(), weekday=0
)
self.url = reverse(
"shifts:shift_template_set_end_date", kwargs={"pk": self.shift_template.pk}
)

def test_shiftTemplate_setEndDateInBetween_futureShiftsAfterEndDateAreCancelled(
self,
):
self.login_as_employee()

user = TapirUserFactory.create(is_in_member_office=False)
register_user_to_shift_template(self.client, user, self.shift_template)

today = timezone.now().date()
shift_1 = self.shift_template.create_shift_if_necessary(
today + datetime.timedelta(days=7)
)
shift_2 = self.shift_template.create_shift_if_necessary(
today + datetime.timedelta(days=14)
)
shift_3 = self.shift_template.create_shift_if_necessary(
today + datetime.timedelta(days=21)
)

end_date = today + datetime.timedelta(days=14)
cancellation_reason = "Shift Template ended"

response = self.client.post(
self.url,
{
"end_date": end_date.strftime("%Y-%m-%d"),
"cancellation_reason": cancellation_reason,
},
)

self.assertRedirects(
response,
reverse(
"shifts:shift_template_detail", kwargs={"pk": self.shift_template.pk}
),
)

self.shift_template.refresh_from_db()
shift_1.refresh_from_db()
shift_2.refresh_from_db()
shift_3.refresh_from_db()

self.assertEqual(self.shift_template.end_date, end_date)

self.assertFalse(shift_1.cancelled)
self.assertFalse(shift_2.cancelled)

self.assertTrue(shift_3.cancelled)
self.assertEqual(shift_3.cancelled_reason, cancellation_reason)

def test_shiftTemplate_sendEndDateAfterAllShifts_noShiftIsCancelled(self):
self.login_as_employee()
today = timezone.now().date()
end_date = today + datetime.timedelta(days=365)
cancellation_reason = "Shift Template ended"
shift = self.shift_template.create_shift_if_necessary(
today + datetime.timedelta(days=7)
)

response = self.client.post(
self.url,
{
"end_date": end_date.strftime("%Y-%m-%d"),
"cancellation_reason": cancellation_reason,
},
)

self.shift_template.refresh_from_db()
shift.refresh_from_db()
self.assertEqual(self.shift_template.end_date, end_date)

self.assertFalse(shift.cancelled)

msgs = list(get_messages(response.wsgi_request))
self.assertGreater(len(msgs), 0)

def test_shiftTemplate_sendEndDate_endDateBeforeStartDate_showsValidationError(
self,
):
self.login_as_employee()

end_date = self.shift_template.start_date - datetime.timedelta(days=7)

response = self.client.post(
self.url,
{
"end_date": end_date.strftime("%Y-%m-%d"),
"cancellation_reason": "Test",
},
)

self.assertEqual(response.status_code, 200)
self.assertIn("end_date", response.context["form"].errors)

self.shift_template.refresh_from_db()
self.assertIsNone(self.shift_template.end_date)
6 changes: 6 additions & 0 deletions tapir/shifts/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import path

from tapir.shifts import views
from tapir.shifts.views import ShiftTemplateEndDateView

app_name = "shifts"
urlpatterns = [
Expand Down Expand Up @@ -240,4 +241,9 @@
views.RecurringShiftwatchListView.as_view(),
name="shiftwatch_overview",
),
path(
"shift_template/<int:pk>/set_end_date/",
ShiftTemplateEndDateView.as_view(),
name="shift_template_set_end_date",
),
]
43 changes: 43 additions & 0 deletions tapir/shifts/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from tapir.shifts.forms import (
CreateShiftAccountEntryForm,
RecurringShiftWatchForm,
ShiftTemplateEndDateForm,
ShiftUserDataForm,
ShiftWatchForm,
)
Expand All @@ -54,6 +55,7 @@
ShiftWatch,
UpdateShiftUserDataLogEntry,
)
from tapir.shifts.services.shift_cancellation_service import ShiftCancellationService
from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator
from tapir.shifts.templatetags.shifts import shift_name_as_class
from tapir.utils.user_utils import UserUtils
Expand Down Expand Up @@ -329,6 +331,47 @@ def get_queryset(self):
)


class ShiftTemplateEndDateView(
LoginRequiredMixin,
PermissionRequiredMixin,
generic.UpdateView,
):
permission_required = PERMISSION_SHIFTS_MANAGE
form_class = ShiftTemplateEndDateForm
template_name = "shifts/shift_template_set_end_date.html"
model = ShiftTemplate

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["shift_template"] = self.object
return context

def get_success_url(self):
return reverse("shifts:shift_template_detail", kwargs={"pk": self.object.pk})

def form_valid(self, form):
with transaction.atomic():
response = super().form_valid(form)
end_date = form.cleaned_data["end_date"]
cancellation_reason = form.cleaned_data["cancellation_reason"]
shifts_to_cancel = Shift.objects.filter(
shift_template=self.object,
start_time__date__gt=end_date,
deleted=False,
cancelled=False,
)
for shift in shifts_to_cancel:
shift.cancelled_reason = cancellation_reason
ShiftCancellationService.cancel(shift)

messages.success(
self.request,
_("Shift template was successfully cancelled"),
)

return response


class ShiftUserDataTable(django_tables2.Table):
class Meta:
model = ShiftUserData
Expand Down
Loading