From 7fee7dc71bea1a1fe77048f6340a7e5ddaa2971e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Mon, 3 Jan 2022 16:02:21 +0100
Subject: [PATCH] data: add page parameters (#59798)

---
 combo/data/migrations/0052_page_parameters.py | 17 ++++++
 combo/data/models.py                          | 18 ++++++-
 combo/manager/forms.py                        | 11 ++++
 combo/manager/static/js/combo.manager.js      | 14 +++++
 .../templates/combo/page_parameters.html      | 36 +++++++++++++
 combo/manager/templates/combo/page_view.html  |  6 +++
 combo/manager/urls.py                         |  5 ++
 combo/manager/views.py                        | 33 +++++++++++-
 combo/public/views.py                         |  3 ++
 tests/test_manager.py                         | 53 +++++++++++++++++++
 tests/test_public.py                          | 50 ++++++++++++++++-
 tests/test_wcs.py                             | 23 +++++---
 12 files changed, 260 insertions(+), 9 deletions(-)
 create mode 100644 combo/data/migrations/0052_page_parameters.py
 create mode 100644 combo/manager/templates/combo/page_parameters.html

diff --git a/combo/data/migrations/0052_page_parameters.py b/combo/data/migrations/0052_page_parameters.py
new file mode 100644
index 00000000..3ddcc3c3
--- /dev/null
+++ b/combo/data/migrations/0052_page_parameters.py
@@ -0,0 +1,17 @@
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('data', '0051_link_cell_max_length'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='page',
+            name='parameters',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict),
+        ),
+    ]
diff --git a/combo/data/models.py b/combo/data/models.py
index 1822e8fd..d10f4844 100644
--- a/combo/data/models.py
+++ b/combo/data/models.py
@@ -45,7 +45,7 @@ from django.db.models.signals import post_delete, post_save, pre_save
 from django.dispatch import receiver
 from django.forms import models as model_forms
 from django.forms.widgets import MediaDefiningClass
-from django.template import TemplateDoesNotExist, TemplateSyntaxError, engines
+from django.template import RequestContext, Template, TemplateDoesNotExist, TemplateSyntaxError, engines
 from django.test.client import RequestFactory
 from django.utils import timezone
 from django.utils.encoding import force_text, python_2_unicode_compatible, smart_bytes
@@ -194,6 +194,7 @@ class Page(models.Model):
     order = models.PositiveIntegerField()
     exclude_from_navigation = models.BooleanField(_('Exclude from navigation'), default=True)
     redirect_url = models.CharField(_('Redirect URL'), max_length=200, blank=True)
+    parameters = JSONField(blank=True, default=dict)
 
     public = models.BooleanField(_('Public'), default=True)
     groups = models.ManyToManyField(Group, verbose_name=_('Groups'), blank=True)
@@ -641,6 +642,21 @@ class Page(models.Model):
     def get_last_update_time(self):
         return self.last_update_timestamp
 
+    def get_parameters(self, request, original_context):
+        result = {}
+        context = RequestContext(request)
+        context.push(original_context)
+        for key, tplt in (self.parameters or {}).items():
+            try:
+                template = Template(tplt)
+            except TemplateSyntaxError:
+                continue
+            result[key] = template.render(context)
+        return result
+
+    def get_parameters_keys(self):
+        return sorted((self.parameters or {}).keys())
+
     def is_new(self):
         return self.creation_timestamp > timezone.now() - datetime.timedelta(days=7)
 
diff --git a/combo/manager/forms.py b/combo/manager/forms.py
index e6b3eae0..b4367663 100644
--- a/combo/manager/forms.py
+++ b/combo/manager/forms.py
@@ -20,6 +20,7 @@ from django import forms
 from django.conf import settings
 from django.contrib.auth.models import Group
 from django.core.exceptions import ValidationError
+from django.forms import formset_factory
 from django.template import Template, TemplateSyntaxError
 from django.template.loader import TemplateDoesNotExist, get_template
 from django.utils.translation import ugettext_lazy as _
@@ -168,6 +169,16 @@ class PageEditPictureForm(forms.ModelForm):
         field_classes = {'picture': ImageIncludingSvgField}
 
 
+class PageEditParameterForm(forms.Form):
+    key = forms.CharField(label=_('Property name'), required=False)
+    value = forms.CharField(
+        label=_('Value template'), widget=forms.TextInput(attrs={'size': 60}), required=False
+    )
+
+
+PageEditParameterFormSet = formset_factory(PageEditParameterForm)
+
+
 class PageVisibilityForm(forms.ModelForm):
     class Meta:
         model = Page
