From 487901b59f8562a8bb0aea6cf9818467384a5b98 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 24 Mar 2022 17:05:17 +0100 Subject: [PATCH 2/4] agendas: add shared custody holiday rules (#62801) --- .../migrations/0114_auto_20220324_1702.py | 80 +++++ chrono/agendas/models.py | 135 +++++++- chrono/manager/forms.py | 46 +++ ...anager_shared_custody_agenda_settings.html | 34 +- chrono/manager/urls.py | 15 + chrono/manager/views.py | 38 +++ tests/api/test_datetimes.py | 69 ++++ tests/manager/test_shared_custody_agenda.py | 92 +++++- tests/test_agendas.py | 312 ++++++++++++++++++ 9 files changed, 816 insertions(+), 5 deletions(-) create mode 100644 chrono/agendas/migrations/0114_auto_20220324_1702.py diff --git a/chrono/agendas/migrations/0114_auto_20220324_1702.py b/chrono/agendas/migrations/0114_auto_20220324_1702.py new file mode 100644 index 00000000..4febe950 --- /dev/null +++ b/chrono/agendas/migrations/0114_auto_20220324_1702.py @@ -0,0 +1,80 @@ +# Generated by Django 2.2.19 on 2022-03-24 16:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0113_auto_20220323_1708'), + ] + + operations = [ + migrations.CreateModel( + name='SharedCustodyHolidayRule', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'years', + models.CharField( + blank=True, + choices=[('', 'All'), ('even', 'Even'), ('odd', 'Odd')], + max_length=16, + verbose_name='Years', + ), + ), + ( + 'periodicity', + models.CharField( + blank=True, + choices=[ + ('first-half', 'First half'), + ('second-half', 'Second half'), + ('first-and-third-quarters', 'First and third quarters'), + ('second-and-fourth-quarters', 'Second and fourth quarters'), + ], + max_length=32, + verbose_name='Periodicity', + ), + ), + ( + 'agenda', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='holiday_rules', + to='agendas.SharedCustodyAgenda', + ), + ), + ( + 'guardian', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='agendas.Person', + verbose_name='Guardian', + ), + ), + ( + 'holiday', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='agendas.TimePeriodExceptionGroup', + verbose_name='Holiday', + ), + ), + ], + options={ + 'ordering': ['holiday__label', 'guardian', 'years', 'periodicity'], + }, + ), + migrations.AddField( + model_name='sharedcustodyperiod', + name='holiday_rule', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.SharedCustodyHolidayRule' + ), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index e63f4cd2..2d736446 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -27,6 +27,7 @@ from dataclasses import dataclass, field import requests import vobject +from dateutil.relativedelta import SU, relativedelta from dateutil.rrule import DAILY, WEEKLY, rrule, rruleset from django.conf import settings from django.contrib.auth.models import Group @@ -3150,8 +3151,25 @@ class SharedCustodyAgenda(models.Model): qs = qs.filter(days__overlap=days) return qs.exists() + def holiday_rule_overlaps(self, holiday, years, periodicity, instance=None): + qs = self.holiday_rules.filter(holiday=holiday) + if hasattr(instance, 'pk'): + qs = qs.exclude(pk=instance.pk) + + if years: + qs = qs.filter(Q(years='') | Q(years=years)) + + if periodicity in ('first-half', 'second-half'): + qs = qs.filter( + periodicity__in=(periodicity, '', 'first-and-third-quarters', 'second-and-fourth-quarters') + ) + elif periodicity in ('first-and-third-quarters', 'second-and-fourth-quarters'): + qs = qs.filter(periodicity__in=(periodicity, '', 'first-half', 'second-half')) + + return qs.exists() + def period_overlaps(self, date_start, date_end, instance=None): - qs = self.periods + qs = self.periods.filter(holiday_rule__isnull=True) if hasattr(instance, 'pk'): qs = qs.exclude(pk=instance.pk) @@ -3219,9 +3237,124 @@ class SharedCustodyRule(models.Model): ordering = ['days__0', 'weeks'] +class SharedCustodyHolidayRule(models.Model): + YEAR_CHOICES = [ + ('', pgettext_lazy('years', 'All')), + ('even', pgettext_lazy('years', 'Even')), + ('odd', pgettext_lazy('years', 'Odd')), + ] + + PERIODICITY_CHOICES = [ + ('first-half', _('First half')), + ('second-half', _('Second half')), + ('first-and-third-quarters', _('First and third quarters')), + ('second-and-fourth-quarters', _('Second and fourth quarters')), + ] + + agenda = models.ForeignKey(SharedCustodyAgenda, on_delete=models.CASCADE, related_name='holiday_rules') + holiday = models.ForeignKey(TimePeriodExceptionGroup, verbose_name=_('Holiday'), on_delete=models.CASCADE) + years = models.CharField(_('Years'), choices=YEAR_CHOICES, blank=True, max_length=16) + periodicity = models.CharField(_('Periodicity'), choices=PERIODICITY_CHOICES, blank=True, max_length=32) + guardian = models.ForeignKey(Person, verbose_name=_('Guardian'), on_delete=models.CASCADE) + + def update_or_create_periods(self): + shared_custody_periods = [] + for exception in self.holiday.exceptions.all(): + date_start = localtime(exception.start_datetime).date() + + if self.years == 'even' and date_start.year % 2: + continue + if self.years == 'odd' and not date_start.year % 2: + continue + + date_start_sunday = date_start + relativedelta(weekday=SU) + date_end = localtime(exception.end_datetime).date() + + number_of_weeks = (date_end - date_start_sunday).days // 7 + + periods = [] + if self.periodicity == 'first-half': + date_end = date_start_sunday + datetime.timedelta(days=7 * (number_of_weeks // 2)) + periods = [(date_start, date_end)] + elif self.periodicity == 'second-half': + date_start = date_start_sunday + datetime.timedelta(days=7 * (number_of_weeks // 2)) + periods = [(date_start, date_end)] + elif self.periodicity == 'first-and-third-quarters' and number_of_weeks >= 4: + weeks_in_quarters = round(number_of_weeks / 4) + first_quarters_date_end = date_start_sunday + datetime.timedelta(days=7 * weeks_in_quarters) + third_quarters_date_start = date_start_sunday + datetime.timedelta( + days=7 * weeks_in_quarters * 2 + ) + third_quarters_date_end = date_start_sunday + datetime.timedelta( + days=7 * weeks_in_quarters * 3 + ) + periods = [ + (date_start, first_quarters_date_end), + (third_quarters_date_start, third_quarters_date_end), + ] + elif self.periodicity == 'second-and-fourth-quarters' and number_of_weeks >= 4: + weeks_in_quarters = round(number_of_weeks / 4) + second_quarters_date_start = date_start_sunday + datetime.timedelta( + days=7 * weeks_in_quarters + ) + second_quarters_date_end = date_start_sunday + datetime.timedelta( + days=7 * weeks_in_quarters * 2 + ) + fourth_quarters_date_start = date_start_sunday + datetime.timedelta( + days=7 * weeks_in_quarters * 3 + ) + periods = [ + (second_quarters_date_start, second_quarters_date_end), + (fourth_quarters_date_start, date_end), + ] + elif not self.periodicity: + periods = [(date_start, date_end)] + + for date_start, date_end in periods: + shared_custody_periods.append( + SharedCustodyPeriod( + guardian=self.guardian, + agenda=self.agenda, + holiday_rule=self, + date_start=date_start, + date_end=date_end, + ) + ) + + with transaction.atomic(): + SharedCustodyPeriod.objects.filter( + guardian=self.guardian, agenda=self.agenda, holiday_rule=self + ).delete() + SharedCustodyPeriod.objects.bulk_create(shared_custody_periods) + + @property + def label(self): + label = self.holiday.label + + if self.periodicity == 'first-half': + label = '%s, %s' % (label, _('the first half')) + elif self.periodicity == 'second-half': + label = '%s, %s' % (label, _('the second half')) + elif self.periodicity == 'first-and-third-quarters': + label = '%s, %s' % (label, _('the first and third quarters')) + elif self.periodicity == 'second-and-fourth-quarters': + label = '%s, %s' % (label, _('the second and fourth quarters')) + + if self.years == 'odd': + label = '%s, %s' % (label, _('on odd years')) + elif self.years == 'even': + label = '%s, %s' % (label, _('on even years')) + + return label + + class Meta: + ordering = ['holiday__label', 'guardian', 'years', 'periodicity'] + + class SharedCustodyPeriod(models.Model): agenda = models.ForeignKey(SharedCustodyAgenda, on_delete=models.CASCADE, related_name='periods') guardian = models.ForeignKey(Person, on_delete=models.CASCADE, related_name='+') + holiday_rule = models.ForeignKey(SharedCustodyHolidayRule, null=True, on_delete=models.CASCADE) date_start = models.DateField(_('Start')) date_end = models.DateField(_('End')) diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py index 20854852..f853fc51 100644 --- a/chrono/manager/forms.py +++ b/chrono/manager/forms.py @@ -28,6 +28,7 @@ from django.conf import settings from django.contrib.auth.models import Group from django.core.exceptions import FieldDoesNotExist from django.db import transaction +from django.db.models import DurationField, ExpressionWrapper, F from django.forms import ValidationError from django.utils.encoding import force_text from django.utils.formats import date_format @@ -50,11 +51,13 @@ from chrono.agendas.models import ( MeetingType, Person, Resource, + SharedCustodyHolidayRule, SharedCustodyPeriod, SharedCustodyRule, Subscription, TimePeriod, TimePeriodException, + TimePeriodExceptionGroup, TimePeriodExceptionSource, UnavailabilityCalendar, VirtualMember, @@ -1339,6 +1342,49 @@ class SharedCustodyRuleForm(forms.ModelForm): return cleaned_data +class SharedCustodyHolidayRuleForm(forms.ModelForm): + guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none()) + + class Meta: + model = SharedCustodyHolidayRule + fields = ['guardian', 'holiday', 'years', 'periodicity'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['guardian'].empty_label = None + self.fields['guardian'].queryset = Person.objects.filter( + pk__in=[self.instance.agenda.first_guardian_id, self.instance.agenda.second_guardian_id] + ) + self.fields['holiday'].queryset = TimePeriodExceptionGroup.objects.filter( + unavailability_calendar__slug='chrono-holidays', + exceptions__isnull=False, + ).distinct() + + def clean(self): + cleaned_data = super().clean() + + holidays = cleaned_data['holiday'].exceptions.annotate( + delta=ExpressionWrapper(F('end_datetime') - F('start_datetime'), output_field=DurationField()) + ) + is_short_holiday = holidays.filter(delta__lt=datetime.timedelta(days=28)).exists() + if 'quarters' in cleaned_data['periodicity'] and is_short_holiday: + raise ValidationError(_('Short holidays cannot be cut into quarters.')) + + if self.instance.agenda.holiday_rule_overlaps( + cleaned_data['holiday'], cleaned_data['years'], cleaned_data['periodicity'], self.instance + ): + raise ValidationError(_('Rule overlaps existing rules.')) + + return cleaned_data + + def save(self, *args, **kwargs): + with transaction.atomic(): + super().save() + self.instance.update_or_create_periods() + + return self.instance + + class SharedCustodyPeriodForm(forms.ModelForm): guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none()) diff --git a/chrono/manager/templates/chrono/manager_shared_custody_agenda_settings.html b/chrono/manager/templates/chrono/manager_shared_custody_agenda_settings.html index b1663f34..32becf4a 100644 --- a/chrono/manager/templates/chrono/manager_shared_custody_agenda_settings.html +++ b/chrono/manager/templates/chrono/manager_shared_custody_agenda_settings.html @@ -10,6 +10,9 @@

{% trans "Settings" %}

{% trans 'Add custody period' %} + {% if has_holidays %} + {% trans 'Add custody rule during holidays' %} + {% endif %} {% trans 'Add custody rule' %} {% endblock %} @@ -45,12 +48,39 @@ +{% if has_holidays %} +
+

{% trans "Custody rules during holidays" %}

+
+ {% if agenda.holiday_rules.all %} + + {% else %} +
+ {% blocktrans trimmed %} + This agenda doesn't specify any custody rules during holidays. It means normal rules will be applied. + {% endblocktrans %} +
+ {% endif %} +
+
+{% endif %} +

{% trans "Exceptional custody periods" %}

- {% if agenda.periods.all %} + {% if exceptional_periods %}