From c01c17ecf8f66779911e354e921c59ef55447f99 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 3 Jun 2022 10:48:56 +0200
Subject: [PATCH] pricing: default criteria configuration (#65328)

---
 lingo/pricing/forms.py                        | 26 ++++---
 .../migrations/0004_criteria_default.py       | 29 ++++++++
 lingo/pricing/models.py                       | 28 ++++++--
 .../lingo/pricing/manager_criteria_form.html  | 12 ++++
 .../lingo/pricing/manager_criteria_list.html  |  6 +-
 lingo/pricing/views.py                        |  2 +-
 tests/pricing/manager/test_criteria.py        | 69 +++++++++++++++++--
 tests/pricing/test_models.py                  | 40 +++++++++--
 8 files changed, 181 insertions(+), 31 deletions(-)
 create mode 100644 lingo/pricing/migrations/0004_criteria_default.py

diff --git a/lingo/pricing/forms.py b/lingo/pricing/forms.py
index c56d044..4795993 100644
--- a/lingo/pricing/forms.py
+++ b/lingo/pricing/forms.py
@@ -40,22 +40,30 @@ class ImportForm(forms.Form):
 class NewCriteriaForm(forms.ModelForm):
     class Meta:
         model = Criteria
-        fields = ['label', 'condition']
+        fields = ['label', 'default', 'condition']
 
-    def clean_condition(self):
-        condition = self.cleaned_data['condition']
-        try:
-            Template('{%% if %s %%}OK{%% endif %%}' % condition)
-        except TemplateSyntaxError:
-            raise ValidationError(_('Invalid syntax.'))
+    def clean(self):
+        cleaned_data = super().clean()
 
-        return condition
+        if cleaned_data.get('default') is True:
+            cleaned_data['condition'] = ''
+        else:
+            condition = cleaned_data['condition']
+            if not condition:
+                self.add_error('condition', self.fields['condition'].default_error_messages['required'])
+            else:
+                try:
+                    Template('{%% if %s %%}OK{%% endif %%}' % condition)
+                except TemplateSyntaxError:
+                    self.add_error('condition', _('Invalid syntax.'))
+
+        return cleaned_data
 
 
 class CriteriaForm(NewCriteriaForm):
     class Meta:
         model = Criteria
-        fields = ['label', 'slug', 'condition']
+        fields = ['label', 'slug', 'default', 'condition']
 
     def clean_slug(self):
         slug = self.cleaned_data['slug']
diff --git a/lingo/pricing/migrations/0004_criteria_default.py b/lingo/pricing/migrations/0004_criteria_default.py
new file mode 100644
index 0000000..cbc4bf1
--- /dev/null
+++ b/lingo/pricing/migrations/0004_criteria_default.py
@@ -0,0 +1,29 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('pricing', '0003_extra_variables'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='criteria',
+            name='default',
+            field=models.BooleanField(
+                default=False,
+                help_text='Will be applied if no other criteria matches',
+                verbose_name='Default criteria',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='criteria',
+            name='condition',
+            field=models.CharField(blank=True, max_length=1000, verbose_name='Condition'),
+        ),
+        migrations.AlterModelOptions(
+            name='criteria',
+            options={'ordering': ['default', 'order']},
+        ),
+    ]
diff --git a/lingo/pricing/models.py b/lingo/pricing/models.py
index 5468485..3329668 100644
--- a/lingo/pricing/models.py
+++ b/lingo/pricing/models.py
@@ -44,6 +44,10 @@ class CriteriaConditionNotFound(PricingError):
     pass
 
 
+class MultipleDefaultCriteriaCondition(PricingError):
+    pass
+
+
 class PricingDataError(PricingError):
     pass
 
@@ -120,18 +124,23 @@ class Criteria(models.Model):
     )
     label = models.CharField(_('Label'), max_length=150)
     slug = models.SlugField(_('Identifier'), max_length=160)
-    condition = models.CharField(_('Condition'), max_length=1000)
+    condition = models.CharField(_('Condition'), max_length=1000, blank=True)
     order = models.PositiveIntegerField()
+    default = models.BooleanField(
+        _('Default criteria'), default=False, help_text=_('Will be applied if no other criteria matches')
+    )
 
     class Meta:
