Projet

Général

Profil

0002-maps-map-cell-8454.patch

Voir les différences:

Subject: [PATCH 2/3] maps: map cell (#8454)

 combo/apps/maps/migrations/0002_mapcell.py      |  41 +++++++++
 combo/apps/maps/models.py                       |  92 ++++++++++++++++++++
 combo/apps/maps/static/css/combo.map.css        |  32 +++++++
 combo/apps/maps/static/js/combo.map.js          |  71 ++++++++++++++++
 combo/apps/maps/templates/maps/map_widget.html  |   4 +
 combo/apps/maps/templates/maps/mapcell.html     |   3 +
 combo/apps/maps/urls.py                         |   4 +
 combo/apps/maps/views.py                        |  35 ++++++++
 combo/manager/templates/combo/manager_base.html |   4 +
 combo/settings.py                               |  10 +++
 debian/control                                  |   1 +
 requirements.txt                                |   1 +
 setup.py                                        |   1 +
 tests/test_maps_cells.py                        | 106 ++++++++++++++++++++++++
 14 files changed, 405 insertions(+)
 create mode 100644 combo/apps/maps/migrations/0002_mapcell.py
 create mode 100644 combo/apps/maps/static/css/combo.map.css
 create mode 100644 combo/apps/maps/static/js/combo.map.js
 create mode 100644 combo/apps/maps/templates/maps/map_widget.html
 create mode 100644 combo/apps/maps/templates/maps/mapcell.html
 create mode 100644 combo/apps/maps/views.py
 create mode 100644 tests/test_maps_cells.py
combo/apps/maps/migrations/0002_mapcell.py
1
# -*- coding: utf-8 -*-
2
from __future__ import unicode_literals
3

  
4
from django.db import migrations, models
5
from django.conf import settings
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    dependencies = [
11
        ('auth', '0006_require_contenttypes_0002'),
12
        ('data', '0026_jsoncell_force_async'),
13
        ('maps', '0001_initial'),
14
    ]
15

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='MapCell',
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=150, verbose_name='Title', blank=True)),
29
                ('default_position', models.CharField(default=settings.COMBO_MAP_DEFAULT_POSITION, max_length=128, null=True, verbose_name='Default position', blank=True)),
30
                ('initial_zoom', models.CharField(default=b'13', max_length=2, verbose_name='Initial zoom level', choices=[(b'0', 'Whole world'), (b'9', 'Wide area'), (b'11', 'Area'), (b'13', 'Town'), (b'16', 'Small road'), (b'19', 'Ant')])),
31
                ('min_zoom', models.CharField(default=b'0', max_length=2, verbose_name='Minimal zoom level', choices=[(b'0', 'Whole world'), (b'9', 'Wide area'), (b'11', 'Area'), (b'13', 'Town'), (b'16', 'Small road'), (b'19', 'Ant')])),
32
                ('max_zoom', models.CharField(default=19, max_length=2, verbose_name='Maximal zoom level', choices=[(b'0', 'Whole world'), (b'9', 'Wide area'), (b'11', 'Area'), (b'13', 'Town'), (b'16', 'Small road'), (b'19', 'Ant')])),
33
                ('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
34
                ('layers', models.ManyToManyField(to='maps.MapLayer', verbose_name='Layers', blank=True)),
35
                ('page', models.ForeignKey(to='data.Page')),
36
            ],
37
            options={
38
                'verbose_name': 'Map Cell',
39
            },
40
        ),
41
    ]
combo/apps/maps/models.py
17 17

  
18 18
from django.db import models
19 19
from django.utils.translation import ugettext_lazy as _
20
from django.core.urlresolvers import reverse_lazy
21
from django import forms
22
from django import template
23
from django.conf import settings
24
from django.core.exceptions import PermissionDenied
25

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

  
29
zoom_levels = [ ('0', _('Whole world')),
30
                ('9', _('Wide area')),
31
                ('11', _('Area')),
32
                ('13', _('Town')),
33
                ('16', _('Small road')),
34
                ('19', _('Ant')),]
20 35

  
21 36
from combo.utils import requests
22 37

  
23 38

  
39
class MapWidget(forms.TextInput):
40
    template_name = 'maps/map_widget.html'
41

  
42
    def render(self, name, value, attrs):
43
        final_attrs = self.build_attrs(attrs, name=name, value=value,
44
                                       type='hidden')
45
        cell_form_template = template.loader.get_template(self.template_name)
46
        return cell_form_template.render(final_attrs)
