maps: add map cell (#8454)

This commit is contained in:
Serghei Mihai 2017-05-15 19:01:38 +02:00 committed by Serghei Mihai
parent 11da88d42c
commit 85ddc786e7
13 changed files with 419 additions and 0 deletions

View File

@ -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',
},
),
]

View File

@ -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,58 @@ 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

View File

@ -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 */
}

View File

@ -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: '<span style="' + markerStyles + '"><i class="leaflet-marker-icon '+feature.properties.icon+'" style="color:'+feature.properties.icon_colour+'"></i></span>'
});
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);
});
});

View File

@ -0,0 +1,3 @@
{% if title %}<h2>{{ title }}</h2>{% endif %}
<div class="combo-cell-map" data-init-zoom="{{ initial_zoom }}" data-min-zoom="{{ min_zoom }}" data-max-zoom="{{ max_zoom }}" data-init-lat="{{ init_lat }}" data-init-lng="{{ init_lng }}" data-geojson-url="{{ geojson_url }}" data-tile-urltemplate="{{ tile_urltemplate}}" data-map-attribution="{{ map_attribution}}">
</div>

View File

@ -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<cell_id>\w+)/$', GeojsonView.as_view(),
name='mapcell-geojson'),
]

36
combo/apps/maps/views.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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()

View File

@ -3,6 +3,8 @@
{% block css %}
<link rel="stylesheet" type="text/css" media="all" href="{{ STATIC_URL }}css/combo.manager.css"/>
<link rel="stylesheet" type="text/css" media="all" href="{% static "xstatic/leaflet.css" %}"></script>
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/combo.map.css" %}"></script>
{% endblock %}
{% block page-title %}{% firstof site_title "Combo" %}{% endblock %}
{% block site-title %}{% firstof site_title "Combo" %}{% endblock %}
@ -30,4 +32,6 @@
<script src="{% static "ckeditor/ckeditor/ckeditor.js" %}"></script>
<script type="text/javascript" src="{% static "ckeditor/ckeditor-init.js" %}"></script>
<script src="{% static "js/combo.manager.js" %}"></script>
<script src="{% static "xstatic/leaflet.js" %}"></script>
<script src="{% static "js/combo.map.js" %}"></script>
{% endblock %}

View File

@ -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 &copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'
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):

1
debian/control vendored
View File

@ -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,

View File

@ -6,6 +6,7 @@ feedparser
django-jsonfield
requests
XStatic-ChartNew.js
XStatic-Leaflet
eopayment>=1.13
python-dateutil
djangorestframework>=3.3, <3.4

View File

@ -111,6 +111,7 @@ setup(
'django-jsonfield',
'requests',
'XStatic-ChartNew.js',
'XStatic-Leaflet',
'eopayment>=1.13',
'python-dateutil',
'djangorestframework>=3.3, <3.4',

106
tests/test_maps_cells.py Normal file
View File

@ -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}))