From b3090c110b86a407b506b040dccebc4282ffcfc9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 11 Feb 2020 14:58:00 +0100
Subject: [PATCH 5/5] maps: define tiles layers with opacity (#22639)

---
 combo/apps/maps/forms.py                      | 19 +++++-
 combo/apps/maps/manager_views.py              | 39 +++++++++++
 .../maps/migrations/0010_map_layer_opacity.py | 22 +++++++
 combo/apps/maps/models.py                     | 49 ++++++++++++--
 combo/apps/maps/static/js/combo.map.js        | 19 ++++--
 combo/apps/maps/templates/maps/map_cell.html  | 12 +++-
 .../maps/templates/maps/map_cell_form.html    |  3 +
 combo/apps/maps/urls.py                       |  3 +
 tests/test_maps_cells.py                      | 64 +++++++++++++++----
 tests/test_maps_manager.py                    | 14 ++++
 10 files changed, 215 insertions(+), 29 deletions(-)
 create mode 100644 combo/apps/maps/migrations/0010_map_layer_opacity.py

diff --git a/combo/apps/maps/forms.py b/combo/apps/maps/forms.py
index d8d8c26..3220ef6 100644
--- a/combo/apps/maps/forms.py
+++ b/combo/apps/maps/forms.py
@@ -73,12 +73,25 @@ class MapLayerForm(forms.ModelForm):
 class MapLayerOptionsForm(forms.ModelForm):
     class Meta:
         model = MapLayerOptions
-        fields = ['map_layer']
+        fields = ['map_layer', 'opacity']
+        widgets = {
+            'opacity': forms.NumberInput(attrs={'step': 0.1, 'min': 0, 'max': 1})
+        }
 
     def __init__(self, *args, **kwargs):
         self.kind = kwargs.pop('kind')
         super(MapLayerOptionsForm, self).__init__(*args, **kwargs)
+        # if edition, no possibility to change the layer
+        if self.instance.pk:
+            del self.fields['map_layer']
+        else:
+            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()
+        # init opacity field only for tiles layers
         if self.kind == 'geojson':
-            self.fields['map_layer'].queryset = self.instance.map_cell.get_free_geojson_layers()
+            del self.fields['opacity']
         else:
-            self.fields['map_layer'].queryset = self.instance.map_cell.get_free_tiles_layers()
+            self.fields['opacity'].required = True
+            self.fields['opacity'].initial = 1
diff --git a/combo/apps/maps/manager_views.py b/combo/apps/maps/manager_views.py
index dbeb520..b7e1f36 100644
--- a/combo/apps/maps/manager_views.py
+++ b/combo/apps/maps/manager_views.py
@@ -94,6 +94,45 @@ class MapCellAddLayer(CreateView):
 map_cell_add_layer = MapCellAddLayer.as_view()
 
 
+class MapCellEditLayer(UpdateView):
+    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
+        self.object = get_object_or_404(
+            MapLayerOptions,
+            pk=kwargs['layeroptions_pk'],
+            map_cell=self.cell)
+        return super(MapCellEditLayer, self).dispatch(request, *args, **kwargs)
+
+    def get_object(self, *args, **kwargs):
+        return self.object
+
+    def get_form_kwargs(self):
+        kwargs = super(MapCellEditLayer, self).get_form_kwargs()
+        kwargs['kind'] = self.object.map_layer.kind
+        return kwargs
+
+    def form_valid(self, form):
+        PageSnapshot.take(
+            self.cell.page,
+            request=self.request,
+            comment=_('changed options of layer "%s" in cell "%s"') % (form.instance.map_layer, self.cell))
+        return super(MapCellEditLayer, 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_edit_layer = MapCellEditLayer.as_view()
+
+
 class MapCellDeleteLayer(DeleteView):
     template_name = 'combo/generic_confirm_delete.html'
 
diff --git a/combo/apps/maps/migrations/0010_map_layer_opacity.py b/combo/apps/maps/migrations/0010_map_layer_opacity.py
new file mode 100644
index 0000000..db64b06
--- /dev/null
+++ b/combo/apps/maps/migrations/0010_map_layer_opacity.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('maps', '0009_map_layer_kind'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='maplayeroptions',
+            name='opacity',
+            field=models.FloatField(
+                help_text='Float value between 0 and 1', null=True,
+                validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)], verbose_name='Opacity'),
+        ),
+    ]
diff --git a/combo/apps/maps/models.py b/combo/apps/maps/models.py
index 1aeb282..718a9d9 100644
--- a/combo/apps/maps/models.py
+++ b/combo/apps/maps/models.py
@@ -17,6 +17,7 @@
 import json
 
 from django.core import serializers
+from django.core import validators
 from django.db import models
 from django.utils import six
 from django.utils.encoding import python_2_unicode_compatible
