diff --git a/castellum/appointments/forms.py b/castellum/appointments/forms.py index 371b9c29a794a88aa9973e34cb826262b16398cb..cbb1831a29c890556b1ff4703c206f25261031d9 100644 --- a/castellum/appointments/forms.py +++ b/castellum/appointments/forms.py @@ -19,22 +19,15 @@ # License along with Castellum. If not, see # . -import itertools - from django import forms from django.conf import settings -from django.db import models from django.utils.translation import gettext_lazy as _ from castellum.pseudonyms.helpers import get_pseudonym from castellum.recruitment.models import Participation from castellum.utils import scheduler -from castellum.utils.expressions import MultiDurationExpr from castellum.utils.forms import DateTimeField -from .models import MINUTE -from .models import Appointment - class AppointmentsForm(forms.ModelForm): class Meta: @@ -70,40 +63,6 @@ class AppointmentsForm(forms.ModelForm): else: return '{} - {}'.format(session.name, duration) - def clean(self): - cleaned_data = super().clean() - - appointments = [] - for session in self.instance.study.studysession_set.all(): - key = 'appointment-{}'.format(session.pk) - start = cleaned_data.get(key) - if start: - appointments.append((session, key, start, start + session.duration * MINUTE)) - - for a, b in itertools.combinations(appointments, 2): - __, key1, start1, end1 = a - __, key2, start2, end2 = b - if start1 < end2 and start2 < end1: - self.add_error(key1, _('Appointments must not overlap.')) - self.add_error(key2, _('Appointments must not overlap.')) - break - - qs = Appointment.objects\ - .exclude(participation=self.instance)\ - .filter(participation__status=Participation.INVITED)\ - .alias( - # corresponds to the Appointment.end property - end=MultiDurationExpr(models.F('start'), MINUTE, models.F('session__duration')), - ) - - for session, key, start, end in appointments: - if session.resource and qs.filter( - session__resource=session.resource, end__gt=start, start__lt=end, - ).exists(): - self.add_error(key, _('The required resource is not available at this time')) - - return cleaned_data - def save(self): pariticipation = super().save() self.appointment_changes = [] diff --git a/castellum/appointments/mixins.py b/castellum/appointments/mixins.py index 2440aef7e62041182a51c5de04c74ee4af81f254..516937499531417ce341ae3d202e0091b8f8cd44 100644 --- a/castellum/appointments/mixins.py +++ b/castellum/appointments/mixins.py @@ -24,6 +24,7 @@ from urllib.parse import quote_plus from django.conf import settings from django.contrib import messages +from django.db import models from django.http import JsonResponse from django.urls import reverse from django.utils import formats @@ -37,6 +38,7 @@ from icalevents import icalparser from castellum.castellum_auth.mixins import PermissionRequiredMixin from castellum.recruitment.mixins import ParticipationMixin from castellum.recruitment.models import Participation +from castellum.studies.models import Resource from castellum.utils import add_working_days from castellum.utils import cached_request from castellum.utils import contrast_color @@ -142,10 +144,47 @@ class BaseAppointmentsUpdateView(ParticipationMixin, PermissionRequiredMixin, Up def get_success_url(self): return self.request.path + def has_participation_overlap(self): + return any(( + self.object.appointment_set + .annotate_end() + .exclude(pk=appointment.pk) + .filter(end__gt=appointment.start, start__lt=appointment.end) + .exists() + ) for appointment in self.object.appointment_set.all()) + + def get_resource_overlaps(self): + inner = ( + Appointment.objects + .annotate_end() + .exclude(pk=models.OuterRef('pk')) + .filter( + session__resource=models.OuterRef('session__resource'), + participation__status=Participation.INVITED, + end__gt=models.OuterRef('start'), + start__lt=models.OuterRef('end'), + ) + ) + outer = ( + self.object.appointment_set.all() + .annotate_end() + .alias(overlap=models.Subquery(inner.values('pk')[:1])) + .exclude(overlap=None) + .order_by() + ) + return Resource.objects.filter(pk__in=outer.values('session__resource__pk')) + def form_valid(self, form): response = super().form_valid(form) send_appointment_notifications(self.request, self.object, form.appointment_changes) + messages.success(self.request, _('Data has been saved.')) + if self.has_participation_overlap(): + messages.warning(self.request, _('Some appointments overlap')) + for resource in self.get_resource_overlaps(): + messages.warning(self.request, _( + 'Some appointments for {resource} overlap' + ).format(resource=resource)) return response diff --git a/castellum/appointments/models.py b/castellum/appointments/models.py index 9ed4c35598bda3a36e2037243959fbe52203a869..c481adc8f2d59499df55a6b2eae568f527e14b0e 100644 --- a/castellum/appointments/models.py +++ b/castellum/appointments/models.py @@ -29,11 +29,20 @@ from django.utils.translation import gettext_lazy as _ from castellum.recruitment.models import Participation from castellum.studies.models import StudySession +from castellum.utils.expressions import MultiDurationExpr from castellum.utils.fields import DateTimeField MINUTE = datetime.timedelta(seconds=60) +class AppointmentQuerySet(models.QuerySet): + def annotate_end(self): + return self.annotate( + # corresponds to the Appointment.end property + end=MultiDurationExpr(models.F('start'), MINUTE, models.F('session__duration')), + ) + + class Appointment(models.Model): session = models.ForeignKey(StudySession, verbose_name=_('Session'), on_delete=models.CASCADE) start = DateTimeField(_('Start')) @@ -44,6 +53,8 @@ class Appointment(models.Model): verbose_name=_('Participation'), ) + objects = AppointmentQuerySet.as_manager() + class Meta: verbose_name = _('Appointment') ordering = ['start'] diff --git a/tests/execution/views/test_appointments_view.py b/tests/execution/views/test_appointments_view.py index b3672da1ff9b0c90a0afb6b314b7275812e227bd..d47edaa49d1cbe509ee7dbb95d313f5c837ab3bb 100644 --- a/tests/execution/views/test_appointments_view.py +++ b/tests/execution/views/test_appointments_view.py @@ -1,6 +1,7 @@ import datetime from django.contrib.auth.models import Permission +from django.contrib.messages import get_messages from django.utils import timezone import pytest @@ -77,6 +78,8 @@ def test_appointment_overlap(client, member, participation, time): 'appointment-{}_1'.format(session2.pk): time, }) assert response.status_code == 302 + messages = [str(m) for m in get_messages(response.wsgi_request)] + assert 'Some appointments overlap' not in messages @pytest.mark.parametrize('time', ( @@ -89,7 +92,7 @@ def test_appointment_overlap(client, member, participation, time): '13:30', )) def test_appointment_resource_overlap(client, member, participation, time): - resource = baker.make(Resource) + resource = baker.make(Resource, name='MRI') session = baker.make( StudySession, study=participation.study, duration=60, resource=resource ) @@ -111,10 +114,12 @@ def test_appointment_resource_overlap(client, member, participation, time): 'appointment-{}_1'.format(session.pk): time, }) assert response.status_code == 302 + messages = [str(m) for m in get_messages(response.wsgi_request)] + assert 'Some appointments for MRI overlap' not in messages def test_appointment_resource_overlap_ignore_not_invited(client, member, participation): - resource = baker.make(Resource) + resource = baker.make(Resource, name='MRI') session = baker.make( StudySession, study=participation.study, duration=60, resource=resource ) @@ -136,6 +141,8 @@ def test_appointment_resource_overlap_ignore_not_invited(client, member, partici 'appointment-{}_1'.format(session.pk): '12:00', }) assert response.status_code == 302 + messages = [str(m) for m in get_messages(response.wsgi_request)] + assert 'Some appointments for MRI overlap' not in messages def test_appointment_change_appointment_permission(client, member, participation):