maps: add map cell (#8454)
This commit is contained in:
parent
11da88d42c
commit
85ddc786e7
|
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
@ -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()
|
|
@ -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 %}
|
||||
|
|
|
@ -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 © <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):
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -6,6 +6,7 @@ feedparser
|
|||
django-jsonfield
|
||||
requests
|
||||
XStatic-ChartNew.js
|
||||
XStatic-Leaflet
|
||||
eopayment>=1.13
|
||||
python-dateutil
|
||||
djangorestframework>=3.3, <3.4
|
||||
|
|
1
setup.py
1
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',
|
||||
|
|
|
@ -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}))
|
Loading…
Reference in New Issue