From ed045503aca163abaa3eb83040b693919f6e6dba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 28 Sep 2021 16:15:08 +0200
Subject: [PATCH] opengis: add custom computed properties on queries (#57295)

---
 passerelle/apps/opengis/forms.py              | 13 ++-
 .../migrations/0015_computed_properties.py    | 17 ++++
 passerelle/apps/opengis/models.py             | 21 ++++-
 .../opengis/templates/opengis/query_form.html | 29 ++++++
 passerelle/apps/opengis/views.py              | 51 +++++++++-
 passerelle/static/js/passerelle.js            | 16 ++++
 tests/test_opengis.py                         | 92 +++++++++++++++++++
 7 files changed, 231 insertions(+), 8 deletions(-)
 create mode 100644 passerelle/apps/opengis/migrations/0015_computed_properties.py

diff --git a/passerelle/apps/opengis/forms.py b/passerelle/apps/opengis/forms.py
index f36a27fa..3d1e08c8 100644
--- a/passerelle/apps/opengis/forms.py
+++ b/passerelle/apps/opengis/forms.py
@@ -17,6 +17,7 @@
 from xml.etree import ElementTree as ET
 
 from django import forms
+from django.forms import formset_factory
 from django.utils.translation import ugettext_lazy as _
 
 from passerelle.base.forms import BaseQueryFormMixin
@@ -24,11 +25,21 @@ from passerelle.base.forms import BaseQueryFormMixin
 from . import models
 
 
+class ComputedPropertyForm(forms.Form):
+    key = forms.CharField(label=_('Property name'), required=False)
+    value = forms.CharField(
+        label=_('Value template'), widget=forms.TextInput(attrs={'size': 60}), required=False
+    )
+
+
+ComputedPropertyFormSet = formset_factory(ComputedPropertyForm)
+
+
 class QueryForm(BaseQueryFormMixin, forms.ModelForm):
     class Meta:
         model = models.Query
         fields = '__all__'
-        exclude = ['resource']
+        exclude = ['resource', 'computed_properties']
 
     def clean_filter_expression(self):
         filter_expression = self.cleaned_data['filter_expression']
diff --git a/passerelle/apps/opengis/migrations/0015_computed_properties.py b/passerelle/apps/opengis/migrations/0015_computed_properties.py
new file mode 100644
index 00000000..1196bef8
--- /dev/null
+++ b/passerelle/apps/opengis/migrations/0015_computed_properties.py
@@ -0,0 +1,17 @@
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('opengis', '0014_auto_20210927_1006'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='query',
+            name='computed_properties',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict),
+        ),
+    ]
diff --git a/passerelle/apps/opengis/models.py b/passerelle/apps/opengis/models.py
index eb01c4e5..2eb929ac 100644
--- a/passerelle/apps/opengis/models.py
+++ b/passerelle/apps/opengis/models.py
@@ -26,9 +26,8 @@ from django.db import models, transaction
 from django.db.models import Q
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404
-from django.template import Context, Template
+from django.template import Context, Template, TemplateSyntaxError
 from django.urls import reverse
-from django.utils import timezone
 from django.utils.text import slugify
 from django.utils.translation import ugettext_lazy as _
 
@@ -492,6 +491,7 @@ class Query(BaseQuery):
         help_text=_('Template used to generate contents used in fulltext search.'),
         blank=True,
     )
+    computed_properties = JSONField(blank=True, default=dict)
 
     delete_view = 'opengis-query-delete'
     edit_view = 'opengis-query-edit'
@@ -614,8 +614,14 @@ class Query(BaseQuery):
     def update_cache(self):
         data = self.resource.features(None, None, type_names=self.typename, xml_filter=self.filter_expression)
         features = []
+        templates = {}
         if self.indexing_template:
-            template = Template(self.indexing_template)
+            templates['text'] = Template(self.indexing_template)
+        for key, tplt in (self.computed_properties or {}).items():
+            try:
+                templates['computed_property_%s' % key] = Template(tplt)
+            except TemplateSyntaxError:
+                pass
         for feature in data['data']:
             geometry = feature.get('geometry') or {}
             if not geometry:
@@ -657,7 +663,14 @@ class Query(BaseQuery):
             text = ''
             if self.indexing_template:
                 context = Context(feature.get('properties', {}))
