Pointage plages libres #78081 #115

Merged
vdeniaud merged 2 commits from wip/78081-plages-libres-permettre-le-point into main 2023-07-18 10:03:36 +02:00
9 changed files with 265 additions and 20 deletions

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.18 on 2023-07-05 10:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0157_convert_week_days'),
]
operations = [
migrations.AddField(
model_name='booking',
name='user_check_end_time',
field=models.TimeField(null=True, verbose_name='Departure'),
),
migrations.AddField(
model_name='booking',
name='user_check_start_time',
field=models.TimeField(null=True, verbose_name='Arrival'),
),
]

View File

@ -2696,6 +2696,8 @@ class Booking(models.Model):
user_was_present = models.BooleanField(null=True)
user_check_type_slug = models.CharField(max_length=160, blank=True, null=True)
user_check_type_label = models.CharField(max_length=150, blank=True, null=True)
user_check_start_time = models.TimeField(_('Arrival'), null=True)
user_check_end_time = models.TimeField(_('Departure'), null=True)
out_of_min_delay = models.BooleanField(default=False)
extra_emails = ArrayField(models.EmailField(), default=list)

View File

@ -576,6 +576,54 @@ class BookingCheckPresenceForm(forms.Form):
]
class PartialBookingCheckForm(forms.ModelForm):
user_was_present = forms.NullBooleanField(
label=_('Status'),
widget=forms.RadioSelect(
choices=(
(None, _('Not checked')),
(True, _('Present')),
(False, _('Absent')),
)
),
required=False,
)
presence_check_type = forms.ChoiceField(label=_('Type'), required=False)
absence_check_type = forms.ChoiceField(label=_('Type'), required=False)
class Meta:
model = Booking
fields = ['user_check_start_time', 'user_check_end_time', 'user_was_present']
widgets = {
'user_check_start_time': widgets.TimeWidget(),
'user_check_end_time': widgets.TimeWidget(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.check_types = get_agenda_check_types(self.instance.event.agenda)
self.fields['presence_check_type'].choices = [(None, '---------')] + [
(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'presence'
]
self.fields['presence_check_type'].initial = self.instance.user_check_type_slug
self.fields['absence_check_type'].choices = [(None, '---------')] + [
(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'absence'
]
self.fields['absence_check_type'].initial = self.instance.user_check_type_slug
def clean(self):
if 'user_was_present' in self.cleaned_data:
kind = 'presence' if self.cleaned_data['user_was_present'] else 'absence'
self.check_type_slug = self.cleaned_data[f'{kind}_check_type']
self.check_type_label = dict(self.fields[f'{kind}_check_type'].choices).get(self.check_type_slug)
def save(self):
if 'user_was_present' in self.cleaned_data:
self.instance.user_check_type_slug = self.check_type_slug
self.instance.user_check_type_label = self.check_type_label
return super().save()
class EventsTimesheetForm(forms.Form):
date_start = forms.DateField(
label=_('Start date'),

View File

@ -676,7 +676,6 @@ div#appbar a.active {
padding: .33rem 0;
}
&--bar {
--background: #1066bc;
--color: white;
box-sizing: border-box;
margin: 0.33rem 0;
@ -684,6 +683,15 @@ div#appbar a.active {
padding: 0.33em 0.66em;
background-color: var(--background);
color: var(--color);
&.booking {
--background: #1066bc;
}
&.check.present {
--background: hsl(120, 57%, 35%);
}
&.check.absent {
--background: hsl(355, 80%, 45%);
}
}
}
}

View File

@ -0,0 +1,43 @@
{% extends "chrono/manager_agenda_view.html" %}
{% load i18n gadjo %}
{% block breadcrumb %}
{{ block.super }}
<a href="">{% trans "Check booking" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Check booking" %}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{{ agenda.get_absolute_url }}">{% trans 'Cancel' %}</a>
</div>
<script>
$(function () {
presence_check_type_select = $('.widget[id=id_presence_check_type_p]');
absence_check_type_select = $('.widget[id=id_absence_check_type_p]');
$('input[type=radio][name=user_was_present]').change(function() {
if (!this.checked)
return;
if (this.value == 'True') {
presence_check_type_select.show();
absence_check_type_select.hide();
} else if (this.value == 'False') {
absence_check_type_select.show();
presence_check_type_select.hide();
} else {
presence_check_type_select.hide();
absence_check_type_select.hide();
}
}).change();
});
</script>
</form>
{% endblock %}

View File

@ -16,24 +16,42 @@
</div>
<div class="partial-booking--registrant-items">
{% for user, bookings in bookings_by_user.items %}
{% for booking in bookings %}
<section class="partial-booking--registrant">
<h3 class="registrant--name">{{ bookings.0.get_user_block }}</h3>
<h3 class="registrant--name">
<a
rel="popup"
href="{% url 'chrono-manager-partial-booking-check' pk=agenda.pk booking_pk=booking.pk %}"
>{{ booking.get_user_block }}</a>
</h3>
<div class="registrant--datas">
{% for booking in bookings %}
<div class="registrant--bar-container">
<p
class="registrant--bar booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<time datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
</p>
</div>
{% if booking.user_was_present is not None %}
<div class="registrant--bar-container">
<p
class="registrant--bar booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
class="registrant--bar check {{ booking.check_css_class }}"
title="{% trans "Checked period:" %}"
style="left: {{ booking.check_css_left }}%; width: {{ booking.check_css_width }}%;"
>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<strong class="sr-only">{% trans "Checked period:" %}</strong>
<time datetime="{{ booking.user_check_start_time|time:"H:i" }}">{{ booking.user_check_start_time|time:"H:i" }}</time>
<time datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
<time datetime="{{ booking.user_check_end_time|time:"H:i" }}">{{ booking.user_check_end_time|time:"H:i" }}</time>
{% if booking.user_check_type_label %}<span>{{ booking.user_check_type_label }}</span>{% endif %}
</p>
</div>
{% endfor %}
{% endif %}
</div>
</section>

View File

@ -429,6 +429,11 @@ urlpatterns = [
views.booking_extra_user_block,
name='chrono-manager-booking-extra-user-block',
),
path(
'agendas/<int:pk>/bookings/<int:booking_pk>/check',
views.partial_booking_check_view,
name='chrono-manager-partial-booking-check',
),
path(
'agendas/<int:pk>/subscriptions/<int:subscription_pk>/extra-user-block',
views.subscription_extra_user_block,

View File

@ -124,6 +124,7 @@ from .forms import (
NewEventForm,
NewMeetingTypeForm,
NewTimePeriodExceptionForm,
PartialBookingCheckForm,
SharedCustodyHolidayRuleForm,
SharedCustodyPeriodForm,
SharedCustodyRuleForm,
@ -1518,13 +1519,17 @@ class AgendaDayView(AgendaDateView, DayArchiveView):
round(100 * ((t1.hour - t2.hour) * 60 + t1.minute - t2.minute) / opening_range_minutes, 2)
)
bookings_by_user = collections.defaultdict(list)
for booking in event.booking_set.all():
context['bookings'] = list(event.booking_set.all())
for booking in context['bookings']:
booking.css_left = get_time_ratio(booking.start_time, start_time)
booking.css_width = get_time_ratio(booking.end_time, booking.start_time)
bookings_by_user[booking.user_external_id].append(booking)
context['bookings_by_user'] = dict(bookings_by_user)
if booking.user_was_present is not None:
booking.check_css_class = 'present' if booking.user_was_present else 'absent'
booking.check_css_left = get_time_ratio(booking.user_check_start_time, start_time)
booking.check_css_width = get_time_ratio(
booking.user_check_end_time, booking.user_check_start_time
)
agenda_day_view = AgendaDayView.as_view()
@ -4349,6 +4354,23 @@ class SharedCustodySettingsView(UpdateView):
shared_custody_settings = SharedCustodySettingsView.as_view()
class PartialBookingCheckView(ViewableAgendaMixin, UpdateView):
template_name = 'chrono/manager_partial_booking_form.html'
model = Booking
pk_url_kwarg = 'booking_pk'
form_class = PartialBookingCheckForm
def get_success_url(self):
date = self.object.event.start_datetime
return reverse(
'chrono-manager-agenda-day-view',
kwargs={'pk': self.agenda.pk, 'year': date.year, 'month': date.month, 'day': date.day},
)
partial_booking_check_view = PartialBookingCheckView.as_view()
def menu_json(request):
if not request.user.is_staff:
homepage_view = HomepageView(request=request)

View File

@ -1,8 +1,10 @@
import datetime
from unittest import mock
import pytest
from chrono.agendas.models import Agenda, Booking, Event
from chrono.utils.lingo import CheckType
from chrono.utils.timezone import make_aware
from tests.utils import login
@ -89,8 +91,8 @@ def test_manager_partial_bookings_day_view(app, admin_user, freezer):
event=event,
)
Booking.objects.create(
user_external_id='yyy',
user_first_name='Jon',
user_external_id='zzz',
user_first_name='Bruce',
user_last_name='Doe',
start_time=datetime.time(12, 00),
end_time=datetime.time(14, 00),
@ -110,9 +112,10 @@ def test_manager_partial_bookings_day_view(app, admin_user, freezer):
range(7, 20)
)
assert len(resp.pyquery('.partial-booking--registrant')) == 2
assert resp.pyquery('.registrant--name')[0].text == 'Jane Doe'
assert resp.pyquery('.registrant--name')[1].text == 'Jon Doe'
assert len(resp.pyquery('.partial-booking--registrant')) == 3
assert resp.pyquery('.registrant--name a')[0].text == 'Jane Doe'
assert resp.pyquery('.registrant--name a')[1].text == 'Jon Doe'
assert resp.pyquery('.registrant--name a')[2].text == 'Bruce Doe'
assert resp.pyquery('.registrant--bar')[0].findall('time')[0].text == '11:00'
assert resp.pyquery('.registrant--bar')[0].findall('time')[1].text == '13:30'
@ -143,3 +146,77 @@ def test_manager_partial_bookings_day_view_24_hours(app, admin_user, freezer):
assert [int(x.text.replace('\u202fh', '')) for x in resp.pyquery('.partial-booking--hour')] == list(
range(0, 24)
)
@mock.patch('chrono.manager.forms.get_agenda_check_types')
def test_manager_partial_bookings_check(check_types, app, admin_user):
check_types.return_value = [
CheckType(slug='foo-reason', label='Foo reason', kind='absence'),
CheckType(slug='bar-reason', label='Bar reason', kind='presence'),
CheckType(slug='baz-reason', label='Baz reason', kind='presence'),
]
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
Booking.objects.create(
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
start_time=datetime.time(11, 00),
end_time=datetime.time(13, 30),
event=event,
)
app = login(app)
today = start_datetime.date()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert len(resp.pyquery('.registrant--bar')) == 1
assert resp.pyquery('.registrant--bar time')[0].text == '11:00'
assert resp.pyquery('.registrant--bar time')[1].text == '13:30'
assert resp.pyquery('.registrant--bar')[0].attrib['style'] == 'left: 30.77%; width: 19.23%;'
resp = resp.click('Jane Doe')
assert resp.form['presence_check_type'].options == [
('', True, '---------'),
('bar-reason', False, 'Bar reason'),
('baz-reason', False, 'Baz reason'),
]
assert resp.form['absence_check_type'].options == [
('', True, '---------'),
('foo-reason', False, 'Foo reason'),
]
resp.form['user_check_start_time'] = '11:00'
resp.form['user_check_end_time'] = '13:15'
resp.form['user_was_present'] = 'True'
resp.form['presence_check_type'] = 'bar-reason'
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 2
assert len(resp.pyquery('.registrant--bar.booking')) == 1
assert len(resp.pyquery('.registrant--bar.check.present')) == 1
assert resp.pyquery('.registrant--bar.check time')[0].text == '11:00'
assert resp.pyquery('.registrant--bar.check time')[1].text == '13:15'
assert resp.pyquery('.registrant--bar.check')[0].attrib['style'] == 'left: 30.77%; width: 17.31%;'
assert 'Bar reason' in resp.text
resp = resp.click('Jane Doe')
assert resp.form['presence_check_type'].value == 'bar-reason'
resp.form['user_was_present'] = 'False'
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 2
assert len(resp.pyquery('.registrant--bar.booking')) == 1
assert len(resp.pyquery('.registrant--bar.check.absent')) == 1
assert 'Bar reason' not in resp.text
resp = resp.click('Jane Doe')
resp.form['user_was_present'] = ''
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 1
assert len(resp.pyquery('.registrant--bar.booking')) == 1
assert 'Bar reason' not in resp.text