maps: add tiles layer (#22639)

This commit is contained in:
Lauréline Guérin 2020-02-10 16:02:44 +01:00
parent 34e627b0d8
commit 7b186a3dfb
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
9 changed files with 241 additions and 65 deletions

View File

@ -27,37 +27,47 @@ class IconRadioSelect(forms.RadioSelect):
option_template_name = 'maps/icon_radio_option.html'
class MapNewLayerForm(forms.ModelForm):
geojson_url = TemplatableURLField(label=_('Geojson URL'))
class Meta:
model = MapLayer
exclude = ('slug', 'cache_duration', 'include_user_identifier')
widgets = {'marker_colour': forms.TextInput(attrs={'type': 'color'}),
'icon_colour': forms.TextInput(attrs={'type': 'color'}),
}
def __init__(self, *args, **kwargs):
super(MapNewLayerForm, self).__init__(*args, **kwargs)
self.fields['icon'].choices = list(
sorted(self.fields['icon'].choices, key=lambda x: slugify(force_text(x[1]))))
class MapLayerForm(forms.ModelForm):
geojson_url = TemplatableURLField(label=_('Geojson URL'))
class Meta:
model = MapLayer
fields = '__all__'
widgets = {'marker_colour': forms.TextInput(attrs={'type': 'color'}),
'icon_colour': forms.TextInput(attrs={'type': 'color'}),
'icon': IconRadioSelect(),
}
exclude = ('kind',)
widgets = {
'marker_colour': forms.TextInput(attrs={'type': 'color'}),
'icon_colour': forms.TextInput(attrs={'type': 'color'}),
}
def __init__(self, *args, **kwargs):
super(MapLayerForm, self).__init__(*args, **kwargs)
if self.instance.pk is None:
# new instance, delete some fields
del self.fields['slug']
del self.fields['cache_duration']
del self.fields['include_user_identifier']
else:
# new widget for icon field
self.fields['icon'].widget = IconRadioSelect()
self.fields['icon'].choices = list(
sorted(self.fields['icon'].choices, key=lambda x: slugify(force_text(x[1]))))
if self.instance.kind == 'geojson':
todelete_fields = ['tiles_template_url', 'tiles_attribution', 'tiles_default']
else:
todelete_fields = [
'geojson_url', 'marker_colour', 'icon', 'icon_colour', 'cache_duration',
'include_user_identifier', 'properties']
for field in todelete_fields:
if field in self.fields:
del self.fields[field]
def clean(self):
cleaned_data = super(MapLayerForm, self).clean()
if self.instance.kind == 'tiles':
if MapLayer.objects.filter(kind='tiles', tiles_default=True).exclude(pk=self.instance.pk):
raise forms.ValidationError(_('Only one default tiles layer can be defined.'))
return cleaned_data
class MapLayerOptionsForm(forms.ModelForm):
@ -66,5 +76,9 @@ class MapLayerOptionsForm(forms.ModelForm):
fields = ['map_layer']
def __init__(self, *args, **kwargs):
self.kind = kwargs.pop('kind')
super(MapLayerOptionsForm, self).__init__(*args, **kwargs)
self.fields['map_layer'].queryset = self.instance.map_cell.get_free_layers()
if self.kind == 'geojson':
self.fields['map_layer'].queryset = self.instance.map_cell.get_free_geojson_layers()
else:
self.fields['map_layer'].queryset = self.instance.map_cell.get_free_tiles_layers()

View File

@ -24,7 +24,7 @@ from combo.data.models import CellBase, PageSnapshot
from .models import Map
from .models import MapLayer
from .models import MapLayerOptions
from .forms import MapNewLayerForm, MapLayerForm, MapLayerOptionsForm
from .forms import MapLayerForm, MapLayerOptionsForm
class MapLayerMixin(object):
@ -34,6 +34,7 @@ class MapLayerMixin(object):
class ManagerHomeView(MapLayerMixin, ListView):
template_name = 'maps/manager_home.html'
ordering = ['kind', 'label']
def get_context_data(self, **kwargs):
context = super(ManagerHomeView, self).get_context_data(**kwargs)
@ -42,9 +43,14 @@ class ManagerHomeView(MapLayerMixin, ListView):
class LayerAddView(MapLayerMixin, CreateView):
form_class = MapNewLayerForm
form_class = MapLayerForm
template_name = 'maps/map_layer_form.html'
def get_form_kwargs(self):
kwargs = super(LayerAddView, self).get_form_kwargs()
kwargs['instance'] = self.model(kind=self.kwargs['kind'])
return kwargs
class LayerEditView(MapLayerMixin, UpdateView):
form_class = MapLayerForm
@ -69,6 +75,7 @@ class MapCellAddLayer(CreateView):
def get_form_kwargs(self):
kwargs = super(MapCellAddLayer, self).get_form_kwargs()
kwargs['instance'] = MapLayerOptions(map_cell=self.cell)
kwargs['kind'] = self.kwargs['kind']
return kwargs
def form_valid(self, form):

View File

@ -20,7 +20,14 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='maplayer',
name='tiles_attribution',
field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='Attribution'),
field=models.CharField(
blank=True,
help_text=(
'For example: Map data © <a href="https://openstreetmap.org">OpenStreetMap</a> contributors, '
'<a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'),
max_length=1024,
null=True,
verbose_name='Attribution'),
),
migrations.AddField(
model_name='maplayer',
@ -30,6 +37,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='maplayer',
name='tiles_template_url',
field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='Tiles URL'),
field=models.CharField(
blank=True,
help_text='For example: https://tiles.entrouvert.org/hdm/{z}/{x}/{y}.png',
max_length=1024,
null=True,
verbose_name='Tiles URL'),
),
]

