From ba6f9b824655e8fe98b044450d0937fd268b845a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Sun, 26 Nov 2017 14:27:09 +0100 Subject: [PATCH 2/2] general: add a daily view for meeting agendas (#11114) --- chrono/manager/static/css/style.scss | 79 +++++++++++++ .../templates/chrono/manager_agenda_day_view.html | 66 +++++++++++ chrono/manager/urls.py | 2 + chrono/manager/views.py | 104 ++++++++++++++++- tests/test_manager.py | 130 ++++++++++++++++++++- 5 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 chrono/manager/templates/chrono/manager_agenda_day_view.html diff --git a/chrono/manager/static/css/style.scss b/chrono/manager/static/css/style.scss index 096a55f..2b8f452 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -73,3 +73,82 @@ a.timeperiod-exception-all { .link-action-icon.upload::before { content: "\f093"; /* upload-sign */ } + +.dayview h2 a { + padding: 0 1ex; +} + +.dayview table { + border-collapse: collapse; +} + +$dayview-column-width: 14vw; +$dayview-row-height: 4.5ex; + +.dayview thead th { + width: $dayview-column-width; + padding-bottom: 1ex; +} + +.dayview tbody th { + box-sizing: border-box; + text-align: left; + padding: 0 2ex; + line-height: $dayview-row-height; + height: $dayview-row-height; +} + +.dayview tbody tr:nth-child(2n+1) th, +.dayview tbody tr:nth-child(2n+1) td { + background: #f0f0f0; + @media print { + border-top: 1px solid #aaa; + } +} + +.dayview td { + padding: 0.5ex 1ex; +} + +/* attr(data-rowspan) is not supported by browsers; emulate it by getting + * the attribute value into a CSS variable. */ +@for $i from 2 through 100 { + [data-rowspan="#{$i}"] { --rowspan: #{$i}; } +} + +.dayview div[data-rowspan] { + margin-top: -1ex; + box-sizing: border-box; + padding: 1ex; + background: #eef; + position: absolute; + width: calc(#{$dayview-column-width} - 2ex); + height: calc(#{$dayview-row-height} * var(--rowspan) - 2ex); + min-height: calc(#{$dayview-row-height} * var(--rowspan) - 2ex); + border: 1px solid #666; + overflow: hidden; + &:hover { + height: auto; + } +} + +div.closed-for-the-day { + display: inline-block; + font-size: 30px; + border: 1px solid #777; + padding: 2em 4em; + box-shadow: 0 2px 2px 2px #666; + background: #fafafd; + animation-name: closed_sign_animation; + animation-duration: 6s; + animation-timing-function: ease-out; +} + +@keyframes closed_sign_animation { + 0% { transform: rotate(2deg); } + 20% { transform: rotate(-2deg); } + 40% { transform: rotate(1deg); } + 60% { transform: rotate(-1deg); } + 80% { transform: rotate(0.5deg); } + 100% { transform: rotate(0deg); } +} diff --git a/chrono/manager/templates/chrono/manager_agenda_day_view.html b/chrono/manager/templates/chrono/manager_agenda_day_view.html new file mode 100644 index 0000000..5cd9abc --- /dev/null +++ b/chrono/manager/templates/chrono/manager_agenda_day_view.html @@ -0,0 +1,66 @@ +{% extends "chrono/manager_agenda_view.html" %} +{% load i18n %} + +{% block bodyargs %}class="dayview"{% endblock %} + +{% block breadcrumb %} +{{ block.super }} +{{ day|date:"SHORT_DATE_FORMAT" }} +{% endblock %} + +{% block appbar %} +

+ + {{ view.date|date:"DATE_FORMAT" }} + +

+{% if user_can_manage %} + {% trans 'Settings' %} +{% endif %} +{% trans 'Print' %} +{% endblock %} + +{% block content %} + +{% for period, desk_bookings in view.get_timeperiods %} + +{% if forloop.first %} + + + + + {% for desk in view.agenda.desk_set.all %} + + {% endfor %} + + + +{% endif %} + + + + {% for booking in desk_bookings %} + {% endfor %} + + +{% if forloop.last %} + +
{{ desk.label }}
{{ period|date:"TIME_FORMAT" }} + {% if booking %} + + {% endif %} +
+{% endif %} + +{% empty %} +
+ {% trans "Closed" %} +
+{% endfor %} + +{% endblock %} diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py index cf80923..d975315 100644 --- a/chrono/manager/urls.py +++ b/chrono/manager/urls.py @@ -24,6 +24,8 @@ urlpatterns = [ name='chrono-manager-agenda-add'), url(r'^agendas/(?P\w+)/$', views.agenda_view, name='chrono-manager-agenda-view'), + url(r'^agendas/(?P\w+)/(?P[0-9]{4})/(?P[0-9]+)/(?P[0-9]+)/$', views.agenda_day_view, + name='chrono-manager-agenda-day-view'), url(r'^agendas/(?P\w+)/settings$', views.agenda_settings, name='chrono-manager-agenda-settings'), url(r'^agendas/(?P\w+)/edit$', views.agenda_edit, diff --git a/chrono/manager/views.py b/chrono/manager/views.py index 068d448..6787b7d 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import datetime import json from django.contrib import messages @@ -21,12 +22,13 @@ from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse, reverse_lazy from django.db.models import Q from django.http import HttpResponse, Http404, HttpResponseRedirect -from django.utils.timezone import now +from django.shortcuts import get_object_or_404 +from django.utils.timezone import now, make_aware from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ungettext from django.utils.encoding import force_text from django.views.generic import (DetailView, CreateView, UpdateView, - ListView, DeleteView, FormView, TemplateView) + ListView, DeleteView, FormView, TemplateView, DayArchiveView) from chrono.agendas.models import (Agenda, Event, MeetingType, TimePeriod, Booking, Desk, TimePeriodException, ICSError) @@ -129,12 +131,110 @@ class AgendaView(DetailView): if not agenda.can_be_viewed(self.request.user): raise PermissionDenied() + if agenda.kind == 'meetings': + # redirect to today view + today = datetime.date.today() + return HttpResponseRedirect(reverse('chrono-manager-agenda-day-view', + kwargs={'pk': agenda.id, + 'year': today.year, + 'month': today.month, + 'day': today.day})) + + # redirect to settings return HttpResponseRedirect( reverse('chrono-manager-agenda-settings', kwargs={'pk': agenda.id})) agenda_view = AgendaView.as_view() +class AgendaDayView(DayArchiveView): + template_name = 'chrono/manager_agenda_day_view.html' + model = Event + month_format = '%m' + date_field = 'start_datetime' + allow_empty = True + allow_future = True + + def dispatch(self, request, *args, **kwargs): + self.agenda = get_object_or_404(Agenda, id=kwargs.get('pk')) + if self.agenda.kind != 'meetings': + raise Http404() + if not self.agenda.can_be_viewed(request.user): + raise PermissionDenied() + + self.date = make_aware(datetime.datetime.strptime( + '%s-%s-%s' % (self.get_year(), self.get_month(), self.get_day()), + '%Y-%m-%d')) + return super(AgendaDayView, self).dispatch(request, *args, **kwargs) + + def get_queryset(self): + queryset = super(AgendaDayView, self).get_queryset() + queryset = queryset.filter(agenda=self.agenda).prefetch_related('booking_set') + return queryset + + def get_context_data(self, **kwargs): + context = super(AgendaDayView, self).get_context_data(**kwargs) + context['agenda'] = self.agenda + context['user_can_manage'] = self.agenda.can_be_managed(self.request.user) + return context + + def get_previous_day_url(self): + previous_day = self.date.date() - datetime.timedelta(days=1) + return reverse('chrono-manager-agenda-day-view', + kwargs={'pk': self.agenda.id, + 'year': previous_day.year, + 'month': previous_day.month, + 'day': previous_day.day}) + + def get_next_day_url(self): + next_day = self.date.date() + datetime.timedelta(days=1) + return reverse('chrono-manager-agenda-day-view', + kwargs={'pk': self.agenda.id, + 'year': next_day.year, + 'month': next_day.month, + 'day': next_day.day}) + + def get_timeperiods(self): + timeperiods = TimePeriod.objects.filter( + desk__agenda=self.agenda, + weekday=self.date.weekday(), + ) + if not timeperiods: + return + + min_timeperiod = min([x.start_time for x in timeperiods]) + max_timeperiod = max([x.end_time for x in timeperiods]) + + interval = datetime.timedelta(minutes=self.agenda.get_base_meeting_duration()) + current_date = self.date.replace(hour=min_timeperiod.hour, minute=min_timeperiod.minute) + max_date = self.date.replace(hour=max_timeperiod.hour, minute=max_timeperiod.minute) + + desks = self.agenda.desk_set.all() + + while current_date < max_date: + # for each timeslot return the timeslot date and a list of per-desk + # bookings + bookings = [] + for desk in desks: + booking = None + event = [x for x in self.object_list if x.desk_id == desk.id and x.start_datetime == current_date] + if event: + # if an event exist, check it has a non cancelled booking + event = event[0] + event_bookings = [x for x in event.booking_set.all() if not x.cancellation_datetime] + if event_bookings: + booking = event_bookings[0] + if event.meeting_type.duration > (interval.total_seconds() / 60): + booking.rowspan = int(event.meeting_type.duration / (interval.total_seconds() / 60)) + + bookings.append(booking) + + yield current_date, bookings + current_date += interval + +agenda_day_view = AgendaDayView.as_view() + + class ManagedAgendaMixin(object): agenda = None diff --git a/tests/test_manager.py b/tests/test_manager.py index bdd4f0c..8a19671 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -43,6 +43,17 @@ def admin_user(): user = User.objects.create_superuser('admin', email=None, password='admin') return user +@pytest.fixture +def api_user(): + try: + user = User.objects.get(username='api-user') + except User.DoesNotExist: + user = User.objects.create(username='john.doe', + first_name=u'John', last_name=u'Doe', email='john.doe@example.net') + user.set_password('password') + user.save() + return user + def login(app, username='admin', password='admin'): login_page = app.get('/login/') login_form = login_page.forms[0] @@ -536,6 +547,7 @@ def test_meetings_agenda_add_meeting_type(app, admin_user): agenda.save() app = login(app) resp = app.get('/manage/agendas/%s/' % agenda.id).follow() + resp = resp.click('Settings') assert "This agenda doesn't have any meeting type yet." in resp.body resp = resp.click('New Meeting Type') resp.form['label'] = 'Blah' @@ -561,6 +573,7 @@ def test_meetings_agenda_delete_meeting_type(app, admin_user): app = login(app) resp = app.get('/manage/agendas/%s/' % agenda.id).follow() + resp = resp.click('Settings') resp = resp.click('Blah') resp = resp.click('Delete') resp = resp.form.submit() @@ -572,10 +585,12 @@ def test_meetings_agenda_add_time_period(app, admin_user): agenda.save() desk = Desk.objects.create(agenda=agenda, label='Desk A') app = login(app) - resp = app.get('/manage/agendas/%s/' % agenda.id).follow() + resp = app.get('/manage/agendas/%s/' % agenda.id, status=302).follow() + resp = resp.click('Settings') assert not 'Add a time period' in resp.body MeetingType(agenda=agenda, label='Blah').save() - resp = app.get('/manage/agendas/%s/' % agenda.id).follow() + resp = app.get('/manage/agendas/%s/' % agenda.id, status=302).follow() + resp = resp.click('Settings') resp = resp.click('Add a time period') resp.form['weekday'].select(text='Wednesday') resp.form['start_time'] = '10:00' @@ -658,6 +673,7 @@ def test_meetings_agenda_add_time_period_as_manager(app, manager_user): agenda.save() resp = app.get('/manage/agendas/%d/' % agenda.id).follow() + resp = resp.click('Settings') assert 'Add a time period' in resp.content assert '/manage/timeperiods/%s/edit' % time_period.id in resp.body assert '/manage/timeperiods/%s/delete' % time_period.id in resp.body @@ -679,6 +695,7 @@ def test_meetings_agenda_add_desk(app, admin_user): MeetingType(agenda=agenda, label='Blah').save() resp = app.get('/manage/agendas/%s/' % agenda.id).follow() + resp = resp.click('Settings') resp = resp.click('New Desk') resp.form['label'] = 'Desk A' resp = resp.form.submit().follow() @@ -709,6 +726,7 @@ def test_meetings_agenda_delete_desk(app, admin_user): MeetingType(agenda=agenda, label='Blah').save() resp = app.get('/manage/agendas/%s/' % agenda.id).follow() + resp = resp.click('Settings') resp = resp.click('New Desk') resp.form['label'] = 'Desk A' resp = resp.form.submit().follow() @@ -776,6 +794,7 @@ def test_meetings_agenda_add_time_period_exception_when_booking_exists(app, admi Booking.objects.create(event=event) login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('Add a time period exception') resp.form['start_datetime'] = '2017-05-22 08:00' resp.form['end_datetime'] = '2017-05-26 17:30' @@ -795,6 +814,7 @@ def test_meetings_agenda_add_time_period_exception_when_cancelled_booking_exists cancellation_datetime=datetime.datetime(2017, 5, 20, 10, 30)) login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('Add a time period exception') resp.form['start_datetime'] = '2017-05-22 08:00' resp.form['end_datetime'] = '2017-05-26 17:30' @@ -810,6 +830,7 @@ def test_meetings_agenda_add_invalid_time_period_exception(app, admin_user): start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('Add a time period exception') resp.form['start_datetime'] = '2017-05-26 17:30' resp.form['end_datetime'] = '2017-05-22 08:00' @@ -825,6 +846,7 @@ def test_meetings_agenda_delete_time_period_exception(app, admin_user): start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('Add a time period exception') today = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) tomorrow = make_aware(today + datetime.timedelta(days=15)) @@ -848,17 +870,20 @@ def test_agenda_import_time_period_exception_from_ics(app, admin_user): MeetingType(agenda=agenda, label='Foo').save() login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') assert 'Import exceptions from .ics' not in resp.content TimePeriod.objects.create(weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') assert 'Import exceptions from .ics' in resp.content resp = resp.click('upload') assert "You can upload a file or specify an address to a remote calendar." in resp resp = resp.form.submit(status=302) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp.form['ics_file'] = Upload('exceptions.ics', 'invalid content', 'text/calendar') resp = resp.form.submit(status=200) @@ -905,6 +930,7 @@ SUMMARY:New Year's Eve END:VEVENT END:VCALENDAR""" resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp.form['ics_file'] = Upload('exceptions.ics', ics_with_exceptions, 'text/calendar') resp = resp.form.submit(status=302) @@ -919,12 +945,14 @@ def test_agenda_import_time_period_exception_with_remote_ics(mocked_get, app, ad MeetingType(agenda=agenda, label='Bar').save() login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') assert 'Import exceptions from .ics' not in resp.content TimePeriod.objects.create(weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') assert 'ics_file' in resp.form.fields @@ -947,6 +975,7 @@ END:VCALENDAR""" exception = TimePeriodException.objects.get(desk=desk) assert exception.external_id == 'random-event-id' resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp.form['ics_url'] = '' resp = resp.form.submit(status=302) @@ -960,12 +989,14 @@ def test_agenda_import_time_period_exception_with_remote_ics_no_events(mocked_ge MeetingType(agenda=agenda, label='Bar').save() login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') assert 'Import exceptions from .ics' not in resp.content TimePeriod.objects.create(weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp.form['ics_url'] = 'http://example.com/foo.ics' mocked_response = mock.Mock() @@ -989,6 +1020,7 @@ VERSION:2.0 PRODID:-//foo.bar//EN END:VCALENDAR""" resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp = resp.form.submit(status=302) assert not TimePeriodException.objects.filter(desk=desk, @@ -1002,12 +1034,14 @@ def test_agenda_update_time_period_exception_from_remote_ics(mocked_get, app, ad MeetingType(agenda=agenda, label='Bar').save() login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') assert 'Import exceptions from .ics' not in resp.content TimePeriod.objects.create(weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp.form['ics_url'] = 'http://example.com/foo.ics' mocked_response = mock.Mock() @@ -1031,6 +1065,7 @@ END:VCALENDAR""" resp = resp.form.submit(status=302) assert TimePeriodException.objects.filter(desk=desk).count() == 2 resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp.form['ics_url'] = 'http://example.com/foo.ics' mocked_response.text = """BEGIN:VCALENDAR @@ -1054,12 +1089,14 @@ def test_agenda_import_time_period_exception_from_remote_ics_with_connection_err MeetingType(agenda=agenda, label='Bar').save() login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') assert 'Import exceptions from .ics' not in resp.content TimePeriod.objects.create(weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') assert 'ics_file' in resp.form.fields @@ -1080,12 +1117,14 @@ def test_agenda_import_time_period_exception_from_forbidden_remote_ics(mocked_ge MeetingType(agenda=agenda, label='Bar').save() login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') assert 'Import exceptions from .ics' not in resp.content TimePeriod.objects.create(weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp.form['ics_url'] = 'http://example.com/foo.ics' mocked_response = mock.Mock() @@ -1104,11 +1143,13 @@ def test_agenda_import_time_period_exception_from_remote_ics_with_ssl_error(mock MeetingType(agenda=agenda, label='Bar').save() login(app) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') assert 'Import exceptions from .ics' not in resp.content TimePeriod.objects.create(weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)) resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() + resp = resp.click('Settings') resp = resp.click('upload') resp.form['ics_url'] = 'https://example.com/foo.ics' mocked_response = mock.Mock() @@ -1118,3 +1159,88 @@ def test_agenda_import_time_period_exception_from_remote_ics_with_ssl_error(mock mocked_get.side_effect = mocked_requests_http_ssl_error resp = resp.form.submit(status=200) assert 'Failed to retrieve remote calendar (SSL error).' in resp.content + +def test_agenda_day_view(app, admin_user, manager_user, api_user): + agenda = Agenda.objects.create(label='New Example', kind='meetings') + desk = Desk.objects.create(agenda=agenda, label='New Desk') + desk.save() + + meetingtype = MeetingType(agenda=agenda, label='Bar', duration=30) + meetingtype.save() + + login(app) + resp = app.get('/manage/agendas/%s/' % agenda.id, status=302) + today = datetime.date.today() + assert resp.location.endswith('%s/%s/%s/' % (today.year, today.month, today.day)) + resp = resp.follow() + assert 'Closed' in resp.body # no time pediod + + TimePeriod(desk=desk, weekday=today.weekday(), + start_time=datetime.time(10, 0), + end_time=datetime.time(18, 0)).save() + resp = app.get('/manage/agendas/%s/' % agenda.id, status=302).follow() + assert not 'Closed' in resp.body + assert not 'div class="booked' in resp.body + assert resp.body.count('