47

  
48

  
24 49
class MapLayer(models.Model):
25 50
    label = models.CharField(_('Label'), max_length=128)
26 51
    geojson_url = models.URLField(_('Geojson URL'))
......
46 71
                feature['properties']['label'] = self.label
47 72
                feature['properties']['icon'] = self.icon
48 73
        return features
74

  
75

  
76
@register_cell_class
77
class MapCell(CellBase):
78
    title = models.CharField(_('Title'), max_length=150, blank=True)
79
    default_position = models.CharField(_('Default position'), null=True, blank=True,
80
                                        default=settings.COMBO_MAP_DEFAULT_POSITION,
81
                                        max_length=128)
82
    initial_zoom = models.CharField(_('Initial zoom level'), max_length=2,
83
                                    choices=zoom_levels, default='13')
84
    min_zoom = models.CharField(_('Minimal zoom level'), max_length=2,
85
                                   choices=zoom_levels, default='0')
86
    max_zoom = models.CharField(_('Maximal zoom level'), max_length=2,
87
                                choices=zoom_levels, default=19)
88
    layers = models.ManyToManyField(MapLayer, verbose_name=_('Layers'), blank=True)
89

  
90
    template_name = 'maps/mapcell.html'
91

  
92
    class Meta:
93
        verbose_name = _('Map Cell')
94

  
95
    class Media:
96
        js = ('xstatic/leaflet.js', 'js/combo.map.js')
97
        css = {'all': ('xstatic/leaflet.css', 'xstatic/css/font-awesome.min.css',
98
                       'css/combo.map.css')}
99

  
100
    def get_default_form_class(self):
101
        fields = ('title', 'default_position', 'initial_zoom', 'min_zoom',
102
                  'max_zoom', 'layers')
103
        lat, lng = self.default_position.split(';')
104
        widgets = {'layers': forms.widgets.CheckboxSelectMultiple,
105
                   'default_position': MapWidget(attrs={'init_lat': lat,
106
                                                        'init_lng': lng,
107
                                                        'init_zoom': self.initial_zoom,
108
                                                        'min_zoom': self.min_zoom,
109
                                                        'max_zoom': self.max_zoom,
110
                                                        'tile_urltemplate': settings.COMBO_MAP_TILE_URLTEMPLATE,
111
                                                        'map_attribution': settings.COMBO_MAP_ATTRIBUTION})}
112
        return forms.models.modelform_factory(self.__class__, fields=fields,
113
                                             widgets=widgets)
114

  
115
    def get_geojson(self, request):
116
        geojson = {'type': 'FeatureCollection', 'features': []}
117
        if not self.page.is_visible(request.user):
118
            raise PermissionDenied()
119
        if not self.is_visible(request.user):
120
            raise PermissionDenied()
121
        for layer in self.layers.all():
122
            geojson['features'] += layer.get_geojson()
123
        return geojson
124

  
125

  
126
    @classmethod
127
    def is_enabled(cls):
128
        return MapLayer.objects.count() > 0
129

  
130
    def get_cell_extra_context(self, context):
131
        ctx = super(MapCell, self).get_cell_extra_context(context)
132
        ctx['title'] = self.title
133
        ctx['initial_lat'], ctx['initial_lng'] = self.default_position.split(';');
134
        ctx['initial_zoom'] = self.initial_zoom
135
        ctx['min_zoom'] = self.min_zoom
136
        ctx['max_zoom'] = self.max_zoom
137
        ctx['geojson_url'] = reverse_lazy('mapcell-geojson', kwargs={'cell_id': self.pk})
138
        ctx['tile_urltemplate'] = settings.COMBO_MAP_TILE_URLTEMPLATE
139
        ctx['map_attribution'] = settings.COMBO_MAP_ATTRIBUTION
140
        return ctx