@@ -333,6 +334,38 @@ class Map(CellBase):
     def is_enabled(cls):
         return MapLayer.objects.exists()
 
+    def get_tiles_layers(self):
+        tiles_layers = []
+        options_qs = (
+            self.maplayeroptions_set
+            .filter(map_layer__kind='tiles')
+            .select_related('map_layer')
+            .order_by('-opacity'))
+        for options in options_qs:
+            tiles_layers.append({
+                'tile_urltemplate': options.map_layer.tiles_template_url,
+                'map_attribution': options.map_layer.tiles_attribution,
+                'opacity': options.opacity or 0,
+            })
+        # check if at least one layer with opacity set to 1 exists
+        if any([l['opacity'] == 1 for l in tiles_layers]):
+            return tiles_layers
+        # add the default tiles layer
+        default_tiles_layer = MapLayer.get_default_tiles_layer()
+        if default_tiles_layer is not None:
+            tiles_layers.insert(0, {
+                'tile_urltemplate': default_tiles_layer.tiles_template_url,
+                'map_attribution': default_tiles_layer.tiles_attribution,
+                'opacity': 1,
+            })
+        else:
+            tiles_layers.insert(0, {
+                'tile_urltemplate': settings.COMBO_MAP_TILE_URLTEMPLATE,
+                'map_attribution': settings.COMBO_MAP_ATTRIBUTION,
+                'opacity': 1,
+            })
+        return tiles_layers
+
     def get_cell_extra_context(self, context):
         ctx = super(Map, self).get_cell_extra_context(context)
         ctx['title'] = self.title
@@ -344,13 +377,7 @@ class Map(CellBase):
         ctx['min_zoom'] = self.min_zoom
         ctx['max_zoom'] = self.max_zoom
         ctx['geojson_url'] = reverse_lazy('mapcell-geojson', kwargs={'cell_id': self.pk})
-        default_tiles_layer = MapLayer.get_default_tiles_layer()
-        if default_tiles_layer is not None:
-            ctx['tile_urltemplate'] = default_tiles_layer.tiles_template_url
-            ctx['map_attribution'] = default_tiles_layer.tiles_attribution
-        else:
-            ctx['tile_urltemplate'] = settings.COMBO_MAP_TILE_URLTEMPLATE
-            ctx['map_attribution'] = settings.COMBO_MAP_ATTRIBUTION
+        ctx['tiles_layers'] = self.get_tiles_layers()
         ctx['max_bounds'] = settings.COMBO_MAP_MAX_BOUNDS
         ctx['group_markers'] = self.group_markers
         ctx['marker_behaviour_onclick'] = self.marker_behaviour_onclick
@@ -400,6 +427,14 @@ class Map(CellBase):
 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')
+    opacity = models.FloatField(
+        verbose_name=_('Opacity'),
+        validators=[
+            validators.MinValueValidator(0),
+            validators.MaxValueValidator(1)],
+        null=True,
+        help_text=_('Float value between 0 and 1'),
+    )
 
     class Meta:
         db_table = 'maps_map_layers'
