maps: new MapLayerOptions model (#22639)

This commit is contained in:
Lauréline Guérin 2020-02-06 11:41:52 +01:00
parent 3d3805d2cd
commit 34e627b0d8
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
13 changed files with 364 additions and 44 deletions

View File

@ -20,7 +20,7 @@ from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from combo.data.fields import TemplatableURLField
from .models import MapLayer
from .models import MapLayer, MapLayerOptions
class IconRadioSelect(forms.RadioSelect):
@ -37,7 +37,6 @@ class MapNewLayerForm(forms.ModelForm):
'icon_colour': forms.TextInput(attrs={'type': 'color'}),
}
def __init__(self, *args, **kwargs):
super(MapNewLayerForm, self).__init__(*args, **kwargs)
self.fields['icon'].choices = list(
@ -59,3 +58,13 @@ class MapLayerForm(forms.ModelForm):
super(MapLayerForm, self).__init__(*args, **kwargs)
self.fields['icon'].choices = list(
sorted(self.fields['icon'].choices, key=lambda x: slugify(force_text(x[1]))))
class MapLayerOptionsForm(forms.ModelForm):
class Meta:
model = MapLayerOptions
fields = ['map_layer']
def __init__(self, *args, **kwargs):
super(MapLayerOptionsForm, self).__init__(*args, **kwargs)
self.fields['map_layer'].queryset = self.instance.map_cell.get_free_layers()

View File

@ -14,13 +14,17 @@
# 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/>.
from django.core.urlresolvers import reverse_lazy
from django.views.generic import (TemplateView, ListView, CreateView,
UpdateView, DeleteView)
from django.core.urlresolvers import reverse, reverse_lazy
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext_lazy as _
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
from combo.data.models import CellBase, PageSnapshot
from .models import Map
from .models import MapLayer
from .forms import MapNewLayerForm, MapLayerForm
from .models import MapLayerOptions
from .forms import MapNewLayerForm, MapLayerForm, MapLayerOptionsForm
class MapLayerMixin(object):
@ -49,3 +53,69 @@ class LayerEditView(MapLayerMixin, UpdateView):
class LayerDeleteView(MapLayerMixin, DeleteView):
template_name = 'maps/map_layer_confirm_delete.html'
class MapCellAddLayer(CreateView):
form_class = MapLayerOptionsForm
template_name = 'maps/layer_options_form.html'
def dispatch(self, request, *args, **kwargs):
try:
self.cell = CellBase.get_cell(kwargs['cell_reference'], page=kwargs['page_pk'])
except Map.DoesNotExist:
raise Http404
return super(MapCellAddLayer, self).dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super(MapCellAddLayer, self).get_form_kwargs()
kwargs['instance'] = MapLayerOptions(map_cell=self.cell)
return kwargs
def form_valid(self, form):
PageSnapshot.take(
self.cell.page,
request=self.request,
comment=_('added layer "%s" to cell "%s"') % (form.instance.map_layer, self.cell))
return super(MapCellAddLayer, self).form_valid(form)
def get_success_url(self):
return '%s#cell-%s' % (
reverse('combo-manager-page-view', kwargs={'pk': self.kwargs.get('page_pk')}),
self.kwargs['cell_reference'])
map_cell_add_layer = MapCellAddLayer.as_view()
class MapCellDeleteLayer(DeleteView):
template_name = 'combo/generic_confirm_delete.html'
def dispatch(self, request, *args, **kwargs):
try:
self.cell = CellBase.get_cell(kwargs['cell_reference'], page=kwargs['page_pk'])
except Map.DoesNotExist:
raise Http404
self.object = get_object_or_404(
MapLayerOptions,
pk=kwargs['layeroptions_pk'],
map_cell=self.cell)
return super(MapCellDeleteLayer, self).dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs):
return self.object
def delete(self, request, *args, **kwargs):
response = super(MapCellDeleteLayer, self).delete(request, *args, **kwargs)
PageSnapshot.take(
self.cell.page,
request=self.request,
comment=_('removed layer "%s" from cell "%s"') % (self.object.map_layer, self.cell))
return response
def get_success_url(self):
return '%s#cell-%s' % (
reverse('combo-manager-page-view', kwargs={'pk': self.kwargs.get('page_pk')}),
self.kwargs['cell_reference'])
map_cell_delete_layer = MapCellDeleteLayer.as_view()

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('maps', '0007_auto_20180706_1345'),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name='MapLayerOptions',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
options={
'db_table': 'maps_map_layers',
},
),
migrations.AlterField(
model_name='map',
name='layers',
field=models.ManyToManyField(blank=True, through='maps.MapLayerOptions', to='maps.MapLayer', verbose_name='Layers'),
),
migrations.AddField(
model_name='maplayeroptions',
name='map_cell',
field=models.ForeignKey(db_column='map_id', on_delete=django.db.models.deletion.CASCADE, to='maps.Map'),
),
migrations.AddField(
model_name='maplayeroptions',
name='map_layer',
field=models.ForeignKey(db_column='maplayer_id', verbose_name='Layer', on_delete=django.db.models.deletion.CASCADE, to='maps.MapLayer'),
),
migrations.AlterUniqueTogether(
name='maplayeroptions',
unique_together=set([('map_cell', 'map_layer')]),
),
],
database_operations=[]
)
]

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2020-02-07 09:00
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('maps', '0008_map_layer_options'),
]
operations = [
migrations.AddField(
model_name='maplayer',
name='kind',
field=models.CharField(choices=[('tiles', 'Tiles'), ('geojson', 'GeoJSON')], default='geojson', max_length=10),
),
migrations.AddField(
model_name='maplayer',
name='tiles_attribution',
field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='Attribution'),
),
migrations.AddField(
model_name='maplayer',
name='tiles_default',
field=models.BooleanField(default=False, verbose_name='Default tiles layer'),
),
migrations.AddField(
model_name='maplayer',
name='tiles_template_url',
field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='Tiles URL'),
),
]

