Projet

Général

Profil

0001-add-calendar-cell-model-16393.patch

Josué Kouka, 19 mai 2017 18:23

Télécharger (23,4 ko)

Voir les différences:

Subject: [PATCH] add calendar cell model (#16393)

 combo/apps/chrono/README                         |  18 ++++
 combo/apps/chrono/__init__.py                    |  28 +++++++
 combo/apps/chrono/migrations/0001_initial.py     |  44 ++++++++++
 combo/apps/chrono/migrations/__init__.py         |   0
 combo/apps/chrono/models.py                      |  69 +++++++++++++++
 combo/apps/chrono/static/chrono/js/calendar.js   |  91 ++++++++++++++++++++
 combo/apps/chrono/templates/chrono/calendar.html |  13 +++
 combo/apps/chrono/urls.py                        |  24 ++++++
 combo/apps/chrono/utils.py                       |  62 ++++++++++++++
 combo/apps/chrono/views.py                       |  54 ++++++++++++
 combo/settings.py                                |   2 +
 debian/control                                   |   1 +
 requirements.txt                                 |   1 +
 setup.py                                         |   1 +
 tests/test_calendar.py                           | 102 +++++++++++++++++++++++
 15 files changed, 510 insertions(+)
 create mode 100644 combo/apps/chrono/README
 create mode 100644 combo/apps/chrono/__init__.py
 create mode 100644 combo/apps/chrono/migrations/0001_initial.py
 create mode 100644 combo/apps/chrono/migrations/__init__.py
 create mode 100644 combo/apps/chrono/models.py
 create mode 100644 combo/apps/chrono/static/chrono/js/calendar.js
 create mode 100644 combo/apps/chrono/templates/chrono/calendar.html
 create mode 100644 combo/apps/chrono/urls.py
 create mode 100644 combo/apps/chrono/utils.py
 create mode 100644 combo/apps/chrono/views.py
 create mode 100644 tests/test_calendar.py
combo/apps/chrono/README
1
Combo calendar cell
2
=================
3

  
4
To be visible, this cell needs a 'chrono' entry in settings.KNOWN_SERVICES
5

  
6
Example of session_var definition
7

  
8
{
9
    "nusery_id": "southpark",
10
    "date_debut": "$start",
11
    "date_fin": "$end"
12
}
13

  
14
$start and $end will be replaced by event start_datetime and end_datetime
15
and all keys in the session_vars dick will be prefixed by a <session_var_[key]>.
16
The result will be:
17

  
18
session_var_nursery_id=southapark&dsession_var_date_debut=2017-05-19T13:30:00&session_var_date_fin=2017-05-19T15:30:00
combo/apps/chrono/__init__.py
1
# combo - content management system
2
# Copyright (C) 2017  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import django.apps
18

  
19

  
20
class AppConfig(django.apps.AppConfig):
21
    name = 'combo.apps.chrono'
22

  
23
    def get_before_urls(self):
24
        from . import urls
25
        return urls.urlpatterns
26

  
27

  
28
default_app_config = 'combo.apps.chrono.AppConfig'
combo/apps/chrono/migrations/0001_initial.py
1
# -*- coding: utf-8 -*-
2
from __future__ import unicode_literals
3

  
4
from django.db import migrations, models
5
import jsonfield.fields
6
import datetime
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    dependencies = [
12
        ('auth', '0006_require_contenttypes_0002'),
13
        ('data', '0025_jsoncell_varnames_str'),
14
    ]
15

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='CalendarCell',
19
            fields=[
20
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
21
                ('placeholder', models.CharField(max_length=20)),
22
                ('order', models.PositiveIntegerField()),
23
                ('slug', models.SlugField(verbose_name='Slug', blank=True)),
24
                ('extra_css_class', models.CharField(max_length=100, verbose_name='Extra classes for CSS styling', blank=True)),
25
                ('public', models.BooleanField(default=True, verbose_name='Public')),
26
                ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
27
                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
28
                ('title', models.CharField(max_length=128, verbose_name='Title')),
29
                ('events_source', models.URLField(verbose_name='Events source URL')),
30
                ('form_url', models.URLField(verbose_name='Application form URL')),
31
                ('session_vars', jsonfield.fields.JSONField(default=dict, verbose_name='Session vars')),
32
                ('slot_duration', models.TimeField(default=datetime.time(0, 30), verbose_name='Slot duration')),
33
                ('minimal_event_duration', models.TimeField(default=datetime.time(2, 0), verbose_name='Minimal event duration')),
34
                ('business_hours_start', models.TimeField(default=datetime.time(8, 0), verbose_name='Business hour start')),
35
                ('business_hours_end', models.TimeField(default=datetime.time(18, 0), verbose_name='Business hour end')),
36
                ('event_default_title', models.CharField(max_length=32, verbose_name='Event default title')),
37
                ('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
38
                ('page', models.ForeignKey(to='data.Page')),
39
            ],
40
            options={
41
                'verbose_name': 'Calendar Cell',
42
            },
43
        ),
44
    ]
