From c51fa2bb8b64f3061581f34466bca8de62df3d2d Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 10 Nov 2020 14:58:09 +0100 Subject: [PATCH] manager: differentiate bookings with colors (#39794) --- .../migrations/0070_auto_20201202_1834.py | 47 +++++++ chrono/agendas/models.py | 22 +++ chrono/api/views.py | 9 +- chrono/manager/static/css/style.scss | 131 ++++++++++++++---- .../chrono/booking_color_legend.html | 8 ++ .../chrono/manager_agenda_day_view.html | 7 +- .../manager_meetings_agenda_month_view.html | 7 +- chrono/manager/views.py | 4 + tests/test_api.py | 45 ++++++ tests/test_manager.py | 77 +++++++++- 10 files changed, 323 insertions(+), 34 deletions(-) create mode 100644 chrono/agendas/migrations/0070_auto_20201202_1834.py create mode 100644 chrono/manager/templates/chrono/booking_color_legend.html diff --git a/chrono/agendas/migrations/0070_auto_20201202_1834.py b/chrono/agendas/migrations/0070_auto_20201202_1834.py new file mode 100644 index 0000000..df66558 --- /dev/null +++ b/chrono/agendas/migrations/0070_auto_20201202_1834.py @@ -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')]),), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 4821ffc..a5a6feb 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -1021,6 +1021,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) @@ -1045,6 +1066,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(): diff --git a/chrono/api/views.py b/chrono/api/views.py index d134495..67b865e 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -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 @@ -739,6 +739,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): @@ -856,6 +857,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. @@ -914,6 +916,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': @@ -1058,6 +1064,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 diff --git a/chrono/manager/static/css/style.scss b/chrono/manager/static/css/style.scss index 7f6a8ad..336e6de 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -107,8 +107,16 @@ a.timeperiod-exception-all { margin-top: 0; } + +table.agenda-table { + border-spacing: 1vw 0; + table-layout: fixed; + background-color: hsla(0, 0%, 0%, 0.06); + 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.06); @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,88 @@ 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, + hsl(100, 20%, 97%) 20%, + hsla(100, 30%, 99%, 0.7) 40%, + hsla(100, 40%, 94%, 0.7) 60%, + hsl(100, 55%, 93%) 100%) + fixed; left: 0.5ex; width: calc(100% - 1ex); } + &.opening-hours { + border-left: 0.5em solid white; + } &.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: #5382CF; + padding: 1ex; + background: + linear-gradient( + 110deg, + hsla(0, 0%, 100%, 0.85) 0%, + hsla(0, 0%, 100%, 0.65) 100%) + currentColor + fixed; + width: calc(100% - 1ex); + border-color: currentColor; + border-left: .5em solid 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 +358,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; + } +} diff --git a/chrono/manager/templates/chrono/booking_color_legend.html b/chrono/manager/templates/chrono/booking_color_legend.html new file mode 100644 index 0000000..5d188e3 --- /dev/null +++ b/chrono/manager/templates/chrono/booking_color_legend.html @@ -0,0 +1,8 @@ +{% load i18n %} + +
+{% trans "Booking colors:" %} +{% for color in colors %} +{{ color }} +{% endfor %} +
diff --git a/chrono/manager/templates/chrono/manager_agenda_day_view.html b/chrono/manager/templates/chrono/manager_agenda_day_view.html index 70ea67d..50e35da 100644 --- a/chrono/manager/templates/chrono/manager_agenda_day_view.html +++ b/chrono/manager/templates/chrono/manager_agenda_day_view.html @@ -68,11 +68,12 @@ {% endif %} {% for booking in desk_info.bookings %} -
{{booking.event.start_datetime|date:"TIME_FORMAT"}} {{ booking.meetings_display }} {% trans "Cancel" %} + {% if booking.color %}{{ booking.color }}{% endif %}
{% endfor %} @@ -90,4 +91,8 @@ {% endfor %} +{% if booking_colors %} +{% include "chrono/booking_color_legend.html" with colors=booking_colors %} +{% endif %} + {% endblock %} diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html index 5647985..bc53264 100644 --- a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html +++ b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html @@ -34,11 +34,12 @@ {% endfor %} {% for slot in day.infos.booked_slots %} -
+
{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}} {{ slot.booking.meetings_display }} {% trans "Cancel" %} {% if not single_desk %}{{ slot.desk }}{% endif %} + {% if slot.booking.color %}{{ slot.booking.color }}{% endif %}
{% endfor %} {% endif %} @@ -58,4 +59,8 @@
{% endfor %} +{% if booking_colors %} +{% include "chrono/booking_color_legend.html" with colors=booking_colors %} +{% endif %} + {% endblock %} diff --git a/chrono/manager/views.py b/chrono/manager/views.py index e146209..c2b5d98 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -67,6 +67,7 @@ from chrono.agendas.models import ( AgendaNotificationsSettings, AgendaReminderSettings, UnavailabilityCalendar, + BookingColor, ) from .forms import ( @@ -871,6 +872,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 diff --git a/tests/test_api.py b/tests/test_api.py index b486cae..0f41d69 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -23,6 +23,7 @@ from chrono.agendas.models import ( TimePeriodException, UnavailabilityCalendar, VirtualMember, + BookingColor, ) import chrono.api.views @@ -1261,6 +1262,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() diff --git a/tests/test_manager.py b/tests/test_manager.py index 64ab7f4..8f8b017 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -2994,7 +2994,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) == 14 + assert len(ctx.captured_queries) == 15 # 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% @@ -3303,7 +3303,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%;', @@ -3934,7 +3934,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) == 15 + assert len(ctx.captured_queries) == 16 # 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% @@ -4032,7 +4032,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%;', @@ -5252,3 +5252,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 -- 2.20.1