plages libres, ajouter une vue pour visualiser le remplissage (#78083) #185
|
@ -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;
|
||||
|
|
|
@ -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" }} h</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 %}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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;',
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue