manager: add a monthly view for meeting agendas (#21326)

This commit is contained in:
Serghei Mihai 2018-06-20 22:16:14 +02:00
parent 102beb5b4a
commit 255bd29468
7 changed files with 315 additions and 8 deletions

View File

@ -101,21 +101,33 @@ table.agenda-table {
} }
@for $i from 1 through 7 { @for $i from 1 through 7 {
.agenda-table.desks-#{$i} { .agenda-table {
width: 100%; width: 100%;
thead th { width: (100%/$i)-1%; } .desks-#{$i} {
thead th { width: (100%/$i)-1%; }
}
} }
} }
.agenda-table tbody th { .agenda-table tbody tr th {
box-sizing: border-box; box-sizing: border-box;
text-align: left;
padding: 1ex 2ex; padding: 1ex 2ex;
vertical-align: top; vertical-align: top;
width: 8ex; width: 8ex;
&.hour {
width: 5%;
text-align: left;
}
a {
color: #000;
border: 0;
}
&.weekday {
width: 12.5%;
}
} }
.agenda-table tbody tr:nth-child(2n+1) th, .agenda-table tbody tr:nth-child(2n+1) th.hour,
.agenda-table tbody tr:nth-child(2n+1) td { .agenda-table tbody tr:nth-child(2n+1) td {
background: #f0f0f0; background: #f0f0f0;
@media print { @media print {
@ -129,6 +141,10 @@ table.agenda-table {
position: relative; position: relative;
} }
.agenda-table tbody tr td.other-month {
background: transparent;
}
@for $i from 1 through 60 { @for $i from 1 through 60 {
table.hourspan-#{$i} tbody td { table.hourspan-#{$i} tbody td {
height: calc(#{$i} * 2.5em); height: calc(#{$i} * 2.5em);
@ -160,6 +176,21 @@ table.agenda-table {
} }
} }
.monthview tbody td div.booking {
padding: 0;
transition: width 100ms ease-in, left 100ms ease-in, color 200ms ease-in;
text-indent: -9999px;
&:hover {
text-indent: 0;
color: inherit;
left: 0% !important;
width: 100% !important
}
span.desk {
display: block;
}
}
span.start-time { span.start-time {
font-size: 80%; font-size: 80%;
} }

View File

@ -27,6 +27,7 @@
<a href="{% url 'chrono-manager-agenda-settings' pk=agenda.id %}">{% trans 'Settings' %}</a> <a href="{% url 'chrono-manager-agenda-settings' pk=agenda.id %}">{% trans 'Settings' %}</a>
{% endif %} {% endif %}
<a href="" onclick="window.print()">{% trans 'Print' %}</a> <a href="" onclick="window.print()">{% trans 'Print' %}</a>
<a href="{% url 'chrono-manager-agenda-month-view' pk=agenda.id year=view.date|date:"Y" month=view.date|date:"n" %}">{% trans 'Month view' %}</a>
</span> </span>
{% endblock %} {% endblock %}
@ -48,7 +49,7 @@
{% endif %} {% endif %}
<tr> <tr>
<th>{{ period|date:"TIME_FORMAT" }}</th> <th class="hour">{{ period|date:"TIME_FORMAT" }}</th>
{% for desk_info in desk_infos %} {% for desk_info in desk_infos %}
<td> <td>

View File

@ -0,0 +1,80 @@
{% extends "chrono/manager_agenda_view.html" %}
{% load i18n %}
{% block bodyargs %}class="monthview"{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a>{{ view.date|date:"F Y" }}</a>
{% endblock %}
{% block appbar %}
<h2>
<a href="{{ view.get_previous_month_url }}"></a>
<span class="date-title">{{ view.date|date:"F Y" }}</span>
{% with selected_month=view.date|date:"n" selected_year=view.date|date:"Y" %}
<div class="date-picker" style="display: none">
<select name="month">{% for month, month_label in view.get_months %}<option value="{{ month }}" {% if selected_month == month %}selected{% endif %}>{{ month_label }}</option>{% endfor %}</select>
<select name="year">{% for year in view.get_years %}<option value="{{ year }}" {% if selected_year == year %}selected{% endif %}>{{year}}</option>{% endfor %}</select>
<button>{% trans 'Set Date' %}</button>
</div>
{% endwith %}
<a href="{{ view.get_next_month_url }}"></a>
</h2>
<span class="actions">
{% 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>
<a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.id year=view.date|date:"Y" month=view.date|date:"m" day=view.date|date:"d" %}">{% trans 'Day view' %}</a>
{% endblock %}
</span>
{% block content %}
{% for week_days in view.get_timetable_infos %}
{% if forloop.first %}
<table class="agenda-table">
<tbody>
{% endif %}
<tr>
<th></th>
{% for day in week_days.days %}
<th class="weekday{% if day.date.day == view.date.day %} current-day{% endif %}">{% if not day.other_month %}<a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.id year=day.date|date:"Y" month=day.date|date:"m" day=day.date|date:"d" %}">{{ day.date|date:"l d" }}</a>{% endif %}</th>
{% endfor %}
</tr>
{% for hour in week_days.periods %}
<tr>
<th class="hour">{{ hour|date:"TIME_FORMAT" }}</th>
{% for day in week_days.days %}
<td{% if day.other_month %} class="other-month"{% endif %}>
{% if forloop.parentloop.first %}
{% for slot in day.infos.opening_hours %}
<div class="opening-hours" style="height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;left:{{ slot.css_left|stringformat:".1f" }}%;"></div>
{% endfor %}
{% for slot in day.infos.booked_slots %}
<div class="booking" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%">
<span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
<a {% if slot.booking.backoffice_url %}href="{{slot.booking.backoffice_url}}"{% endif %}
>{% if slot.booking.label or slot.booking.user_name %}
{{slot.booking.label}}{% if slot.booking.label and slot.booking.user_name %} - {% endif %} {{slot.booking.user_name}}
{% else %}{% trans "booked" %}{% endif %}</a>
<span class="desk">{{ slot.desk }}</span>
</div>
{% endfor %}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
{% if forloop.last %}
</tbody>
</table>
{% endif %}
{% empty %}
<div class="closed-for-the-day">
<p>{% trans "No opening hours this month." %}</p>
</div>
{% endfor %}
{% endblock %}

View File

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

View File

@ -29,7 +29,8 @@ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext from django.utils.translation import ungettext
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.views.generic import (DetailView, CreateView, UpdateView, from django.views.generic import (DetailView, CreateView, UpdateView,
ListView, DeleteView, FormView, TemplateView, DayArchiveView) ListView, DeleteView, FormView, TemplateView, DayArchiveView,
MonthArchiveView)
from chrono.agendas.models import (Agenda, Event, MeetingType, TimePeriod, from chrono.agendas.models import (Agenda, Event, MeetingType, TimePeriod,
Booking, Desk, TimePeriodException, ICSError) Booking, Desk, TimePeriodException, ICSError)
@ -268,6 +269,110 @@ class AgendaDayView(AgendaDateView, DayArchiveView):
agenda_day_view = AgendaDayView.as_view() agenda_day_view = AgendaDayView.as_view()
class AgendaMonthView(AgendaDateView, MonthArchiveView):
template_name = 'chrono/manager_agenda_month_view.html'
def get_previous_month_url(self):
previous_month = self.get_previous_month(self.date.date())
return reverse('chrono-manager-agenda-month-view',
kwargs={'pk': self.agenda.id,
'year': previous_month.year,
'month': previous_month.month})
def get_next_month_url(self):
next_month = self.get_next_month(self.date.date())
return reverse('chrono-manager-agenda-month-view',
kwargs={'pk': self.agenda.id,
'year': next_month.year,
'month': next_month.month})
def get_day(self):
return '1'
def get_timetable_infos(self):
timeperiods = TimePeriod.objects.filter(desk__agenda=self.agenda)
if not timeperiods:
return
first_week_number = self.date.isocalendar()[1]
last_month_day = self.get_next_month(self.date.date()) - datetime.timedelta(days=1)
last_week_number = last_month_day.isocalendar()[1]
for week_number in range(first_week_number, last_week_number + 1):
yield self.get_week_timetable_infos(week_number-first_week_number, timeperiods)
def get_week_timetable_infos(self, week_index, timeperiods):
date = self.date + datetime.timedelta(week_index*7)
year, week_number, dow = date.isocalendar()
start_date = date - datetime.timedelta(dow)
self.min_timeperiod = min([x.start_time for x in timeperiods])
self.max_timeperiod = max([x.end_time for x in timeperiods])
interval = datetime.timedelta(minutes=60)
period = self.date.replace(hour=self.min_timeperiod.hour, minute=0)
max_date = self.date.replace(hour=self.max_timeperiod.hour, minute=0)
periods = []
while period < max_date:
periods.append(period)
period = period + interval
return {'days': [self.get_day_timetable_infos(start_date + datetime.timedelta(i), interval) for i in range(1, 8)],
'periods': periods}
def get_day_timetable_infos(self, day, interval):
period = current_date = day.replace(hour=self.min_timeperiod.hour, minute=0)
timetable = {'date': current_date,
'other_month': day.month != self.date.month,
'infos': {'opening_hours': [], 'booked_slots': []}}
desks = self.agenda.desk_set.all()
desks_len = len(desks)
max_date = day.replace(hour=self.max_timeperiod.hour, minute=0)
# compute booking and opening hours only for current month
if self.date.month != day.month:
return timetable
while period <= max_date:
left = 1
period_end = period + interval
for desk_index, desk in enumerate(desks):
width = (98.0 / desks_len) - 1
for event in [x for x in self.object_list if x.desk_id == desk.id and
x.start_datetime >= period and x.start_datetime < period_end]:
# don't consider cancelled bookings
bookings = [x for x in event.booking_set.all() if not x.cancellation_datetime]
if not bookings:
continue
booking = {'css_top': 100 * (event.start_datetime - current_date).seconds // 3600,
'css_height': 100 * event.meeting_type.duration // 60,
'css_width': width,
'css_left': left,
'desk': desk,
'booking': bookings[0]
}
timetable['infos']['booked_slots'].append(booking)
# get desks opening hours on last period iteration
if period == max_date:
for hour in desk.get_opening_hours(current_date):
timetable['infos']['opening_hours'].append({
'css_top': 100 * (hour.begin - current_date).seconds // 3600,
'css_height': 100 * (hour.end - hour.begin).seconds // 3600,
'css_width': width,
'css_left': left,
})
left += width + 1
period += interval
return timetable
agenda_monthly_view = AgendaMonthView.as_view()
class ManagedAgendaMixin(object): class ManagedAgendaMixin(object):
agenda = None agenda = None

View File

@ -1359,4 +1359,91 @@ def test_agenda_day_view_late_meeting(app, admin_user, manager_user, api_user):
login(app) login(app)
resp = app.get('/manage/agendas/%s/' % agenda.id, status=302).follow() resp = app.get('/manage/agendas/%s/' % agenda.id, status=302).follow()
assert resp.text.count('<tr') == 15 assert resp.text.count('<tr') == 15
assert '<th>11 p.m.</th>' in resp.text assert '<th class="hour">11 p.m.</th>' in resp.text
def test_agenda_month_view(app, admin_user, manager_user, api_user):
agenda = Agenda.objects.create(label='Passeports', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='Desk A')
meetingtype = MeetingType(agenda=agenda, label='passeport', duration=20)
meetingtype.save()
login(app)
resp = app.get('/manage/agendas/%s/' % agenda.id, status=302)
resp = resp.follow()
assert 'Month view' in resp.text
resp = resp.click('Month view')
today = datetime.date.today()
assert resp.request.url.endswith('%s/%s/' % (today.year, today.month))
assert 'Day view' in resp.text # date view link should be present
assert 'No opening hours this month.' in resp.text
timeperiod_weekday = today.weekday()
timeperiod = TimePeriod(desk=desk, weekday=timeperiod_weekday,
start_time=datetime.time(10, 0),
end_time=datetime.time(18, 0))
timeperiod.save()
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
assert not 'No opening hours this month.' in resp.text
assert not '<div class="booking' in resp.text
first_month_day = today.replace(day=1)
last_month_day = today.replace(day=1, month=today.month+1) - datetime.timedelta(days=1)
start_week_number = first_month_day.isocalendar()[1]
end_week_number = last_month_day.isocalendar()[1]
weeks_number = end_week_number - start_week_number + 1
assert resp.text.count('<tr') == 9 * weeks_number
# check opening hours cells
assert '<div class="opening-hours" style="height:800.0%;top:0.0%;width:97.0%;left:1.0%' in resp.text
# 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']
booking = app.post(booking_url)
booking_2 = 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/' % (
agenda.id, date.year, date.month))
assert resp.text.count('<div class="booking" style="left:1.0%;height:33.0%;') == 2 # booking cells
desk = Desk.objects.create(agenda=agenda, label='Desk B')
timeperiod = TimePeriod(desk=desk, weekday=timeperiod_weekday,
start_time=datetime.time(10, 0),
end_time=datetime.time(18, 0))
timeperiod.save()
app.reset()
booking_3 = app.post(booking_url)
login(app)
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
# count occurences of timeperiod weekday in current month
d = first_month_day
weekdays = 0
while d <= last_month_day:
if d.weekday() == timeperiod_weekday:
weekdays += 1
d += datetime.timedelta(days=1)
assert resp.text.count('<div class="opening-hours"') == 2 * weekdays
current_month = today.strftime('%Y-%m')
if current_month in booking_url or current_month in booking_url2:
assert resp.text.count('<div class="booking"') == 3
# cancel bookings
app.reset()
app.post(booking.json['api']['cancel_url'])
app.post(booking_2.json['api']['cancel_url'])
app.post(booking_3.json['api']['cancel_url'])
# make sure the are not
login(app)
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
assert resp.text.count('<div class="booking"') == 0

View File

@ -22,6 +22,7 @@ deps =
pylint<1.8 pylint<1.8
pylint-django<0.9 pylint-django<0.9
django-webtest<1.9.3 django-webtest<1.9.3
pytz
py2: django-mellon py2: django-mellon
py3: django-mellon>=1.2.35 py3: django-mellon>=1.2.35
pytest-freezegun pytest-freezegun