From bd26432bfd7d6053e9d61265e0d1e06aebbeb746 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 6 Feb 2020 11:41:52 +0100
Subject: [PATCH 2/5] maps: new MapLayerOptions model (#22639)

---
 combo/apps/maps/forms.py                      | 13 +++-
 combo/apps/maps/manager_views.py              | 78 ++++++++++++++++++-
 .../maps/migrations/0008_map_layer_options.py | 48 ++++++++++++
 .../maps/migrations/0009_map_layer_kind.py    | 35 +++++++++
 combo/apps/maps/models.py                     | 64 +++++++++++++--
 .../templates/maps/layer_options_form.html    | 22 ++++++
 .../maps/templates/maps/map_cell_form.html    | 26 +++++++
 combo/apps/maps/urls.py                       | 17 ++--
 combo/data/models.py                          | 13 +++-
 combo/manager/static/css/combo.manager.css    | 15 ++--
 tests/test_import_export.py                   | 24 +++++-
 tests/test_maps_cells.py                      | 19 +++--
 tests/test_maps_manager.py                    | 34 +++++++-
 13 files changed, 364 insertions(+), 44 deletions(-)
 create mode 100644 combo/apps/maps/migrations/0008_map_layer_options.py
 create mode 100644 combo/apps/maps/migrations/0009_map_layer_kind.py
 create mode 100644 combo/apps/maps/templates/maps/layer_options_form.html
 create mode 100644 combo/apps/maps/templates/maps/map_cell_form.html

diff --git a/combo/apps/maps/forms.py b/combo/apps/maps/forms.py
index b9473dd..b6278b9 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 c934677..cc6216a 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 <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()
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 0000000..e0e3671
--- /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 0000000..4b9cbcb
--- /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 2582299..b6c0860 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 0000000..aa4b38e
--- /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 %}
+<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 %}
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 0000000..003bfec
--- /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 %}
+<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 %}
diff --git a/combo/apps/maps/urls.py b/combo/apps/maps/urls.py
index 6f58cb2..e47cb4a 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<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 = [
diff --git a/combo/data/models.py b/combo/data/models.py
index 68255c9..468d811 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 eaf7864..3b581a0 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 ff1bb65..3e55dee 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 368b010..bee63f9 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 1c54d73..c6b5716 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
-- 
2.20.1

