0001-manager-differentiate-bookings-with-colors-39794.patch
chrono/agendas/migrations/0070_auto_20201110_1456.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-11-10 13:56 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations, models |
|
6 |
import django.db.models.deletion |
|
7 | ||
8 | ||
9 |
class Migration(migrations.Migration): |
|
10 | ||
11 |
dependencies = [ |
|
12 |
('agendas', '0069_translate_holidays'), |
|
13 |
] |
|
14 | ||
15 |
operations = [ |
|
16 |
migrations.CreateModel( |
|
17 |
name='BookingColor', |
|
18 |
fields=[ |
|
19 |
( |
|
20 |
'id', |
|
21 |
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), |
|
22 |
), |
|
23 |
('label', models.CharField(max_length=250, verbose_name='Label')), |
|
24 |
( |
|
25 |
'code', |
|
26 |
models.CharField(blank=True, default='', max_length=6, verbose_name='Color code (hex)'), |
|
27 |
), |
|
28 |
( |
|
29 |
'agenda', |
|
30 |
models.ForeignKey( |
|
31 |
on_delete=django.db.models.deletion.CASCADE, |
|
32 |
related_name='booking_colors', |
|
33 |
to='agendas.Agenda', |
|
34 |
), |
|
35 |
), |
|
36 |
], |
|
37 |
), |
|
38 |
migrations.AddField( |
|
39 |
model_name='booking', |
|
40 |
name='color', |
|
41 |
field=models.ForeignKey( |
|
42 |
null=True, |
|
43 |
on_delete=django.db.models.deletion.SET_NULL, |
|
44 |
related_name='bookings', |
|
45 |
to='agendas.BookingColor', |
|
46 |
), |
|
47 |
), |
|
48 |
migrations.AlterUniqueTogether(name='bookingcolor', unique_together=set([('agenda', 'label')]),), |
|
49 |
] |
chrono/agendas/models.py | ||
---|---|---|
1016 | 1016 |
self.save() |
1017 | 1017 | |
1018 | 1018 | |
1019 |
class BookingColor(models.Model): |
|
1020 |
agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE, related_name='booking_colors') |
|
1021 |
label = models.CharField(_('Label'), max_length=250) |
|
1022 |
code = models.CharField(_('Color code (hex)'), max_length=6, blank=True, default='') |
|
1023 | ||
1024 |
palette = ('f1b37d', 'a3dbe1', 'cecf2e', 'c0c0c0', 'dfb7b7', 'b5cfb5', 'b8aac5', 'f2dfc3') |
|
1025 | ||
1026 |
class Meta: |
|
1027 |
unique_together = ('agenda', 'label') |
|
1028 | ||
1029 |
def save(self, *args, **kwargs): |
|
1030 |
if not self.code: |
|
1031 |
already_used = BookingColor.objects.filter(agenda=self.agenda).values_list('code', flat=True) |
|
1032 |
available = set(self.palette) - set(already_used) |
|
1033 |
if available: |
|
1034 |
self.code = next(iter(available)) |
|
1035 |
super().save(*args, **kwargs) |
|
1036 | ||
1037 | ||
1019 | 1038 |
class Booking(models.Model): |
1020 | 1039 |
event = models.ForeignKey(Event, on_delete=models.CASCADE) |
1021 | 1040 |
extra_data = JSONField(null=True) |
... | ... | |
1040 | 1059 |
form_url = models.URLField(blank=True) |
1041 | 1060 |
backoffice_url = models.URLField(blank=True) |
1042 | 1061 |
cancel_callback_url = models.URLField(blank=True) |
1062 |
color = models.ForeignKey(BookingColor, null=True, on_delete=models.SET_NULL, related_name='bookings') |
|
1043 | 1063 | |
1044 | 1064 |
def save(self, *args, **kwargs): |
1045 | 1065 |
with transaction.atomic(): |
chrono/api/views.py | ||
---|---|---|
37 | 37 |
from rest_framework.views import APIView |
38 | 38 | |
39 | 39 |
from chrono.api.utils import Response |
40 |
from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodException, Desk |
|
40 |
from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodException, Desk, BookingColor
|
|
41 | 41 |
from ..interval import IntervalSet |
42 | 42 | |
43 | 43 | |
... | ... | |
694 | 694 |
count = serializers.IntegerField(min_value=1) |
695 | 695 |
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True) |
696 | 696 |
force_waiting_list = serializers.BooleanField(default=False) |
697 |
use_color_for = serializers.CharField(max_length=250, allow_blank=True) |
|
697 | 698 | |
698 | 699 | |
699 | 700 |
class StringOrListField(serializers.ListField): |
... | ... | |
811 | 812 |
extra_data[k] = v |
812 | 813 | |
813 | 814 |
available_desk = None |
815 |
color = None |
|
814 | 816 | |
815 | 817 |
if agenda.accept_meetings(): |
816 | 818 |
# slots are actually timeslot ids (meeting_type:start_datetime), not events ids. |
... | ... | |
862 | 864 |
for slot in all_free_slots: |
863 | 865 |
datetimes_by_desk[slot.desk.id].add(slot.start_datetime) |
864 | 866 | |
867 |
color_label = payload.get('use_color_for') |
|
868 |
if color_label: |
|
869 |
color, _ = BookingColor.objects.get_or_create(agenda=agenda, label=color_label) |
|
870 | ||
865 | 871 |
available_desk = None |
866 | 872 | |
867 | 873 |
if agenda.kind == 'virtual': |
... | ... | |
1006 | 1012 |
cancel_callback_url=payload.get('cancel_callback_url', ''), |
1007 | 1013 |
user_display_label=payload.get('user_display_label', ''), |
1008 | 1014 |
extra_data=extra_data, |
1015 |
color=color, |
|
1009 | 1016 |
) |
1010 | 1017 |
if primary_booking is not None: |
1011 | 1018 |
new_booking.primary_booking = primary_booking |
chrono/manager/static/css/style.scss | ||
---|---|---|
211 | 211 |
z-index: 3; |
212 | 212 |
height: auto !important; |
213 | 213 |
} |
214 |
a { |
|
215 |
color: #003199; |
|
216 |
border-bottom-color: #003199; |
|
217 |
} |
|
214 | 218 |
} |
215 | 219 |
} |
216 | 220 | |
... | ... | |
311 | 315 |
p.email-subject { |
312 | 316 |
text-align: center; |
313 | 317 |
} |
318 | ||
319 |
span.color-legend-label { |
|
320 |
padding: 0.2em; |
|
321 |
border-radius: 0.5em; |
|
322 |
} |
chrono/manager/templates/chrono/booking_color_legend.html | ||
---|---|---|
1 |
{% load i18n %} |
|
2 | ||
3 |
<div> |
|
4 |
<strong>{% trans "Booking colors:" %}</strong> |
|
5 |
{% for color in agenda.booking_colors.all %} |
|
6 |
<span class="color-legend-label" style="background: #{{ color.code }};">{{ color.label }}</span> |
|
7 |
{% endfor %} |
|
8 |
</div> |
chrono/manager/templates/chrono/manager_agenda_day_view.html | ||
---|---|---|
69 | 69 | |
70 | 70 |
{% for booking in desk_info.bookings %} |
71 | 71 |
<div class="booking" |
72 |
style="height: {{ booking.css_height }}%; min-height: {{ booking.css_height }}%; top: {{ booking.css_top }}%;" |
|
72 |
style="height: {{ booking.css_height }}%; min-height: {{ booking.css_height }}%; top: {{ booking.css_top }}%;{% if booking.color.code %}background:#{{ booking.color.code }};{% endif %}"
|
|
73 | 73 |
><span class="start-time">{{booking.event.start_datetime|date:"TIME_FORMAT"}}</span> |
74 | 74 |
<a {% if booking.backoffice_url %}href="{{booking.backoffice_url}}"{% endif %}>{{ booking.meetings_display }}</a> |
75 | 75 |
<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> |
... | ... | |
90 | 90 |
</div> |
91 | 91 |
{% endfor %} |
92 | 92 | |
93 |
{% if agenda.booking_colors.exists %} |
|
94 |
{% include "chrono/booking_color_legend.html" %} |
|
95 |
{% endif %} |
|
96 | ||
93 | 97 |
{% endblock %} |
chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html | ||
---|---|---|
34 | 34 |
{% endfor %} |
35 | 35 | |
36 | 36 |
{% for slot in day.infos.booked_slots %} |
37 |
<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" }}%"> |
|
37 |
<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" }}%;{% if slot.booking.color.code %}background:#{{ slot.booking.color.code }};{% endif %}">
|
|
38 | 38 |
<span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span> |
39 | 39 |
<a {% if slot.booking.backoffice_url %}href="{{slot.booking.backoffice_url}}"{% endif %}>{{ slot.booking.meetings_display }}</a> |
40 | 40 |
<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> |
... | ... | |
58 | 58 |
</div> |
59 | 59 |
{% endfor %} |
60 | 60 | |
61 |
{% if agenda.booking_colors.exists %} |
|
62 |
{% include "chrono/booking_color_legend.html" %} |
|
63 |
{% endif %} |
|
64 | ||
61 | 65 |
{% endblock %} |
tests/test_api.py | ||
---|---|---|
1263 | 1263 |
assert Booking.objects.count() == 2 |
1264 | 1264 | |
1265 | 1265 | |
1266 |
def test_booking_api_meeting_colors(app, meetings_agenda, user): |
|
1267 |
agenda_id = meetings_agenda.slug |
|
1268 |
meeting_type = MeetingType.objects.get(agenda=meetings_agenda) |
|
1269 |
datetimes_resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) |
|
1270 |
event_id = datetimes_resp.json['data'][2]['id'] |
|
1271 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
1272 | ||
1273 |
resp = app.post( |
|
1274 |
'/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'use_color_for': 'Cooking',}, |
|
1275 |
) |
|
1276 |
booking = Booking.objects.get(id=resp.json['booking_id']) |
|
1277 |
assert booking.color.label == 'Cooking' |
|
1278 |
assert booking.color.code != '' |
|
1279 | ||
1280 |
event_id = datetimes_resp.json['data'][3]['id'] |
|
1281 |
resp = app.post_json( |
|
1282 |
'/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'use_color_for': 'Cooking',}, |
|
1283 |
) |
|
1284 |
new_booking = Booking.objects.get(id=resp.json['booking_id']) |
|
1285 |
assert new_booking.color == booking.color |
|
1286 | ||
1287 |
event_id = datetimes_resp.json['data'][4]['id'] |
|
1288 |
resp = app.post_json( |
|
1289 |
'/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'use_color_for': 'Swimming',}, |
|
1290 |
) |
|
1291 |
new_booking = Booking.objects.get(id=resp.json['booking_id']) |
|
1292 |
assert new_booking.color.label == 'Swimming' |
|
1293 |
assert new_booking.color != booking.color |
|
1294 | ||
1295 | ||
1266 | 1296 |
def test_booking_api_meeting_with_resources(app, user): |
1267 | 1297 |
tomorrow = datetime.date.today() + datetime.timedelta(days=1) |
1268 | 1298 |
tomorrow_str = tomorrow.isoformat() |
tests/test_manager.py | ||
---|---|---|
5027 | 5027 |
unavailability_calendar.edit_role = group |
5028 | 5028 |
unavailability_calendar.save() |
5029 | 5029 |
app.get(url) |
5030 | ||
5031 | ||
5032 |
@pytest.mark.parametrize( |
|
5033 |
'view', |
|
5034 |
( |
|
5035 |
'/manage/agendas/%(agenda)s/%(year)d/%(month)d/%(day)d/', |
|
5036 |
'/manage/agendas/%(agenda)s/%(year)d/%(month)d/', |
|
5037 |
), |
|
5038 |
) |
|
5039 |
def test_agenda_booking_colors(app, admin_user, api_user, view): |
|
5040 |
agenda = Agenda.objects.create(label='New Example', kind='meetings') |
|
5041 |
desk = Desk.objects.create(agenda=agenda, label='New Desk') |
|
5042 |
meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30) |
|
5043 |
today = datetime.date.today() |
|
5044 |
timeperiod = TimePeriod.objects.create( |
|
5045 |
desk=desk, weekday=today.weekday(), start_time=datetime.time(10, 0), end_time=datetime.time(18, 0) |
|
5046 |
) |
|
5047 | ||
5048 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
5049 |
datetimes_resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug)) |
|
5050 |
booking_url = datetimes_resp.json['data'][0]['api']['fillslot_url'] |
|
5051 | ||
5052 |
# book first slot without colors |
|
5053 |
resp = app.post(booking_url) |
|
5054 |
date = Booking.objects.all()[0].event.start_datetime |
|
5055 | ||
5056 |
app.reset() |
|
5057 |
login(app) |
|
5058 | ||
5059 |
url = view % {'agenda': agenda.id, 'year': date.year, 'month': date.month, 'day': date.day} |
|
5060 |
resp = app.get(url) |
|
5061 |
assert len(resp.pyquery.find('div.booking')) == 1 |
|
5062 |
assert 'background' not in resp.pyquery.find('div.booking')[0].attrib |
|
5063 |
assert 'Booking colors:' not in resp.text |
|
5064 | ||
5065 |
app.reset() |
|
5066 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
5067 |
booking_url2 = datetimes_resp.json['data'][1]['api']['fillslot_url'] |
|
5068 |
booking_url3 = datetimes_resp.json['data'][2]['api']['fillslot_url'] |
|
5069 |
resp = app.post_json(booking_url2, params={'use_color_for': 'Cooking'}) |
|
5070 |
resp = app.post_json(booking_url3, params={'use_color_for': 'Cooking'}) |
|
5071 |
booking = Booking.objects.get(pk=resp.json['booking_id']) |
|
5072 | ||
5073 |
app.reset() |
|
5074 |
login(app) |
|
5075 | ||
5076 |
resp = app.get(url) |
|
5077 |
assert len(resp.pyquery.find('div.booking')) == 3 |
|
5078 |
assert len(resp.pyquery.find('span.color-legend-label')) == 1 |
|
5079 |
assert 'background:#%s' % booking.color.code in resp.pyquery.find('div.booking')[1].attrib['style'] |
|
5080 |
assert 'background:#%s' % booking.color.code in resp.pyquery.find('div.booking')[2].attrib['style'] |
|
5081 |
assert 'Booking colors:' in resp.text |
|
5082 | ||
5083 |
app.reset() |
|
5084 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
5085 |
booking_url4 = datetimes_resp.json['data'][3]['api']['fillslot_url'] |
|
5086 |
resp = app.post_json(booking_url4, params={'use_color_for': 'Swimming'}) |
|
5087 |
new_booking = Booking.objects.get(pk=resp.json['booking_id']) |
|
5088 | ||
5089 |
app.reset() |
|
5090 |
login(app) |
|
5091 | ||
5092 |
resp = app.get(url) |
|
5093 |
assert len(resp.pyquery.find('div.booking')) == 4 |
|
5094 |
assert len(resp.pyquery.find('span.color-legend-label')) == 2 |
|
5095 |
assert 'background:#%s' % new_booking.color.code in resp.pyquery.find('div.booking')[3].attrib['style'] |
|
5096 |
assert new_booking.color.code != booking.color.code |
|
5030 |
- |