agendas: add shared custody models (#62146)

This commit is contained in:
Valentin Deniaud 2022-02-22 15:59:27 +01:00
parent 2ce8babd54
commit adad089c09
5 changed files with 544 additions and 12 deletions

View File

@ -0,0 +1,137 @@
# Generated by Django 2.2.19 on 2022-03-03 16:03
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0109_auto_20220203_1051'),
]
operations = [
migrations.CreateModel(
name='Person',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('user_external_id', models.CharField(max_length=250, unique=True)),
('name', models.CharField(max_length=250)),
],
),
migrations.CreateModel(
name='SharedCustodyAgenda',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('children', models.ManyToManyField(related_name='agendas', to='agendas.Person')),
(
'first_guardian',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='+',
to='agendas.Person',
verbose_name='First guardian',
),
),
(
'second_guardian',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='+',
to='agendas.Person',
verbose_name='Second guardian',
),
),
],
),
migrations.CreateModel(
name='SharedCustodyRule',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
(
'days',
django.contrib.postgres.fields.ArrayField(
base_field=models.IntegerField(
choices=[
(0, 'Mo'),
(1, 'Tu'),
(2, 'We'),
(3, 'Th'),
(4, 'Fr'),
(5, 'Sa'),
(6, 'Su'),
]
),
size=None,
verbose_name='Days',
),
),
(
'weeks',
models.CharField(
blank=True,
choices=[('', 'All'), ('even', 'Even'), ('odd', 'Odd')],
max_length=16,
verbose_name='Weeks',
),
),
(
'agenda',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='rules',
to='agendas.SharedCustodyAgenda',
),
),
(
'guardian',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='agendas.Person',
verbose_name='Guardian',
),
),
],
options={
'ordering': ['days__0', 'weeks'],
},
),
migrations.CreateModel(
name='SharedCustodyPeriod',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('date_start', models.DateField(verbose_name='Start')),
('date_end', models.DateField(verbose_name='End')),
(
'agenda',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='periods',
to='agendas.SharedCustodyAgenda',
),
),
(
'guardian',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='agendas.Person'
),
),
],
options={
'ordering': ['date_start'],
},
),
]

View File