-                text = simplify(template.render(context))
+                text = simplify(templates['text'].render(context))
+            context = Context(feature)
+            for key in self.computed_properties or {}:
+                if not templates.get('computed_property_%s' % key):
+                    continue
+                if not feature.get('properties'):
+                    feature['properties'] = {}
+                feature['properties'][key] = templates['computed_property_%s' % key].render(context)
             features.append(
                 FeatureCache(
                     query=self,
diff --git a/passerelle/apps/opengis/templates/opengis/query_form.html b/passerelle/apps/opengis/templates/opengis/query_form.html
index 1c8a99b3..a5f171e9 100644
--- a/passerelle/apps/opengis/templates/opengis/query_form.html
+++ b/passerelle/apps/opengis/templates/opengis/query_form.html
@@ -1 +1,30 @@
 {% extends "passerelle/manage/resource_child_form.html" %}
+{% load i18n gadjo %}
+
+{% block form %}
+    {{ block.super }}
+    <h3>{% trans "Computed properties" %}</h3>
+    {{ formset.management_form }}
+    <table id="computed-property-forms">
+        <thead>
+            <tr>
+                {% for field in formset.0 %}
+                <th class="column-{{ field.name }}{% if field.required %} required{% endif %}">{{ field.label }}</th>
+                {% endfor %}
+            </tr>
+        </thead>
+        <tbody>
+        {% for sub_form in formset %}
+        <tr class='computed-property-form'>
+            {% for field in sub_form %}
+            <td class="field-{{ field.name }}">
+                {{ field.errors.as_ul }}
+                {{ field }}
+            </td>
+            {% endfor %}
+        </tr>
+        {% endfor %}
+        </tbody>
+    </table>
+    <button id="add-computed-property-form" type="button">{% trans "Add another computed property" %}</button>
+{% endblock %}
diff --git a/passerelle/apps/opengis/views.py b/passerelle/apps/opengis/views.py
index 7113ed2d..565fdccc 100644
--- a/passerelle/apps/opengis/views.py
+++ b/passerelle/apps/opengis/views.py
@@ -14,18 +14,52 @@
 # 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.http import HttpResponseRedirect
 from django.views.generic import CreateView, DeleteView, UpdateView
 
 from passerelle.base.mixins import ResourceChildViewMixin
 
 from . import models
-from .forms import QueryForm
+from .forms import ComputedPropertyFormSet, QueryForm
 
 
-class QueryNew(ResourceChildViewMixin, CreateView):
+class QueryMixin:
+    def form_valid(self, form):
+        self.object = form.save(commit=False)  # save object and update cache only once
+        return HttpResponseRedirect(self.get_success_url())
+
+    def post(self, *args, **kwargs):
+        self.object = self.get_object()
+        form = self.get_form()
+        formset = ComputedPropertyFormSet(data=self.request.POST)
+        if form.is_valid() and formset.is_valid():
+            response = self.form_valid(form)
+            self.object.computed_properties = {}
+            for sub_data in formset.cleaned_data:
+                if not sub_data.get('key'):
+                    continue
+                self.object.computed_properties[sub_data['key']] = sub_data['value']
+            self.object.save()
+            return response
+        else:
+            return self.form_invalid(form)
+
+
+class QueryNew(QueryMixin, ResourceChildViewMixin, CreateView):
     model = models.Query
     form_class = QueryForm
 
+    def get_object(self):
+        return None
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        data = None
+        if self.request.method == 'POST':
+            data = self.request.POST
+        context['formset'] = ComputedPropertyFormSet(data=data)
+        return context
+
     def get_form_kwargs(self):
         kwargs = super(QueryNew, self).get_form_kwargs()
         kwargs['instance'] = self.model(resource=self.resource)
@@ -35,10 +69,21 @@ class QueryNew(ResourceChildViewMixin, CreateView):
         return self.object.get_absolute_url()
 
 
-class QueryEdit(ResourceChildViewMixin, UpdateView):
+class QueryEdit(QueryMixin, ResourceChildViewMixin, UpdateView):
     model = models.Query
     form_class = QueryForm
 
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        data = None
+        if self.request.method == 'POST':
+            data = self.request.POST
+        context['formset'] = ComputedPropertyFormSet(
+            data=data,
+            initial=[{'key': k, 'value': v} for k, v in self.get_object().computed_properties.items()],
+        )
+        return context
+
 
 class QueryDelete(ResourceChildViewMixin, DeleteView):
     model = models.Query
diff --git a/passerelle/static/js/passerelle.js b/passerelle/static/js/passerelle.js
index 526ffa6b..99f46016 100644
--- a/passerelle/static/js/passerelle.js
+++ b/passerelle/static/js/passerelle.js
@@ -48,4 +48,20 @@ $(function() {
           var $slug_field = $(this).parents('form').find('[name=' + $(this).data('slug-sync') + ']');
           $slug_field.val($.slugify($(this).val()));
   });
+
+  if ($('#add-computed-property-form').length) {
+    var property_forms = $('.computed-property-form');
+    var total_form = $('#id_form-TOTAL_FORMS');
+    var form_num = property_forms.length - 1;
+    $('#add-computed-property-form').on('click', function() {
+      var new_form = $(property_forms[0]).clone();
+      var form_regex = RegExp(`form-(\\d){1}-`,'g');
+      form_num++;
+      new_form.html(new_form.html().replace(form_regex, `form-${form_num}-`));
+      new_form.appendTo('#computed-property-forms tbody');
+      $('#id_form-' + form_num + '-key').val('');
+      $('#id_form-' + form_num + '-value').val('');
+      total_form.val(form_num + 1);
+    })
+  }
 });