combo/apps/chrono/models.py
1
# combo - content management system
2
# Copyright (C) 2017  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.db import models
18
from django.utils.translation import ugettext_lazy as _
19
from django.utils.dateparse import parse_time
20

  
21
from combo.data.models import CellBase
22
from combo.data.library import register_cell_class
23
from .utils import is_chrono_enabled
24

  
25
from jsonfield import JSONField
26

  
27

  
28
@register_cell_class
29
class CalendarCell(CellBase):
30

  
31
    title = models.CharField(_('Title'), max_length=128)
32
    events_source = models.URLField(_('Events source URL'))
33
    form_url = models.URLField(_('Application form URL'))
34
    session_vars = JSONField(_('Session vars'))
35
    slot_duration = models.TimeField(
36
        _('Slot duration'), default=parse_time('00:30'))
37
    minimal_event_duration = models.TimeField(
38
        _('Minimal event duration'), default=parse_time('02:00'))
39
    business_hours_start = models.TimeField(
40
        _('Business hour start'), default=parse_time('08:00'))
41
    business_hours_end = models.TimeField(
42
        _('Business hour end'), default=parse_time('18:00'))
43
    event_default_title = models.CharField(
44
        _('Event default title'), max_length=32)
45

  
46
    template_name = 'chrono/calendar.html'
47

  
48
    class Meta:
49
        verbose_name = _('Calendar Cell')
50

  
51
    class Media:
52
        js = (
53
            'xstatic/lib/moment.min.js',
54
            'xstatic/fullcalendar.min.js',
55
            'xstatic/locale-all.js',
56
            'chrono/js/calendar.js'
57
        )
58
        css = {'all': ('xstatic/fullcalendar.min.css',)}
59

  
60
    @classmethod
61
    def is_enabled(cls):
62
        return is_chrono_enabled()
63

  
64
    def render(self, context):
65
        include = ['title', 'slot_duration', 'business_hours_start', 'business_hours_end',
66
                   'minimal_event_duration', 'event_default_title']
67
        context['chrono'] = json.dumps({key: str(value) for key, value in vars(self).items()
68
                                       if key in include})
69
        return super(CalendarCell, self).render(context)
combo/apps/chrono/static/chrono/js/calendar.js
1
$(function() {
2

  
3
    function renderCal(cal_id){
4
        $(cal_id).fullCalendar({
5
            locale: locale,
6
            header: {
7
                left: "prev,next today",
8
                center: "title",
9
                right: "agendaWeek,agendaDay"
10
            },
11
            defaultView: "agendaWeek",
12
            weekends: false,
13
            allDaySlot: false,
14
            slotDuration: chrono.slot_duration,
15
            forceEventDuration: true,
16
            defaultTimedEventDuration: chrono.default_event_duration,
17
            scrollTime: "08:00",
18
            businessHours: {
19
                start: chrono.business_hours_start,
20
                end: chrono.business_hours_end
21
            },
22
            events: events_source_url,
23
            eventOverlap: function(stillEvent, movingEvent) {
24
                return true;
25
            },
26
            editable: true,
27
            selectable: true,
28
            selectHelper: true,
29
            unselectAuto: false,
30
            selectConstraint: {
31
                 start: chrono.business_hours_start,
32
                 end: chrono.business_hours_end,
33
                id: 'available'
34
            },
35
            select: function(start, end) {
36
                // can't select in the past
37
                if (start.isBefore(moment())){
38
                    $(cal_id).fullCalendar("unselect");
39
                    return false;
40
                }
41
                var title = chrono.event_default_title;
42
                var eventData;
43
                if (title && title.trim()) {
44
                    eventData = {
45
                        title: title,
46
                        start: start,
47
                        end: end
48
                    };
49
                    $(cal_id).fullCalendar("renderEvent", eventData);
50
                }
51
                $(cal_id).fullCalendar("unselect");
52
            },
53
            eventRender: function(event, element) {
54
                var start = moment(event.start).fromNow();
55
                element.attr("title", start);
56
            },
57
            loading: function() {
58
            },
59
            eventClick: function(calEvent, jsEvent, view){
60
                if (calEvent.source){
61
                    console.log(calEvent.source);
62
                    return false;
63
                }
64
                params = {
65
                    start: calEvent.start.format(),
66
                    end: calEvent.end.format()
67
                }
68
                form_url = events_booking_url + "?" + $.param(params)
69
                $.ajax({
70
                    type: "POST",
71
                    url: events_booking_url,
72
                    data: JSON.stringify({
73
                        start: calEvent.start.format(),
74
                        end: calEvent.end.format()
75
                    }),
76
                    dataType: "json",
77
                    success: function(response){
78
                        console.log(response.url);
79
                        window.location = response.url;
80
                    },
81
                    error: function(xhr, status, error){
82
                        console.log(JSON.stringify(status));
83
                    }
84
                })
85
            },
86
        });
87
    }
88
    $("div[data-calendar]").each(function(i, cal){
89
        renderCal("#" + cal.id);
90
    });
91
});
combo/apps/chrono/templates/chrono/calendar.html
1
{% load i18n %}
2

  
3
<h2>{{title}}</h2>
4

  
5
<script type="text/javascript">
6
    var events_source_url = "{% url 'chrono-events' cell.pk %}";