@ -23,6 +23,7 @@ import math
import sys
import uuid
from contextlib import contextmanager
from dataclasses import dataclass, field
import requests
import vobject
@ -34,6 +35,7 @@ from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import IntegrityError, connection, models, transaction
from django.db.models import Count, F, Max, Prefetch, Q
from django.db.models.functions import Coalesce
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist, engines
from django.urls import reverse
from django.utils import functional
@ -45,11 +47,12 @@ from django.utils.module_loading import import_string
from django.utils.safestring import mark_safe
from django.utils.text import slugify
from django.utils.timezone import is_aware, localtime, make_aware, make_naive, now, utc
from django.utils.translation import ugettext
from django.utils.translation import pgettext, ugettext
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext
from chrono.interval import Interval, IntervalSet
from chrono.utils.db import SumCardinality
from chrono.utils.publik_urls import translate_from_publik_url
from chrono.utils.requests_wrapper import requests as requests_wrapper
@ -75,6 +78,16 @@ WEEKDAYS_PLURAL = {
6: _('Sundays'),
}
WEEKDAY_CHOICES = [
(0, _('Mo')),
(1, _('Tu')),
(2, _('We')),
(3, _('Th')),
(4, _('Fr')),
(5, _('Sa')),
(6, _('Su')),
]
def is_midnight(dtime):
dtime = localtime(dtime)
@ -1397,16 +1410,6 @@ class MeetingType(models.Model):
class Event(models.Model):
WEEKDAY_CHOICES = [
(0, _('Mo')),
(1, _('Tu')),
(2, _('We')),
(3, _('Th')),
(4, _('Fr')),
(5, _('Sa')),
(6, _('Su')),
]
INTERVAL_CHOICES = [
(1, _('Every week')),
(2, _('Every two weeks')),
@ -3024,3 +3027,168 @@ class Subscription(models.Model):
return Template(self.agenda.get_booking_user_block_template()).render(template_vars)
except (VariableDoesNotExist, TemplateSyntaxError):
return
class Person(models.Model):
user_external_id = models.CharField(max_length=250, unique=True)
name = models.CharField(max_length=250)
def __str__(self):
return self.name
@dataclass(frozen=True)
class SharedCustodySlot:
guardian: Person = field(compare=False)
date: datetime.date
def __str__(self):
return self.guardian.name
class SharedCustodyAgenda(models.Model):
first_guardian = models.ForeignKey(
Person, verbose_name=_('First guardian'), on_delete=models.CASCADE, related_name='+'
)
second_guardian = models.ForeignKey(
Person, verbose_name=_('Second guardian'), on_delete=models.CASCADE, related_name='+'
)
children = models.ManyToManyField(Person, related_name='agendas')
@property
def label(self):
return _('Custody agenda of %(first_guardian)s and %(second_guardian)s') % {
'first_guardian': self.first_guardian.name,
'second_guardian': self.second_guardian.name,
}
def get_absolute_url(self):
return reverse('chrono-manager-shared-custody-agenda-view', kwargs={'pk': self.pk})
def get_settings_url(self):
return reverse('chrono-manager-shared-custody-agenda-settings', kwargs={'pk': self.pk})
def get_custody_slots(self, min_date, max_date):
slots = set()
periods = self.periods.filter(date_start__lt=max_date, date_end__gt=min_date)
for period in periods:
date = period.date_start
while date < period.date_end and date < max_date:
slots.add(SharedCustodySlot(guardian=period.guardian, date=date))
date += datetime.timedelta(days=1)
for rule in self.rules.all():
slots.update(rule.get_slots(min_date, max_date))
slots = sorted(slots, key=lambda x: x.date)
return slots
def is_complete(self):
day_counts = self.rules.aggregate(
all_week=Coalesce(SumCardinality('days', filter=Q(weeks='')), 0),
even_week=Coalesce(SumCardinality('days', filter=Q(weeks='even')), 0),
odd_week=Coalesce(SumCardinality('days', filter=Q(weeks='odd')), 0),
)
even_week_day_count = day_counts['all_week'] + day_counts['even_week']
odd_week_day_count = day_counts['all_week'] + day_counts['odd_week']
return bool(even_week_day_count == 7 and odd_week_day_count == 7)
def rule_overlaps(self, days, weeks, instance=None):
qs = self.rules
if hasattr(instance, 'pk'):
qs = qs.exclude(pk=instance.pk)
if weeks:
qs = qs.filter(Q(weeks='') | Q(weeks=weeks))
qs = qs.filter(days__overlap=days)
return qs.exists()
def period_overlaps(self, date_start, date_end, instance=None):
qs = self.periods
if hasattr(instance, 'pk'):
qs = qs.exclude(pk=instance.pk)
qs = qs.extra(
where=["(date_start, date_end) OVERLAPS (%s, %s)"],
params=[date_start, date_end],
)
return qs.exists()
class SharedCustodyRule(models.Model):
WEEK_CHOICES = [
('', pgettext('weeks', 'All')),
('even', _('Even')),
('odd', _('Odd')),
]
agenda = models.ForeignKey(SharedCustodyAgenda, on_delete=models.CASCADE, related_name='rules')
days = ArrayField(
models.IntegerField(choices=WEEKDAY_CHOICES),
verbose_name=_('Days'),
)
weeks = models.CharField(_('Weeks'), choices=WEEK_CHOICES, blank=True, max_length=16)
guardian = models.ForeignKey(Person, verbose_name=_('Guardian'), on_delete=models.CASCADE)
def get_slots(self, min_date, max_date):
recurrence_rule = {
'freq': WEEKLY,
'byweekday': self.days,
}
if self.weeks == 'odd':
recurrence_rule['byweekno'] = list(range(1, 55, 2))
elif self.weeks == 'even':
recurrence_rule['byweekno'] = list(range(0, 54, 2))
return [
SharedCustodySlot(self.guardian, dt.date())
for dt in rrule(dtstart=min_date, until=max_date - datetime.timedelta(days=1), **recurrence_rule)
]
@property
def label(self):
days_count = len(self.days)
if days_count == 7:
repeat = _('daily')
elif days_count > 1 and (self.days[-1] - self.days[0]) == days_count - 1:
# days are contiguous
repeat = _('from %(weekday)s to %(last_weekday)s') % {
'weekday': str(WEEKDAYS[self.days[0]]),
'last_weekday': str(WEEKDAYS[self.days[-1]]),
}
else:
repeat = _('on %(weekdays)s') % {
'weekdays': ', '.join([str(WEEKDAYS_PLURAL[i]) for i in self.days])
}
if self.weeks == 'odd':
repeat = '%s, %s' % (repeat, _('on odd weeks'))
elif self.weeks == 'even':
repeat = '%s, %s' % (repeat, _('on even weeks'))
return repeat
class Meta:
ordering = ['days__0', 'weeks']
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='+')
date_start = models.DateField(_('Start'))
date_end = models.DateField(_('End'))
class Meta:
ordering = ['date_start']
def __str__(self):
if self.date_end == self.date_start + datetime.timedelta(days=1):
exc_repr = '%s' % date_format(self.date_start, 'SHORT_DATE_FORMAT')
else:
exc_repr = '%s%s' % (
date_format(self.date_start, 'SHORT_DATE_FORMAT'),
date_format(self.date_end, 'SHORT_DATE_FORMAT'),
)
return '%s, %s' % (self.guardian.name, exc_repr)

