Projet

Général

Profil

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

Josué Kouka, 18 mai 2017 17:35

Télécharger (19,9 ko)

Voir les différences:

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

 combo/apps/chrono/README                         |  10 +++
 combo/apps/chrono/__init__.py                    |  28 +++++++
 combo/apps/chrono/models.py                      |  69 +++++++++++++++
 combo/apps/chrono/static/chrono/chrono.css       |  95 +++++++++++++++++++++
 combo/apps/chrono/static/chrono/chrono.js        |  76 +++++++++++++++++
 combo/apps/chrono/templates/chrono/calendar.html |  10 +++
 combo/apps/chrono/urls.py                        |  24 ++++++
 combo/apps/chrono/utils.py                       |  61 ++++++++++++++
 combo/apps/chrono/views.py                       |  54 ++++++++++++
 combo/settings.py                                |   2 +
 tests/test_calendar.py                           | 102 +++++++++++++++++++++++
 11 files changed, 531 insertions(+)
 create mode 100644 combo/apps/chrono/README
 create mode 100644 combo/apps/chrono/__init__.py
 create mode 100644 combo/apps/chrono/models.py
 create mode 100644 combo/apps/chrono/static/chrono/chrono.css
 create mode 100644 combo/apps/chrono/static/chrono/chrono.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
session_vars are defineed such as:
7
{
8
    "my_session_var1_name": "value",
9
    "my_session_var2_name": "ref_to_received_key",
10
}
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/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
import json
18
from datetime import datetime
19

  
20
from django.db import models
21
from django.utils.translation import ugettext_lazy as _
22
from django.utils.dateparse import parse_time
23
from django.core.urlresolvers import reverse
24

  
25
from combo.data.models import CellBase
26
from combo.data.library import register_cell_class
27
from .utils import is_chrono_enabled
28

  
29
from jsonfield import JSONField
30

  
31

  
32
@register_cell_class
33
class CalendarCell(CellBase):
34

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

  
48
    template_name = 'chrono/calendar.html'
49

  
50
    class Meta:
51
        verbose_name = _('Calendar Cell')
52

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

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

  
65
    def render(self, context):