diff --git a/combo/manager/static/js/combo.manager.js b/combo/manager/static/js/combo.manager.js
index db9cce5a..29c4bac4 100644
--- a/combo/manager/static/js/combo.manager.js
+++ b/combo/manager/static/js/combo.manager.js
@@ -397,6 +397,20 @@ $(function() {
     window.location = $(this).parent('div').find('option:selected').data('add-url');
     return false;
   });
+
+  $(document).on('click', '#add-page-property-form', function() {
+    if (typeof property_forms === "undefined") {var property_forms = $('.page-property-form');}
+    if (typeof total_forms === "undefined") {var total_form = $('#id_form-TOTAL_FORMS');}
+    if (typeof form_num === "undefined") {var form_num = property_forms.length - 1;}
+    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('#page-property-forms tbody');
+    $('#id_form-' + form_num + '-key').val('');
+    $('#id_form-' + form_num + '-value').val('');
+    total_form.val(form_num + 1);
+  })
 });
 
 
diff --git a/combo/manager/templates/combo/page_parameters.html b/combo/manager/templates/combo/page_parameters.html
new file mode 100644
index 00000000..9d5287ea
--- /dev/null
+++ b/combo/manager/templates/combo/page_parameters.html
@@ -0,0 +1,36 @@
+{% extends "combo/page_add.html" %}
+{% load i18n %}
+
+{% block content %}
+<form method="post" enctype="multipart/form-data">
+  {% csrf_token %}
+  <h3>{% trans "Page properties" %}</h3>
+  {{ form.management_form }}
+  <table id="page-property-forms">
+      <thead>
+          <tr>
+              {% for field in form.0 %}
+              <th class="column-{{ field.name }}{% if field.required %} required{% endif %}">{{ field.label }}</th>
+              {% endfor %}
+          </tr>
+      </thead>
+      <tbody>
+      {% for sub_form in form %}
+      <tr class='page-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-page-property-form" type="button">{% trans "Add another property" %}</button>
+  <div class="buttons">
+    <button class="submit-button">{% trans "Save" %}</button>
+    <a class="cancel" href="{% url 'combo-manager-page-view' pk=object.pk %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/combo/manager/templates/combo/page_view.html b/combo/manager/templates/combo/page_view.html
index d009a2fc..c923bbe2 100644
--- a/combo/manager/templates/combo/page_view.html
+++ b/combo/manager/templates/combo/page_view.html
@@ -90,6 +90,12 @@
 (<a rel="popup" href="{% url 'combo-manager-page-edit-picture' pk=object.id %}">{% trans 'change' %}</a>)
 </p>
 
+<p>
+<label>{% trans 'Parameters:' %}</label>
+{% for key in object.get_parameters_keys %}<i>{{ key }}</i>{% if not forloop.last %}, {% endif %}{% empty %}<i>{% trans 'none' context 'parameters' %}</i>{% endfor %}
+(<a rel="popup" href="{% url 'combo-manager-page-edit-parameters' pk=object.id %}">{% trans 'change' %}</a>)
+</p>
+
 </div>
 
 {% if object.parent_id or previous_page or next_page %}
diff --git a/combo/manager/urls.py b/combo/manager/urls.py
index 430ce556..cad7a6e8 100644
--- a/combo/manager/urls.py
+++ b/combo/manager/urls.py
@@ -64,6 +64,11 @@ urlpatterns = [
         views.page_remove_picture,
         name='combo-manager-page-remove-picture',
     ),