combo/apps/maps/static/css/combo.map.css
1
div.combo-cell-map {
2
    height: 60vh;
3
}
4

  
5
/* leaflet styles */
6

  
7
div.leaflet-div-icon span {
8
    width: 2.3rem;
9
    height: 2.3rem;
10
    display: block;
11
    left: -1rem;
12
    top: -1rem;
13
    position: relative;
14
    border-radius: 11rem 6rem 0.8rem;
15
    transform: scale(1, 1.3) rotate(45deg);
16
    border: 1px solid #aaa;
17
}
18

  
19
div.leaflet-popup-content span {
20
    display: block;
21
}
22

  
23
div.leaflet-popup-content span.field-label {
24
    font-weight: bold;
25
    text-transform: capitalize;
26
}
27

  
28
div.leaflet-div-icon span i:before {
29
    display: inline-block;
30
    margin: 9px;
31
    transform: scale(1.1) rotate(-45deg);
32
}
combo/apps/maps/static/js/combo.map.js
1
$(function() {
2
    function render_map() {
3
        $('div.combo-cell-map').each(function() {
4
            var $map_widget = $(this);
5
            var map_options = Object();
6
            var initial_zoom = parseInt($map_widget.data('init-zoom'));
7
            if (! isNaN(initial_zoom)) {
8
                map_options.zoom = initial_zoom;
9
            } else {
10
                map_options.zoom = 13;
11
            }
12
            var max_zoom = parseInt($map_widget.data('max_zoom'));
13
            if (!isNaN(max_zoom)) map_options.maxZoom = max_zoom;
14
            var min_zoom = parseInt($map_widget.data('min-zoom'));
15
            if (!isNaN(min_zoom)) map_options.minZoom = min_zoom;
16
            var latlng = [$map_widget.data('init-lat'), $map_widget.data('init-lng')];
17
            var geojson_url = $map_widget.data('geojson-url');
18
            var map_tile_url = $map_widget.data('tile-urltemplate');
19
            var map_attribution = $map_widget.data('map-attribution');
20
            var map = L.map(this, map_options);
21
            var store_position_selector = $map_widget.data('store-position');
22
            map.setView(latlng, map_options.zoom);
23

  
24
            L.tileLayer(map_tile_url,
25
                {
26
                    attribution: map_attribution
27
                }).addTo(map);
28
            if (store_position_selector) {
29
                map.marker = L.marker(latlng);
30
                map.marker.addTo(map);
31
                var hidden = $('input#' + store_position_selector);
32
                map.on('click', function(e) {
33
                    map.marker.setLatLng(e.latlng);
34
                    hidden.val(e.latlng.lat + ';' + e.latlng.lng);
35
                });
36
            }
37
            if (geojson_url) {
38
                $.getJSON(geojson_url, function(data) {
39
                    var geo_json = L.geoJson(data, {
40
                        onEachFeature: function(feature, layer) {
41
                            if (feature.display_fields) {
42
                                var popup = '';
43
                                $.each(feature.display_fields, function(key, value) {
44
                                    popup += '<p class="popup-field"><span class="field-label">' + key + '</span>';
45
                                    popup += '<span class="field-value">' + value + '</span></p>';
46
                                });
47
                            } else {
48
                                var popup = '<p class="popup-field">' + feature.properties.label + '</p>';
49
                            }
50
                            layer.bindPopup(popup);
51
                        },
52
                        pointToLayer: function (feature, latlng) {
53
                            var markerStyles = "background-color: "+feature.properties.colour+";";
54
                            marker = L.divIcon({iconAnchor: [0, 30],
55
                                                popupAnchor: [5, -45],
56
                                                html: '<span style="' + markerStyles + '"><i class="fa '+feature.properties.icon+'" style="color:'+feature.properties.icon_colour+'"></i></span>'
57
                                               });
58
                            return L.marker(latlng, {icon: marker});
59
                        }
60
                    });
61
                    map.fitBounds(geo_json.getBounds());
62
                    geo_json.addTo(map);
63
                });
64
            }
65
        });
66
    }
67
    $('div.combo-cell-map').parents('div.cell').on('combo:cellform-reloaded', function() {
68
        render_map();
69
    });
70
    $('div.combo-cell-map').parents('div.cell').trigger('combo:cellform-reloaded');
71
});
combo/apps/maps/templates/maps/map_widget.html
1
<div class="combo-cell-map" data-init-zoom="{{ init_zoom }}" data-min-zoom="{{ min_zoom }}" data-max-zoom="{{ max_zoom }}" data-init-lat="{{ init_lat }}" data-init-lng="{{ init_lng }}" data-store-position="combo-map-{{ id }}" data-tile-urltemplate="{{ tile_urltemplate}}" data-map-attribution="{{ map_attribution}}">
2
</div>
3

  
4
<input type="{{ type }}" name="{{ name }}" value="{{ value }}" id="combo-map-{{ id }}" />
combo/apps/maps/templates/maps/mapcell.html
1
<h2>{{ title }}</h2>
2
<div class="combo-cell-map" data-init-zoom="{{ initial_zoom }}" data-min-zoom="{{ min_zoom }}" data-max-zoom="{{ max_zoom }}" data-init-lat="{{ initial_lat }}" data-init-lng="{{ initial_lng }}" data-geojson-url="{{ geojson_url }}" data-tile-urltemplate="{{ tile_urltemplate}}" data-map-attribution="{{ map_attribution}}">
3
</div>
combo/apps/maps/urls.py
21 21
from .manager_views import (ManagerHomeView, LayersManagerView, LayerAddView,
22 22
                LayerEditView, LayerDeleteView)