View File

@ -20,6 +20,7 @@ from django.core import serializers
from django.db import models
from django.utils import six
from django.utils.encoding import python_2_unicode_compatible
from django.utils.html import escape
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse_lazy
@ -33,6 +34,11 @@ from combo.data.library import register_cell_class
from combo.utils import requests, get_templated_url
KIND = [
('tiles', _('Tiles')),
('geojson', _('GeoJSON')),
]
ICONS = [
('ambulance', _('Ambulance')),
('asterisk', _('Asterisk')),
@ -96,6 +102,21 @@ class MapLayer(models.Model):
label = models.CharField(_('Label'), max_length=128)
slug = models.SlugField(_('Identifier'))
kind = models.CharField(max_length=10, choices=KIND, default='geojson')
tiles_template_url = models.CharField(
_('Tiles URL'),
max_length=1024,
help_text=_('For example: %s') % settings.COMBO_MAP_TILE_URLTEMPLATE,
blank=True, null=True)
tiles_attribution = models.CharField(
_('Attribution'),
max_length=1024,
help_text=_('For example: %s') % escape(settings.COMBO_MAP_ATTRIBUTION),
blank=True, null=True)
tiles_default = models.BooleanField(_('Default tiles layer'), default=False)
geojson_url = models.CharField(_('Geojson URL'), max_length=1024)
marker_colour = models.CharField(_('Marker colour'), max_length=7, default='#0000FF')
icon = models.CharField(_('Marker icon'), max_length=32, blank=True, null=True,
@ -298,7 +319,7 @@ class Map(CellBase):
def get_geojson(self, request):
geojson = {'type': 'FeatureCollection', 'features': []}
layers = self.layers.all()
layers = self.layers.filter(kind='geojson')
for layer in layers:
geojson['features'] += layer.get_geojson(request,
multiple_layers=bool(len(layers) > 1))
@ -326,9 +347,16 @@ class Map(CellBase):
ctx['marker_behaviour_onclick'] = self.marker_behaviour_onclick
return ctx
def get_free_layers(self):
used_layers = MapLayerOptions.objects.filter(map_cell=self).values('map_layer')
return MapLayer.objects.exclude(pk__in=used_layers)
def get_maplayer_options(self):
return self.maplayeroptions_set.order_by('map_layer__kind', 'map_layer__label')
def get_free_geojson_layers(self):
used_layers = self.maplayeroptions_set.values('map_layer')
return MapLayer.objects.filter(kind='geojson').exclude(pk__in=used_layers)
def get_free_tiles_layers(self):
used_layers = self.maplayeroptions_set.values('map_layer')
return MapLayer.objects.filter(kind='tiles').exclude(pk__in=used_layers)
def export_subobjects(self):
return {'layers': [x.get_as_serialized_object() for x in MapLayerOptions.objects.filter(map_cell=self)]}

View File

@ -3,9 +3,9 @@
{% block appbar %}
{% if form.instance.pk %}
<h2>{% trans "Edit layer" %}</h2>
<h2>{% if form.kind == 'geojson' %}{% trans "Edit GeoJSON layer" %}{% else %}{% trans "Edit tiles layer" %}{% endif %}</h2>
{% else %}
<h2>{% trans "New layer" %}</h2>
<h2>{% if form.kind == 'geojson' %}{% trans "New GeoJSON layer" %}{% else %}{% trans "New tiles layer" %}{% endif %}</h2>
{% endif %}
{% endblock %}

View File

@ -4,7 +4,8 @@
{% block appbar %}
<h2>{% trans 'Maps' %}</h2>
<span class="actions">
<a rel="popup" href="{% url 'maps-manager-layer-add' %}">{% trans 'New' %}</a>
<a rel="popup" href="{% url 'maps-manager-layer-add' kind='geojson' %}">{% trans 'New GeoJSON layer' %}</a>
<a rel="popup" href="{% url 'maps-manager-layer-add' kind='tiles' %}">{% trans 'New tiles layer' %}</a>
</span>
{% endblock %}
@ -13,8 +14,8 @@
<ul class="objects-list single-links layers">
{% for layer in object_list %}
<li>
<a class="layer-icon-{{ layer.icon }}" href="{% url 'maps-manager-layer-edit' slug=layer.slug %}">{{ layer.label }}</a>
<a rel="popup" class="delete" href="{% url 'maps-manager-layer-delete' slug=layer.slug %}">{% trans "remove" %}</a>
<a class="layer-icon-{{ layer.icon }}" href="{% url 'maps-manager-layer-edit' slug=layer.slug %}">{{ layer.label }} {% if layer.kind == 'tiles' %}({{ layer.get_kind_display }}{% if layer.tiles_default %}{% trans "default layer" %}{% endif %}){% endif %}</a>
<a rel="popup" class="delete" href="{% url 'maps-manager-layer-delete' slug=layer.slug %}">{% trans "remove" %}</a>
</li>
{% endfor %}
</ul>

View File

@ -3,14 +3,14 @@
{% block cell-form %}
{{ form.as_p }}
{% with cell.maplayeroptions_set.all as options %}
{% with cell.get_maplayer_options as options %}
{% if options %}
<p><label>{% trans "Layers:" %}</label></p>
<div>
<ul class="objects-list list-of-layers">
{% for option in options %}
<li>
<span>{{ option.map_layer }}</span>
<span>{{ option.map_layer.label }} {% if option.map_layer.kind == 'tiles' %}({{ option.map_layer.get_kind_display }}){% endif %}</span>
<a rel="popup" title="{% trans "Delete" %}" class="link-action-icon delete" href="{% url 'maps-manager-cell-delete-layer' page_pk=page.pk cell_reference=cell.get_reference layeroptions_pk=option.pk %}">{% trans "Delete" %}</a>
</li>
{% endfor %}
@ -18,9 +18,16 @@
</div>
{% endif %}
{% endwith %}
{% if cell.get_free_layers.exists %}
{% with cell.get_free_geojson_layers.exists as free_geojson and cell.get_free_tiles_layers.exists as free_tiles %}
{% if free_geojson or free_tiles %}
<div class="buttons">
<a rel="popup" href="{% url 'maps-manager-cell-add-layer' page_pk=page.pk cell_reference=cell.get_reference %}">{% trans "Add a layer" %}</a>
{% if free_geojson %}
<a rel="popup" href="{% url 'maps-manager-cell-add-layer' page_pk=page.pk cell_reference=cell.get_reference kind='geojson' %}">{% trans "Add a GeoJSON layer" %}</a>
{% endif %}
{% if free_tiles %}
{% if free_geojson %}|{% endif%} <a rel="popup" href="{% url 'maps-manager-cell-add-layer' page_pk=page.pk cell_reference=cell.get_reference kind='tiles' %}">{% trans "Add a tiles layer" %}</a>
{% endif %}
</div>
{% endif %}
{% endwith %}
{% endblock %}

View File

@ -24,12 +24,12 @@ from .views import GeojsonView
maps_manager_urls = [
url('^$', manager_views.ManagerHomeView.as_view(), name='maps-manager-homepage'),
url('^layers/add/$', manager_views.LayerAddView.as_view(), name='maps-manager-layer-add'),
url('^layers/add/(?P<kind>geojson|tiles)/$', manager_views.LayerAddView.as_view(), name='maps-manager-layer-add'),
url(r'^layers/(?P<slug>[\w-]+)/edit/$', manager_views.LayerEditView.as_view(),
name='maps-manager-layer-edit'),
url(r'^layers/(?P<slug>[\w-]+)/delete/$', manager_views.LayerDeleteView.as_view(),
name='maps-manager-layer-delete'),
url(r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/add-layer/$',
url(r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/add-layer/(?P<kind>geojson|tiles)/$',
manager_views.map_cell_add_layer,
name='maps-manager-cell-add-layer'),
url(r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/layer/(?P<layeroptions_pk>\d+)/delete/$',

View File

@ -11,6 +11,29 @@ from combo.data.models import Page
pytestmark = pytest.mark.django_db
@pytest.fixture
def layer():
return MapLayer.objects.create(
label='Test',
kind='geojson',
geojson_url='http://example.org/geojson',
icon='bicycle',
marker_colour='#FFFFFF',
icon_colour='#FFFFFF',
)
@pytest.fixture
def tiles_layer():
return MapLayer.objects.create(
label='Test2',
kind='tiles',
tiles_template_url='http://somedomain.com/blabla/{z}/{x}/{y}{r}.png',
tiles_attribution='Foo bar',
tiles_default=True,
)
def login(app, username='admin', password='admin'):
login_page = app.get('/login/')
login_form = login_page.forms[0]
@ -20,16 +43,23 @@ def login(app, username='admin', password='admin'):
assert resp.status_int == 302
return app
def test_access(app, admin_user):
app = login(app)
resp = app.get('/manage/', status=200)
assert '/manage/maps/' in resp.text
def test_add_layer(app, admin_user):
MapLayer.objects.all().delete()
def test_add_geojson_layer(app, admin_user):
app = login(app)
resp = app.get('/manage/maps/', status=200)
resp = resp.click('New')
resp = resp.click('New GeoJSON layer')
assert 'slug' not in resp.context['form']
assert 'cache_duration' not in resp.context['form']
assert 'include_user_identifier' not in resp.context['form']
assert 'tiles_template_url' not in resp.context['form']
assert 'tiles_attribution' not in resp.context['form']
assert 'tiles_default' not in resp.context['form']
resp.forms[0]['label'] = 'Test'
resp.forms[0]['geojson_url'] = 'http://example.net/geojson'
assert resp.form['marker_colour'].value == '#0000FF'
@ -43,22 +73,79 @@ def test_add_layer(app, admin_user):
layer = MapLayer.objects.get()
assert layer.label == 'Test'
assert layer.slug == 'test'
assert layer.kind == 'geojson'
def test_edit_layer(app, admin_user):
test_add_layer(app, admin_user)
def test_add_tiles_layer(app, admin_user):
app = login(app)
resp = app.get('/manage/maps/', status=200)
resp = resp.click('Test')
assert '<li><span class="icon-ambulance"><label' in resp.text
resp.forms[0]['geojson_url'] = 'http://example.net/new_geojson'
resp = resp.click('New tiles layer')
assert 'slug' not in resp.context['form']
assert 'cache_duration' not in resp.context['form']
assert 'include_user_identifier' not in resp.context['form']
assert 'geojson_url' not in resp.context['form']
assert 'marker_colour' not in resp.context['form']
assert 'icon' not in resp.context['form']
assert 'icon_colour' not in resp.context['form']
assert 'properties' not in resp.context['form']
resp.forms[0]['label'] = 'Test'
resp.forms[0]['tiles_template_url'] = 'http://somedomain.com/blabla/{z}/{x}/{y}{r}.png'
resp.forms[0]['tiles_attribution'] = 'Foo bar'
resp.forms[0]['tiles_default'] = True
resp = resp.forms[0].submit()
assert resp.location.endswith('/manage/maps/')
assert MapLayer.objects.count() == 1
layer = MapLayer.objects.get()
assert layer.label == 'Test'
assert layer.slug == 'test'
assert layer.kind == 'tiles'
assert layer.tiles_default is True
# only one default layer
resp = app.get('/manage/maps/', status=200)
resp = resp.click('New tiles layer')
resp.forms[0]['label'] = 'Test2'
resp.forms[0]['tiles_template_url'] = 'http://somedomain.com/blabla/{z}/{x}/{y}{r}.png'
resp.forms[0]['tiles_attribution'] = 'Foo bar'
resp.forms[0]['tiles_default'] = True
resp = resp.forms[0].submit()
assert '<li>Only one default tiles layer can be defined.</li>' in resp.text
def test_edit_geojson_layer(app, admin_user, layer):
app = login(app)
resp = app.get('/manage/maps/', status=200)
resp = resp.click('Test')
assert 'tiles_template_url' not in resp.context['form']
assert 'tiles_attribution' not in resp.context['form']
assert 'tiles_default' not in resp.context['form']
assert '<li><span class="icon-ambulance"><label' in resp.text
resp.forms[0]['geojson_url'] = 'http://example.net/new_geojson'
resp = resp.forms[0].submit()
assert resp.location.endswith('/manage/maps/')
layer.refresh_from_db()
assert layer.geojson_url == 'http://example.net/new_geojson'
def test_delete_layer(app, admin_user):
test_add_layer(app, admin_user)
def test_edit_tiles_layer(app, admin_user, tiles_layer):
app = login(app)
resp = app.get('/manage/maps/', status=200)
resp = resp.click('Test')
assert 'cache_duration' not in resp.context['form']
assert 'include_user_identifier' not in resp.context['form']
assert 'geojson_url' not in resp.context['form']
assert 'marker_colour' not in resp.context['form']
assert 'icon' not in resp.context['form']
assert 'icon_colour' not in resp.context['form']
assert 'properties' not in resp.context['form']
resp.forms[0]['tiles_default'] = False
resp = resp.forms[0].submit()
assert resp.location.endswith('/manage/maps/')
tiles_layer.refresh_from_db()
assert tiles_layer.tiles_default is False
def test_delete_layer(app, admin_user, layer):
app = login(app)
resp = app.get('/manage/maps/', status=200)
resp = resp.click('remove')
@ -68,12 +155,11 @@ def test_delete_layer(app, admin_user):
assert MapLayer.objects.count() == 0
def test_list_layers(app, admin_user):
def test_list_layers(app, admin_user, layer):
page = Page.objects.create(title='One', slug='one', template_name='standard')
map1 = Map.objects.create(page=page, placeholder='map 1', order=0)
map2 = Map.objects.create(page=page, placeholder='map 2', order=1)
test_add_layer(app, admin_user)
app = login(app)
resp = app.get('/manage/maps/', status=200)
assert '/manage/pages/{}/#cell-{}'.format(page.pk, map1.get_reference()) in resp.text
@ -81,9 +167,7 @@ def test_list_layers(app, admin_user):
@mock.patch('combo.apps.maps.models.requests.get')
def test_download_geojson(mock_request, app, admin_user):
test_add_layer(app, admin_user)
layer = MapLayer.objects.get()
def test_download_geojson(mock_request, app, admin_user, layer):
mocked_response = mock.Mock()
mock_request.GET = {}
mocked_response.json.return_value = [{'type': 'Feature',
@ -125,18 +209,16 @@ def test_download_geojson(mock_request, app, admin_user):
assert item['properties']['layer']['icon_colour'] == '#FFFFFF'
def test_add_delete_layer(app, admin_user):
layer = MapLayer.objects.create(
label='bicycles',
geojson_url='http://example.org/geojson',
)
def test_add_delete_layer(app, admin_user, layer, tiles_layer):
page = Page.objects.create(title='One', slug='one', template_name='standard')
cell = Map.objects.create(page=page, placeholder='content', order=0, public=True, title='Map')
app = login(app)
resp = app.get('/manage/pages/%s/' % page.pk)
assert list(cell.get_free_layers()) == [layer]
resp = resp.click(href='.*/add-layer/$')
assert list(cell.get_free_geojson_layers()) == [layer]
assert list(cell.get_free_tiles_layers()) == [tiles_layer]
resp = resp.click(href='.*/add-layer/geojson/$')
assert list(resp.context['form'].fields['map_layer'].queryset) == [layer]
resp.forms[0]['map_layer'] = layer.pk
resp = resp.forms[0].submit()
assert resp.status_int == 302
@ -147,8 +229,33 @@ def test_add_delete_layer(app, admin_user):
assert options.map_layer == layer
resp = resp.follow()
assert list(cell.get_free_layers()) == []
assert '/add-layer/$' not in resp.text
assert list(cell.get_free_geojson_layers()) == []
assert list(cell.get_free_tiles_layers()) == [tiles_layer]
assert '/add-layer/geojson/' not in resp.text
resp = resp.click(href='.*/layer/%s/delete/$' % options.pk)
resp = resp.forms[0].submit()
assert resp.status_int == 302
assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
assert MapLayerOptions.objects.count() == 0
resp = resp.follow()
assert list(cell.get_free_geojson_layers()) == [layer]
assert list(cell.get_free_tiles_layers()) == [tiles_layer]
resp = resp.click(href='.*/add-layer/tiles/$')
assert list(resp.context['form'].fields['map_layer'].queryset) == [tiles_layer]
resp.forms[0]['map_layer'] = tiles_layer.pk
resp = resp.forms[0].submit()
assert resp.status_int == 302
assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
assert MapLayerOptions.objects.count() == 1
options = MapLayerOptions.objects.get()
assert options.map_cell == cell
assert options.map_layer == tiles_layer
resp = resp.follow()
assert list(cell.get_free_geojson_layers()) == [layer]
assert list(cell.get_free_tiles_layers()) == []
assert '/add-layer/tiles/' not in resp.text
resp = resp.click(href='.*/layer/%s/delete/$' % options.pk)
resp = resp.forms[0].submit()
assert resp.status_int == 302