7
    var events_booking_url = "{% url 'chrono-booking' cell.pk %}";
8
    var chrono = JSON.parse(unescape('{{chrono | safe }}'));
9
    var locale = "{{LANGUAGE_CODE|slice:"-3"}}"  ;
10
</script>
11

  
12
<div id="calendar_{{cell.pk}}" data-calendar="{{cell.pk}}">
13
</div>
combo/apps/chrono/urls.py
1
# combo - content management system
2
# Copyright (C) 2017  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.conf.urls import url
18

  
19
from .views import EventsView, BookingView
20

  
21
urlpatterns = [
22
    url(r'^chrono/events/(?P<pk>[\w,-]+)/', EventsView.as_view(), name='chrono-events'),
23
    url(r'^chrono/book/(?P<pk>[\w,-]+)/', BookingView.as_view(), name='chrono-booking'),
24
]
combo/apps/chrono/utils.py
1
# combo - content management system
2
# Copyright (C) 2017 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.conf import settings
18
from combo.utils import requests
19

  
20

  
21
def get_chrono_service():
22
    if hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('chrono'):
23
        return settings.KNOWN_SERVICES['chrono'].values()[0]
24

  
25

  
26
def is_chrono_enabled():
27
    return get_chrono_service()
28

  
29

  
30
def convert_widget_format(events):
31
    data = []
32
    for event in events.get('data'):
33
        data.append({
34
            'id': 'available',
35
            'title': event.get('text'),
36
            'start': event.get('datetime'),
37
            'editable': False,
38
            'durationEditable': False,
39
            'rendering': 'inverse-background'
40
        })
41
    return data
42

  
43

  
44
def get_chrono_events(chrono_url, **kwargs):
45
    response = requests.get(
46
        chrono_url,
47
        headers={'accept': 'application/json'},
48
        **kwargs)
49
    data = convert_widget_format(response.json())
50
    return data
51

  
52

  
53
def build_session_vars(cell, data):
54
    session_vars = {}
55
    for key, value in cell.session_vars.items():
56
        key = 'session_var_%s' % key
57
        value = value.strip('$')
58
        if data.get(value):
59
            session_vars.update({key: data[value]})
60
        else:
61
            session_vars.update({key: value})
62
    return session_vars
combo/apps/chrono/views.py
1
# combo - content management system
2
# Copyright (C) 2017  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import json
18
import urllib
19

  
20
from django.http import JsonResponse
21
from django.views.decorators.csrf import csrf_exempt
22
from django.views.generic import View
23
from django.views.generic.detail import SingleObjectMixin
24

  
25
from .models import CalendarCell
26
from .utils import get_chrono_events, build_session_vars
27

  
28

  
29
class EventsView(SingleObjectMixin, View):
30

  
31
    http_method_names = ['get']
32
    model = CalendarCell
33

  
34
    def get(self, request, *args, **kwargs):
35
        cell = self.get_object()
36
        data = get_chrono_events(cell.events_source)
37
        return JsonResponse(data, safe=False)
38

  
39

  
40
class BookingView(SingleObjectMixin, View):
41

  
42
    http_method_names = ['post']
43
    model = CalendarCell
44

  
45
    @csrf_exempt
46
    def dispatch(self, request, *args, **kwargs):
47
        return super(BookingView, self).dispatch(request, *args, **kwargs)
48

  
49
    def post(self, request, *args, **kwargs):
50
        data = json.loads(request.body)
51
        cell = self.get_object()
52
        params = build_session_vars(cell, data)
53
        url = '%s?%s' % (cell.form_url, urllib.urlencode(params))
54
        return JsonResponse({'url': url}, safe=False)
combo/settings.py
75 75
    'combo.apps.notifications',
76 76
    'combo.apps.search',
