0001-pricing-default-criteria-configuration-65328.patch
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 |
print(cleaned_data) |
|
61 |
return cleaned_data |
|
53 | 62 | |
54 | 63 | |
55 | 64 |
class CriteriaForm(NewCriteriaForm): |
56 | 65 |
class Meta: |
57 | 66 |
model = Criteria |
58 |
fields = ['label', 'slug', 'condition'] |
|
67 |
fields = ['label', 'slug', 'default', 'condition']
|
|
59 | 68 | |
60 | 69 |
def clean_slug(self): |
61 | 70 |
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 |
- |