23 23

  
24
from .views import GeojsonView
25

  
24 26
maps_manager_urls = [
25 27
    url('^$', ManagerHomeView.as_view(), name='maps-manager-homepage'),
26 28
    url('^layers/$', LayersManagerView.as_view(), name='maps-manager-layers-list'),
......
36 38
urlpatterns = [
37 39
    url(r'^manage/maps/', decorated_includes(manager_required,
38 40
        include(maps_manager_urls))),
41
    url(r'^ajax/mapcell/geojson/(?P<cell_id>\w+)/$', GeojsonView.as_view(),
42
        name='mapcell-geojson'),
39 43
]
combo/apps/maps/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

  
19
from django.views.generic.base import View
20
from django.http import HttpResponse, Http404
21

  
22
from .models import MapCell
23

  
24

  
25
class GeojsonView(View):
26

  
27
    def get(self, request, *args, **kwargs):
28
        try:
29
            cell = MapCell.objects.get(pk=kwargs['cell_id'])
30
        except MapCell.DoesNotExist:
31
            raise Http404()
32

  
33
        geojson = cell.get_geojson(request)
34
        content_type = 'application/json'
35
        return HttpResponse(json.dumps(geojson), content_type=content_type)
combo/manager/templates/combo/manager_base.html
3 3

  
4 4
{% block css %}
5 5
<link rel="stylesheet" type="text/css" media="all" href="{{ STATIC_URL }}css/combo.manager.css"/>
6
<link rel="stylesheet" type="text/css" media="all" href="{% static "xstatic/leaflet.css" %}"></script>
7
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/combo.map.css" %}"></script>
6 8
{% endblock %}
7 9
{% block page-title %}{% firstof site_title "Combo" %}{% endblock %}
8 10
{% block site-title %}{% firstof site_title "Combo" %}{% endblock %}
......
31 33
<script type="text/javascript" src="{% static "ckeditor/ckeditor-init.js" %}"></script>
32 34
<script src="{% static "js/combo.manager.js" %}"></script>
33 35
<script src="{% static "xstatic/jquery.colourpicker.js" %}"></script>
36
<script src="{% static "xstatic/leaflet.js" %}"></script>
37
<script src="{% static "js/combo.map.js" %}"></script>
34 38
{% endblock %}
combo/settings.py
82 82
    'xstatic.pkg.chartnew_js',
83 83
    'xstatic.pkg.font_awesome',
84 84
    'xstatic.pkg.jquery_colourpicker',
85
    'xstatic.pkg.leaflet',
85 86
)
86 87

  
87 88
INSTALLED_APPS = plugins.register_plugins_apps(INSTALLED_APPS)
......
285 286
# dashboard support
286 287
COMBO_DASHBOARD_ENABLED = False
287 288

  
289
# default position on maps
290
COMBO_MAP_DEFAULT_POSITION = '48.83369263315934;2.3233688436448574'
291

  
292
# default map tiles url
293
COMBO_MAP_TILE_URLTEMPLATE = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
294

  
295
# default combo map attribution
296
COMBO_MAP_ATTRIBUTION = 'Map data &copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'
297

  
288 298
local_settings_file = os.environ.get('COMBO_SETTINGS_FILE',
289 299
        os.path.join(os.path.dirname(__file__), 'local_settings.py'))
290 300
if os.path.exists(local_settings_file):
debian/control
18 18
    python-xstatic-chartnew-js,
19 19
    python-xstatic-font-awesome,
20 20
    python-xstatic-jquery-colourpicker,
21
    python-xstatic-leaflet,
21 22
    python-eopayment (>= 1.9),
22 23
    python-django-haystack (>= 2.4.0),
23 24
    python-sorl-thumbnail,
requirements.txt
8 8
XStatic-ChartNew.js
9 9
XStatic-Font-Awesome
10 10
Xstatic-Jquery-Colourpicker
11
XStatic-Leaflet
11 12
eopayment>=1.13
12 13
python-dateutil
13 14
djangorestframework>=3.3, <3.4
setup.py
113 113
        'XStatic-ChartNew.js',