+    url(
+        r'^pages/(?P<pk>\d+)/parameters/$',
+        views.page_edit_parameters,
+        name='combo-manager-page-edit-parameters',
+    ),
     url(r'^pages/(?P<pk>\d+)/delete$', staff_required(views.page_delete), name='combo-manager-page-delete'),
     url(r'^pages/(?P<pk>\d+)/export$', views.page_export, name='combo-manager-page-export'),
     url(
diff --git a/combo/manager/views.py b/combo/manager/views.py
index 18802835..393969f8 100644
--- a/combo/manager/views.py
+++ b/combo/manager/views.py
@@ -18,7 +18,7 @@ import datetime
 import hashlib
 import json
 import tarfile
-from operator import attrgetter
+from operator import attrgetter, itemgetter
 
 from django.conf import settings
 from django.contrib import messages
@@ -64,6 +64,7 @@ from .forms import (
     PageDuplicateForm,
     PageEditDescriptionForm,
     PageEditIncludeInNavigationForm,
+    PageEditParameterFormSet,
     PageEditPictureForm,
     PageEditRedirectionForm,
     PageEditRolesForm,
@@ -366,6 +367,36 @@ class PageRemovePictureView(ManagedPageMixin, DetailView):
 page_remove_picture = PageRemovePictureView.as_view()
 
 
+class PageEditParametersView(PageEditView):
+    form_class = PageEditParameterFormSet
+    comment = _('changed parameters')
+    template_name = 'combo/page_parameters.html'
+
+    def get_initial(self):
+        return sorted(
+            ({'key': k, 'value': v} for k, v in self.get_object().parameters.items()), key=itemgetter('key')
+        )
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('instance')
+        return kwargs
+
+    def form_valid(self, form):
+        self.object = self.get_object()
+        self.object.parameters = {}
+        for sub_data in form.cleaned_data:
+            if not sub_data.get('key'):
+                continue
+            self.object.parameters[sub_data['key']] = sub_data['value']
+        self.object.save()
+        PageSnapshot.take(self.object, request=self.request, comment=self.comment)
+        return HttpResponseRedirect(self.get_success_url())
+
+
+page_edit_parameters = PageEditParametersView.as_view()
+
+
 class PageView(ManagedPageMixin, DetailView):
     model = Page
     template_name = 'combo/page_view.html'
diff --git a/combo/public/views.py b/combo/public/views.py
index c02bf177..7859f327 100644
--- a/combo/public/views.py
+++ b/combo/public/views.py
@@ -102,6 +102,9 @@ def modify_global_context(request, ctx):
             pass
     if 'name_id' in ctx:
         ctx['selected_user'] = get_user_from_name_id(ctx['name_id'])
+    if 'page' in ctx:
+        page = ctx['page']
+        ctx.update(page.get_parameters(request, ctx))
 
 
 @csrf_exempt
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 5d05f35f..d3eb2119 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -571,6 +571,59 @@ def test_page_edit_picture(app, admin_user):
     assert Page.objects.get(id=page.id).picture.url in resp.text
 
 
+def test_page_edit_parameters(app, admin_user):
+    app = login(app)
+    page = Page.objects.create(title='One', slug='one', template_name='standard')
+    assert page.parameters == {}
+    resp = app.get('/manage/pages/%s/' % page.id)
+    assert resp.text.count('<i>none</i>') == 4
+    resp = resp.click(href='.*/parameters/')
+    resp.form['form-0-key'] = 'foo'
+    resp.form['form-0-value'] = 'bar'
+    resp = resp.form.submit().follow()
+    page.refresh_from_db()
+    assert page.parameters == {'foo': 'bar'}
+    assert resp.text.count('<i>none</i>') == 3
+    assert '<i>foo</i>' in resp
+
+    resp = resp.click(href='.*/parameters/')
+    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().follow()
+    page.refresh_from_db()
+    assert page.parameters == {
+        'foo': 'bar-bis',
+        'blah': 'baz',
+    }
+    assert resp.text.count('<i>none</i>') == 3
+    assert '<i>blah</i>, <i>foo</i>' in resp
+
+    resp = resp.click(href='.*/parameters/')
+    assert resp.form['form-TOTAL_FORMS'].value == '3'
+    assert resp.form['form-0-key'].value == 'blah'
+    assert resp.form['form-0-value'].value == 'baz'
+    assert resp.form['form-1-key'].value == 'foo'
+    assert resp.form['form-1-value'].value == 'bar-bis'
+    assert resp.form['form-2-key'].value == ''
+    assert resp.form['form-2-value'].value == ''
+    resp.form['form-1-key'] = 'foo'
+    resp.form['form-1-value'] = 'bar'
+    resp.form['form-0-key'] = ''
+    resp = resp.form.submit().follow()
+    page.refresh_from_db()
+    assert page.parameters == {
+        'foo': 'bar',
+    }
+    assert resp.text.count('<i>none</i>') == 3
+    assert '<i>foo</i>' in resp
+
+
 def test_page_placeholder_restricted_visibility(app, admin_user):
     app = login(app)
 
diff --git a/tests/test_public.py b/tests/test_public.py
index 29250e56..3a9a8f65 100644
--- a/tests/test_public.py
+++ b/tests/test_public.py
@@ -577,7 +577,7 @@ def test_style_demo(app, admin_user):
         assert Page.objects.count() == 0
 
 
-def test_page_async_cell(app):
+def test_page_async_cell(app, nocache):
     Page.objects.all().delete()
     page = Page(title='Home', slug='index', template_name='standard')
     page.save()
@@ -1070,6 +1070,54 @@ def test_sub_slug(app, john_doe, jane_doe):
         assert resp.context['card_foo_bar_id'] == '42'
 
 
+def test_page_parameters(app):
+    page = Page.objects.create(
+        title='Home',
+        slug='page',
+        template_name='standard',
+        parameters={'foo': 'bar', 'bar_id': '{{ 40|add:2 }}'},
+    )
+    cell = JsonCell.objects.create(
+        page=page,
+        url='http://example.net',
+        order=0,
+        placeholder='content',
+        template_string='XX{{ foo }}YY{{ bar_id }}ZZ',
+    )
+
+    with mock.patch('combo.utils.requests.get') as requests_get:
+        requests_get.return_value = mock.Mock(content='{}', status_code=200)
+        resp = app.get('/page/')
+    assert '<div>XXbarYY42ZZ</div>' in resp
+
+    with mock.patch('combo.utils.requests.get') as requests_get:
+        requests_get.return_value = mock.Mock(content='{}', status_code=200)
+        resp = app.get(
+            reverse(
+                'combo-public-ajax-page-cell',
+                kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
+            )
+        )
+    assert resp.text.strip() == 'XXbarYY42ZZ'
+
+    # check sub_slug/parameters override
+    page.sub_slug = '(?P<fooo>[a-z]+)'
+    page.save()
+    cell.template_string = 'XX{{ foo }}YY{{ bar_id }}ZZ{{ fooo }}AA'
+    cell.save()
+    with mock.patch('combo.utils.requests.get') as requests_get:
+        requests_get.return_value = mock.Mock(content='{}', status_code=200)
+        resp = app.get('/page/baz/')
+    assert '<div>XXbarYY42ZZbazAA</div>' in resp
+
+    page.sub_slug = '(?P<foo>[a-z]+)'
+    page.save()
+    with mock.patch('combo.utils.requests.get') as requests_get:
+        requests_get.return_value = mock.Mock(content='{}', status_code=200)
+        resp = app.get('/page/baz/')
+    assert '<div>XXbarYY42ZZAA</div>' in resp
+
+
 def test_cell_slugs(app):
     Page.objects.all().delete()
     page = Page(title='Home', slug='index', template_name='standard')
diff --git a/tests/test_wcs.py b/tests/test_wcs.py
index f95b9a32..08335b70 100644
--- a/tests/test_wcs.py
+++ b/tests/test_wcs.py
@@ -2183,12 +2183,7 @@ def test_card_cell_render_identifier(mock_send, nocache, app):
     assert '/api/cards/card_model_1/list' in mock_send.call_args_list[1][0][0].url
     assert '/api/cards/card_model_1/13/' in mock_send.call_args_list[2][0][0].url
 
-    for card_ids in [
-        '{% for card in cards|objects:"card_model_1" %}{{ card.id }},{% endfor %}',
-        '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}',
-    ]:
-        cell.card_ids = card_ids
-        cell.save()
+    def test_card_ids():
         mock_send.reset_mock()
         resp = app.get(page.get_online_url())
         assert len(resp.context['cells']) == 3
@@ -2209,6 +2204,22 @@ def test_card_cell_render_identifier(mock_send, nocache, app):
                 in mock_send.call_args_list[i * 2 + 2][0][0].url
             )
 
+    for card_ids in [
+        '{% for card in cards|objects:"card_model_1" %}{{ card.id }},{% endfor %}',
+        '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}',
+    ]:
+        cell.card_ids = card_ids
+        cell.save()
+        test_card_ids()
+
+        cell.card_ids = '{{ var1 }}'
+        cell.save()
+        page.parameters = {'var1': card_ids}
+        page.save()
+        test_card_ids()
+        page.parameters = {}
+        page.save()
+
     # with a card_ids template, but result is empty
     cell.card_ids = '{{ foo }}'
     cell.save()
-- 
2.34.1

