diff --git a/combo/apps/maps/forms.py b/combo/apps/maps/forms.py index b9473dd6..b6278b97 100644 --- a/combo/apps/maps/forms.py +++ b/combo/apps/maps/forms.py @@ -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() diff --git a/combo/apps/maps/manager_views.py b/combo/apps/maps/manager_views.py index c9346778..cc6216aa 100644 --- a/combo/apps/maps/manager_views.py +++ b/combo/apps/maps/manager_views.py @@ -14,13 +14,17 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -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() diff --git a/combo/apps/maps/migrations/0008_map_layer_options.py b/combo/apps/maps/migrations/0008_map_layer_options.py new file mode 100644 index 00000000..e0e36715 --- /dev/null +++ b/combo/apps/maps/migrations/0008_map_layer_options.py @@ -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=[] + ) + ] diff --git a/combo/apps/maps/migrations/0009_map_layer_kind.py b/combo/apps/maps/migrations/0009_map_layer_kind.py new file mode 100644 index 00000000..4b9cbcb7 --- /dev/null +++ b/combo/apps/maps/migrations/0009_map_layer_kind.py @@ -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'), + ), + ] diff --git a/combo/apps/maps/models.py b/combo/apps/maps/models.py index 25822995..b6c08605 100644 --- a/combo/apps/maps/models.py +++ b/combo/apps/maps/models.py @@ -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 diff --git a/combo/apps/maps/templates/maps/layer_options_form.html b/combo/apps/maps/templates/maps/layer_options_form.html new file mode 100644 index 00000000..aa4b38ec --- /dev/null +++ b/combo/apps/maps/templates/maps/layer_options_form.html @@ -0,0 +1,22 @@ +{% extends "combo/manager_base.html" %} +{% load i18n %} + +{% block appbar %} +{% if form.instance.pk %} +

{% trans "Edit layer" %}

+{% else %} +

{% trans "New layer" %}

+{% endif %} +{% endblock %} + +{% block content %} + +
+ {% csrf_token %} + {{ form.as_p }} +
+ + {% trans 'Cancel' %} +
+
+{% endblock %} diff --git a/combo/apps/maps/templates/maps/map_cell_form.html b/combo/apps/maps/templates/maps/map_cell_form.html new file mode 100644 index 00000000..003bfec6 --- /dev/null +++ b/combo/apps/maps/templates/maps/map_cell_form.html @@ -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 %} +

+
+ +
+{% endif %} +{% endwith %} +{% if cell.get_free_layers.exists %} +
+ {% trans "Add a layer" %} +
+{% endif %} +{% endblock %} diff --git a/combo/apps/maps/urls.py b/combo/apps/maps/urls.py index 6f58cb2c..e47cb4ac 100644 --- a/combo/apps/maps/urls.py +++ b/combo/apps/maps/urls.py @@ -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[\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[\w-]+)/edit/$', manager_views.LayerEditView.as_view(), name='maps-manager-layer-edit'), - url(r'^layers/(?P[\w-]+)/delete/$', LayerDeleteView.as_view(), + url(r'^layers/(?P[\w-]+)/delete/$', manager_views.LayerDeleteView.as_view(), name='maps-manager-layer-delete'), + url(r'^pages/(?P\d+)/cell/(?P[\w_-]+)/add-layer/$', + manager_views.map_cell_add_layer, + name='maps-manager-cell-add-layer'), + url(r'^pages/(?P\d+)/cell/(?P[\w_-]+)/layer/(?P\d+)/delete/$', + manager_views.map_cell_delete_layer, + name='maps-manager-cell-delete-layer'), ] urlpatterns = [ diff --git a/combo/data/models.py b/combo/data/models.py index c587943c..00867d94 100644 --- a/combo/data/models.py +++ b/combo/data/models.py @@ -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 {} diff --git a/combo/manager/static/css/combo.manager.css b/combo/manager/static/css/combo.manager.css index eaf78645..3b581a0b 100644 --- a/combo/manager/static/css/combo.manager.css +++ b/combo/manager/static/css/combo.manager.css @@ -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"; } diff --git a/tests/test_import_export.py b/tests/test_import_export.py index ff1bb652..3e55dee4 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -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() diff --git a/tests/test_maps_cells.py b/tests/test_maps_cells.py index 368b010a..bee63f9e 100644 --- a/tests/test_maps_cells.py +++ b/tests/test_maps_cells.py @@ -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] diff --git a/tests/test_maps_manager.py b/tests/test_maps_manager.py index 1c54d73f..c6b57167 100644 --- a/tests/test_maps_manager.py +++ b/tests/test_maps_manager.py @@ -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