Projet

Général

Profil

0001-manager-add-a-monthly-view-for-meeting-agendas-21326.patch

Serghei Mihai, 05 septembre 2018 15:39

Télécharger (17,4 ko)

Voir les différences:

Subject: [PATCH] manager: add a monthly view for meeting agendas (#21326)

 chrono/manager/static/css/style.scss          |  41 ++++++-
 .../chrono/manager_agenda_day_view.html       |   3 +-
 .../chrono/manager_agenda_month_view.html     |  80 ++++++++++++++
 chrono/manager/urls.py                        |   2 +
 chrono/manager/views.py                       | 104 +++++++++++++++++-
 tests/test_manager.py                         |  89 ++++++++++++++-
 tox.ini                                       |   1 +
 7 files changed, 312 insertions(+), 8 deletions(-)
 create mode 100644 chrono/manager/templates/chrono/manager_agenda_month_view.html
chrono/manager/static/css/style.scss
101 101
}
102 102

  
103 103
@for $i from 1 through 7 {
104
	.agenda-table.desks-#{$i} {
104
	.agenda-table {
105 105
		width: 100%;
106
		thead th { width: (100%/$i)-1%; }
106
		.desks-#{$i} {
107
			thead th { width: (100%/$i)-1%; }
108
		}
107 109
	}