77 77
    'combo.apps.usersearch',
78
    'combo.apps.chrono',
78 79
    'haystack',
79 80
    'xstatic.pkg.chartnew_js',
81
    'xstatic.pkg.fullcalendar',
80 82
)
81 83

  
82 84
INSTALLED_APPS = plugins.register_plugins_apps(INSTALLED_APPS)
debian/control
18 18
    python-xstatic-chartnew-js,
19 19
    python-eopayment (>= 1.9),
20 20
    python-django-haystack (>= 2.4.0)
21
    python-xstatic-fullcalendar (>= 3.4.0)
21 22
Recommends: python-django-mellon, python-whoosh
22 23
Conflicts: python-lingo
23 24
Description: Portal Management System (Python module)
requirements.txt
11 11
djangorestframework>=3.3, <3.4
12 12
django-haystack
13 13
whoosh
14
-e git+git@repos.entrouvert.org:debian/xstatic-fullcalendar.git@1b71292fa08859bda1f4412bcfe77714d14cd861#egg=XStatic_fullCalendar
setup.py
116 116
        'djangorestframework>=3.3, <3.4',
117 117
        'django-haystack',
118 118
        'whoosh',
119
        'XStatic-fullcalendar>=3.4.0'
119 120
        ],
120 121
    zip_safe=False,
121 122
    cmdclass={
tests/test_calendar.py
1
import json
2
import urlparse
3

  
4
import pytest
5
import mock
6

  
7
from django.utils.dateparse import parse_time
8

  
9
from combo.data.models import Page
10
from combo.apps.chrono.models import CalendarCell
11

  
12
pytestmark = pytest.mark.django_db
13

  
14
CHRONO_EVENTS = {
15
    "data": [
16
        {
17
            "disabled": True,
18
            "text": "19 mai 2017 08:00",
19
            "api": {
20
                "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/86/"
21
            },
22
            "id": 86,
23
            "datetime": "2017-05-19 08:00:00"
24
        },
25
        {
26
            "disabled": True,
27
            "text": "19 mai 2017 08:30",
28
            "api": {
29
                "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/87/"
30
            },
31
            "id": 87,
32
            "datetime": "2017-05-19 08:30:00"
33
        },
34
        {
35
            "disabled": True,
36
            "text": "19 mai 2017 09:00",
37
            "api": {
38
                "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/88/"
39
            },
40
            "id": 88,
41
            "datetime": "2017-05-19 09:00:00"
42
        },
43
        {
44
            "disabled": True,
45
            "text": "19 mai 2017 09:30",
46
            "api": {
47
                "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/"
48
            },
49
            "id": 89,
50
            "datetime": "2017-05-19 09:30:00"
51
        }
52
    ]
53
}
54

  
55

  
56
class MockedRequestResponse(mock.Mock):
57

  
58
    def json(self):
59
        return json.loads(self.content)
60

  
61

  
62
@pytest.fixture
63
def cell(db):
64
    page = Page.objects.create()
65
    cell = CalendarCell(
66
        page=page, title='Example Of Calendar', order=1,
67
        events_source='http://example.net/api/events/',
68
        form_url='http://example.net/form/whatever/',
69
        session_vars={
70
            "start_dt": "$start", "end_dt": "$end",
71
            "whatever_slug": "whatever"
72
        },
73
        slot_duration=parse_time('00:30'),
74
        business_hours_start=parse_time('08:00'),
75
        business_hours_end=parse_time('18:00'),
76
        event_default_title='Available'
77
    )
78
    cell.save()
79
    return cell
80

  
81

  
82
@mock.patch('combo.apps.chrono.utils.requests.get')
83
def test_get_events(mocked_get, app, cell):
84
    mocked_get.return_value = MockedRequestResponse(content=json.dumps(CHRONO_EVENTS))
85
    resp = app.get('/chrono/events/%s/' % cell.pk)
86
    assert len(resp.json) == 4
87
    for datum in resp.json:
88
        assert datum['id'] == 'available'
89
        assert datum['rendering'] == 'inverse-background'
90

  
91

  
92
def test_redirection_session_vars(app, cell, settings):
93
    params = {
94
        'start': '2017-05-19T10:30:23',
95
        'end': '2017-05-19T12:30:14',
96
    }
97
    resp = app.post_json('/chrono/book/%s/' % cell.pk, params=params)
98
    parsed = urlparse.urlparse(resp.json['url'])
99
    qs = urlparse.parse_qs(parsed.query)
100
    assert qs['session_var_whatever_slug'] == ['whatever']
101
    assert qs['session_var_start_dt'] == [params['start']]
102
    assert qs['session_var_end_dt'] == [params['end']]
0
-