plages libres, ajouter une vue pour visualiser le remplissage (#78083) #185

Merged
vdeniaud merged 2 commits from wip/78083-plages-libres-ajouter-une-vue-po into main 2023-12-18 10:05:50 +01:00
4 changed files with 203 additions and 3 deletions

View File

@ -659,6 +659,85 @@ div#main-content.partial-booking-dayview {
visibility: hidden;
}
}
&--occupation-rate-list {
position: static;
display: grid;
grid-template-rows: 40px auto;
align-items: end;
margin-top: 0.33rem;
margin-bottom: 1rem;
border-top: 3px solid var(--zebra-color);
grid-template-columns: repeat(var(--nb-hours), 1fr);
@media (min-width: 761px) {
grid-template-columns: var(--registrant-name-width) repeat(var(--nb-hours), 1fr);
}
}
.occupation-rate-list--title {
margin: 0;
font-size: 1rem;
font-weight: normal;
justify-self: end;
align-self: end;
padding: .66rem;
padding-bottom: 0;
@media (max-width: 760px) {
grid-column: 1/-1;
grid-row: 2/3;
}
}
.occupation-rate {
@function linear-progress($from, $to) {
$ratio: #{($to - $from) / 100};
@return "calc(#{$ratio} * var(--rate-percent) + #{$from})";
}
--hue: #{linear-progress(40, 10)};
--saturation: #{linear-progress(50%, 75%)};
--luminosity: #{linear-progress(65%, 50%)};
background-color: hsl(var(--hue), var(--saturation), var(--luminosity));
height: calc(1% * var(--rate-percent));
margin: 0;
opacity: 80%;
position: relative;
&--info {
display: block;
position: absolute;
z-index: 5;
padding: .33em .66em;
text-align: center;
background-color: var(--font-color);
color: white;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: .5em;
font-weight: bold;
filter: drop-shadow(0 0 3px white);
&::before {
content: "";
display: block;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: .5em solid transparent;
border-bottom-color: var(--font-color);
}
}
&:not(:hover) .occupation-rate--info {
display: none;
}
&:hover {
opacity: 100%;
z-index: 4;
}
&.overbooked {
--hue: 0;
--saturation: 95%;
--luminosity: 40%;
}
}
&--registrant-items {
margin-top: 0.5rem;
position: relative;

View File

@ -72,11 +72,28 @@
</script>
</div>
{% endif %}
<div class="partial-booking--hours-list" aria-hidden="true">
<div class="partial-booking--hours-list">
{% for hour in hours %}
<div class="partial-booking--hour">{{ hour|time:"H" }}&#x202Fh</div>
{% endfor %}
</div>
<div class="partial-booking--occupation-rate-list">
<h3 class="occupation-rate-list--title">{% trans "Occupation rate" %}</h3>
{% for rate in occupation_rates %}
<p
class="occupation-rate {% if rate.overbooked %}overbooked{% endif %}"
style="--rate-percent: {{ rate.height_percent }};"
aria-label="{% blocktrans trimmed with start=rate.start_time|time:"H:i" end=rate.end_time|time:"H:i" %}
From {{ start }} to {{ end }}:
{% endblocktrans %}
{{ rate.percent }}% ({{ rate.booked_places }}/{{ event.places }})"
>
<span class="occupation-rate--info">
{{ rate.percent }}% <br> ({{ rate.booked_places }}/{{ event.places }})
</span>
</p>
{% endfor %}
</div>
<div class="partial-booking--registrant-items">
{% for user in users %}

View File

@ -29,9 +29,10 @@ from dateutil.relativedelta import MO, relativedelta
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction
from django.db.models import BooleanField, Count, Max, Min, Q, Value
from django.db import IntegrityError, models, transaction
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Func, Max, Min, Q, Value
from django.db.models.deletion import ProtectedError
from django.db.models.functions import Cast
from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template.defaultfilters import title
@ -1485,6 +1486,7 @@ class EventChecksMixin:
context['filterset'] = booked_filterset
context['results'] = results
context['waiting'] = waiting_qs
context['booked'] = booked_qs
class AgendaDayView(EventChecksMixin, AgendaDateView, DayArchiveView):
@ -1710,6 +1712,41 @@ class AgendaDayView(EventChecksMixin, AgendaDateView, DayArchiveView):
context['users'] = users_info.values()
booked_qs = (
context['booked']
.annotate(
computed_end_time=ExpressionWrapper(
# trick to exclude upper bound from postgres generate_series function,
# because a booking ending at 10:00 should not count for 10:00 - 11:00 slot
F('end_time') - datetime.timedelta(minutes=1),
output_field=models.TimeField(),
),
start_hour=Cast('start_time__hour', models.IntegerField()),
end_hour=Cast('computed_end_time__hour', models.IntegerField()),
hour=Func(F('start_hour'), F('end_hour'), function='generate_series'),
)
.order_by()
)
booking_counts_qs = booked_qs.values('hour').annotate(count=Count('id'))
booking_counts_by_hour = {x['hour']: x['count'] for x in booking_counts_qs}
context['occupation_rates'] = []
for time in context['hours']:
booked_places = booking_counts_by_hour.get(time.hour, 0)
ratio = booked_places / event.places
percent = int(ratio * 100)
context['occupation_rates'].append(
{
'booked_places': booked_places,
'percent': percent,
'height_percent': min(percent, 100),
'overbooked': bool(ratio > 1),
'start_time': time,
'end_time': (datetime.datetime.combine(now(), time) + datetime.timedelta(hours=1)).time(),
}
)
agenda_day_view = AgendaDayView.as_view()

View File

@ -1181,3 +1181,70 @@ def test_manager_partial_bookings_month_view(app, admin_user, freezer):
assert [int(x.text) for x in resp.pyquery('thead th a')] == list(range(1, 31))
assert [x.text for x in resp.pyquery('tbody tr th')] == ['Subscription Next Month']
assert len(resp.pyquery('tbody tr td')) == 30
def test_manager_partial_bookings_occupation_rates(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 10, 0))
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(17, 00), places=3, agenda=agenda
)
Booking.objects.create(
user_external_id='1',
user_first_name='User',
user_last_name='10h - 17h',
start_time=datetime.time(10, 00),
end_time=datetime.time(17, 00),
event=event,
)
Booking.objects.create(
user_external_id='2',
user_first_name='User',
user_last_name='10h10 - 12h30',
start_time=datetime.time(10, 10),
end_time=datetime.time(12, 30),
event=event,
)
Booking.objects.create(
user_external_id='3',
user_first_name='User',
user_last_name='11h30 - 15h45',
start_time=datetime.time(11, 30),
end_time=datetime.time(15, 45),
event=event,
)
Booking.objects.create(
user_external_id='4',
user_first_name='User',
user_last_name='11h15 - 12h10',
start_time=datetime.time(11, 15),
end_time=datetime.time(12, 10),
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('.partial-booking--registrant')) == 4
hours = [x.text.replace('\u202f', ' ') for x in resp.pyquery('.partial-booking--hour')]
labels = [resp.pyquery(x).text() for x in resp.pyquery('.occupation-rate--info')]
styles = [x.attrib['style'] for x in resp.pyquery('.occupation-rate')]
overbooked = [
' overbooked' if 'overbooked' in x.attrib['class'] else '' for x in resp.pyquery('.occupation-rate')
]
assert ['%s: %s / %s%s' % x for x in zip(hours, labels, styles, overbooked)] == [
'09 h: 0%\n(0/3) / --rate-percent: 0;',
'10 h: 66%\n(2/3) / --rate-percent: 66;',
'11 h: 133%\n(4/3) / --rate-percent: 100; overbooked',
'12 h: 133%\n(4/3) / --rate-percent: 100; overbooked',
'13 h: 66%\n(2/3) / --rate-percent: 66;',
'14 h: 66%\n(2/3) / --rate-percent: 66;',
'15 h: 66%\n(2/3) / --rate-percent: 66;',
'16 h: 33%\n(1/3) / --rate-percent: 33;',
'17 h: 0%\n(0/3) / --rate-percent: 0;',
'18 h: 0%\n(0/3) / --rate-percent: 0;',
]