Projet

Général

Profil

0001-pricing-default-criteria-configuration-65328.patch

Lauréline Guérin, 07 juin 2022 17:11

Télécharger (19,8 ko)

Voir les différences:

Subject: [PATCH] pricing: default criteria configuration (#65328)

 lingo/pricing/forms.py                        | 26 ++++---
 .../migrations/0004_criteria_default.py       | 29 ++++++++
 lingo/pricing/models.py                       | 22 ++++--
 .../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                  | 39 +++++++++--
 8 files changed, 174 insertions(+), 31 deletions(-)
 create mode 100644 lingo/pricing/migrations/0004_criteria_default.py
lingo/pricing/forms.py
40 40
class NewCriteriaForm(forms.ModelForm):
41 41
    class Meta:
42 42
        model = Criteria
43
        fields = ['label', 'condition']
43
        fields = ['label', 'default', 'condition']
44 44

  
45
    def clean_condition(self):
46
        condition = self.cleaned_data['condition']
47
        try:
48
            Template('{%% if %s %%}OK{%% endif %%}' % condition)
49
        except TemplateSyntaxError:
50
            raise ValidationError(_('Invalid syntax.'))
45
    def clean(self):
46
        cleaned_data = super().clean()
51 47

  
52
        return condition
48
        if cleaned_data.get('default') is True:
49
            cleaned_data['condition'] = ''
50
        else:
51
            condition = cleaned_data['condition']
52
            if not condition:
53
                self.add_error('condition', self.fields['condition'].default_error_messages['required'])
54
            else:
55
                try:
56
                    Template('{%% if %s %%}OK{%% endif %%}' % condition)
57
                except TemplateSyntaxError:
58
                    self.add_error('condition', _('Invalid syntax.'))
59

  
60
        return cleaned_data
53 61

  
54 62

  
55 63
class CriteriaForm(NewCriteriaForm):
56 64
    class Meta:
57 65
        model = Criteria
58
        fields = ['label', 'slug', 'condition']
66
        fields = ['label', 'slug', 'default', 'condition']
59 67

  
60 68
    def clean_slug(self):
61 69
        slug = self.cleaned_data['slug']
lingo/pricing/migrations/0004_criteria_default.py
1
from django.db import migrations, models
2

  
3

  
4
class Migration(migrations.Migration):
5

  
6
    dependencies = [
7
        ('pricing', '0003_extra_variables'),
8
    ]
9

  
10
    operations = [
11
        migrations.AddField(
12
            model_name='criteria',
13
            name='default',
14
            field=models.BooleanField(
15
                default=False,
16
                help_text='Will be applied if no other criteria matches',
17
                verbose_name='Default criteria',
18
            ),
19
        ),
20
        migrations.AlterField(
21
            model_name='criteria',
22
            name='condition',
23
            field=models.CharField(blank=True, max_length=1000, verbose_name='Condition'),
24
        ),
25
        migrations.AlterModelOptions(
26
            name='criteria',
27
            options={'ordering': ['default', 'order']},
28
        ),
29
    ]
lingo/pricing/models.py
120 120
    )
121 121
    label = models.CharField(_('Label'), max_length=150)
122 122
    slug = models.SlugField(_('Identifier'), max_length=160)
123
    condition = models.CharField(_('Condition'), max_length=1000)
123
    condition = models.CharField(_('Condition'), max_length=1000, blank=True)
124 124
    order = models.PositiveIntegerField()
125
    default = models.BooleanField(
126
        _('Default criteria'), default=False, help_text=_('Will be applied if no other criteria matches')
127
    )
125 128

  
126 129
    class Meta:
127
        ordering = ['order']
130
        ordering = ['default', 'order']
128 131
        unique_together = ['category', 'slug']
129 132

  
130 133
    def __str__(self):
131 134
        return self.label
132 135

  
133 136
    def save(self, *args, **kwargs):
134
        if self.order is None:
137
        if self.default is True:
138
            self.order = 0