-        ordering = ['order']
+        ordering = ['default', 'order']
         unique_together = ['category', 'slug']
 
     def __str__(self):
         return self.label
 
     def save(self, *args, **kwargs):
-        if self.order is None:
+        if self.default is True:
+            self.order = 0
+        elif self.order is None:
             max_order = (
                 Criteria.objects.filter(category=self.category)
                 .aggregate(models.Max('order'))
@@ -416,19 +425,26 @@ class AgendaPricing(models.Model):
             criterias[category.slug] = None
             categories.append(category.slug)
             # find the first matching criteria (criterias are ordered)
-            for criteria in self.pricing.criterias.filter(category=category):
+            for criteria in self.pricing.criterias.filter(category=category, default=False):
                 condition = criteria.compute_condition(context)
                 if condition:
                     criterias[category.slug] = criteria.slug
                     break
+            if criterias[category.slug] is not None:
+                continue
+            # if no match, take default criteria if only once defined
+            default_criterias = self.pricing.criterias.filter(category=category, default=True)
+            if len(default_criterias) > 1:
+                raise MultipleDefaultCriteriaCondition(details={'category': category.slug})
+            if not default_criterias:
+                raise CriteriaConditionNotFound(details={'category': category.slug})
+            criterias[category.slug] = default_criterias[0].slug
 
         # now search for pricing values matching found criterias
         pricing_data = self.pricing_data
         # for each category (ordered)
         for category in categories:
             criteria = criterias[category]
-            if criteria is None:
-                raise CriteriaConditionNotFound(details={'category': category})
             if not isinstance(pricing_data, dict):
                 raise PricingDataFormatError(
                     details={'category': category, 'pricing': pricing_data, 'wanted': 'dict'}
diff --git a/lingo/pricing/templates/lingo/pricing/manager_criteria_form.html b/lingo/pricing/templates/lingo/pricing/manager_criteria_form.html
index e4aa30e..d555d15 100644
--- a/lingo/pricing/templates/lingo/pricing/manager_criteria_form.html
+++ b/lingo/pricing/templates/lingo/pricing/manager_criteria_form.html
@@ -23,6 +23,18 @@
 <form method="post" enctype="multipart/form-data">
   {% csrf_token %}
   {{ form.as_p }}
+   <script>
+    $(function() {
+      $('#id_default').on('change', function() {
+        if ($(this).is(':checked')) {
+          $('#id_condition').parent().hide();
+        } else {
+          $('#id_condition').parent().show();
+        }
+      });
+      $('#id_default').trigger('change');
+    });
+   </script>
   <div class="buttons">
     <button class="submit-button">{% trans "Save" %}</button>
     <a class="cancel" href="{% url 'lingo-manager-pricing-criteria-list' %}">{% trans 'Cancel' %}</a>
diff --git a/lingo/pricing/templates/lingo/pricing/manager_criteria_list.html b/lingo/pricing/templates/lingo/pricing/manager_criteria_list.html
index cb4da6d..02a21eb 100644
--- a/lingo/pricing/templates/lingo/pricing/manager_criteria_list.html
+++ b/lingo/pricing/templates/lingo/pricing/manager_criteria_list.html
@@ -37,9 +37,9 @@ Use drag and drop with the ⣿ handles to reorder criterias inside a category.
   <div>
   <ul class="objects-list single-links sortable" data-order-url="{% url 'lingo-manager-pricing-criteria-order' object.pk %}">
     {% for criteria in object.criterias.all %}
-    <li class="sortable-item" data-item-id="{{ criteria.pk }}">
-        <span class="handle">⣿</span>
-        <a rel="popup" href="{% url 'lingo-manager-pricing-criteria-edit' object.pk criteria.pk %}">{{ criteria }}</a>
+    <li{% if not criteria.default %} class="sortable-item" data-item-id="{{ criteria.pk }}"{% endif %}>
+        {% if not criteria.default %}<span class="handle">⣿</span>{% endif %}
+        <a rel="popup" href="{% url 'lingo-manager-pricing-criteria-edit' object.pk criteria.pk %}">{{ criteria }}{% if criteria.default %} <span class="extra-info">- {% trans "default" %}</span>{% endif %}</a>
         <a class="delete" rel="popup" href="{% url 'lingo-manager-pricing-criteria-delete' object.pk criteria.pk %}">{% trans "delete"%}</a>
     </li>
     {% endfor %}
diff --git a/lingo/pricing/views.py b/lingo/pricing/views.py
index bfc5ced..c8cca42 100644
--- a/lingo/pricing/views.py
+++ b/lingo/pricing/views.py
@@ -541,7 +541,7 @@ class CriteriaOrder(DetailView):
             new_order = [int(x) for x in request.GET['new-order'].split(',')]
         except ValueError:
             return HttpResponseBadRequest('incorrect new-order parameter')
-        criterias = category.criterias.all()
+        criterias = category.criterias.filter(default=False)
         if set(new_order) != {x.pk for x in criterias} or len(new_order) != len(criterias):
             return HttpResponseBadRequest('incorrect new-order parameter')
         criterias_by_id = {c.pk: c for c in criterias}
diff --git a/tests/pricing/manager/test_criteria.py b/tests/pricing/manager/test_criteria.py
index a1e99f0..7bd72bc 100644
--- a/tests/pricing/manager/test_criteria.py
+++ b/tests/pricing/manager/test_criteria.py
@@ -64,6 +64,9 @@ def test_add_criteria(app, admin_user):
     assert 'slug' not in resp.context['form'].fields
     resp = resp.form.submit()
     assert resp.context['form'].errors['condition'] == ['Invalid syntax.']
+    resp.form['condition'] = ''
+    resp = resp.form.submit()
+    assert resp.context['form'].errors['condition'] == ['This field is required.']
     resp.form['condition'] = 'qf < 1'
     resp = resp.form.submit()
     criteria = Criteria.objects.latest('pk')
@@ -73,6 +76,7 @@ def test_add_criteria(app, admin_user):
     assert criteria.slug == 'qf-1'
     assert criteria.condition == 'qf < 1'
     assert criteria.order == 1
+    assert criteria.default is False
 
     resp = app.get('/manage/pricing/criteria/category/%s/add/' % category.pk)
     resp.form['label'] = 'QF < 1'
@@ -85,6 +89,21 @@ def test_add_criteria(app, admin_user):
     assert criteria.slug == 'qf-1-1'
     assert criteria.condition == 'qf < 1'
     assert criteria.order == 2
+    assert criteria.default is False
+
+    resp = app.get('/manage/pricing/criteria/category/%s/add/' % category.pk)
+    resp.form['label'] = 'ELSE'
+    resp.form['condition'] = 'qf < 1 #'
+    resp.form['default'] = True
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/pricing/criterias/')
+    criteria = Criteria.objects.latest('pk')
+    assert criteria.label == 'ELSE'
+    assert criteria.category == category
+    assert criteria.slug == 'else'
+    assert criteria.condition == ''
+    assert criteria.order == 0
+    assert criteria.default is True
 
 
 def test_edit_criteria(app, admin_user):
@@ -103,15 +122,30 @@ def test_edit_criteria(app, admin_user):
     resp = resp.form.submit()
     assert resp.context['form'].errors['slug'] == ['Another criteria exists with the same identifier.']
     assert resp.context['form'].errors['condition'] == ['Invalid syntax.']
+    resp.form['condition'] = ''
+    resp.form['slug'] = criteria3.slug
+    resp = resp.form.submit()
+    assert resp.context['form'].errors['condition'] == ['This field is required.']
 
     resp.form['condition'] = 'qf <= 1'
-    resp.form['slug'] = criteria3.slug
     resp = resp.form.submit()
     assert resp.location.endswith('/manage/pricing/criterias/')
     criteria.refresh_from_db()
     assert criteria.label == 'QF 1 bis'
     assert criteria.slug == 'foo-bar'
     assert criteria.condition == 'qf <= 1'
+    assert criteria.order == 1
+    assert criteria.default is False
+
+    resp = app.get('/manage/pricing/criteria/category/%s/%s/edit/' % (category.pk, criteria.pk))
+    resp.form['condition'] = 'qf <= 1 #'
+    resp.form['default'] = True
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/pricing/criterias/')
+    criteria.refresh_from_db()
+    assert criteria.condition == ''
+    assert criteria.order == 0
+    assert criteria.default is True
 
 
 def test_delete_criteria(app, admin_user):
@@ -133,13 +167,20 @@ def test_reorder_criterias(app, admin_user):
     criteria2 = Criteria.objects.create(label='QF 2', category=category)
     criteria3 = Criteria.objects.create(label='QF 3', category=category)
     criteria4 = Criteria.objects.create(label='QF 4', category=category)
-    assert list(category.criterias.values_list('pk', flat=True).order_by('order')) == [
+    default1 = Criteria.objects.create(label='ELSE', category=category, default=True)
+    default2 = Criteria.objects.create(label='OTHER ELSE', category=category, default=True)
+    assert list(category.criterias.filter(default=False).values_list('pk', flat=True).order_by('order')) == [
         criteria1.pk,
         criteria2.pk,
         criteria3.pk,
         criteria4.pk,
     ]
-    assert list(category.criterias.values_list('order', flat=True).order_by('order')) == [1, 2, 3, 4]
+    assert list(
+        category.criterias.filter(default=False).values_list('order', flat=True).order_by('order')
+    ) == [1, 2, 3, 4]
+    assert list(
+        category.criterias.filter(default=True).values_list('order', flat=True).order_by('order')
+    ) == [0, 0]
 
     app = login(app)
     # missing get params
@@ -151,6 +192,10 @@ def test_reorder_criterias(app, admin_user):
         ','.join(str(x) for x in [criteria1.pk, criteria2.pk, criteria4.pk]),
         # criteria1 mentionned twice
         ','.join(str(x) for x in [criteria1.pk, criteria2.pk, criteria3.pk, criteria4.pk, criteria1.pk]),
+        # defaults can not be ordered
+        ','.join(
+            str(x) for x in [criteria1.pk, criteria2.pk, criteria3.pk, criteria4.pk, default1.pk, default2.pk]
+        ),
         # not an id
         'foo,1,2,3,4',
         ' 1 ,2,3,4',
@@ -162,13 +207,18 @@ def test_reorder_criterias(app, admin_user):
             status=400,
         )
     # not changed
-    assert list(category.criterias.values_list('pk', flat=True).order_by('order')) == [
+    assert list(category.criterias.filter(default=False).values_list('pk', flat=True).order_by('order')) == [
         criteria1.pk,
         criteria2.pk,
         criteria3.pk,
         criteria4.pk,
     ]
-    assert list(category.criterias.values_list('order', flat=True).order_by('order')) == [1, 2, 3, 4]
+    assert list(
+        category.criterias.filter(default=False).values_list('order', flat=True).order_by('order')
+    ) == [1, 2, 3, 4]
+    assert list(
+        category.criterias.filter(default=True).values_list('order', flat=True).order_by('order')
+    ) == [0, 0]
 
     # change order
     app.get(
@@ -177,10 +227,15 @@ def test_reorder_criterias(app, admin_user):
             'new-order': ','.join(str(x) for x in [criteria3.pk, criteria1.pk, criteria4.pk, criteria2.pk])
         },
     )
-    assert list(category.criterias.values_list('pk', flat=True).order_by('order')) == [
+    assert list(category.criterias.filter(default=False).values_list('pk', flat=True).order_by('order')) == [
         criteria3.pk,
         criteria1.pk,
         criteria4.pk,
         criteria2.pk,
     ]
-    assert list(category.criterias.values_list('order', flat=True).order_by('order')) == [1, 2, 3, 4]
+    assert list(
+        category.criterias.filter(default=False).values_list('order', flat=True).order_by('order')
+    ) == [1, 2, 3, 4]
+    assert list(
+        category.criterias.filter(default=True).values_list('order', flat=True).order_by('order')
+    ) == [0, 0]
diff --git a/tests/pricing/test_models.py b/tests/pricing/test_models.py
index 30729a6..9c8808b 100644
--- a/tests/pricing/test_models.py
+++ b/tests/pricing/test_models.py
@@ -15,6 +15,7 @@ from lingo.pricing.models import (
     Criteria,
     CriteriaCategory,
     CriteriaConditionNotFound,
+    MultipleDefaultCriteriaCondition,
     Pricing,
     PricingBookingCheckTypeError,
     PricingBookingNotCheckedError,
@@ -101,12 +102,16 @@ def test_criteria_order():
     category = CriteriaCategory.objects.create(label='Foo')
     criteria = Criteria.objects.create(label='Foo bar', category=category)
     assert criteria.order == 1
+    criteria = Criteria.objects.create(label='Foo bar', category=category, default=True)
+    assert criteria.order == 0
 
 
 def test_criteria_existing_order():
     category = CriteriaCategory.objects.create(label='Foo')
     criteria = Criteria.objects.create(label='Foo bar', order=42, category=category)
     assert criteria.order == 42
+    criteria = Criteria.objects.create(label='Foo bar', order=42, category=category, default=True)
+    assert criteria.order == 0
 
 
 def test_criteria_duplicate_orders():
@@ -119,6 +124,11 @@ def test_criteria_duplicate_orders():
     assert criteria.order == 2
     criteria = Criteria.objects.create(label='Foo baz', category=category)
     assert criteria.order == 3
+    criteria.default = True
+    criteria.save()
+    assert criteria.order == 0
+    criteria = Criteria.objects.create(label='Foo baz', category=category, default=True)
+    assert criteria.order == 0
 
 
 def test_pricing_slug():
@@ -365,6 +375,8 @@ def test_get_pricing_context(mock_send, context, nocache):
         ('1 <= qf and qf < 2', {'qf': 10}, False),
         ('1 <= qf and qf < 2', {'qf': 1}, True),
         ('1 <= qf and qf < 2', {'qf': 1.5}, True),
+        # no condition
+        ('', {}, False),
     ],
 )
 def test_compute_condition(condition, context, result):
@@ -407,6 +419,24 @@ def test_compute_pricing():
         agenda_pricing.compute_pricing(context={'qf': 2})
     assert e.value.details == {'category': 'qf'}
 
+    # but with a default criteria, there is a match, but agenda_pricing.pricing_data is not defined
+    default_criteria1 = Criteria.objects.create(
+        label='Else 1', slug='else-1', category=category, default=True
+    )
+    pricing.criterias.add(default_criteria1)
+    with pytest.raises(PricingDataFormatError) as e:
+        agenda_pricing.compute_pricing(context={'qf': 2})
+    assert e.value.details == {'category': 'qf', 'pricing': None, 'wanted': 'dict'}
+    # with more than one default criteria, fail
+    default_criteria2 = Criteria.objects.create(
+        label='Else 2', slug='else-2', category=category, default=True
+    )
+    pricing.criterias.add(default_criteria2)
+    with pytest.raises(MultipleDefaultCriteriaCondition) as e:
+        agenda_pricing.compute_pricing(context={'qf': 2})
+    assert e.value.details == {'category': 'qf'}
+    Criteria.objects.filter(default=True).delete()  # remove default criterias
+
     # criteria found, but agenda_pricing.pricing_data is not defined
     criteria1.condition = 'qf < 1'
     criteria1.save()
@@ -447,10 +477,10 @@ def test_compute_pricing():
     # more complexe pricing model
     category2 = CriteriaCategory.objects.create(label='Domicile', slug='domicile')
     criteria1 = Criteria.objects.create(
-        label='Commune', slug='dom-0', condition='domicile == "commune"', category=category2
+        label='Commune', slug='dom-0', condition='domicile == "commune"', category=category2, order=1
     )
     criteria2 = Criteria.objects.create(
-        label='Hors commune', slug='dom-1', condition='domicile != "commune"', category=category2
+        label='Hors commune', slug='else', category=category2, default=True, order=0
     )
     pricing.categories.add(category2, through_defaults={'order': 2})
     pricing.criterias.add(criteria1)
@@ -462,7 +492,7 @@ def test_compute_pricing():
             'qf:qf-0': 3,
             'qf:qf-1': 5,
         },
-        'domicile:dom-1': {
+        'domicile:else': {
             'qf:qf-0': 7,
             'qf:qf-1': 10,
         },
@@ -484,11 +514,11 @@ def test_compute_pricing():
     )
     assert agenda_pricing.compute_pricing(context={'qf': 2, 'domicile': 'ext'}) == (
         10,
-        {'domicile': 'dom-1', 'qf': 'qf-1'},
+        {'domicile': 'else', 'qf': 'qf-1'},
     )
     assert agenda_pricing.compute_pricing(context={'qf': 0, 'domicile': 'ext'}) == (
         7,
-        {'domicile': 'dom-1', 'qf': 'qf-0'},
+        {'domicile': 'else', 'qf': 'qf-0'},
     )
 
 
-- 
2.35.1

