general: add a daily view for meeting agendas (#11114)

This commit is contained in:
Frédéric Péters 2017-11-26 14:27:09 +01:00
parent c5310aca40
commit db68cda706
5 changed files with 362 additions and 4 deletions

View File

@ -73,3 +73,66 @@ a.timeperiod-exception-all {
.link-action-icon.upload::before {
content: "\f093"; /* upload-sign */
}
.dayview h2 a {
padding: 0 1ex;
}
.dayview h2 span {
display: inline-block;
min-width: 22ex;
}
.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;
}
}

View File

@ -0,0 +1,66 @@
{% extends "chrono/manager_agenda_view.html" %}
{% load i18n %}
{% block bodyargs %}class="dayview"{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a>{{ day|date:"SHORT_DATE_FORMAT" }}</a>
{% endblock %}
{% block appbar %}
<h2>
<a href="{{ view.get_previous_day_url }}"></a>
<span class="day-title">{{ view.date|date:"l j F Y" }}</span>
<a href="{{ view.get_next_day_url }}"></a>
</h2>
{% if user_can_manage %}
<a href="{% url 'chrono-manager-agenda-settings' pk=agenda.id %}">{% trans 'Settings' %}</a>
{% endif %}
<a href="" onclick="window.print()">{% trans 'Print' %}</a>
{% endblock %}
{% block content %}
{% for period, desk_bookings in view.get_timeperiods %}
{% if forloop.first %}
<table>
<thead>
<tr>
<td></td>
{% for desk in view.agenda.desk_set.all %}
<th>{{ desk.label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% endif %}
<tr>
<th>{{ period|date:"TIME_FORMAT" }}</th>
{% for booking in desk_bookings %}
<td>
{% if booking %}
<div class="booked" {% if booking.rowspan %}data-rowspan="{{ booking.rowspan }}"{% endif %}>
<a {% if booking.backoffice_url %}href="{{booking.backoffice_url}}"{% endif %}
>{% if booking.label or booking.user_name %}
{{booking.label}}{% if booking.label and booking.user_name %} - {% endif %} {{booking.user_name}}
{% else %}{% trans "booked" %}{% endif %}</a>
</div>
{% endif %}
</td>{% endfor %}
</tr>
{% if forloop.last %}
</tbody>
</table>
{% endif %}
{% empty %}
<div class="closed-for-the-day">
<p>{% trans "No opening hours this day." %}</p>
</div>
{% endfor %}
{% endblock %}

View File

@ -24,6 +24,8 @@ urlpatterns = [
name='chrono-manager-agenda-add'),
url(r'^agendas/(?P<pk>\w+)/$', views.agenda_view,
name='chrono-manager-agenda-view'),
url(r'^agendas/(?P<pk>\w+)/(?P<year>[0-9]{4})/(?P<month>[0-9]+)/(?P<day>[0-9]+)/$', views.agenda_day_view,
name='chrono-manager-agenda-day-view'),
url(r'^agendas/(?P<pk>\w+)/settings$', views.agenda_settings,
name='chrono-manager-agenda-settings'),
url(r'^agendas/(?P<pk>\w+)/edit$', views.agenda_edit,

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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

View File

@ -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]
@ -537,6 +548,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'
@ -562,6 +574,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()
@ -573,10 +586,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'
@ -659,6 +674,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
@ -680,6 +696,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()
@ -710,6 +727,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()
@ -777,6 +795,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 = resp.form.submit() # submit empty form
# fields should be marked with errors
@ -793,6 +812,7 @@ def test_meetings_agenda_add_time_period_exception_when_booking_exists(app, admi
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('Add a time period exception', href='desk/%s/' % desk.id)
resp.form['start_datetime'] = '2017-05-22 08:00'
resp.form['end_datetime'] = '2017-05-26 17:30'
@ -811,6 +831,7 @@ def test_meetings_agenda_add_time_period_exception_when_cancelled_booking_exists
cancellation_datetime=make_aware(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'
@ -826,6 +847,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'
@ -841,6 +863,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))
@ -864,17 +887,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)
@ -921,6 +947,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)
@ -935,12 +962,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
@ -963,6 +992,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)
@ -976,12 +1006,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()
@ -1005,6 +1037,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,
@ -1018,12 +1051,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()
@ -1047,6 +1082,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
@ -1070,12 +1106,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
@ -1096,12 +1134,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()
@ -1120,11 +1160,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()
@ -1134,3 +1176,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('<tr') == 17
# book some slots
app.reset()
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug))
booking_url = resp.json['data'][0]['api']['fillslot_url']
booking_url2 = resp.json['data'][2]['api']['fillslot_url']
resp = app.post(booking_url)
resp = app.post_json(booking_url2,
params={'label': 'foo', 'user': 'bar', 'url': 'http://baz/'})
app.reset()
login(app)
date = Booking.objects.all()[0].event.start_datetime
resp = app.get('/manage/agendas/%s/%d/%d/%d/' % (
agenda.id, date.year, date.month, date.day))
assert resp.body.count('div class="booked') == 2
assert resp.body.count('data-rowspan') == 0
# create a shorted meeting type, this will double the number of rows and
# the bookings will have to span multiple rows.
meetingtype = MeetingType(agenda=agenda, label='Baz', duration=15)
meetingtype.save()
resp = app.get('/manage/agendas/%s/%d/%d/%d/' % (
agenda.id, date.year, date.month, date.day))
assert resp.body.count('<tr') == 33
assert resp.body.count('div class="booked') == 2
assert resp.body.count('data-rowspan') == 2
# cancel a booking
app.reset()
app.authorization = ('Basic', ('john.doe', 'password'))
booking = Booking.objects.all()[0]
resp = app.post('/api/booking/%s/cancel/' % booking.id)
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 1
app.reset()
login(app)
resp = app.get('/manage/agendas/%s/%d/%d/%d/' % (
agenda.id, date.year, date.month, date.day))
assert resp.body.count('div class="booked') == 1
assert resp.body.count('data-rowspan') == 1
# wrong type
agenda2 = Agenda(label=u'Foo bar')
agenda2.save()
resp = app.get('/manage/agendas/%s/%d/%d/%d/' % (
agenda2.id, date.year, date.month, date.day), status=404)
# not enough permissions
agenda2.view_role = manager_user.groups.all()[0]
agenda2.save()
app.reset()
app = login(app, username='manager', password='manager')
resp = app.get('/manage/agendas/%s/%d/%d/%d/' % (
agenda.id, date.year, date.month, date.day), status=403)
# just enough permissions
agenda.view_role = manager_user.groups.all()[0]
agenda.save()
resp = app.get('/manage/agendas/%s/%d/%d/%d/' % (
agenda.id, date.year, date.month, date.day), status=200)