diff --git a/combo/apps/maps/static/js/combo.map.js b/combo/apps/maps/static/js/combo.map.js
index e9cd0bb..1283e8d 100644
--- a/combo/apps/maps/static/js/combo.map.js
+++ b/combo/apps/maps/static/js/combo.map.js
@@ -124,8 +124,6 @@ $(function() {
         map_options.zoomControl = false;
         var latlng = [$map_widget.data('init-lat'), $map_widget.data('init-lng')];
         var geojson_url = $map_widget.data('geojson-url');
-        var map_tile_url = $map_widget.data('tile-urltemplate');
-        var map_attribution = $map_widget.data('map-attribution');
         if ($map_widget.data('max-bounds-lat1')) {
           map_options.maxBounds = L.latLngBounds(
                   L.latLng($map_widget.data('max-bounds-lat1'), $map_widget.data('max-bounds-lng1')),
@@ -175,11 +173,18 @@ $(function() {
             });
         }
 
-        L.tileLayer(map_tile_url,
-            {
-                attribution: map_attribution,
-                maxZoom: map_options.maxZoom
-            }).addTo(map);
+        var map_id = $map_widget.data('cell-id');
+        var tiles_layers = window['tiles_'+map_id];
+        $.each(tiles_layers, function(idx, layer) {
+            L.tileLayer(
+                layer.tile_urltemplate,
+                {
+                    attribution: layer.map_attribution,
+                    opacity: layer.opacity,
+                    maxZoom: map_options.maxZoom
+                }
+            ).addTo(map);
+        });
         if (geojson_url) {
             map.add_geojson_layer(function(geo_json) {
                 var bounds = geo_json.getBounds();
diff --git a/combo/apps/maps/templates/maps/map_cell.html b/combo/apps/maps/templates/maps/map_cell.html
index 880906e..d16af41 100644
--- a/combo/apps/maps/templates/maps/map_cell.html
+++ b/combo/apps/maps/templates/maps/map_cell.html
@@ -6,7 +6,6 @@
         data-init-zoom="{{ initial_zoom }}" data-min-zoom="{{ min_zoom }}"
         data-max-zoom="{{ max_zoom }}" data-init-lat="{{ init_lat }}"
         data-init-lng="{{ init_lng }}" data-geojson-url="{{ geojson_url }}"
-        data-tile-urltemplate="{{ tile_urltemplate}}" data-map-attribution="{{ map_attribution}}"
         data-include-geoloc-button="true"
         {% if group_markers %}data-group-markers="1"{% endif %}
         data-marker-behaviour-onclick="{{ cell.marker_behaviour_onclick }}"
@@ -16,7 +15,18 @@
         data-max-bounds-lat2="{{ max_bounds.corner2.lat }}"
         data-max-bounds-lng2="{{ max_bounds.corner2.lng }}"
         {% endif %}
+        data-cell-id="{{ cell.pk }}"
       >
 {% endlocalize %}
+<script>
+  var tiles_{{ cell.pk }} = [];
+  {% for layer in tiles_layers %}
+    tiles_{{ cell.pk }}.push({
+      tile_urltemplate: {{ layer.tile_urltemplate|as_json|safe }},
+      map_attribution: {{ layer.map_attribution|as_json|safe }},
+      opacity: {{ layer.opacity|as_json|safe }}
+    });
+  {% endfor %}
+</script>
 </div>
 {% endblock %}
diff --git a/combo/apps/maps/templates/maps/map_cell_form.html b/combo/apps/maps/templates/maps/map_cell_form.html
index 983da25..ab5e2a1 100644
--- a/combo/apps/maps/templates/maps/map_cell_form.html
+++ b/combo/apps/maps/templates/maps/map_cell_form.html
@@ -11,6 +11,9 @@
     {% for option in options %}
     <li>
       <span>{{ option.map_layer.label }} {% if option.map_layer.kind == 'tiles' %}({{ option.map_layer.get_kind_display }}){% endif %}</span>
+      {% if option.map_layer.kind == 'tiles' %}
+      <a rel="popup" title="{% trans "Edit" %}" class="link-action-icon edit" href="{% url 'maps-manager-cell-edit-layer' page_pk=page.pk cell_reference=cell.get_reference layeroptions_pk=option.pk %}">{% trans "Edit" %}</a>
+      {% endif %}
       <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 %}
diff --git a/combo/apps/maps/urls.py b/combo/apps/maps/urls.py
index c26f29c..a24d083 100644
--- a/combo/apps/maps/urls.py
+++ b/combo/apps/maps/urls.py
@@ -32,6 +32,9 @@ maps_manager_urls = [
     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+)/edit/$',
+        manager_views.map_cell_edit_layer,
+        name='maps-manager-cell-edit-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'),
diff --git a/tests/test_maps_cells.py b/tests/test_maps_cells.py
index 6032d80..2d06c1c 100644
--- a/tests/test_maps_cells.py
+++ b/tests/test_maps_cells.py
@@ -137,7 +137,7 @@ def test_cell_rendering(app, layer, tiles_layer):
     page.save()
     cell = Map(page=page, placeholder='content', order=0, title='Map with points')
     cell.save()
-    MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
+    options = MapLayerOptions.objects.create(map_cell=cell, map_layer=layer)
     context = {'request': RequestFactory().get('/')}
     rendered = cell.render(context)
     assert 'data-init-zoom="13"' in rendered
@@ -147,8 +147,6 @@ def test_cell_rendering(app, layer, tiles_layer):
     assert 'data-init-lng="2.3233688436448574"' in rendered
     assert 'data-geojson-url="/ajax/mapcell/geojson/1/"' in rendered
     assert 'data-group-markers="1"' not in rendered
-    assert 'data-tile-urltemplate="%s"' % tiles_layer.tiles_template_url in rendered
-    assert 'data-map-attribution="%s"' % tiles_layer.tiles_attribution in rendered
     resp = app.get('/test_map_cell/')
     assert 'xstatic/leaflet.js' in resp.text
     assert 'js/combo.map.js' in resp.text
@@ -160,16 +158,60 @@ def test_cell_rendering(app, layer, tiles_layer):
     rendered = cell.render(context)
     assert 'data-group-markers="1"' in rendered
 
+
+def test_cell_tiles_layers(tiles_layer):
+    page = Page.objects.create(title='xxx', slug='test_map_cell', template_name='standard')
+    cell = Map.objects.create(page=page, placeholder='content', order=0, title='Map with points')
+
+    # no tiles layer for this map, take default tiles layers, tiles_layer
+    assert cell.get_tiles_layers() == [{
+        'tile_urltemplate': tiles_layer.tiles_template_url,
+        'map_attribution': tiles_layer.tiles_attribution,
+        'opacity': 1,
+    }]
+
+    # tiles_layer is not set as default, fallback on settings
     tiles_layer.tiles_default = False
     tiles_layer.save()
-    rendered = cell.render(context)
-    assert 'data-tile-urltemplate="%s"' % settings.COMBO_MAP_TILE_URLTEMPLATE in rendered
-    assert 'data-map-attribution="%s"' % escape(settings.COMBO_MAP_ATTRIBUTION) in rendered
-
-    tiles_layer.delete()
-    rendered = cell.render(context)
-    assert 'data-tile-urltemplate="%s"' % settings.COMBO_MAP_TILE_URLTEMPLATE in rendered
-    assert 'data-map-attribution="%s"' % escape(settings.COMBO_MAP_ATTRIBUTION) in rendered
+    assert cell.get_tiles_layers() == [{
+        'tile_urltemplate': settings.COMBO_MAP_TILE_URLTEMPLATE,
+        'map_attribution': settings.COMBO_MAP_ATTRIBUTION,
+        'opacity': 1,
+    }]
+
+    # add a tile layer to the map, with opacity 1
+    options = MapLayerOptions.objects.create(map_cell=cell, map_layer=tiles_layer, opacity=1)
+    assert cell.get_tiles_layers() == [{
+        'tile_urltemplate': tiles_layer.tiles_template_url,
+        'map_attribution': tiles_layer.tiles_attribution,
+        'opacity': 1,
+    }]
+
+    # opacity is less than 1 => add default tiles layer, defined in settings
+    options.opacity = 0.5
+    options.save()
+    assert cell.get_tiles_layers() == [{
+        'tile_urltemplate': settings.COMBO_MAP_TILE_URLTEMPLATE,
+        'map_attribution': settings.COMBO_MAP_ATTRIBUTION,
+        'opacity': 1,
+    }, {
+        'tile_urltemplate': tiles_layer.tiles_template_url,
+        'map_attribution': tiles_layer.tiles_attribution,
+        'opacity': 0.5,
+    }]
+
+    # set tiles_layer as default => add tiles_layer
+    tiles_layer.tiles_default = True
+    tiles_layer.save()
+    assert cell.get_tiles_layers() == [{
+        'tile_urltemplate': tiles_layer.tiles_template_url,
+        'map_attribution': tiles_layer.tiles_attribution,
+        'opacity': 1,
+    }, {
+        'tile_urltemplate': tiles_layer.tiles_template_url,
+        'map_attribution': tiles_layer.tiles_attribution,
+        'opacity': 0.5,
+    }]
 
 
 def test_get_geojson_on_non_public_page(app, layer):
diff --git a/tests/test_maps_manager.py b/tests/test_maps_manager.py
index c751878..ae35355 100644
--- a/tests/test_maps_manager.py
+++ b/tests/test_maps_manager.py
@@ -219,6 +219,7 @@ def test_add_delete_layer(app, admin_user, layer, tiles_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]
+    assert 'opacity' not in resp.context['form'].fields
     resp.forms[0]['map_layer'] = layer.pk
     resp = resp.forms[0].submit()
     assert resp.status_int == 302
@@ -229,6 +230,7 @@ def test_add_delete_layer(app, admin_user, layer, tiles_layer):
     assert options.map_layer == layer
 
     resp = resp.follow()
+    assert '/layer/%s/edit/' % options.pk 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
@@ -244,6 +246,7 @@ def test_add_delete_layer(app, admin_user, layer, 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.forms[0]['opacity'] = 1
     resp = resp.forms[0].submit()
     assert resp.status_int == 302
     assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
@@ -251,6 +254,17 @@ def test_add_delete_layer(app, admin_user, layer, tiles_layer):
     options = MapLayerOptions.objects.get()
     assert options.map_cell == cell
     assert options.map_layer == tiles_layer
+    assert options.opacity == 1
+
+    resp = resp.follow()
+    resp = resp.click(href='.*/layer/%s/edit/$' % options.pk)
+    assert 'map_layer' not in resp.context['form'].fields
+    resp.forms[0]['opacity'] = 0.5
+    resp = resp.forms[0].submit()
+    assert resp.status_int == 302
+    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
+    options.refresh_from_db()
+    assert options.opacity == 0.5
 
     resp = resp.follow()
     assert list(cell.get_free_geojson_layers()) == [layer]
-- 
2.20.1

