manager: differentiate bookings with colors (#39794)

This commit is contained in:
Valentin Deniaud 2020-11-10 14:58:09 +01:00
parent b33972c356
commit 8409186543
10 changed files with 321 additions and 34 deletions

View File

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-02 17:34
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('agendas', '0069_translate_holidays'),
]
operations = [
migrations.CreateModel(
name='BookingColor',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('label', models.CharField(max_length=250, verbose_name='Label')),
('index', models.PositiveSmallIntegerField()),
(
'agenda',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='booking_colors',
to='agendas.Agenda',
),
),
],
options={'ordering': ('pk',),},
),
migrations.AddField(
model_name='booking',
name='color',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='bookings',
to='agendas.BookingColor',
),
),
migrations.AlterUniqueTogether(name='bookingcolor', unique_together=set([('agenda', 'label')]),),
]

View File

@ -1016,6 +1016,27 @@ class Event(models.Model):
self.save()
class BookingColor(models.Model):
COLOR_COUNT = 8
agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE, related_name='booking_colors')
label = models.CharField(_('Label'), max_length=250)
index = models.PositiveSmallIntegerField()
class Meta:
unique_together = ('agenda', 'label')
ordering = ('pk',)
def save(self, *args, **kwargs):
if self.index is None:
last_color = BookingColor.objects.filter(agenda=self.agenda).last() or BookingColor(index=-1)
self.index = (last_color.index + 1) % self.COLOR_COUNT
super().save(*args, **kwargs)
def __str__(self):
return '%s' % self.label
class Booking(models.Model):
event = models.ForeignKey(Event, on_delete=models.CASCADE)
extra_data = JSONField(null=True)
@ -1040,6 +1061,7 @@ class Booking(models.Model):
form_url = models.URLField(blank=True)
backoffice_url = models.URLField(blank=True)
cancel_callback_url = models.URLField(blank=True)
color = models.ForeignKey(BookingColor, null=True, on_delete=models.SET_NULL, related_name='bookings')
def save(self, *args, **kwargs):
with transaction.atomic():

View File

@ -37,7 +37,7 @@ from rest_framework import permissions, serializers, status
from rest_framework.views import APIView
from chrono.api.utils import Response
from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodException, Desk
from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodException, Desk, BookingColor
from ..interval import IntervalSet
@ -738,6 +738,7 @@ class SlotSerializer(serializers.Serializer):
count = serializers.IntegerField(min_value=1)
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
force_waiting_list = serializers.BooleanField(default=False)
use_color_for = serializers.CharField(max_length=250, allow_blank=True)
class StringOrListField(serializers.ListField):
@ -855,6 +856,7 @@ class Fillslots(APIView):
extra_data[k] = v
available_desk = None
color = None
if agenda.accept_meetings():
# slots are actually timeslot ids (meeting_type:start_datetime), not events ids.
@ -913,6 +915,10 @@ class Fillslots(APIView):
for slot in all_free_slots:
datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
color_label = payload.get('use_color_for')
if color_label:
color = BookingColor.objects.get_or_create(agenda=agenda, label=color_label)[0]
available_desk = None
if agenda.kind == 'virtual':
@ -1057,6 +1063,7 @@ class Fillslots(APIView):
cancel_callback_url=payload.get('cancel_callback_url', ''),
user_display_label=payload.get('user_display_label', ''),
extra_data=extra_data,
color=color,
)
if primary_booking is not None:
new_booking.primary_booking = primary_booking

View File

