From 7a65dbaddb130ea9fcac128ad817cbdd037640f7 Mon Sep 17 00:00:00 2001 From: Serghei Mihai Date: Mon, 15 May 2017 19:01:38 +0200 Subject: [PATCH 2/2] maps: add map cell (#8454) --- combo/apps/maps/migrations/0002_map.py | 39 +++++++++ combo/apps/maps/models.py | 70 ++++++++++++++++ combo/apps/maps/static/css/combo.map.css | 90 ++++++++++++++++++++ combo/apps/maps/static/js/combo.map.js | 53 ++++++++++++ combo/apps/maps/templates/maps/map_cell.html | 3 + combo/apps/maps/urls.py | 4 + combo/apps/maps/views.py | 36 ++++++++ combo/manager/templates/combo/manager_base.html | 4 + combo/settings.py | 12 +++ debian/control | 1 + requirements.txt | 1 + setup.py | 1 + tests/test_maps_cells.py | 106 ++++++++++++++++++++++++ 13 files changed, 420 insertions(+) create mode 100644 combo/apps/maps/migrations/0002_map.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_cell.html create mode 100644 combo/apps/maps/views.py create mode 100644 tests/test_maps_cells.py diff --git a/combo/apps/maps/migrations/0002_map.py b/combo/apps/maps/migrations/0002_map.py new file mode 100644 index 0000000..de5606a --- /dev/null +++ b/combo/apps/maps/migrations/0002_map.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0027_page_picture'), + ('auth', '0006_require_contenttypes_0002'), + ('maps', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Map', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('placeholder', models.CharField(max_length=20)), + ('order', models.PositiveIntegerField()), + ('slug', models.SlugField(verbose_name='Slug', blank=True)), + ('extra_css_class', models.CharField(max_length=100, verbose_name='Extra classes for CSS styling', blank=True)), + ('public', models.BooleanField(default=True, verbose_name='Public')), + ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')), + ('last_update_timestamp', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=150, verbose_name='Title', blank=True)), + ('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')])), + ('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')])), + ('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')])), + ('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)), + ('layers', models.ManyToManyField(to='maps.MapLayer', verbose_name='Layers', blank=True)), + ('page', models.ForeignKey(to='data.Page')), + ], + options={ + 'verbose_name': 'Map', + }, + ), + ] diff --git a/combo/apps/maps/models.py b/combo/apps/maps/models.py index 371755f..80c7a85 100644 --- a/combo/apps/maps/models.py +++ b/combo/apps/maps/models.py @@ -18,7 +18,14 @@ from django.db import models from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ +from django.core.urlresolvers import reverse_lazy +from django import forms +from django import template +from django.conf import settings +from django.core.exceptions import PermissionDenied +from combo.data.models import CellBase +from combo.data.library import register_cell_class from combo.utils import requests @@ -38,6 +45,13 @@ ICONS = [ ('truck', _('Truck')), ] +ZOOM_LEVELS = [ ('0', _('Whole world')), + ('9', _('Wide area')), + ('11', _('Area')), + ('13', _('Town')), + ('16', _('Small road')), + ('19', _('Ant')),] + class MapLayer(models.Model): label = models.CharField(_('Label'), max_length=128) @@ -84,3 +98,59 @@ class MapLayer(models.Model): feature['properties']['label'] = self.label feature['properties']['icon'] = self.icon return features + + +@register_cell_class +class Map(CellBase): + title = models.CharField(_('Title'), max_length=150, blank=True) + initial_zoom = models.CharField(_('Initial zoom level'), max_length=2, + choices=ZOOM_LEVELS, default='13') + min_zoom = models.CharField(_('Minimal zoom level'), max_length=2, + choices=ZOOM_LEVELS, default='0') + max_zoom = models.CharField(_('Maximal zoom level'), max_length=2, + choices=ZOOM_LEVELS, default=19) + layers = models.ManyToManyField(MapLayer, verbose_name=_('Layers'), blank=True) + + template_name = 'maps/map_cell.html' + + class Meta: + verbose_name = _('Map') + + class Media: + js = ('xstatic/leaflet.js', 'js/combo.map.js') + css = {'all': ('xstatic/leaflet.css', 'css/combo.map.css')} + + def get_default_position(self): + return settings.COMBO_MAP_DEFAULT_POSITION + + def get_default_form_class(self): + fields = ('title', 'initial_zoom', 'min_zoom', + 'max_zoom', 'layers') + widgets = {'layers': forms.widgets.CheckboxSelectMultiple} + return forms.models.modelform_factory(self.__class__, fields=fields, + widgets=widgets) + + def get_geojson(self, request): + geojson = {'type': 'FeatureCollection', 'features': []} + for layer in self.layers.all(): + geojson['features'] += layer.get_geojson(request) + return geojson + + + @classmethod + def is_enabled(cls): + return MapLayer.objects.count() > 0 + + def get_cell_extra_context(self, context): + ctx = super(Map, self).get_cell_extra_context(context) + ctx['title'] = self.title + default_position = self.get_default_position() + ctx['init_lat'] = default_position['lat'] + ctx['init_lng'] = default_position['lng'] + ctx['initial_zoom'] = self.initial_zoom + ctx['min_zoom'] = self.min_zoom + ctx['max_zoom'] = self.max_zoom + ctx['geojson_url'] = reverse_lazy('mapcell-geojson', kwargs={'cell_id': self.pk}) + ctx['tile_urltemplate'] = settings.COMBO_MAP_TILE_URLTEMPLATE + ctx['map_attribution'] = settings.COMBO_MAP_ATTRIBUTION + return ctx diff --git a/combo/apps/maps/static/css/combo.map.css b/combo/apps/maps/static/css/combo.map.css new file mode 100644 index 0000000..0cfcc9f --- /dev/null +++ b/combo/apps/maps/static/css/combo.map.css @@ -0,0 +1,90 @@ +div.combo-cell-map { + height: 60vh; +} + +/* leaflet styles */ + +div.leaflet-div-icon span { + width: 2.3rem; + height: 2.3rem; + display: block; + left: -1rem; + top: -1rem; + position: relative; + border-radius: 11rem 6rem 0.8rem; + transform: scale(1, 1.3) rotate(45deg); + border: 1px solid #aaa; +} + +div.leaflet-popup-content span { + display: block; +} + +div.leaflet-popup-content span.field-value { + font-weight: bold; +} + +div.leaflet-div-icon span i:before { + display: inline-block; + margin: 9px; + transform: scale(1.1) rotate(-45deg); +} + +/* leaflet markers icons */ + +i.leaflet-marker-icon { + font: normal normal normal 1rem/1 FontAwesome; +} + +i.leaflet-marker-icon.home::before { + content: "\f015"; /* home */ +} + +i.leaflet-marker-icon.building::before { + content: "\f0f7"; /* building */ +} + +i.leaflet-marker-icon.hospital::before { + content: "\f0f8"; /* hospital */ +} + +i.leaflet-marker-icon.ambulance::before { + content: "\f0f9"; /* ambulance */ +} + +i.leaflet-marker-icon.taxi::before { + content: "\f1ba"; /* taxi */ +} + +i.leaflet-marker-icon.subway::before { + content: "\f239"; /* subway */ +} + +i.leaflet-marker-icon.wheelchair::before { + content: "\f193"; /* wheelchair */ +} + +i.leaflet-marker-icon.bicycle::before { + content: "\f206"; /* bicycle */ +} + +i.leaflet-marker-icon.car::before { + content: "\f1b9"; /* car */ +} + +i.leaflet-marker-icon.train::before { + content: "\f238"; /* train */ +} + +i.leaflet-marker-icon.bus::before { + content: "\f207"; /* bus */ +} + +i.leaflet-marker-icon.motorcycle::before { + content: "\f21c"; /* motorcycle */ +} + +i.leaflet-marker-icon.truck::before { + content: "\f0d1"; /* truck */ +} + diff --git a/combo/apps/maps/static/js/combo.map.js b/combo/apps/maps/static/js/combo.map.js new file mode 100644 index 0000000..5059252 --- /dev/null +++ b/combo/apps/maps/static/js/combo.map.js @@ -0,0 +1,53 @@ +$(function() { + function render_map(cell) { + var $map_widget = $(cell).find('div.combo-cell-map'); + var map_options = Object(); + var initial_zoom = parseInt($map_widget.data('init-zoom')); + if (! isNaN(initial_zoom)) { + map_options.zoom = initial_zoom; + } else { + map_options.zoom = 13; + } + var max_zoom = parseInt($map_widget.data('max_zoom')); + if (!isNaN(max_zoom)) map_options.maxZoom = max_zoom; + var min_zoom = parseInt($map_widget.data('min-zoom')); + if (!isNaN(min_zoom)) map_options.minZoom = min_zoom; + var latlng = [$map_widget.data('init-lat'), $map_widget.data('init-lng')]; + var geojson_url = $map_widget.data('geojson-url'); + var map_tile_url = $map_widget.data('tile-urltemplate'); + var map_attribution = $map_widget.data('map-attribution'); + var map = L.map($map_widget[0], map_options); + var store_position_selector = $map_widget.data('store-position'); + map.setView(latlng, map_options.zoom); + + L.tileLayer(map_tile_url, + { + attribution: map_attribution + }).addTo(map); + if (geojson_url) { + $.getJSON(geojson_url, function(data) { + var geo_json = L.geoJson(data, { + onEachFeature: function(feature, layer) { + $(cell).trigger('combo:map-feature-click', {'feature': feature, 'layer': layer}); + }, + pointToLayer: function (feature, latlng) { + var markerStyles = "background-color: "+feature.properties.colour+";"; + marker = L.divIcon({iconAnchor: [0, 30], + popupAnchor: [5, -45], + html: '' + }); + return L.marker(latlng, {icon: marker}); + } + }); + var bounds = geo_json.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds); + geo_json.addTo(map); + } + }); + } + }; + $('div.cell.map').each(function() { + render_map(this); + }); +}); diff --git a/combo/apps/maps/templates/maps/map_cell.html b/combo/apps/maps/templates/maps/map_cell.html new file mode 100644 index 0000000..1e7c24d --- /dev/null +++ b/combo/apps/maps/templates/maps/map_cell.html @@ -0,0 +1,3 @@ +{% if title %}

{{ title }}

{% endif %} +
+
diff --git a/combo/apps/maps/urls.py b/combo/apps/maps/urls.py index a57574c..20e97e2 100644 --- a/combo/apps/maps/urls.py +++ b/combo/apps/maps/urls.py @@ -21,6 +21,8 @@ from combo.urls_utils import decorated_includes, manager_required from .manager_views import (ManagerHomeView, LayerAddView, LayerEditView, LayerDeleteView) +from .views import GeojsonView + maps_manager_urls = [ url('^$', ManagerHomeView.as_view(), name='maps-manager-homepage'), url('^layers/add/$', LayerAddView.as_view(), name='maps-manager-layer-add'), @@ -33,4 +35,6 @@ maps_manager_urls = [ urlpatterns = [ url(r'^manage/maps/', decorated_includes(manager_required, include(maps_manager_urls))), + url(r'^ajax/mapcell/geojson/(?P\w+)/$', GeojsonView.as_view(), + name='mapcell-geojson'), ] diff --git a/combo/apps/maps/views.py b/combo/apps/maps/views.py new file mode 100644 index 0000000..6877e22 --- /dev/null +++ b/combo/apps/maps/views.py @@ -0,0 +1,36 @@ +# combo - content management system +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json + +from django.views.generic.base import View +from django.http import HttpResponse, Http404, HttpResponseForbidden + +from .models import Map + + +class GeojsonView(View): + + def get(self, request, *args, **kwargs): + try: + cell = Map.objects.get(pk=kwargs['cell_id']) + except Map.DoesNotExist: + raise Http404() + if cell.page.is_visible(request.user) and cell.is_visible(request.user): + geojson = cell.get_geojson(request) + content_type = 'application/json' + return HttpResponse(json.dumps(geojson), content_type=content_type) + return HttpResponseForbidden() diff --git a/combo/manager/templates/combo/manager_base.html b/combo/manager/templates/combo/manager_base.html index 943352c..0de95c9 100644 --- a/combo/manager/templates/combo/manager_base.html +++ b/combo/manager/templates/combo/manager_base.html @@ -3,6 +3,8 @@ {% block css %} + + {% endblock %} {% block page-title %}{% firstof site_title "Combo" %}{% endblock %} {% block site-title %}{% firstof site_title "Combo" %}{% endblock %} @@ -30,4 +32,6 @@ + + {% endblock %} diff --git a/combo/settings.py b/combo/settings.py index 3a678fe..778b6f0 100644 --- a/combo/settings.py +++ b/combo/settings.py @@ -80,6 +80,7 @@ INSTALLED_APPS = ( 'combo.apps.maps', 'haystack', 'xstatic.pkg.chartnew_js', + 'xstatic.pkg.leaflet', ) INSTALLED_APPS = plugins.register_plugins_apps(INSTALLED_APPS) @@ -283,6 +284,17 @@ COMBO_WELCOME_PAGE_PATH = None # dashboard support COMBO_DASHBOARD_ENABLED = False +# default position on maps +COMBO_MAP_DEFAULT_POSITION = {'lat': '48.83369263315934', + 'lng': '2.3233688436448574' + } + +# default map tiles url +COMBO_MAP_TILE_URLTEMPLATE = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + +# default combo map attribution +COMBO_MAP_ATTRIBUTION = 'Map data © OpenStreetMap contributors, CC-BY-SA' + local_settings_file = os.environ.get('COMBO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')) if os.path.exists(local_settings_file): diff --git a/debian/control b/debian/control index 388f2ca..f82fb8e 100644 --- a/debian/control +++ b/debian/control @@ -16,6 +16,7 @@ Depends: ${misc:Depends}, ${python:Depends}, python-feedparser, python-django-cmsplugin-blurp, python-xstatic-chartnew-js, + python-xstatic-leaflet, python-eopayment (>= 1.9), python-django-haystack (>= 2.4.0), python-sorl-thumbnail, diff --git a/requirements.txt b/requirements.txt index a1ee7e3..eb70fa9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ feedparser django-jsonfield requests XStatic-ChartNew.js +XStatic-Leaflet eopayment>=1.13 python-dateutil djangorestframework>=3.3, <3.4 diff --git a/setup.py b/setup.py index 90513dd..280cbf5 100644 --- a/setup.py +++ b/setup.py @@ -111,6 +111,7 @@ setup( 'django-jsonfield', 'requests', 'XStatic-ChartNew.js', + 'XStatic-Leaflet', 'eopayment>=1.13', 'python-dateutil', 'djangorestframework>=3.3, <3.4', diff --git a/tests/test_maps_cells.py b/tests/test_maps_cells.py new file mode 100644 index 0000000..3154ca7 --- /dev/null +++ b/tests/test_maps_cells.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +import pytest + +from django.contrib.auth.models import User +from django.test.client import RequestFactory +from django.template import Context +from django.test import Client +from django.core.urlresolvers import reverse +from django.contrib.auth.models import Group + +from combo.data.models import Page +from combo.apps.maps.models import MapLayer, Map + +pytestmark = pytest.mark.django_db + +client = Client() + +@pytest.fixture +def user(): + try: + user = User.objects.get(username='admin') + except User.DoesNotExist: + user = User.objects.create_user('admin', email=None, password='admin') + return user + +@pytest.fixture +def layer(): + try: + layer = MapLayer.objects.get() + except MapLayer.DoesNotExist: + layer = MapLayer() + layer.geojson_url = 'http://example.net/geojson' + layer.marker_colour = 'FF0000' + layer.icon = 'fa-bicycle' + layer.icon_colour = '0000FF' + layer.save() + return layer + +def login(username='admin', password='admin'): + resp = client.post('/login/', {'username': username, 'password': password}) + assert resp.status_code == 302 + +def test_cell_disabled(): + MapLayer.objects.all().delete() + assert Map.is_enabled() is False + +def test_cell_enabled(layer): + assert Map.is_enabled() is True + +def test_cell_rendering(layer): + page = Page(title='xxx', slug='test_map_cell', template_name='standard') + page.save() + cell = Map(page=page, placeholder='content', order=0, title = 'Map with points') + cell.save() + cell.layers.add(layer) + context = Context({'request': RequestFactory().get('/')}) + rendered = cell.render(context) + assert 'data-init-zoom="13"' in rendered + assert 'data-min-zoom="0"' in rendered + assert 'data-max-zoom="19"' in rendered + assert 'data-init-lat="48.83369263315934"' in rendered + assert 'data-init-lng="2.3233688436448574"' in rendered + assert 'data-geojson-url="/ajax/mapcell/geojson/1/"' in rendered + resp = client.get('/test_map_cell/') + assert 'xstatic/leaflet.js' in resp.content + assert 'js/combo.map.js' in resp.content + assert 'xstatic/leaflet.css' in resp.content + assert 'css/combo.map.css' in resp.content + + +def test_get_geojson_on_non_public_page(layer): + page = Page(title='xxx', slug='new', template_name='standard', + public=False) + page.save() + cell = Map(page=page, placeholder='content', order=0, + title = 'Map with points') + cell.save() + cell.layers.add(layer) + resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id})) + assert resp.status_code == 403 + +def test_get_geojson_on_non_publik_cell(layer): + page = Page(title='xxx', slug='new', template_name='standard') + page.save() + cell = Map(page=page, placeholder='content', order=0, public=False, + title = 'Map with points') + cell.save() + cell.layers.add(layer) + resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id})) + assert resp.status_code == 403 + +def test_geojson_on_restricted_cell(layer, user): + page = Page(title='xxx', slug='new', template_name='standard') + page.save() + group = Group.objects.create(name='map tester') + cell = Map(page=page, placeholder='content', order=0, public=False) + cell.title = 'Map with points' + cell.save() + cell.layers.add(layer) + cell.groups.add(group) + login() + resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id})) + assert resp.status_code == 403 + user.groups.add(group) + user.save() + resp = client.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id})) -- 2.11.0