diff --git a/tests/test_opengis.py b/tests/test_opengis.py
index 14dbbf00..1b7c8291 100644
--- a/tests/test_opengis.py
+++ b/tests/test_opengis.py
@@ -668,6 +668,44 @@ def test_opengis_query_cache_update(mocked_get, app, connector, query):
     assert feature.data['properties']['code_post'] == 38000
 
 
+@mock.patch('passerelle.utils.Request.get')
+def test_opengis_query_computed_properties(mocked_get, app, connector, query):
+    mocked_get.side_effect = geoserver_geolocated_responses
+    query.update_cache()
+
+    assert FeatureCache.objects.count() == 6
+    for feature in FeatureCache.objects.all():
+        assert 'foobar' not in feature.data['properties']
+        assert 'color' not in feature.data['properties']
+
+    query.computed_properties = {
+        'foobar': 'foo',
+        'color': '{% if properties.numero %}green{% else %}red{% endif %}',
+    }
+    query.save()
+    query.update_cache()
+
+    assert FeatureCache.objects.count() == 6
+    for feature in FeatureCache.objects.all():
+        assert feature.data['properties']['foobar'] == 'foo'
+        assert (
+            feature.data['properties']['color'] == 'green'
+            if feature.data['properties'].get('numero')
+            else 'red'
+        )
+
+    query.computed_properties = {
+        'foobar': '',
+        'color': '{% if properties.numero %}green{% else %}red{% endif }',
+    }
+    query.save()
+    query.update_cache()
+    assert FeatureCache.objects.count() == 6
+    for feature in FeatureCache.objects.all():
+        assert feature.data['properties']['foobar'] == ''
+        assert 'color' not in feature.data['properties']  # syntax error
+
+
 @mock.patch('passerelle.utils.Request.get')
 def test_opengis_query_q_endpoint(mocked_get, app, connector, query):
     endpoint = utils.generic_endpoint_url('opengis', 'query/test_query/', slug=connector.slug)
@@ -911,6 +949,60 @@ def test_opengis_query_unicity(admin_user, app, connector, query):
     assert resp.status_code == 302
 
 
+def test_opengis_query_computed_properties_form(admin_user, app, connector):
+    app = login(app)
+    resp = app.get('/manage/opengis/%s/query/new/' % connector.slug)
+    resp.form['slug'] = 'foo-bar'
+    resp.form['name'] = 'Foo Bar'
+    resp.form['typename'] = 'foo'
+    resp.form['form-0-key'] = 'foo'
+    resp.form['form-0-value'] = 'bar'
+    resp = resp.form.submit()
+    assert resp.status_code == 302
+    assert Query.objects.filter(resource=connector).count() == 1
+    query = Query.objects.latest('pk')
+    assert query.computed_properties == {
+        'foo': 'bar',
+    }
+    assert Job.objects.filter(method_name='update_queries', status='registered').count() == 1
+
+    resp = app.get('/manage/opengis/%s/query/%s/' % (connector.slug, query.pk))
+    assert resp.form['form-TOTAL_FORMS'].value == '2'
+    assert resp.form['form-0-key'].value == 'foo'
+    assert resp.form['form-0-value'].value == 'bar'
+    assert resp.form['form-1-key'].value == ''
+    assert resp.form['form-1-value'].value == ''
+    resp.form['form-0-value'] = 'bar-bis'
+    resp.form['form-1-key'] = 'blah'
+    resp.form['form-1-value'] = 'baz'
+    resp = resp.form.submit()
+    assert resp.status_code == 302
+    query.refresh_from_db()
+    assert query.computed_properties == {
+        'foo': 'bar-bis',
+        'blah': 'baz',
+    }
+    assert Job.objects.filter(method_name='update_queries', status='registered').count() == 2
+
+    resp = app.get('/manage/opengis/%s/query/%s/' % (connector.slug, query.pk))
+    assert resp.form['form-TOTAL_FORMS'].value == '3'
+    assert resp.form['form-0-key'].value == 'foo'
+    assert resp.form['form-0-value'].value == 'bar-bis'
+    assert resp.form['form-1-key'].value == 'blah'
+    assert resp.form['form-1-value'].value == 'baz'
+    assert resp.form['form-2-key'].value == ''
+    assert resp.form['form-2-value'].value == ''
+    resp.form['form-0-key'] = 'foo'
+    resp.form['form-0-value'] = 'bar'
+    resp.form['form-1-key'] = ''
+    resp = resp.form.submit()
+    assert resp.status_code == 302
+    query.refresh_from_db()
+    assert query.computed_properties == {
+        'foo': 'bar',
+    }
+
+
 def test_opengis_export_import(query):
     assert OpenGIS.objects.count() == 1
     assert Query.objects.count() == 1
-- 
2.33.0

