From add46908185b3831837ca22a708b42df921e92b7 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_20201110_1456.py | 49 ++++++++++++++ chrono/agendas/models.py | 20 ++++++ chrono/api/views.py | 9 ++- chrono/manager/static/css/style.scss | 9 +++ .../chrono/booking_color_legend.html | 8 +++ .../chrono/manager_agenda_day_view.html | 6 +- .../manager_meetings_agenda_month_view.html | 6 +- tests/test_api.py | 30 +++++++++ tests/test_manager.py | 67 +++++++++++++++++++ 9 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 chrono/agendas/migrations/0070_auto_20201110_1456.py create mode 100644 chrono/manager/templates/chrono/booking_color_legend.html diff --git a/chrono/agendas/migrations/0070_auto_20201110_1456.py b/chrono/agendas/migrations/0070_auto_20201110_1456.py new file mode 100644 index 0000000..5b2e5ab --- /dev/null +++ b/chrono/agendas/migrations/0070_auto_20201110_1456.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-11-10 13:56 +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')), + ( + 'code', + models.CharField(blank=True, default='', max_length=6, verbose_name='Color code (hex)'), + ), + ( + 'agenda', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='booking_colors', + to='agendas.Agenda', + ), + ), + ], + ), + 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 0488203..5cc8193 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -1016,6 +1016,25 @@ class Event(models.Model): self.save() +class BookingColor(models.Model): + agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE, related_name='booking_colors') + label = models.CharField(_('Label'), max_length=250) + code = models.CharField(_('Color code (hex)'), max_length=6, blank=True, default='') + + palette = ('f1b37d', 'a3dbe1', 'cecf2e', 'c0c0c0', 'dfb7b7', 'b5cfb5', 'b8aac5', 'f2dfc3') + + class Meta: + unique_together = ('agenda', 'label') + + def save(self, *args, **kwargs): + if not self.code: + already_used = BookingColor.objects.filter(agenda=self.agenda).values_list('code', flat=True) + available = set(self.palette) - set(already_used) + if available: + self.code = next(iter(available)) + super().save(*args, **kwargs) + + class Booking(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE) extra_data = JSONField(null=True) @@ -1040,6 +1059,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 cde824d..7abbf38 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 @@ -694,6 +694,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): @@ -811,6 +812,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. @@ -862,6 +864,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) + available_desk = None if agenda.kind == 'virtual': @@ -1006,6 +1012,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..58327a7 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -211,6 +211,10 @@ a.timeperiod-exception-all { z-index: 3; height: auto !important; } + a { + color: #003199; + border-bottom-color: #003199; + } } } @@ -311,3 +315,8 @@ div.booking a.cancel { p.email-subject { text-align: center; } + +span.color-legend-label { + padding: 0.2em; + border-radius: 0.5em; +} 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..c09df95 --- /dev/null +++ b/chrono/manager/templates/chrono/booking_color_legend.html @@ -0,0 +1,8 @@ +{% load i18n %} + +
+{% trans "Booking colors:" %} +{% for color in agenda.booking_colors.all %} +{{ color.label }} +{% 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..86f6ae5 100644 --- a/chrono/manager/templates/chrono/manager_agenda_day_view.html +++ b/chrono/manager/templates/chrono/manager_agenda_day_view.html @@ -69,7 +69,7 @@ {% for booking in desk_info.bookings %}
{{booking.event.start_datetime|date:"TIME_FORMAT"}} {{ booking.meetings_display }} {% trans "Cancel" %} @@ -90,4 +90,8 @@
{% endfor %} +{% if agenda.booking_colors.exists %} +{% include "chrono/booking_color_legend.html" %} +{% 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..05e8b3e 100644 --- a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html +++ b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html @@ -34,7 +34,7 @@ {% endfor %} {% for slot in day.infos.booked_slots %} -
+
{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}} {{ slot.booking.meetings_display }} {% trans "Cancel" %} @@ -58,4 +58,8 @@
{% endfor %} +{% if agenda.booking_colors.exists %} +{% include "chrono/booking_color_legend.html" %} +{% endif %} + {% endblock %} diff --git a/tests/test_api.py b/tests/test_api.py index 8f3e956..6fd0f4c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1263,6 +1263,36 @@ def test_booking_api_meeting(app, meetings_agenda, user): assert Booking.objects.count() == 2 +def test_booking_api_meeting_colors(app, meetings_agenda, user): + agenda_id = meetings_agenda.slug + meeting_type = MeetingType.objects.get(agenda=meetings_agenda) + 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_id, event_id), params={'use_color_for': 'Cooking',}, + ) + booking = Booking.objects.get(id=resp.json['booking_id']) + assert booking.color.label == 'Cooking' + assert booking.color.code != '' + + event_id = datetimes_resp.json['data'][3]['id'] + resp = app.post_json( + '/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'use_color_for': 'Cooking',}, + ) + new_booking = Booking.objects.get(id=resp.json['booking_id']) + assert new_booking.color == booking.color + + event_id = datetimes_resp.json['data'][4]['id'] + resp = app.post_json( + '/api/agenda/%s/fillslot/%s/' % (agenda_id, 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 != booking.color + + 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 059fd91..e04eba7 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -5027,3 +5027,70 @@ def test_unavailability_calendar_delete_unavailability_permissions(app, manager_ unavailability_calendar.edit_role = group unavailability_calendar.save() app.get(url) + + +@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 'background' not in resp.pyquery.find('div.booking')[0].attrib + 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('span.color-legend-label')) == 1 + assert 'background:#%s' % booking.color.code in resp.pyquery.find('div.booking')[1].attrib['style'] + assert 'background:#%s' % booking.color.code in resp.pyquery.find('div.booking')[2].attrib['style'] + assert 'Booking colors:' in resp.text + + 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('span.color-legend-label')) == 2 + assert 'background:#%s' % new_booking.color.code in resp.pyquery.find('div.booking')[3].attrib['style'] + assert new_booking.color.code != booking.color.code -- 2.20.1