View File

@ -35,6 +35,7 @@ from django.utils.timezone import localtime, make_aware, now
from django.utils.translation import ugettext_lazy as _
from chrono.agendas.models import (
WEEKDAY_CHOICES,
WEEKDAYS_LIST,
AbsenceReason,
AbsenceReasonGroup,
@ -177,7 +178,7 @@ class NewEventForm(forms.ModelForm):
help_text=_('This field will not be editable once event has bookings.'),
)
recurrence_days = forms.TypedMultipleChoiceField(
choices=Event.WEEKDAY_CHOICES,
choices=WEEKDAY_CHOICES,
coerce=int,
required=False,
widget=WeekdaysWidget,

View File

@ -15,6 +15,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db.migrations.operations.base import Operation
from django.db.models import Aggregate
class SumCardinality(Aggregate):
template = 'SUM(CARDINALITY(%(expressions)s))'
class EnsureJsonbType(Operation):

View File

@ -25,7 +25,11 @@ from chrono.agendas.models import (
EventCancellationReport,
ICSError,
MeetingType,
Person,
Resource,
SharedCustodyAgenda,
SharedCustodyPeriod,
SharedCustodyRule,
TimePeriod,
TimePeriodException,
TimePeriodExceptionSource,
@ -2630,3 +2634,220 @@ def test_recurring_events_create_past_recurrences(freezer):
)
daily_event.create_all_recurrences()
assert daily_event.recurrences.count() == 6
@pytest.mark.freeze_time('2022-02-22 14:00') # Tuesday of 8th week
def test_shared_custody_agenda():
father = Person.objects.create(user_external_id='father_id', name='John Doe')
mother = Person.objects.create(user_external_id='mother_id', name='Jane Doe')
agenda = SharedCustodyAgenda.objects.create(first_guardian=father, second_guardian=mother)
SharedCustodyRule.objects.create(agenda=agenda, days=list(range(7)), weeks='even', guardian=father)
SharedCustodyRule.objects.create(agenda=agenda, days=list(range(7)), weeks='odd', guardian=mother)
slots = agenda.get_custody_slots(now().date(), now().date() + datetime.timedelta(days=30))
assert [x.date for x in slots] == [now().date() + datetime.timedelta(days=i) for i in range(30)]
assert all(x.guardian == father for x in slots if x.date.isocalendar()[1] % 2 == 0)
assert all(x.guardian == mother for x in slots if x.date.isocalendar()[1] % 2 == 1)
# add mother custody period on father's week, on 23/02 and 24/02
SharedCustodyPeriod.objects.create(
agenda=agenda,
guardian=mother,
date_start=datetime.date(year=2022, month=2, day=23),
date_end=datetime.date(year=2022, month=2, day=25),
)
slots = agenda.get_custody_slots(now().date(), now().date() + datetime.timedelta(days=5))
slots = [(x.date.strftime('%d/%m'), x.guardian.name) for x in slots]
assert slots == [
('22/02', 'John Doe'),
('23/02', 'Jane Doe'),
('24/02', 'Jane Doe'),
('25/02', 'John Doe'),
('26/02', 'John Doe'),
]
# add father custody period on father's week, nothing should change
SharedCustodyPeriod.objects.create(
agenda=agenda,
guardian=father,
date_start=datetime.date(year=2022, month=2, day=25),
date_end=datetime.date(year=2022, month=3, day=3),
)
slots = agenda.get_custody_slots(now().date(), now().date() + datetime.timedelta(days=5))
slots = [(x.date.strftime('%d/%m'), x.guardian.name) for x in slots]
assert slots == [
('22/02', 'John Doe'),
('23/02', 'Jane Doe'),
('24/02', 'Jane Doe'),
('25/02', 'John Doe'),
('26/02', 'John Doe'),
]
@pytest.mark.freeze_time('2022-02-22 14:00') # Tuesday
def test_shared_custody_agenda_different_periodicity():
father = Person.objects.create(user_external_id='father_id', name='John Doe')
mother = Person.objects.create(user_external_id='mother_id', name='Jane Doe')
agenda = SharedCustodyAgenda.objects.create(first_guardian=father, second_guardian=mother)
SharedCustodyRule.objects.create(agenda=agenda, days=[1, 2, 3], guardian=father)
SharedCustodyRule.objects.create(agenda=agenda, days=[0, 4, 5, 6], guardian=mother)
slots = agenda.get_custody_slots(now().date(), now().date() + datetime.timedelta(days=14))
assert [(x.date.strftime('%A %d/%m'), x.guardian.name) for x in slots] == [
('Tuesday 22/02', 'John Doe'),
('Wednesday 23/02', 'John Doe'),
('Thursday 24/02', 'John Doe'),
('Friday 25/02', 'Jane Doe'),
('Saturday 26/02', 'Jane Doe'),
('Sunday 27/02', 'Jane Doe'),
('Monday 28/02', 'Jane Doe'),
('Tuesday 01/03', 'John Doe'),
('Wednesday 02/03', 'John Doe'),
('Thursday 03/03', 'John Doe'),
('Friday 04/03', 'Jane Doe'),
('Saturday 05/03', 'Jane Doe'),
('Sunday 06/03', 'Jane Doe'),
('Monday 07/03', 'Jane Doe'),
]
@pytest.mark.parametrize(
'rules,complete',
(
([], False),
([{'days': [0]}], False),
([{'days': [0], 'weeks': 'odd'}], False),
([{'days': list(range(7))}], True),
([{'days': list(range(7)), 'weeks': 'odd'}], False),
([{'days': list(range(7)), 'weeks': 'odd'}, {'days': list(range(7)), 'weeks': 'even'}], True),
([{'days': [0, 1, 2]}, {'days': [3, 4, 5, 6]}], True),
([{'days': [0, 1, 2]}, {'days': [3, 4, 5]}], False),
([{'days': [0, 1, 2, 3]}, {'days': [3, 4, 5, 6]}], False), # overlapping rules, should not exist
(
[
{'days': [0, 1, 2]},
{'days': [3, 4, 5]},
{'days': [6], 'weeks': 'odd'},
{'days': [6], 'weeks': 'even'},
],
True,
),
),
)
def test_shared_custody_agenda_is_complete(rules, complete):
father = Person.objects.create(user_external_id='father_id', name='John Doe')
mother = Person.objects.create(user_external_id='mother_id', name='Jane Doe')
agenda = SharedCustodyAgenda.objects.create(first_guardian=father, second_guardian=mother)
for i, rule in enumerate(rules):
guardian = father if i % 2 else mother
SharedCustodyRule.objects.create(agenda=agenda, guardian=guardian, **rule)
assert agenda.is_complete() is complete
@pytest.mark.parametrize(
'rules,days,weeks,overlaps',
(
([], [1], '', False),
([{'days': [0]}], [1], '', False),
([{'days': [1]}], [1], '', True),
([{'days': [0]}, {'days': [1]}], [1], '', True),
([{'days': [0], 'weeks': 'odd'}, {'days': [1]}], [1], '', True),
([{'days': [0]}, {'days': [1], 'weeks': 'odd'}], [1], '', True),
([{'days': [0]}, {'days': [1]}], [1], 'odd', True),
([{'days': [0]}, {'days': [1], 'weeks': 'odd'}], [1], 'even', False),
([{'days': [0, 1], 'weeks': 'odd'}], [1], 'odd', True),
([{'days': [0, 1]}], [1], '', True),
([{'days': [0, 1], 'weeks': 'even'}], [1], 'odd', False),
([{'days': [0, 1]}], [0, 3, 4], '', True),
([{'days': [0, 1]}], [2, 3], '', False),
),
)
def test_shared_custody_agenda_rule_overlaps(rules, days, weeks, overlaps):
father = Person.objects.create(user_external_id='father_id', name='John Doe')
mother = Person.objects.create(user_external_id='mother_id', name='Jane Doe')
agenda = SharedCustodyAgenda.objects.create(first_guardian=father, second_guardian=mother)
for i, rule in enumerate(rules):
guardian = father if i % 2 else mother
SharedCustodyRule.objects.create(agenda=agenda, guardian=guardian, **rule)
assert agenda.rule_overlaps(days, weeks) is overlaps
@pytest.mark.parametrize(
'periods,date_start,date_end,overlaps',
(
([], '2022-02-03', '2022-02-04', False),
([('2022-02-03', '2022-02-04')], '2022-02-03', '2022-02-04', True),
([('2022-02-03', '2022-02-04')], '2022-02-01', '2022-02-04', True),
([('2022-02-03', '2022-02-04')], '2022-02-03', '2022-02-06', True),
([('2022-02-03', '2022-02-04')], '2022-02-04', '2022-02-06', False),
([('2022-02-03', '2022-02-04')], '2022-02-01', '2022-02-03', False),
([('2022-02-03', '2022-02-04'), ('2022-01-31', '2022-02-01')], '2022-02-01', '2022-02-03', False),
([('2022-02-03', '2022-02-04'), ('2022-01-31', '2022-02-01')], '2022-01-01', '2022-02-10', True),
([('2022-02-03', '2022-02-10')], '2022-02-05', '2022-02-06', True),
),
)
def test_shared_custody_agenda_period_overlaps(periods, date_start, date_end, overlaps):
father = Person.objects.create(user_external_id='father_id', name='John Doe')
mother = Person.objects.create(user_external_id='mother_id', name='Jane Doe')
agenda = SharedCustodyAgenda.objects.create(first_guardian=father, second_guardian=mother)
for i, dates in enumerate(periods):
guardian = father if i % 2 else mother
SharedCustodyPeriod.objects.create(
agenda=agenda, guardian=guardian, date_start=dates[0], date_end=dates[1]
)
assert agenda.period_overlaps(date_start, date_end) is overlaps
def test_shared_custody_agenda_rule_label():
father = Person.objects.create(user_external_id='father_id', name='John Doe')
mother = Person.objects.create(user_external_id='mother_id', name='Jane Doe')
agenda = SharedCustodyAgenda.objects.create(first_guardian=father, second_guardian=mother)
rule = SharedCustodyRule.objects.create(agenda=agenda, guardian=father, days=list(range(7)))
assert rule.label == 'daily'
rule.days = [1, 2, 3, 4]
rule.save()
assert rule.label == 'from Tuesday to Friday'
rule.days = [4, 5, 6]
rule.save()
assert rule.label == 'from Friday to Sunday'
rule.days = [1, 4, 6]
rule.save()
assert rule.label == 'on Tuesdays, Fridays, Sundays'
rule.days = [0]
rule.weeks = 'even'
rule.save()
assert rule.label == 'on Mondays, on even weeks'
rule.weeks = 'odd'
rule.save()
assert rule.label == 'on Mondays, on odd weeks'
def test_shared_custody_agenda_period_label(freezer):
father = Person.objects.create(user_external_id='father_id', name='John Doe')
mother = Person.objects.create(user_external_id='mother_id', name='Jane Doe')
agenda = SharedCustodyAgenda.objects.create(first_guardian=father, second_guardian=mother)
period = SharedCustodyPeriod.objects.create(
agenda=agenda,
guardian=father,
date_start=datetime.date(2021, 7, 10),
date_end=datetime.date(2021, 7, 11),
)
assert str(period) == 'John Doe, 07/10/2021'
period.date_end = datetime.date(2021, 7, 13)
period.save()
assert str(period) == 'John Doe, 07/10/2021 → 07/13/2021'