View File

@ -267,9 +267,15 @@ class Map(CellBase):
group_markers = models.BooleanField(_('Group markers in clusters'), default=False)
marker_behaviour_onclick = models.CharField(_('Marker behaviour on click'), max_length=32,
default='none', choices=MARKER_BEHAVIOUR_ONCLICK)
layers = models.ManyToManyField(MapLayer, verbose_name=_('Layers'), blank=True)
layers = models.ManyToManyField(
MapLayer,
through='MapLayerOptions',
verbose_name=_('Layers'),
blank=True
)
template_name = 'maps/map_cell.html'
manager_form_template = 'maps/map_cell_form.html'
class Meta:
verbose_name = _('Map')
@ -287,10 +293,8 @@ class Map(CellBase):
def get_default_form_class(self):
fields = ('title', 'initial_state', 'initial_zoom', 'min_zoom',
'max_zoom', 'group_markers', 'marker_behaviour_onclick', 'layers')
widgets = {'layers': forms.widgets.CheckboxSelectMultiple}
return forms.models.modelform_factory(self.__class__, fields=fields,
widgets=widgets)
'max_zoom', 'group_markers', 'marker_behaviour_onclick')
return forms.models.modelform_factory(self.__class__, fields=fields)
def get_geojson(self, request):
geojson = {'type': 'FeatureCollection', 'features': []}
@ -302,7 +306,7 @@ class Map(CellBase):
@classmethod
def is_enabled(cls):
return MapLayer.objects.count() > 0
return MapLayer.objects.exists()
def get_cell_extra_context(self, context):
ctx = super(Map, self).get_cell_extra_context(context)
@ -322,6 +326,52 @@ 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 export_subobjects(self):
return {'layers': [x.get_as_serialized_object() for x in MapLayerOptions.objects.filter(map_cell=self)]}
@classmethod
def prepare_serialized_data(cls, cell_data):
# ensure compatibility with old exports
if 'layers' in cell_data['fields']:
layers = cell_data['fields'].pop('layers')
cell_data['layers'] = [
{
'fields': {'map_layer': layer},
'model': 'maps.maplayeroptions'
} for layer in layers
]
return cell_data
def import_subobjects(self, cell_json):
if 'layers' not in cell_json:
return
for layer in cell_json['layers']:
layer['fields']['map_cell'] = self.pk
for layer in serializers.deserialize('json', json.dumps(cell_json['layers'])):
layer.save()
def duplicate_m2m(self, new_cell):
# set layers
new_cell.layers.set(self.layers.all())
for layer in self.layers.all():
MapLayerOptions.objects.create(map_cell=new_cell, map_layer=layer)
class MapLayerOptions(models.Model):
map_cell = models.ForeignKey(Map, on_delete=models.CASCADE, db_column='map_id')
map_layer = models.ForeignKey(MapLayer, verbose_name=_('Layer'), on_delete=models.CASCADE, db_column='maplayer_id')
class Meta:
db_table = 'maps_map_layers'
unique_together = ('map_cell', 'map_layer')
def get_as_serialized_object(self):
serialized_options = json.loads(
serializers.serialize('json', [self], use_natural_foreign_keys=True, use_natural_primary_keys=True)
)[0]
del serialized_options['fields']['map_cell']
del serialized_options['pk']
return serialized_options

View File