139
        elif self.order is None:
135 140
            max_order = (
136 141
                Criteria.objects.filter(category=self.category)
137 142
                .aggregate(models.Max('order'))
......
416 421
            criterias[category.slug] = None
417 422
            categories.append(category.slug)
418 423
            # find the first matching criteria (criterias are ordered)
419
            for criteria in self.pricing.criterias.filter(category=category):
424
            for criteria in self.pricing.criterias.filter(category=category, default=False):
420 425
                condition = criteria.compute_condition(context)
421 426
                if condition:
422 427
                    criterias[category.slug] = criteria.slug
423 428
                    break
429
            if criterias[category.slug] is not None:
430
                continue
431
            # if no match, take default criteria if only once defined
432
            default_criterias = self.pricing.criterias.filter(category=category, default=True)
433
            if len(default_criterias) > 1 or not default_criterias:
434
                raise CriteriaConditionNotFound(details={'category': category.slug})
435
            criterias[category.slug] = default_criterias[0].slug
424 436

  
425 437
        # now search for pricing values matching found criterias
426 438
        pricing_data = self.pricing_data
427 439
        # for each category (ordered)
428 440
        for category in categories:
429 441
            criteria = criterias[category]
430
            if criteria is None:
431
                raise CriteriaConditionNotFound(details={'category': category})
432 442
            if not isinstance(pricing_data, dict):
433 443
                raise PricingDataFormatError(
434 444
                    details={'category': category, 'pricing': pricing_data, 'wanted': 'dict'}
lingo/pricing/templates/lingo/pricing/manager_criteria_form.html
23 23
<form method="post" enctype="multipart/form-data">
24 24
  {% csrf_token %}
25 25
  {{ form.as_p }}
26
   <script>
27
    $(function() {
28
      $('#id_default').on('change', function() {
29
        if ($(this).is(':checked')) {
30
          $('#id_condition').parent().hide();
31
        } else {
32
          $('#id_condition').parent().show();
33
        }
34
      });
35
      $('#id_default').trigger('change');
36
    });
37
   </script>
26 38
  <div class="buttons">
27 39
    <button class="submit-button">{% trans "Save" %}</button>
28 40
    <a class="cancel" href="{% url 'lingo-manager-pricing-criteria-list' %}">{% trans 'Cancel' %}</a>
lingo/pricing/templates/lingo/pricing/manager_criteria_list.html
37 37
  <div>
38 38
  <ul class="objects-list single-links sortable" data-order-url="{% url 'lingo-manager-pricing-criteria-order' object.pk %}">
39 39
    {% for criteria in object.criterias.all %}
40
    <li class="sortable-item" data-item-id="{{ criteria.pk }}">
41
        <span class="handle">⣿</span>
42
        <a rel="popup" href="{% url 'lingo-manager-pricing-criteria-edit' object.pk criteria.pk %}">{{ criteria }}</a>
40
    <li{% if not criteria.default %} class="sortable-item" data-item-id="{{ criteria.pk }}"{% endif %}>
41
        {% if not criteria.default %}<span class="handle">⣿</span>{% endif %}
42
        <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>
43 43
        <a class="delete" rel="popup" href="{% url 'lingo-manager-pricing-criteria-delete' object.pk criteria.pk %}">{% trans "delete"%}</a>
44 44
    </li>
45 45
    {% endfor %}
lingo/pricing/views.py
541 541
            new_order = [int(x) for x in request.GET['new-order'].split(',')]
542 542
        except ValueError:
543 543
            return HttpResponseBadRequest('incorrect new-order parameter')
544
        criterias = category.criterias.all()
544
        criterias = category.criterias.filter(default=False)
545 545
        if set(new_order) != {x.pk for x in criterias} or len(new_order) != len(criterias):
546 546
            return HttpResponseBadRequest('incorrect new-order parameter')
547 547
        criterias_by_id = {c.pk: c for c in criterias}
tests/pricing/manager/test_criteria.py
64 64
    assert 'slug' not in resp.context['form'].fields
65 65
    resp = resp.form.submit()
66 66
    assert resp.context['form'].errors['condition'] == ['Invalid syntax.']
67
    resp.form['condition'] = ''
68
    resp = resp.form.submit()
69
    assert resp.context['form'].errors['condition'] == ['This field is required.']
67 70
    resp.form['condition'] = 'qf < 1'
68 71
    resp = resp.form.submit()
69 72
    criteria = Criteria.objects.latest('pk')
......
73 76
    assert criteria.slug == 'qf-1'
74 77
    assert criteria.condition == 'qf < 1'
75 78
    assert criteria.order == 1
79
    assert criteria.default is False
76 80

  
77 81
    resp = app.get('/manage/pricing/criteria/category/%s/add/' % category.pk)
78 82
    resp.form['label'] = 'QF < 1'
......
85 89
    assert criteria.slug == 'qf-1-1'
86 90
    assert criteria.condition == 'qf < 1'
87 91
    assert criteria.order == 2
92
    assert criteria.default is False
93

  
94
    resp = app.get('/manage/pricing/criteria/category/%s/add/' % category.pk)
95
    resp.form['label'] = 'ELSE'
96
    resp.form['condition'] = 'qf < 1 #'
97
    resp.form['default'] = True
98
    resp = resp.form.submit()
99
    assert resp.location.endswith('/manage/pricing/criterias/')
100
    criteria = Criteria.objects.latest('pk')
101
    assert criteria.label == 'ELSE'
102
    assert criteria.category == category
103
    assert criteria.slug == 'else'
104
    assert criteria.condition == ''
105
    assert criteria.order == 0
106
    assert criteria.default is True
88 107

  
89 108

  
90 109
def test_edit_criteria(app, admin_user):
......
103 122
    resp = resp.form.submit()
104 123
    assert resp.context['form'].errors['slug'] == ['Another criteria exists with the same identifier.']
105 124
    assert resp.context['form'].errors['condition'] == ['Invalid syntax.']
125
    resp.form['condition'] = ''
126
    resp.form['slug'] = criteria3.slug
127
    resp = resp.form.submit()
128
    assert resp.context['form'].errors['condition'] == ['This field is required.']
106 129

  
107 130
    resp.form['condition'] = 'qf <= 1'
108
    resp.form['slug'] = criteria3.slug
109 131
    resp = resp.form.submit()
110 132
    assert resp.location.endswith('/manage/pricing/criterias/')
111 133
    criteria.refresh_from_db()
112 134
    assert criteria.label == 'QF 1 bis'
113 135
    assert criteria.slug == 'foo-bar'
114 136
    assert criteria.condition == 'qf <= 1'
137
    assert criteria.order == 1
138
    assert criteria.default is False
139

  
140
    resp = app.get('/manage/pricing/criteria/category/%s/%s/edit/' % (category.pk, criteria.pk))
141
    resp.form['condition'] = 'qf <= 1 #'
142
    resp.form['default'] = True
143
    resp = resp.form.submit()
144
    assert resp.location.endswith('/manage/pricing/criterias/')
145
    criteria.refresh_from_db()
146
    assert criteria.condition == ''
147
    assert criteria.order == 0
148
    assert criteria.default is True
115 149

  
116 150

  
117 151
def test_delete_criteria(app, admin_user):
......
133 167
    criteria2 = Criteria.objects.create(label='QF 2', category=category)
134 168
    criteria3 = Criteria.objects.create(label='QF 3', category=category)
135 169
    criteria4 = Criteria.objects.create(label='QF 4', category=category)
136
    assert list(category.criterias.values_list('pk', flat=True).order_by('order')) == [
170
    default1 = Criteria.objects.create(label='ELSE', category=category, default=True)
171
    default2 = Criteria.objects.create(label='OTHER ELSE', category=category, default=True)
172
    assert list(category.criterias.filter(default=False).values_list('pk', flat=True).order_by('order')) == [
137 173
        criteria1.pk,
138 174
        criteria2.pk,
139 175
        criteria3.pk,
140 176
        criteria4.pk,
141 177
    ]
142
    assert list(category.criterias.values_list('order', flat=True).order_by('order')) == [1, 2, 3, 4]
178
    assert list(
179
        category.criterias.filter(default=False).values_list('order', flat=True).order_by('order')
180
    ) == [1, 2, 3, 4]
181
    assert list(
182
        category.criterias.filter(default=True).values_list('order', flat=True).order_by('order')
183
    ) == [0, 0]
143 184

  
144 185
    app = login(app)
145 186
    # missing get params
......
151 192
        ','.join(str(x) for x in [criteria1.pk, criteria2.pk, criteria4.pk]),
152 193
        # criteria1 mentionned twice
153 194
        ','.join(str(x) for x in [criteria1.pk, criteria2.pk, criteria3.pk, criteria4.pk, criteria1.pk]),
195
        # defaults can not be ordered
196
        ','.join(
197
            str(x) for x in [criteria1.pk, criteria2.pk, criteria3.pk, criteria4.pk, default1.pk, default2.pk]
198
        ),
154 199
        # not an id
155 200
        'foo,1,2,3,4',
156 201
        ' 1 ,2,3,4',
......
162 207
            status=400,
163 208
        )
164 209
    # not changed
165
    assert list(category.criterias.values_list('pk', flat=True).order_by('order')) == [
210
    assert list(category.criterias.filter(default=False).values_list('pk', flat=True).order_by('order')) == [
166 211
        criteria1.pk,
167 212
        criteria2.pk,
168 213
        criteria3.pk,
169 214
        criteria4.pk,
170 215
    ]
171
    assert list(category.criterias.values_list('order', flat=True).order_by('order')) == [1, 2, 3, 4]
216
    assert list(
217
        category.criterias.filter(default=False).values_list('order', flat=True).order_by('order')
218
    ) == [1, 2, 3, 4]
219
    assert list(
220
        category.criterias.filter(default=True).values_list('order', flat=True).order_by('order')
221
    ) == [0, 0]
172 222

  
173 223
    # change order
174 224
    app.get(
......
177 227
            'new-order': ','.join(str(x) for x in [criteria3.pk, criteria1.pk, criteria4.pk, criteria2.pk])
178 228
        },
179 229
    )
180
    assert list(category.criterias.values_list('pk', flat=True).order_by('order')) == [
230
    assert list(category.criterias.filter(default=False).values_list('pk', flat=True).order_by('order')) == [
181 231
        criteria3.pk,
182 232
        criteria1.pk,
183 233
        criteria4.pk,
184 234
        criteria2.pk,
185 235
    ]
186
    assert list(category.criterias.values_list('order', flat=True).order_by('order')) == [1, 2, 3, 4]
236
    assert list(
237
        category.criterias.filter(default=False).values_list('order', flat=True).order_by('order')
238
    ) == [1, 2, 3, 4]
239
    assert list(
240
        category.criterias.filter(default=True).values_list('order', flat=True).order_by('order')
241
    ) == [0, 0]
tests/pricing/test_models.py
101 101
    category = CriteriaCategory.objects.create(label='Foo')
102 102
    criteria = Criteria.objects.create(label='Foo bar', category=category)
103 103
    assert criteria.order == 1
104
    criteria = Criteria.objects.create(label='Foo bar', category=category, default=True)
105
    assert criteria.order == 0
104 106

  
105 107

  
106 108
def test_criteria_existing_order():
107 109
    category = CriteriaCategory.objects.create(label='Foo')
108 110
    criteria = Criteria.objects.create(label='Foo bar', order=42, category=category)
109 111
    assert criteria.order == 42
112
    criteria = Criteria.objects.create(label='Foo bar', order=42, category=category, default=True)
113
    assert criteria.order == 0
110 114

  
111 115

  
112 116
def test_criteria_duplicate_orders():
......
119 123
    assert criteria.order == 2
120 124
    criteria = Criteria.objects.create(label='Foo baz', category=category)
121 125
    assert criteria.order == 3
126
    criteria.default = True
127
    criteria.save()
128
    assert criteria.order == 0
129
    criteria = Criteria.objects.create(label='Foo baz', category=category, default=True)
130
    assert criteria.order == 0
122 131

  
123 132

  
124 133
def test_pricing_slug():
......
365 374
        ('1 <= qf and qf < 2', {'qf': 10}, False),
366 375
        ('1 <= qf and qf < 2', {'qf': 1}, True),
367 376
        ('1 <= qf and qf < 2', {'qf': 1.5}, True),
377
        # no condition
378
        ('', {}, False),
368 379
    ],
369 380
)
370 381
def test_compute_condition(condition, context, result):
......
407 418
        agenda_pricing.compute_pricing(context={'qf': 2})
408 419
    assert e.value.details == {'category': 'qf'}
409 420

  
421
    # but with a default criteria, there is a match, but agenda_pricing.pricing_data is not defined
422
    default_criteria1 = Criteria.objects.create(
423
        label='Else 1', slug='else-1', category=category, default=True
424
    )
425
    pricing.criterias.add(default_criteria1)
426
    with pytest.raises(PricingDataFormatError) as e:
427
        agenda_pricing.compute_pricing(context={'qf': 2})
428
    assert e.value.details == {'category': 'qf', 'pricing': None, 'wanted': 'dict'}
429
    # with more than one default criteria, fail
430
    default_criteria2 = Criteria.objects.create(
431
        label='Else 2', slug='else-2', category=category, default=True
432
    )
433
    pricing.criterias.add(default_criteria2)
434
    with pytest.raises(CriteriaConditionNotFound) as e:
435
        agenda_pricing.compute_pricing(context={'qf': 2})
436
    assert e.value.details == {'category': 'qf'}
437
    Criteria.objects.filter(default=True).delete()  # remove default criterias
438

  
410 439
    # criteria found, but agenda_pricing.pricing_data is not defined
411 440
    criteria1.condition = 'qf < 1'
412 441
    criteria1.save()
......
447 476
    # more complexe pricing model
448 477
    category2 = CriteriaCategory.objects.create(label='Domicile', slug='domicile')
449 478
    criteria1 = Criteria.objects.create(
450
        label='Commune', slug='dom-0', condition='domicile == "commune"', category=category2
479
        label='Commune', slug='dom-0', condition='domicile == "commune"', category=category2, order=1
451 480
    )
452 481
    criteria2 = Criteria.objects.create(
453
        label='Hors commune', slug='dom-1', condition='domicile != "commune"', category=category2
482
        label='Hors commune', slug='else', category=category2, default=True, order=0
454 483
    )
455 484
    pricing.categories.add(category2, through_defaults={'order': 2})
456 485
    pricing.criterias.add(criteria1)
......
462 491
            'qf:qf-0': 3,
463 492
            'qf:qf-1': 5,
464 493
        },
465
        'domicile:dom-1': {
494
        'domicile:else': {
466 495
            'qf:qf-0': 7,
467 496
            'qf:qf-1': 10,
468 497
        },
......
484 513
    )
485 514
    assert agenda_pricing.compute_pricing(context={'qf': 2, 'domicile': 'ext'}) == (
486 515
        10,
487
        {'domicile': 'dom-1', 'qf': 'qf-1'},
516
        {'domicile': 'else', 'qf': 'qf-1'},
488 517
    )
489 518
    assert agenda_pricing.compute_pricing(context={'qf': 0, 'domicile': 'ext'}) == (
490 519
        7,
491
        {'domicile': 'dom-1', 'qf': 'qf-0'},
520
        {'domicile': 'else', 'qf': 'qf-0'},
492 521
    )
493 522

  
494 523

  
495
-