manager: add shared custody views (#62146)

This commit is contained in:
Valentin Deniaud 2022-02-22 15:59:27 +01:00
parent 0fd2e6a51a
commit c0679178ba
7 changed files with 550 additions and 0 deletions

View File

@ -46,7 +46,10 @@ from chrono.agendas.models import (
Desk,
Event,
MeetingType,
Person,
Resource,
SharedCustodyPeriod,
SharedCustodyRule,
Subscription,
TimePeriod,
TimePeriodException,
@ -1217,3 +1220,67 @@ class AgendasExportForm(forms.Form):
)
absence_reason_groups = forms.BooleanField(label=_('Absence reason groups'), required=False, initial=True)
categories = forms.BooleanField(label=_('Categories'), required=False, initial=True)
class SharedCustodyRuleForm(forms.ModelForm):
guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none())
days = forms.TypedMultipleChoiceField(
choices=WEEKDAY_CHOICES,
coerce=int,
required=False,
widget=WeekdaysWidget,
label=_('Days'),
)
class Meta:
model = SharedCustodyRule
fields = ['guardian', 'days', 'weeks']
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]
)
def clean(self):
cleaned_data = super().clean()
if self.instance.agenda.rule_overlaps(
days=cleaned_data['days'], weeks=cleaned_data['weeks'], instance=self.instance
):
raise ValidationError(_('Rule overlaps existing rules.'))
return cleaned_data
class SharedCustodyPeriodForm(forms.ModelForm):
guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none())
class Meta:
model = SharedCustodyPeriod
fields = ['guardian', 'date_start', 'date_end']
widgets = {
'date_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'date_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
}
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]
)
def clean(self):
cleaned_data = super().clean()
if self.instance.agenda.period_overlaps(
date_start=cleaned_data['date_start'], date_end=cleaned_data['date_end'], instance=self.instance
):
raise ValidationError(_('Period overlaps existing periods.'))
if cleaned_data['date_end'] <= cleaned_data['date_start']:
self.add_error('date_end', _('End date must be greater than start date.'))
return cleaned_data

View File