@ -0,0 +1,22 @@
{% extends "combo/manager_base.html" %}
{% load i18n %}
{% block appbar %}
{% if form.instance.pk %}
<h2>{% trans "Edit layer" %}</h2>
{% else %}
<h2>{% trans "New layer" %}</h2>
{% endif %}
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'combo-manager-page-view' pk=form.instance.map_cell.page_id %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "combo/cell_form.html" %}
{% load i18n %}
{% block cell-form %}
{{ form.as_p }}
{% with cell.maplayeroptions_set.all 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>
<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 %}
</ul>
</div>
{% endif %}
{% endwith %}
{% if cell.get_free_layers.exists %}
<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>
</div>
{% endif %}
{% endblock %}

View File

@ -18,18 +18,23 @@ from django.conf.urls import url, include
from combo.urls_utils import decorated_includes, manager_required
from .manager_views import (ManagerHomeView, LayerAddView,
LayerEditView, LayerDeleteView)
from . import manager_views
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'),
url(r'^layers/(?P<slug>[\w-]+)/edit/$', LayerEditView.as_view(),
url('^$', manager_views.ManagerHomeView.as_view(), name='maps-manager-homepage'),
url('^layers/add/$', 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/$', LayerDeleteView.as_view(),
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/$',
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/$',
manager_views.map_cell_delete_layer,
name='maps-manager-cell-delete-layer'),
]
urlpatterns = [

View File

@ -413,13 +413,14 @@ class Page(models.Model):
@classmethod
def load_serialized_cells(cls, cells):
# load new cells
deserialized_cells = serializers.deserialize('json', json.dumps(cells),
ignorenonexistent=True)
for index, cell in enumerate(deserialized_cells):
for cell_data in cells:
model = apps.get_model(cell_data['model'])
cell_data = model.prepare_serialized_data(cell_data)
cell = list(serializers.deserialize('json', json.dumps([cell_data]), ignorenonexistent=True))[0]
cell.save()
# will populate cached_* attributes
cell.object.save()
cell.object.import_subobjects(cells[index])
cell.object.import_subobjects(cell_data)
@classmethod
def load_serialized_pages(cls, json_site):
@ -774,6 +775,10 @@ class CellBase(six.with_metaclass(CellMeta, models.Model)):
def get_external_links_data(self):
return []
@classmethod
def prepare_serialized_data(cls, cell_data):
return cell_data
def export_subobjects(self):
return {}

View File

@ -485,7 +485,8 @@ ul.objects-list.list-of-links li {
padding-left: 0;
}
ul.objects-list.list-of-links li a.link-action-icon {
ul.objects-list.list-of-links li a.link-action-icon,
ul.objects-list.list-of-layers li a.link-action-icon {
height: 100%;
position: absolute;
right: 0;
@ -499,7 +500,8 @@ ul.objects-list.list-of-links li a.link-action-icon {
line-height: 3em;
}
ul.objects-list.list-of-links li a.link-action-icon::before {
ul.objects-list.list-of-links li a.link-action-icon::before,
ul.objects-list.list-of-layers li a.link-action-icon::before {
font-family: FontAwesome;
text-indent: 0px;
text-align: center;
@ -507,14 +509,17 @@ ul.objects-list.list-of-links li a.link-action-icon::before {
width: 100%;
}
ul.objects-list.list-of-links li a.link-action-icon.delete::before {
ul.objects-list.list-of-links li a.link-action-icon.delete::before,
ul.objects-list.list-of-layers li a.link-action-icon.delete::before {
content: "\f057"; /* remove-sign */
}
ul.objects-list.list-of-links li a.link-action-icon.edit {
ul.objects-list.list-of-links li a.link-action-icon.edit,
ul.objects-list.list-of-layers li a.link-action-icon.edit {
right: 3em;
}
ul.objects-list.list-of-links li a.link-action-icon.edit::before {
ul.objects-list.list-of-links li a.link-action-icon.edit::before,
ul.objects-list.list-of-layers li a.link-action-icon.edit::before {
content: "\f044";
}

View File

@ -16,7 +16,7 @@ from django.utils.six import BytesIO, StringIO
from combo.apps.assets.models import Asset
from combo.apps.gallery.models import Image, GalleryCell
from combo.apps.maps.models import MapLayer, Map
from combo.apps.maps.models import MapLayer, Map, MapLayerOptions
from combo.apps.pwa.models import PwaSettings, PwaNavigationEntry
from combo.data.models import Page, TextCell
from combo.data.utils import export_site, import_site, MissingGroups
@ -123,20 +123,36 @@ def test_import_export_map_layers(app, some_map_layers):
import_site(data={}, if_empty=True)
assert MapLayer.objects.count() == 2
def test_import_export_map_cells(app, some_data, some_map_layers):
page = Page.objects.get(slug='one')
cell = Map(page=page, order=0, placeholder='content')
cell.save()
cell.layers.add(MapLayer.objects.get(slug='foo'))
cell.save()
MapLayerOptions.objects.create(map_cell=cell, map_layer=MapLayer.objects.get(slug='foo'))
site_export = get_output_of_command('export_site')
import_site(data={}, clean=True)
assert Map.objects.count() == 0
assert MapLayer.objects.count() == 0
import_site(data=json.loads(site_export), clean=True)
site_data = json.loads(site_export)
import_site(data=site_data, clean=True)
assert Map.objects.count() == 1
assert MapLayer.objects.filter(slug='foo').exists() is True
assert Map.objects.all()[0].layers.all()[0].slug == 'foo'
# test old export format
import_site(data={}, clean=True)
assert Map.objects.count() == 0
assert MapLayer.objects.count() == 0
del site_data['pages'][0]['cells'][0]['layers']
site_data['pages'][0]['cells'][0]['fields']['layers'] = [['foo']]
import_site(data=site_data, clean=True)
assert Map.objects.count() == 1
assert MapLayer.objects.filter(slug='foo').exists() is True
assert Map.objects.all()[0].layers.all()[0].slug == 'foo'
def test_group_restrictions_import_export(app, some_data):
group = Group(name='A Group')
group.save()

View File

@ -9,7 +9,7 @@ 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
from combo.apps.maps.models import MapLayer, Map, MapLayerOptions
from .test_manager import login
@ -123,7 +123,7 @@ def test_cell_rendering(app, layer):
page.save()
cell = Map(page=page, placeholder='content', order=0, title='Map with points')
cell.save()
cell.layers.add(layer)
MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
context = {'request': RequestFactory().get('/')}
rendered = cell.render(context)
assert 'data-init-zoom="13"' in rendered
@ -152,7 +152,7 @@ def test_get_geojson_on_non_public_page(app, layer):
cell = Map(page=page, placeholder='content', order=0,
title='Map with points')
cell.save()
cell.layers.add(layer)
MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
app.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}), status=403)
def test_get_geojson_on_non_publik_cell(app, layer):
@ -161,7 +161,7 @@ def test_get_geojson_on_non_publik_cell(app, layer):
cell = Map(page=page, placeholder='content', order=0, public=False,
title='Map with points')
cell.save()
cell.layers.add(layer)
MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
app.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}), status=403)
def test_geojson_on_restricted_cell(app, layer, user):
@ -171,7 +171,7 @@ def test_geojson_on_restricted_cell(app, layer, user):
cell = Map(page=page, placeholder='content', order=0, public=False)
cell.title = 'Map with points'
cell.save()
cell.layers.add(layer)
MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
cell.groups.add(group)
login(app)
app.get(reverse('mapcell-geojson', kwargs={'cell_id': cell.id}), status=403)
@ -193,7 +193,7 @@ def test_get_geojson(app, layer, user):
cell.save()
layer.geojson_url = 'http://example.org/geojson?t1'
layer.save()
cell.layers.add(layer)
MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
# check cache duration
with mock.patch('combo.utils.requests_wrapper.RequestsSession.request') as requests_get:
@ -307,7 +307,7 @@ def test_get_geojson(app, layer, user):
layer2.icon = 'fa-bicycle'
layer2.icon_colour = '0000FF'
layer2.save()
cell.layers.add(layer2)
MapLayerOptions.objects.create(map_cell=cell, map_layer=layer2)
with mock.patch('combo.utils.requests_wrapper.RequestsSession.request') as requests_get:
requests_get.return_value = mock.Mock(
@ -338,7 +338,7 @@ def test_get_geojson_properties(app, layer, user):
cell.title = 'Map'
cell.save()
layer.save()
cell.layers.add(layer)
MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
with mock.patch('combo.utils.requests_wrapper.RequestsSession.request') as requests_get:
layer.geojson_url = 'http://example.org/geojson?t1'
@ -397,8 +397,7 @@ def test_get_geojson_properties(app, layer, user):
def test_duplicate(layer):
page = Page.objects.create(title='xxx', slug='new', template_name='standard')
cell = Map.objects.create(page=page, placeholder='content', order=0, public=True, title='Map')
layer.save()
cell.layers.add(layer)
MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
new_cell = cell.duplicate()
assert list(new_cell.layers.all()) == [layer]

View File

@ -3,10 +3,9 @@
import pytest
import mock
from django.contrib.auth.models import User
from combo.apps.maps.models import Map
from combo.apps.maps.models import MapLayer
from combo.apps.maps.models import MapLayerOptions
from combo.data.models import Page
pytestmark = pytest.mark.django_db
@ -124,3 +123,34 @@ def test_download_geojson(mock_request, app, admin_user):
assert item['properties']['layer']['label'] == 'Test'
assert item['properties']['layer']['colour'] == '#FFFFFF'
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',
)
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/$')
resp.forms[0]['map_layer'] = 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 == layer
resp = resp.follow()
assert list(cell.get_free_layers()) == []
assert '/add-layer/$' 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