108 110
}
109 111

  
110
.agenda-table tbody th {
112
.agenda-table tbody tr th {
111 113
	box-sizing: border-box;
112
	text-align: left;
113 114
	padding: 1ex 2ex;
114 115
	vertical-align: top;
115 116
	width: 8ex;
117
	&.hour {
118
		width: 5%;
119
		text-align: left;
120
	}
121
	a {
122
		color: #000;
123
		border: 0;
124
	}
125
	&.weekday {
126
		width: 12.5%;
127
	}
116 128
}
117 129

  
118
.agenda-table tbody tr:nth-child(2n+1) th,
130
.agenda-table tbody tr:nth-child(2n+1) th.hour,
119 131
.agenda-table tbody tr:nth-child(2n+1) td {
120 132
	background: #f0f0f0;
121 133
	@media print {
......
129 141
	position: relative;
130 142
}
131 143

  
144
.agenda-table tbody tr td.other-month {
145
	background: transparent;
146
}
147

  
132 148
@for $i from 1 through 60 {
133 149
	table.hourspan-#{$i} tbody td {
134 150
		height: calc(#{$i} * 2.5em);
......
160 176
	}
161 177
}
162 178

  
179
.monthview tbody td div.booking {
180
	padding: 0;
181
	transition: width 100ms ease-in, left 100ms ease-in, color 200ms ease-in;
182
	text-indent: -9999px;
183
	&:hover {
184
		text-indent: 0;
185
		color: inherit;
186
		left: 0% !important;
187
		width: 100% !important
188
	}
189
	span.desk {
190
		display: block;
191
	}
192
}
193

  
163 194
span.start-time {
164 195
	font-size: 80%;
165 196
}
chrono/manager/templates/chrono/manager_agenda_day_view.html
27 27
  <a href="{% url 'chrono-manager-agenda-settings' pk=agenda.id %}">{% trans 'Settings' %}</a>
28 28
{% endif %}
29 29
<a href="" onclick="window.print()">{% trans 'Print' %}</a>
30
<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>
30 31
</span>
31 32
{% endblock %}
32 33

  
......
48 49
{% endif %}
49 50

  
50 51
    <tr>
51
      <th>{{ period|date:"TIME_FORMAT" }}</th>
52
      <th class="hour">{{ period|date:"TIME_FORMAT" }}</th>
52 53
      {% for desk_info in desk_infos %}
53 54
      <td>
54 55

  
chrono/manager/templates/chrono/manager_agenda_month_view.html
1
{% extends "chrono/manager_agenda_view.html" %}
2
{% load i18n %}
3

  
4
{% block bodyargs %}class="monthview"{% endblock %}
5

  
6
{% block breadcrumb %}
7
{{ block.super }}
8
<a>{{ view.date|date:"F Y" }}</a>
9
{% endblock %}
10

  
11
{% block appbar %}
12
<h2>
13
  <a href="{{ view.get_previous_month_url }}">←</a>
14
  <span class="date-title">{{ view.date|date:"F Y" }}</span>
15
  {% with selected_month=view.date|date:"n" selected_year=view.date|date:"Y" %}
16
    <div class="date-picker" style="display: none">
17
    <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>
18
    <select name="year">{% for year in view.get_years %}<option value="{{ year }}" {% if selected_year == year %}selected{% endif %}>{{year}}</option>{% endfor %}</select>
19
    <button>{% trans 'Set Date' %}</button>
20
    </div>
21
  {% endwith %}
22
  <a href="{{ view.get_next_month_url }}">→</a>
23
</h2>
24
<span class="actions">
25
{% if user_can_manage %}
26
  <a href="{% url 'chrono-manager-agenda-settings' pk=agenda.id %}">{% trans 'Settings' %}</a>
27
{% endif %}
28
<a href="" onclick="window.print()">{% trans 'Print' %}</a>
29
<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>
30
{% endblock %}
31
</span>
32

  
33
{% block content %}
34
{% for week_days in view.get_timetable_infos %}
35
{% if forloop.first %}
36
<table class="agenda-table">
37
  <tbody>
38
{% endif %}
39
  <tr>
40
    <th></th>
41
    {% for day in week_days.days %}
42
    <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>
43
    {% endfor %}
44
  </tr>
45
  {% for hour in week_days.periods %}
46
  <tr>
47
    <th class="hour">{{ hour|date:"TIME_FORMAT" }}</th>
48
    {% for day in week_days.days %}
49
    <td{% if day.other_month %} class="other-month"{% endif %}>
50
      {% if forloop.parentloop.first %}
51
      {% for slot in day.infos.opening_hours %}
52
      <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>
53
      {% endfor %}
54
      {% for slot in day.infos.booked_slots %}
55
      <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" }}%">
56
        <span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
57
        <a {% if slot.booking.backoffice_url %}href="{{slot.booking.backoffice_url}}"{% endif %}
58
           >{% if slot.booking.label or slot.booking.user_name %}
59
          {{slot.booking.label}}{% if slot.booking.label and slot.booking.user_name %} - {% endif %} {{slot.booking.user_name}}
60
          {% else %}{% trans "booked" %}{% endif %}</a>
61
        <span class="desk">{{ slot.desk }}</span>
62
        </div>
63
      {% endfor %}
64
      {% endif %}
65
    </td>
66
    {% endfor %}
67
  </tr>
68
  {% endfor %}
69
{% if forloop.last %}
70
  </tbody>
71
</table>
72
{% endif %}
73

  
74
{% empty %}
75
<div class="closed-for-the-day">
76
  <p>{% trans "No opening hours this month." %}</p>
77
</div>
78
{% endfor %}
79

  
80
{% endblock %}
chrono/manager/urls.py
24 24
            name='chrono-manager-agenda-add'),
25 25
        url(r'^agendas/(?P<pk>\w+)/$', views.agenda_view,
26 26
            name='chrono-manager-agenda-view'),
27
        url(r'^agendas/(?P<pk>\w+)/(?P<year>[0-9]{4})/(?P<month>[0-9]+)/$', views.agenda_monthly_view,
28
            name='chrono-manager-agenda-month-view'),
27 29
        url(r'^agendas/(?P<pk>\w+)/(?P<year>[0-9]{4})/(?P<month>[0-9]+)/(?P<day>[0-9]+)/$', views.agenda_day_view,
28 30
            name='chrono-manager-agenda-day-view'),
29 31
        url(r'^agendas/(?P<pk>\w+)/settings$', views.agenda_settings,
chrono/manager/views.py
29 29
from django.utils.translation import ungettext
30 30
from django.utils.encoding import force_text
31 31
from django.views.generic import (DetailView, CreateView, UpdateView,
32
        ListView, DeleteView, FormView, TemplateView, DayArchiveView)
32
        ListView, DeleteView, FormView, TemplateView, DayArchiveView,
33
        MonthArchiveView)
33 34

  
34 35
from chrono.agendas.models import (Agenda, Event, MeetingType, TimePeriod,
35 36
                                   Booking, Desk, TimePeriodException, ICSError)
......
268 269
agenda_day_view = AgendaDayView.as_view()
269 270

  
270 271

  
272
class AgendaMonthView(AgendaDateView, MonthArchiveView):
273
    template_name = 'chrono/manager_agenda_month_view.html'
274

  
275
    def get_previous_month_url(self):
276
        previous_month = self.get_previous_month(self.date.date())
277
        return reverse('chrono-manager-agenda-month-view',
278
                kwargs={'pk': self.agenda.id,
279
                        'year': previous_month.year,
280
                        'month': previous_month.month})
281

  
282
    def get_next_month_url(self):
283
        next_month = self.get_next_month(self.date.date())
284
        return reverse('chrono-manager-agenda-month-view',
285
                kwargs={'pk': self.agenda.id,
286
                        'year': next_month.year,
287
                        'month': next_month.month})
288

  
289
    def get_day(self):
290
        return '1'
291

  
292
    def get_timetable_infos(self):
293
        timeperiods = TimePeriod.objects.filter(desk__agenda=self.agenda)
294
        if not timeperiods:
295
            return
296

  
297
        first_week_number = self.date.isocalendar()[1]
298
        last_month_day = self.get_next_month(self.date.date()) - datetime.timedelta(days=1)
299
        last_week_number = last_month_day.isocalendar()[1]
300

  
301
        for week_number in range(first_week_number, last_week_number + 1):
302
            yield self.get_week_timetable_infos(week_number-first_week_number, timeperiods)
303

  
304
    def get_week_timetable_infos(self, week_index, timeperiods):
305

  
306
        date = self.date + datetime.timedelta(week_index*7)
307
        year, week_number, dow = date.isocalendar()
308
        start_date = date - datetime.timedelta(dow)
309

  
310
        self.min_timeperiod = min([x.start_time for x in timeperiods])
311
        self.max_timeperiod = max([x.end_time for x in timeperiods])
312
        interval = datetime.timedelta(minutes=60)
313

  
314
        period = self.date.replace(hour=self.min_timeperiod.hour, minute=0)
315
        max_date = self.date.replace(hour=self.max_timeperiod.hour, minute=0)
316

  
317
        periods = []
318
        while period < max_date:
319
            periods.append(period)
320
            period = period + interval
321

  
322
        return {'days': [self.get_day_timetable_infos(start_date + datetime.timedelta(i), interval) for i in range(1, 8)],
323
                'periods': periods}
324

  
325
    def get_day_timetable_infos(self, day, interval):
326
        period = current_date = day.replace(hour=self.min_timeperiod.hour, minute=0)
327
        timetable = {'date': current_date,
328
                     'other_month': day.month != self.date.month,
329
                     'infos': {'opening_hours': [], 'booked_slots': []}}
330

  
331
        desks = self.agenda.desk_set.all()
332
        desks_len = len(desks)
333
        max_date = day.replace(hour=self.max_timeperiod.hour, minute=0)
334

  
335
        # compute booking and opening hours only for current month
336
        if self.date.month != day.month:
337
            return timetable
338

  
339
        while period <= max_date:
340
            period_end = period + interval
341
            for desk_index, desk in enumerate(desks):
342
                for event in [x for x in self.object_list if x.desk_id == desk.id and
343
                              x.start_datetime >= period and x.start_datetime < period_end]:
344
                    # don't consider cancelled bookings
345
                    bookings = [x for x in event.booking_set.all() if not x.cancellation_datetime]
346
                    if not bookings:
347
                        continue
348
                    booking = {'css_top': 100 * (event.start_datetime - current_date).seconds // 3600,
349
                               'css_height': 100 * event.meeting_type.duration // 60,
350
                               'css_width': 100.0 / desks_len,
351
                               'css_left': 100.0 * desk_index / desks_len,
352
                               'desk': desk,
353
                               'booking': bookings[0]
354
                    }
355
                    timetable['infos']['booked_slots'].append(booking)
356

  
357
                # get desks opening hours on last period iteration
358
                if period == max_date:
359
                    for hour in desk.get_opening_hours(current_date):
360
                        timetable['infos']['opening_hours'].append({
361
                            'css_top': 100 * (hour.begin - current_date).seconds // 3600,
362
                            'css_height': 100 * (hour.end - hour.begin).seconds // 3600,
363
                            'css_width': 100.0 / desks_len,
364
                            'css_left': 100.0 * desk_index / desks_len
365
                        })
366
            period += interval
367

  
368
        return timetable
369

  
370
agenda_monthly_view = AgendaMonthView.as_view()
371

  
372

  
271 373
class ManagedAgendaMixin(object):
272 374
    agenda = None
273 375

  
tests/test_manager.py
1359 1359
    login(app)
1360 1360
    resp = app.get('/manage/agendas/%s/' % agenda.id, status=302).follow()
1361 1361
    assert resp.text.count('<tr') == 15
1362
    assert '<th>11 p.m.</th>' in resp.text
1362
    assert '<th class="hour">11 p.m.</th>' in resp.text
1363

  
1364
def test_agenda_month_view(app, admin_user, manager_user, api_user):
1365
    agenda = Agenda.objects.create(label='Passeports', kind='meetings')
1366
    desk = Desk.objects.create(agenda=agenda, label='Desk A')
1367

  
1368
    meetingtype = MeetingType(agenda=agenda, label='passeport', duration=20)
1369
    meetingtype.save()
1370

  
1371
    login(app)
1372
    resp = app.get('/manage/agendas/%s/' % agenda.id, status=302)
1373
    resp = resp.follow()
1374
    assert 'Month view' in resp.text
1375
    resp = resp.click('Month view')
1376
    today = datetime.date.today()
1377
    assert resp.request.url.endswith('%s/%s/' % (today.year, today.month))
1378

  
1379
    assert 'Day view' in resp.text # date view link should be present
1380
    assert 'No opening hours this month.' in resp.text
1381

  
1382
    timeperiod_weekday = today.weekday()
1383
    timeperiod = TimePeriod(desk=desk, weekday=timeperiod_weekday,
1384
            start_time=datetime.time(10, 0),
1385
            end_time=datetime.time(18, 0))
1386
    timeperiod.save()
1387
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
1388
    assert not 'No opening hours this month.' in resp.text
1389
    assert not '<div class="booking' in resp.text
1390
    first_month_day = today.replace(day=1)
1391
    last_month_day = today.replace(day=1, month=today.month+1) - datetime.timedelta(days=1)
1392
    start_week_number = first_month_day.isocalendar()[1]
1393
    end_week_number = last_month_day.isocalendar()[1]
1394
    weeks_number = end_week_number - start_week_number + 1
1395
    assert resp.text.count('<tr') == 9 * weeks_number
1396

  
1397
    # check opening hours cells
1398
    assert '<div class="opening-hours" style="height:800.0%;top:0.0%;width:100.0%;left:0.0%' in resp.text
1399

  
1400
    # book some slots
1401
    app.reset()
1402
    app.authorization = ('Basic', ('john.doe', 'password'))
1403
    resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug))