@ -270,6 +270,21 @@ table.agenda-table {
}
}
.agenda-table tbody tr td.guardian {
vertical-align: middle;
background-image:
linear-gradient(
110deg,
hsla(0, 0%, 100%, 0.85) 0%,
hsla(0, 0%, 100%, 0.65) 100%);
&.first-guardian {
background-color: hsl(30, 100%, 46%);
}
&.second-guardian {
background-color: hsl(120, 57%, 35%);
}
}
.monthview tbody td div.booking {
text-indent: -9999px;
&:not(:hover) {

View File

@ -0,0 +1,35 @@
{% extends "chrono/manager_agenda_month_view.html" %}
{% load i18n %}
{% block content %}
{% if agenda.is_complete %}
{% for week, slots in slots_by_week.items %}
{% if forloop.first %}
<table class="agenda-table month-view single-desk">
<tbody>
{% endif %}
<tr>
<th></th>
{% for slot in slots %}
<th class="weekday {% if slot.date == today %}today{% endif %}"><span>{{ slot.date|date:"l j" }}</span></th>
{% endfor %}
</tr>
<tr>
<th>{% trans "Week" %} {{ week }}</th>
{% for slot in slots %}
<td class="guardian {% if slot.guardian == agenda.first_guardian %}first-guardian{% else %}second-guardian{% endif %}">{{ slot }}</td>
{% endfor %}
</tr>
{% if forloop.last %}
</tbody>
</table>
{% endif %}
{% endfor %}
{% else %}
<div class="warningnotice">
<p>{% trans "Configuraton is not completed yet." %}</p>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends "chrono/manager_agenda_view.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href=".">{% trans "Settings" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Settings" %}</h2>
<span class="actions">
<a rel="popup" href="{% url 'chrono-manager-shared-custody-agenda-add-period' pk=object.id %}">{% trans 'Add custody period' %}</a>
<a rel="popup" href="{% url 'chrono-manager-shared-custody-agenda-add-rule' pk=object.id %}">{% trans 'Add custody rule' %}</a>
</span>
{% endblock %}
{% block content %}
{% if not agenda.is_complete %}
<div class="warningnotice">
<p>{% trans "Custody rules are not complete." %}</p>
</div>
{% endif %}
<div class="section">
<h3>{% trans "Custody rules" %}</h3>
<div>
{% if agenda.rules.all %}
<ul class="objects-list single-links">
{% for rule in agenda.rules.all %}
<li><a rel="popup" href="{% url 'chrono-manager-shared-custody-agenda-edit-rule' pk=agenda.pk rule_pk=rule.pk %}">
<span class="rule-info">
{{ rule.guardian.name }}, {{ rule.label }}
</span>
</a>
<a rel="popup" class="delete" href="{% url 'chrono-manager-shared-custody-agenda-delete-rule' pk=agenda.pk rule_pk=rule.pk %}?next=settings">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>
{% else %}
<div class="big-msg-info">
{% blocktrans %}
This agenda doesn't have any custody rules yet.
{% endblocktrans %}
</div>
{% endif %}
</div>
</div>
<div class="section">
<h3>{% trans "Exceptional custody periods" %}</h3>
<div>
{% if agenda.periods.all %}
<ul class="objects-list single-links">
{% for period in agenda.periods.all %}
<li>
<a rel="popup" href="{% url 'chrono-manager-shared-custody-agenda-edit-period' pk=agenda.pk period_pk=period.pk %}">
{{ period }}
<a rel="popup" class="delete" href="{% url 'chrono-manager-shared-custody-agenda-delete-period' pk=agenda.pk period_pk=period.pk %}?next=settings">{% trans "remove" %}</a>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<div class="big-msg-info">
{% blocktrans %}
This agenda doesn't have any custody period. They can be used to specify explicit moments when one of the guardian should have custody, regardless of global rules.
{% endblocktrans %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -376,5 +376,50 @@ urlpatterns = [
views.agenda_import_events_sample_csv,
name='chrono-manager-sample-events-csv',
),
url(
r'^shared-custody/(?P<pk>\d+)/$',
views.shared_custody_agenda_view,
name='chrono-manager-shared-custody-agenda-view',
),
url(
r'^shared-custody/(?P<pk>\d+)/(?P<year>[0-9]{4})/(?P<month>[0-9]+)/$',
views.shared_custody_agenda_monthly_view,
name='chrono-manager-shared-custody-agenda-month-view',
),
url(
r'^shared-custody/(?P<pk>\d+)/settings/$',
views.shared_custody_agenda_settings,
name='chrono-manager-shared-custody-agenda-settings',
),
url(
r'^shared-custody/(?P<pk>\d+)/add-rule$',
views.shared_custody_agenda_add_rule,
name='chrono-manager-shared-custody-agenda-add-rule',
),
url(
r'^shared-custody/(?P<pk>\d+)/rules/(?P<rule_pk>\d+)/edit$',
views.shared_custody_agenda_edit_rule,
name='chrono-manager-shared-custody-agenda-edit-rule',
),
url(
r'^shared-custody/(?P<pk>\d+)/rules/(?P<rule_pk>\d+)/delete$',
views.shared_custody_agenda_delete_rule,
name='chrono-manager-shared-custody-agenda-delete-rule',
),
url(
r'^shared-custody/(?P<pk>\d+)/add-period$',
views.shared_custody_agenda_add_period,
name='chrono-manager-shared-custody-agenda-add-period',
),
url(
r'^shared-custody/(?P<pk>\d+)/periods/(?P<period_pk>\d+)/edit$',
views.shared_custody_agenda_edit_period,
name='chrono-manager-shared-custody-agenda-edit-period',
),
url(
r'^shared-custody/(?P<pk>\d+)/periods/(?P<period_pk>\d+)/delete$',
views.shared_custody_agenda_delete_period,
name='chrono-manager-shared-custody-agenda-delete-period',
),
url(r'^menu.json$', views.menu_json),
]

View File

@ -25,6 +25,7 @@ import uuid
from operator import attrgetter
import requests
from dateutil.relativedelta import MO, relativedelta
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction
@ -54,6 +55,7 @@ from django.views.generic import (
UpdateView,
View,
)
from django.views.generic.dates import MonthMixin, YearMixin
from weasyprint import HTML
from chrono.agendas.models import (
@ -72,6 +74,9 @@ from chrono.agendas.models import (
ICSError,
MeetingType,
Resource,
SharedCustodyAgenda,
SharedCustodyPeriod,
SharedCustodyRule,
TimePeriod,
TimePeriodException,
TimePeriodExceptionSource,
@ -106,6 +111,8 @@ from .forms import (
NewEventForm,
NewMeetingTypeForm,
NewTimePeriodExceptionForm,
SharedCustodyPeriodForm,
SharedCustodyRuleForm,
SubscriptionCheckFilterSet,
TimePeriodAddForm,
TimePeriodExceptionForm,
@ -3342,6 +3349,161 @@ class UnavailabilityCalendarAddUnavailabilityView(ManagedUnavailabilityCalendarM
unavailability_calendar_add_unavailability = UnavailabilityCalendarAddUnavailabilityView.as_view()
class SharedCustodyAgendaMixin:
agenda = None
def set_agenda(self, **kwargs):
self.agenda = get_object_or_404(SharedCustodyAgenda, id=kwargs.get('pk'))
def dispatch(self, request, *args, **kwargs):
self.set_agenda(**kwargs)
if not self.check_permissions(request.user):
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def check_permissions(self, user):
return user.is_staff
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['agenda'] = self.agenda
context['user_can_manage'] = True
return context
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
if not kwargs.get('instance'):
kwargs['instance'] = self.model()
kwargs['instance'].agenda = self.agenda
return kwargs
def get_success_url(self):
return reverse('chrono-manager-shared-custody-agenda-settings', kwargs={'pk': self.agenda.id})
class SharedCustodyAgendaView(SharedCustodyAgendaMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
return reverse(
'chrono-manager-shared-custody-agenda-month-view',
kwargs={'pk': self.agenda.pk, 'year': now().year, 'month': now().month},
)
shared_custody_agenda_view = SharedCustodyAgendaView.as_view()
class SharedCustodyAgendaMonthView(SharedCustodyAgendaMixin, YearMixin, MonthMixin, DateMixin, DetailView):
template_name = 'chrono/manager_shared_custody_agenda_month_view.html'
model = SharedCustodyAgenda
def dispatch(self, request, *args, **kwargs):
try:
self.date = datetime.date(year=int(self.get_year()), month=int(self.get_month()), day=1)
except ValueError:
raise Http404('invalid date')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['today'] = localtime().date()
first_monday_this_month = self.date - datetime.timedelta(days=self.date.weekday())
first_monday_next_month = self.date + relativedelta(months=1, day=1, weekday=MO(1))
slots = self.object.get_custody_slots(first_monday_this_month, first_monday_next_month)
slots_by_week = collections.defaultdict(list)
for slot in slots:
slots_by_week[slot.date.isocalendar()[1]].append(slot)
context['slots_by_week'] = dict(slots_by_week)
return context
def get_previous_month_url(self):
previous_month = self.date - relativedelta(months=1)
return reverse(
'chrono-manager-shared-custody-agenda-month-view',
kwargs={'pk': self.object.id, 'year': previous_month.year, 'month': previous_month.month},
)
def get_next_month_url(self):
next_month = self.date + relativedelta(months=1)
return reverse(
'chrono-manager-shared-custody-agenda-month-view',
kwargs={'pk': self.object.id, 'year': next_month.year, 'month': next_month.month},
)
shared_custody_agenda_monthly_view = SharedCustodyAgendaMonthView.as_view()
class SharedCustodyAgendaSettings(SharedCustodyAgendaMixin, DetailView):
template_name = 'chrono/manager_shared_custody_agenda_settings.html'
model = SharedCustodyAgenda
shared_custody_agenda_settings = SharedCustodyAgendaSettings.as_view()
class SharedCustodyAgendaAddRuleView(SharedCustodyAgendaMixin, CreateView):
title = _('Add custody rule')
template_name = 'chrono/manager_agenda_form.html'
form_class = SharedCustodyRuleForm
model = SharedCustodyRule
shared_custody_agenda_add_rule = SharedCustodyAgendaAddRuleView.as_view()
class SharedCustodyAgendaEditRuleView(SharedCustodyAgendaMixin, UpdateView):
title = _('Edit custody rule')
template_name = 'chrono/manager_agenda_form.html'
form_class = SharedCustodyRuleForm
model = SharedCustodyRule
pk_url_kwarg = 'rule_pk'
shared_custody_agenda_edit_rule = SharedCustodyAgendaEditRuleView.as_view()
class SharedCustodyAgendaDeleteRuleView(SharedCustodyAgendaMixin, DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = SharedCustodyRule
pk_url_kwarg = 'rule_pk'
shared_custody_agenda_delete_rule = SharedCustodyAgendaDeleteRuleView.as_view()
class SharedCustodyAgendaAddPeriodView(SharedCustodyAgendaMixin, CreateView):
title = _('Add custody period')
template_name = 'chrono/manager_agenda_form.html'
form_class = SharedCustodyPeriodForm
model = SharedCustodyPeriod
shared_custody_agenda_add_period = SharedCustodyAgendaAddPeriodView.as_view()
class SharedCustodyAgendaEditPeriodView(SharedCustodyAgendaMixin, UpdateView):
title = _('Edit custody period')
template_name = 'chrono/manager_agenda_form.html'
form_class = SharedCustodyPeriodForm
model = SharedCustodyPeriod
pk_url_kwarg = 'period_pk'
shared_custody_agenda_edit_period = SharedCustodyAgendaEditPeriodView.as_view()
class SharedCustodyAgendaDeletePeriodView(SharedCustodyAgendaMixin, DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = SharedCustodyPeriod
pk_url_kwarg = 'period_pk'
shared_custody_agenda_delete_period = SharedCustodyAgendaDeletePeriodView.as_view()
def menu_json(request):
if not request.user.is_staff:
homepage_view = HomepageView(request=request)

View File

@ -0,0 +1,155 @@
import datetime
import pytest
from chrono.agendas.models import Person, SharedCustodyAgenda, SharedCustodyPeriod, SharedCustodyRule
from tests.utils import login
pytestmark = pytest.mark.django_db
@pytest.mark.freeze_time('2022-02-22 14:00') # Tuesday
def test_shared_custody_agenda_settings_rules(app, admin_user):
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)
app = login(app)
resp = app.get('/manage/shared-custody/%s/' % agenda.pk).follow()
resp = resp.click('Settings')
assert 'Custody agenda of John Doe and Jane Doe' in resp.text
assert 'Custody rules are not complete.' in resp.text
assert 'This agenda doesn\'t have any custody rules yet.' in resp.text
resp = resp.click('Add custody rule')
resp.form['guardian'] = father.pk
resp.form['days'] = list(range(7))
resp.form['weeks'] = 'even'
resp = resp.form.submit().follow()
assert 'Custody rules are not complete.' in resp.text
assert 'John Doe, daily, on even weeks' in resp.text
resp = resp.click('Add custody rule')
resp.form['guardian'] = mother.pk
resp.form['days'] = list(range(7))
resp.form['weeks'] = 'odd'
resp = resp.form.submit().follow()
assert 'Custody rules are not complete.' not in resp.text
assert 'John Doe, daily, on even weeks' in resp.text
assert 'Jane Doe, daily, on odd weeks' in resp.text
resp = resp.click('John Doe, daily, on even weeks')
resp.form['days'] = list(range(6))
resp = resp.form.submit().follow()
assert 'Custody rules are not complete.' in resp.text
resp = resp.click('John Doe, from Monday to Saturday, on even weeks')
resp.form['days'] = [0]
resp.form['weeks'] = 'odd'
resp = resp.form.submit()
assert 'Rule overlaps existing rules.' in resp.text
resp.form['weeks'] = 'even'
resp = resp.form.submit().follow()
resp = resp.click('remove', index=1)
resp = resp.form.submit().follow()
assert SharedCustodyRule.objects.count() == 1
@pytest.mark.freeze_time('2022-02-22 14:00') # Tuesday
def test_shared_custody_agenda_settings_periods(app, admin_user):
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)
app = login(app)
resp = app.get('/manage/shared-custody/%s/settings/' % agenda.pk)
assert 'This agenda doesn\'t have any custody period.' in resp.text
resp = resp.click('Add custody period')
resp.form['guardian'] = father.pk
resp.form['date_start'] = '2022-03-01'
resp.form['date_end'] = '2022-03-03'
resp = resp.form.submit().follow()
assert 'This agenda doesn\'t have any custody period.' not in resp.text
assert 'John Doe, 03/01/2022 → 03/03/2022' in resp.text
resp = resp.click('John Doe, 03/01/2022 → 03/03/2022')
resp.form['guardian'] = mother.pk
resp = resp.form.submit().follow()
assert 'Jane Doe, 03/01/2022 → 03/03/2022' in resp.text
resp = resp.click('Add custody period')
resp.form['guardian'] = mother.pk
resp.form['date_start'] = '2022-03-05'
resp.form['date_end'] = '2022-03-03'
resp = resp.form.submit()
assert 'End date must be greater than start date.' in resp.text
resp.form['date_start'] = '2022-03-02'
resp.form['date_end'] = '2022-03-06'
resp = resp.form.submit()
assert 'Period overlaps existing periods.' in resp.text
resp = app.get('/manage/shared-custody/%s/settings/' % agenda.pk)
resp = resp.click('remove', href='delete')
resp = resp.form.submit().follow()
assert not SharedCustodyPeriod.objects.exists()
@pytest.mark.freeze_time('2022-02-22 14:00') # Tuesday
def test_shared_custody_agenda_month_view(app, admin_user):
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, guardian=father, days=list(range(7)), weeks='even')
app = login(app)
resp = app.get('/manage/shared-custody/%s/' % agenda.pk).follow()
assert 'Custody agenda of John Doe and Jane Doe' in resp.text
assert 'February 2022' in resp.text
assert 'Configuraton is not completed yet.' in resp.text
SharedCustodyRule.objects.create(agenda=agenda, guardian=mother, days=list(range(7)), weeks='odd')
resp = app.get('/manage/shared-custody/%s/' % agenda.pk).follow()
assert 'Configuraton is not completed yet.' not in resp.text
assert all('Week %s' % i in resp.text for i in range(5, 10))
assert resp.pyquery('tbody tr th.today span').text() == 'Tuesday 22'
days = [x.text for x in resp.pyquery('tbody tr th span')]
assert len(days) == 7 * 5
assert days[:3] == ['Monday 31', 'Tuesday 1', 'Wednesday 2']
assert days[-3:] == ['Friday 4', 'Saturday 5', 'Sunday 6']
tds = [x.text for x in resp.pyquery('tbody tr td')]
for week_number, i in zip(range(5, 10), range(0, 29, 7)):
guardian = 'Jane Doe' if week_number % 2 else 'John Doe'
assert tds[i : i + 7] == [guardian] * 7
SharedCustodyPeriod.objects.create(
agenda=agenda,
guardian=father,
date_start=datetime.date(2022, 2, 1),
date_end=datetime.date(2022, 2, 3),
)
resp = app.get('/manage/shared-custody/%s/' % agenda.pk).follow()
tds = [x.text for x in resp.pyquery('tbody tr td')]
assert tds[:7] == ['Jane Doe', 'John Doe', 'John Doe', 'Jane Doe', 'Jane Doe', 'Jane Doe', 'Jane Doe']
old_resp = resp
resp = resp.click('')
assert 'March 2022' in resp.text
assert 'today' not in resp.text
assert all('Week %s' % i in resp.text for i in range(9, 14))
days = [x.text for x in resp.pyquery('tbody tr th span')]
assert len(days) == 7 * 5
assert days[:3] == ['Monday 28', 'Tuesday 1', 'Wednesday 2']
assert days[-3:] == ['Friday 1', 'Saturday 2', 'Sunday 3']
resp = resp.click('')
assert resp.text == old_resp.text
app.get('/manage/shared-custody/%s/42/42/' % agenda.pk, status=404)