@ -107,8 +107,16 @@ a.timeperiod-exception-all {
margin-top: 0;
}
table.agenda-table {
border-spacing: 1vw 0;
table-layout: fixed;
background-color: white;
padding: 0.5em 0;
padding-bottom: 2em;
}
.agenda-table thead th {
width: 14vw;
padding-bottom: 1ex;
font-weight: normal;
}
@ -126,10 +134,8 @@ a.timeperiod-exception-all {
box-sizing: border-box;
padding: 1.2ex 2ex;
vertical-align: top;
width: 8ex;
font-weight: normal;
&.hour {
width: 5%;
text-align: left;
}
a {
@ -137,7 +143,6 @@ a.timeperiod-exception-all {
border: 0;
}
&.weekday {
width: 12.5%;
padding-top: 3rem;
&.today {
font-weight: bold;
@ -149,17 +154,22 @@ a.timeperiod-exception-all {
// don't add extra padding on top row
padding-top: 1ex;
}
// hour cells width
.agenda-table thead tr:first-child td:first-child,
.agenda-table tbody tr:first-child th:not(.weekday) {
width: 5em;
}
.agenda-table tbody tr.odd th.hour,
.agenda-table tbody tr.odd td {
background: #f0f0f0;
background: hsla(0, 0%, 0%, 0.04);
@media print {
border-top: 1px solid #aaa;
}
}
.agenda-table tbody tr.odd td.other-month {
background: #f8f8f8;
background: transparent;
}
@ -170,15 +180,6 @@ a.timeperiod-exception-all {
border: 0;
}
.agenda-table.month-view {
border-spacing: 0;
}
.agenda-table.month-view tbody td {
border: 5px solid white;
border-width: 0 5px;
}
@for $i from 1 through 60 {
table.hourspan-#{$i} tbody td {
height: calc(#{$i} * 2.5em);
@ -187,42 +188,86 @@ a.timeperiod-exception-all {
.agenda-table tbody td div {
box-sizing: border-box;
padding: 1ex;
position: absolute;
overflow: hidden;
&.opening-hours, &.exception-hours {
z-index: 1;
background: #b1ea4d linear-gradient(135deg, #b1ea4d 0%, #459522 100%);
opacity: 0.6;
background:
linear-gradient(
135deg,
hsla(65, 65%, 94%, 0.4) 20%,
hsla(65, 55%, 92%, 0.7) 70%,
hsl(65, 50%, 90%) 90%) fixed;
left: 0.5ex;
width: calc(100% - 1ex);
}
&.opening-hours {
border-left: 0.5em solid hsl(57, 65%, 85%);
}
&.exception-hours {
background: #fee linear-gradient(135deg, #fee 0%, #fdd 100%);
background:
repeating-linear-gradient(
135deg,
hsla(10, 10%, 75%, 0.7) 0,
hsla(10, 10%, 80%, 0.55) 10px,
transparent 11px,
transparent 20px);
text-align: center;
}
&.booking {
background: #eef linear-gradient(135deg, #eef 0%, #ddf 100%);
box-shadow: 0 0 1px 0 #2d2dad;
width: calc(100% - 2ex);
border: 1px solid #aaa;
left: 0.5ex;
color: hsl(210, 84%, 40%);
padding: 1ex;
background:
linear-gradient(
110deg,
hsla(0, 0%, 100%, 0.85) 0%,
hsla(0, 0%, 100%, 0.65) 100%)
currentColor;
width: calc(100% - 1ex);
border-left: .5em solid;
border-bottom: 1px solid;
border-color: currentColor;
z-index: 2;
&:hover {
z-index: 3;
height: auto !important;
}
> * {
color: hsla(0, 0%, 0%, 0.6);
}
a {
// color: currentColor;
color: hsla(0, 0%, 0%, 0.7);
border-bottom-color: inherit;
&:hover {
color: black;
}
}
}
}
.monthview tbody td div.booking {
padding: 0;
transition: width 100ms ease-in, left 100ms ease-in, color 200ms ease-in;
box-shadow: 0 0 0 0 #888;
transition:
width 100ms ease-in,
left 100ms ease-in,
color 200ms ease-in,
box-shadow 200ms ease-in,
padding 100ms ease-in;
text-indent: -9999px;
&:not(:hover) {
padding-top: 0;
padding-bottom: 0;
}
&:hover {
text-indent: 0;
color: inherit;
left: 0% !important;
width: 100% !important
width: 100% !important;
box-shadow: 0 0 1em 0 #888;
}
span.desk {
display: block;
@ -311,3 +356,33 @@ div.booking a.cancel {
p.email-subject {
text-align: center;
}
// booking colors
$booking-colors: (
0: hsl(30, 100%, 46%),
1: hsl(120, 57%, 35%),
2: hsl(270, 40%, 50%),
3: hsl(355, 80%, 45%),
4: hsl(10, 70%, 30%),
5: hsl(60, 98%, 30%),
6: hsl(150, 57%, 25%),
7: hsl(320, 70%, 60%)
);
.booking-colors {
margin-top: 1.5rem;
}
.booking-color-label {
padding: .33em .66em;
border-radius: 0.33em;
color: hsla(0, 0%, 100%, 0.9) !important;
font-weight: bold;
font-size: .65em;
}
@each $index, $color in $booking-colors {
.agenda-table tbody td div.booking-color-#{$index} {
color: $color;
}
.booking-bg-color-#{$index} {
background-color: $color;
}
}

View File

@ -0,0 +1,8 @@
{% load i18n %}
<div class="booking-colors">
<strong>{% trans "Booking colors:" %}</strong>
{% for color in colors %}
<span class="booking-color-label booking-bg-color-{{ color.index }}">{{ color }}</span>
{% endfor %}
</div>

View File

@ -68,11 +68,12 @@
{% endif %}
{% for booking in desk_info.bookings %}
<div class="booking"
<div class="booking{% if booking.color %} booking-color-{{ booking.color.index }}{% endif %}"
style="height: {{ booking.css_height }}%; min-height: {{ booking.css_height }}%; top: {{ booking.css_top }}%;"
><span class="start-time">{{booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
<a {% if booking.backoffice_url %}href="{{booking.backoffice_url}}"{% endif %}>{{ booking.meetings_display }}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=booking.event.agenda_id booking_pk=booking.pk %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% if booking.color %}<span class="booking-color-label booking-bg-color-{{ booking.color.index }}">{{ booking.color }}</span>{% endif %}
</div>
{% endfor %}
</td>
@ -90,4 +91,8 @@
</div>
{% endfor %}
{% if booking_colors %}
{% include "chrono/booking_color_legend.html" with colors=booking_colors %}
{% endif %}
{% endblock %}

View File

@ -34,11 +34,12 @@
{% endfor %}
{% for slot in day.infos.booked_slots %}
<div class="booking" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%">
<div class="booking{% if slot.booking.color %} booking-color-{{ slot.booking.color.index }}{% endif %}" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;">
<span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
<a {% if slot.booking.backoffice_url %}href="{{slot.booking.backoffice_url}}"{% endif %}>{{ slot.booking.meetings_display }}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=slot.booking.event.agenda_id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
{% if slot.booking.color %}<span class="booking-color-label booking-bg-color-{{ slot.booking.color.index }}">{{ slot.booking.color }}</span>{% endif %}
</div>
{% endfor %}
{% endif %}
@ -58,4 +59,8 @@
</div>
{% endfor %}
{% if booking_colors %}
{% include "chrono/booking_color_legend.html" with colors=booking_colors %}
{% endif %}
{% endblock %}

View File

@ -70,6 +70,7 @@ from chrono.agendas.models import (
AgendaNotificationsSettings,
AgendaReminderSettings,
UnavailabilityCalendar,
BookingColor,
)
from .forms import (
@ -883,6 +884,9 @@ class AgendaDateView(DateMixin, ViewableAgendaMixin):
context['hour_span'] = max(60 // self.agenda.get_base_meeting_duration(), 1)
except ValueError: # no meeting types defined
context['hour_span'] = 1
context['booking_colors'] = BookingColor.objects.filter(
agenda=self.agenda, bookings__event__in=self.object_list
).distinct()
context['user_can_manage'] = self.agenda.can_be_managed(self.request.user)
return context

View File

@ -23,6 +23,7 @@ from chrono.agendas.models import (
TimePeriodException,
UnavailabilityCalendar,
VirtualMember,
BookingColor,
)
import chrono.api.views
@ -1265,6 +1266,50 @@ def test_booking_api_meeting(app, meetings_agenda, user):
assert Booking.objects.count() == 2
def test_booking_api_meeting_colors(app, user):
agenda = Agenda.objects.create(
label='foo', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=56
)
meeting_type = MeetingType.objects.create(agenda=agenda, label='Blah', duration=30)
default_desk = Desk.objects.create(agenda=agenda, label='Desk 1')
time_period = TimePeriod.objects.create(
weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=default_desk
)
datetimes_resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
event_id = datetimes_resp.json['data'][2]['id']
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post(
'/api/agenda/%s/fillslot/%s/' % (agenda.pk, event_id), params={'use_color_for': 'Cooking',},
)
booking = Booking.objects.get(id=resp.json['booking_id'])
assert booking.color.label == 'Cooking'
assert booking.color.index == 0
event_id = datetimes_resp.json['data'][3]['id']
resp = app.post_json(
'/api/agenda/%s/fillslot/%s/' % (agenda.pk, event_id), params={'use_color_for': 'Cooking',},
)
new_booking = Booking.objects.get(id=resp.json['booking_id'])
assert new_booking.color.index == 0
event_id = datetimes_resp.json['data'][4]['id']
resp = app.post_json(
'/api/agenda/%s/fillslot/%s/' % (agenda.pk, event_id), params={'use_color_for': 'Swimming',},
)
new_booking = Booking.objects.get(id=resp.json['booking_id'])
assert new_booking.color.label == 'Swimming'
assert new_booking.color.index == 1
for i in range((BookingColor.COLOR_COUNT * 2) - 2):
event_id = datetimes_resp.json['data'][i]['id']
resp = app.post_json(
'/api/agenda/%s/fillslot/%s/' % (agenda.pk, event_id), params={'use_color_for': str(i),},
)
assert BookingColor.objects.count() == BookingColor.COLOR_COUNT * 2
assert BookingColor.objects.distinct('index').order_by().count() == BookingColor.COLOR_COUNT
def test_booking_api_meeting_with_resources(app, user):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
tomorrow_str = tomorrow.isoformat()

View File

@ -2996,7 +2996,7 @@ def test_agenda_day_view(app, admin_user, manager_user, api_user):
resp = app.get(
'/manage/agendas/%s/%d/%d/%d/' % (agenda.id, date.year, date.month, date.day), status=200
)
assert len(ctx.captured_queries) == 12
assert len(ctx.captured_queries) == 13
# day is displaying rows from 10am to 6pm,
# opening hours, 10am to 1pm gives top: 300%
# rest of the day, 1pm to 6(+1)pm gives 600%
@ -3305,7 +3305,7 @@ def test_agenda_month_view(app, admin_user, manager_user, api_user):
)
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
assert len(ctx.captured_queries) == 9
assert len(ctx.captured_queries) == 10
assert resp.pyquery.find('.exception-hours')[0].attrib == {
'class': 'exception-hours',
'style': 'height:800.0%;top:0.0%;width:48.0%;left:50.0%;',
@ -3957,7 +3957,7 @@ def test_virtual_agenda_day_view(app, admin_user, manager_user):
resp = app.get(
'/manage/agendas/%s/%d/%d/%d/' % (agenda.id, date.year, date.month, date.day), status=200
)
assert len(ctx.captured_queries) == 13
assert len(ctx.captured_queries) == 14
# day is displaying rows from 10am to 6pm,
# opening hours, 10am to 1pm gives top: 300%
# rest of the day, 1pm to 6(+1)pm gives 600%
@ -4055,7 +4055,7 @@ def test_virtual_agenda_month_view(app, admin_user):
)
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
assert len(ctx.captured_queries) == 10
assert len(ctx.captured_queries) == 11
assert resp.pyquery.find('.exception-hours')[0].attrib == {
'class': 'exception-hours',
'style': 'height:800.0%;top:0.0%;width:48.0%;left:1.0%;',
@ -5281,3 +5281,72 @@ def test_manager_agenda_booking_delays(app, admin_user):
assert '42 days' in resp.text
agenda.refresh_from_db()
assert agenda.maximal_booking_delay == 42
@pytest.mark.parametrize(
'view',
(
'/manage/agendas/%(agenda)s/%(year)d/%(month)d/%(day)d/',
'/manage/agendas/%(agenda)s/%(year)d/%(month)d/',
),
)
def test_agenda_booking_colors(app, admin_user, api_user, view):
agenda = Agenda.objects.create(label='New Example', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='New Desk')
meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30)
today = datetime.date.today()
timeperiod = TimePeriod.objects.create(
desk=desk, weekday=today.weekday(), start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
)
app.authorization = ('Basic', ('john.doe', 'password'))
datetimes_resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug))
booking_url = datetimes_resp.json['data'][0]['api']['fillslot_url']
# book first slot without colors
resp = app.post(booking_url)
date = Booking.objects.all()[0].event.start_datetime
app.reset()
login(app)
url = view % {'agenda': agenda.id, 'year': date.year, 'month': date.month, 'day': date.day}
resp = app.get(url)
assert len(resp.pyquery.find('div.booking')) == 1
assert 'booking-color-' not in resp.text
assert 'Booking colors:' not in resp.text
app.reset()
app.authorization = ('Basic', ('john.doe', 'password'))
booking_url2 = datetimes_resp.json['data'][1]['api']['fillslot_url']
booking_url3 = datetimes_resp.json['data'][2]['api']['fillslot_url']
resp = app.post_json(booking_url2, params={'use_color_for': 'Cooking'})
resp = app.post_json(booking_url3, params={'use_color_for': 'Cooking'})
booking = Booking.objects.get(pk=resp.json['booking_id'])
app.reset()
login(app)
resp = app.get(url)
assert len(resp.pyquery.find('div.booking')) == 3
assert len(resp.pyquery.find('div.booking.booking-color-%s' % booking.color.index)) == 2
assert 'Booking colors:' in resp.text
assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 1
assert resp.text.count('Cooking') == 3 # 2 bookings + legend
app.reset()
app.authorization = ('Basic', ('john.doe', 'password'))
booking_url4 = datetimes_resp.json['data'][3]['api']['fillslot_url']
resp = app.post_json(booking_url4, params={'use_color_for': 'Swimming'})
new_booking = Booking.objects.get(pk=resp.json['booking_id'])
app.reset()
login(app)
resp = app.get(url)
assert len(resp.pyquery.find('div.booking')) == 4
assert len(resp.pyquery.find('div.booking.booking-color-%s' % booking.color.index)) == 2
assert len(resp.pyquery.find('div.booking.booking-color-%s' % new_booking.color.index)) == 1
assert resp.text.count('Swimming') == 2 # 1 booking + legend
assert 'Booking colors:' in resp.text
assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 2