114 114
        'XStatic-Font-Awesome',
115 115
        'XStatic-Jquery-Colourpicker',
116
        'XStatic-Leaflet',
116 117
        'eopayment>=1.13',
117 118
        'python-dateutil',
118 119
        'djangorestframework>=3.3, <3.4',
tests/test_maps_cells.py
1
# -*- coding: utf-8 -*-
2
import pytest
3

  
4
from django.contrib.auth.models import User
5
from django.test.client import RequestFactory
6
from django.template import Context
7
from django.test import Client
8
from django.core.urlresolvers import reverse
9
from django.contrib.auth.models import Group
10

  
11
from combo.data.models import Page
12
from combo.apps.maps.models import MapLayer, MapCell
13

  
14
pytestmark = pytest.mark.django_db
15

  
16
client = Client()
17

  
18
@pytest.fixture
19
def user():
20
    try:
21
        user = User.objects.get(username='admin')
22
    except User.DoesNotExist:
23
        user = User.objects.create_user('admin', email=None, password='admin')
24
    return user
25

  
26
@pytest.fixture
27
def layer():
28
    try:
29
        layer = MapLayer.objects.get()
30
    except MapLayer.DoesNotExist:
31
        layer = MapLayer()
32
        layer.geojson_url = 'http://example.net/geojson'
33
        layer.marker_colour = 'FF0000'
34
        layer.icon = 'fa-bicycle'
35
        layer.icon_colour = '0000FF'
36
        layer.save()
37
    return layer
38

  
39
def login(username='admin', password='admin'):
40
    resp = client.post('/login/', {'username': username, 'password': password})
41
    assert resp.status_code == 302
42

  
43
def test_cell_disabled():
44
    MapLayer.objects.all().delete()
45
    assert MapCell.is_enabled() is False
46

  
47
def test_cell_enabled(layer):
48
    assert MapCell.is_enabled() is True
49

  
50
def test_cell_rendering(layer):
51
    page = Page(title='xxx', slug='test_map_cell', template_name='standard')
52
    page.save()
53
    cell = MapCell(page=page, placeholder='content', order=0,
54
                   title = 'Map with points')
55
    cell.save()
56
    cell.layers.add(layer)
57
    context = Context({'request': RequestFactory().get('/')})
58
    rendered = cell.render(context)
59
    assert 'data-init-zoom="13"' in rendered
60
    assert 'data-min-zoom="0"' in rendered
61
    assert 'data-max-zoom="19"' in rendered
62
    assert 'data-init-lat="48.83369263315934"' in rendered
63
    assert 'data-init-lng="2.3233688436448574"' in rendered
64
    assert 'data-geojson-url="/ajax/mapcell/geojson/1/"' in rendered
65
    resp = client.get('/test_map_cell/')
66
    print dir(resp)
67
    assert 'xstatic/leaflet.js' in resp.content
68
    assert 'js/combo.map.js' in resp.content
69
    assert 'xstatic/leaflet.css' in resp.content
70
    assert 'xstatic/css/font-awesome.min.css' in resp.content
71
    assert 'css/combo.map.css' in resp.content
72

  
73

  
74
def test_get_geojson_on_non_public_page(layer):
75
    page = Page(title='xxx', slug='new', template_name='standard',
76
                public=False)
77
    page.save()
78
    cell = MapCell(page=page, placeholder='content', order=0,
79
                   title = 'Map with points')
80
    cell.save()
81
    cell.layers.add(layer)
82
    resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}))
83
    assert resp.status_code == 403
84

  
85
def test_get_geojson_on_non_publik_cell(layer):
86
    page = Page(title='xxx', slug='new', template_name='standard')
87
    page.save()
88
    cell = MapCell(page=page, placeholder='content', order=0, public=False,
89
                   title = 'Map with points')
90
    cell.save()
91
    cell.layers.add(layer)
92
    resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}))
93
    assert resp.status_code == 403
94

  
95
def test_geojson_on_restricted_cell(layer, user):
96
    page = Page(title='xxx', slug='new', template_name='standard')
97
    page.save()
98
    group = Group.objects.create(name='map tester')
99
    cell = MapCell(page=page, placeholder='content', order=0, public=False)
100
    cell.title = 'Map with points'
101
    cell.save()
102
    cell.layers.add(layer)
103
    cell.groups.add(group)
104
    login()
105
    resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}))
106
    assert resp.status_code == 403
0
-