1404
    booking_url = resp.json['data'][0]['api']['fillslot_url']
1405
    booking_url2 = resp.json['data'][2]['api']['fillslot_url']
1406
    booking = app.post(booking_url)
1407
    booking_2 = app.post_json(booking_url2,
1408
            params={'label': 'foo', 'user': 'bar', 'url': 'http://baz/'})
1409

  
1410
    app.reset()
1411
    login(app)
1412
    date = Booking.objects.all()[0].event.start_datetime
1413
    resp = app.get('/manage/agendas/%s/%d/%d/' % (
1414
        agenda.id, date.year, date.month))
1415
    assert resp.text.count('<div class="booking" style="left:0.0%;height:33.0%;') == 2 # booking cells
1416
    desk = Desk.objects.create(agenda=agenda, label='Desk B')
1417
    timeperiod = TimePeriod(desk=desk, weekday=timeperiod_weekday,
1418
            start_time=datetime.time(10, 0),
1419
            end_time=datetime.time(18, 0))
1420
    timeperiod.save()
1421

  
1422
    app.reset()
1423
    booking_3 = app.post(booking_url)
1424
    login(app)
1425
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
1426

  
1427
    # count occurences of timeperiod weekday in current month
1428
    d = first_month_day
1429
    weekdays = 0
1430
    while d <= last_month_day:
1431
        if d.weekday() == timeperiod_weekday:
1432
            weekdays += 1
1433
        d += datetime.timedelta(days=1)
1434

  
1435
    assert resp.text.count('<div class="opening-hours"') == 2 * weekdays
1436
    current_month = today.strftime('%Y-%m')
1437
    if current_month in booking_url or current_month in booking_url2:
1438
        assert resp.text.count('<div class="booking"') == 3
1439

  
1440
    # cancel bookings
1441
    app.reset()
1442
    app.post(booking.json['api']['cancel_url'])
1443
    app.post(booking_2.json['api']['cancel_url'])
1444
    app.post(booking_3.json['api']['cancel_url'])
1445

  
1446
    # make sure the are not
1447
    login(app)
1448
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
1449
    assert resp.text.count('<div class="booking"') == 0
tox.ini
22 22
  pylint<1.8
23 23
  pylint-django<0.9
24 24
  django-webtest<1.9.3
25
  pytz
25 26
  py2: django-mellon
26 27
  py3: django-mellon>=1.2.35
27 28
  pytest-freezegun
28
-