66
        include = ['slot_duration', 'business_hours_start', 'business_hours_end', '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/chrono.css
1
#calendar {
2
    width: 900px;
3
    margin: 0 auto;
4
}
5

  
6

  
7
page {
8
  max-width: 960px; padding: 0 15px; margin: 40px auto;
9
  @media (max-height: 790px) {
10
    margin-top: 0;
11
  }
12
}
13
.page-header h1 { .text-center; font-weight: 100; }
14

  
15
.input, select {
16
  padding: 2px 5px;
17
}
18
.btn {
19
  padding: .2em .8em;
20
  border-radius: 4px;
21
  border: 1px solid #bcbcbc;
22
	box-shadow: 0 1px 3px rgba(0,0,0,0.12);
23
	background-image: linear-gradient(180deg, rgba(255,255,255,1) 0%,rgba(239,239,239,1) 60%,rgba(225,223,226,1) 100%);
24
  background-repeat: no-repeat; // fix for firefox
25
}
26

  
27
.bubble {
28
  box-shadow: 0 2px 4px rgba(0,0,0,.2); border-radius: 2px;
29
  background: #fff; padding: 15px;
30
  width: 420px;
31
  z-index: 99;
32
  position: absolute;
33

  
34
  .close {
35
    position: absolute; font-size: 24px; line-height: 1;
36
    padding: 0 5px;
37
    right: 5px; top: 5px;
38
  }
39
}
40

  
41
.bubble {
42
  @border-color: #ccc;
43
  border: 1px solid @border-color;
44
  .arrow, .arrow:after {
45
    position: absolute; height: 0; width: 0; font-size: 0; .horizontal-border(transparent, 10px);
46
  }
47
  &-top, &-bottom {
48
    .arrow {
49
      left: 50%; margin-left: -10px;
50
    }
51
    .arrow:after {
52
      content: ''; left: -10px;
53
    }
54
  }
55
  &-top {
56
    .arrow {
57
      border-top: @border-color 10px solid; top: 100%;
58
    }
59
    .arrow:after {
60
      border-top: #FFF  10px solid; bottom: 1px;
61
    }
62
  }
63
  &-bottom {
64
    .arrow {
65
      border-bottom: @border-color 10px solid; bottom: 100%;
66
    }
67
    .arrow:after {
68
      border-bottom: #FFF  10px solid; top: 1px;
69
    }
70
  }
71
}
72

  
73

  
74
.form-group {
75
  .clearfix; padding-bottom: 8px;
76
  &>label {
77
    float: left; width: 4em; text-align: right; padding-right: 5px;
78
  }
79
  &>input, &>.input-wrapper {
80
    margin-left: 4em;
81
    display: block;
82
  }
83
}
84

  
85

  
86
.btn-delete {
87
  margin-top: 5px;
88
  display: none;
89
  .text-danger;
90
  &:hover {
91
    text-decoration: underline;
92
  }
93
}
94

  
95
.usage { margin-top: 10px; }
combo/apps/chrono/static/chrono/chrono.js
1
$(function() { // document ready
2
  var calendar = $('#calendar').fullCalendar({
3
    header: {
4
      left: 'prev,next today',
5
      center: 'title',
6
      right: 'agendaWeek,agendaDay'
7
    },
8
    // slotDuration: chrono.slot_duration,
9
    defaultView: 'agendaWeek',
10
    weekends: false,
11
    defaultTimedEventDuration: '02:00',
12
    allDaySlot: false,
13
    scrollTime: '08:00',
14
    businessHours: {
15
      start: chrono.business_hours_start,
16
      end: chrono.business_hours_end,
17
    },
18
    events: events_source_url,
19
    eventOverlap: function(stillEvent, movingEvent) {
20
      return true;
21
    },
22
    editable: true,
23
    selectable: true,
24
    selectHelper: true,
25
    select: function(start, end) {
26
      if (start.isBefore(moment())){
27
          $('#calendar').fullCalendar('unselect');
28
          return false;
29
      }
30
      var title = chrono.event_default_title;
31
      var eventData;
32
      if (title && title.trim()) {
33
        eventData = {
34
          title: title,
35
          start: start,
36
          end: end
37
        };
38
        calendar.fullCalendar('renderEvent', eventData);
39
      }
40
      calendar.fullCalendar('unselect');
41
    },
42
    eventRender: function(event, element) {
43
      var start = moment(event.start).fromNow();
44
      element.attr('title', start);
45
    },
46
    loading: function() {
47
    },
48
    eventClick: function(calEvent, jsEvent, view){
49
        if (calEvent.source){
50
            console.log(calEvent.source);
51
            return false;
52
        }
53
        params = {
54
            start: calEvent.start.format(),
55
            end: calEvent.end.format()
56
        }
57
        form_url = events_booking_url + '?' + $.param(params)
58
        $.ajax({
59
            type: "POST",
60
            url: events_booking_url,
61
            data: JSON.stringify({
62
                'start': calEvent.start.format(),
63
                'end': calEvent.end.format()
64
            }),
65
            dataType: 'json',
66
            success: function(response){
67
                console.log(response.url);
68
                window.location = response.url;
69
            },
70
            error: function(xhr, status, error){
71
                console.log(JSON.stringify(status));
72
            }
73
        });
74
    }
75
  });
76
});
combo/apps/chrono/templates/chrono/calendar.html
1
<h2>Calendar</h2>
2

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

  
9
<div id="calendar">
10
</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': 'unselected',
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
        if data.get(value):
58
            session_vars.update({key: data[value]})
59
        else:
60
            session_vars.update({key: value})
61
    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
from django.shortcuts import get_object_or_404
19
from django.http import JsonResponse, HttpResponseRedirect, HttpResponseNotAllowed
20
from django.views.decorators.csrf import csrf_exempt
21
from django.views.generic import View
22
from django.views.generic.detail import SingleObjectMixin
23

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

  
27

  
28
class EventsView(SingleObjectMixin, View):
29

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

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

  
38

  
39
class BookingView(SingleObjectMixin, View):
40

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

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

  
48
    def post(self, request, *args, **kwargs):
49
        data = json.loads(request.body)
50
        cell = self.get_object()
51
        params = build_session_vars(cell, data)
52
        params = '&'.join(['%s=%s' % (key, value) for key, value in params.items()])
53
        url = '%s?%s' % (cell.form_url, 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)
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'] == 'unselected'
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
-