Tarification: appliquer un taux de réduction au tarif trouvé dans la grille tarifaire (#81231) #100

Merged
lguerin merged 2 commits from wip/81231-pricing-reduction-rate into main 2023-09-21 15:56:05 +02:00
10 changed files with 446 additions and 9 deletions

View File

@ -0,0 +1,30 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pricing', '0012_payer'),
]
operations = [
migrations.AddField(
model_name='agendapricing',
name='min_pricing',
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=9, null=True, verbose_name='Minimal pricing'
),
),
migrations.AddField(
model_name='pricing',
name='reduction_rate',
field=models.CharField(
blank=True,
help_text=(
'The result is expressed as a percentage, and must be between 0 and 100. '
'Leave it empty to apply the pricing without reduction.'
),
max_length=1000,
verbose_name='Reduction rate (template)',
),
),
]

View File

@ -54,6 +54,18 @@ class PricingDataFormatError(PricingError):
pass
class PricingReductionRateError(PricingError):
pass
class PricingReductionRateFormatError(PricingError):
pass
class PricingReductionRateValueError(PricingError):
pass
class PricingUnknownCheckStatusError(PricingError):
pass
@ -186,6 +198,15 @@ class Pricing(models.Model):
)
criterias = models.ManyToManyField(Criteria)
extra_variables = models.JSONField(blank=True, default=dict)
reduction_rate = models.CharField(
_('Reduction rate (template)'),
max_length=1000,
blank=True,
help_text=_(
'The result is expressed as a percentage, and must be between 0 and 100. '
'Leave it empty to apply the pricing without reduction.'
),
)
class Meta:
ordering = ['label']
@ -340,6 +361,9 @@ class AgendaPricing(models.Model):
flat_fee_schedule = models.BooleanField(_('Flat fee schedule'), default=False)
subscription_required = models.BooleanField(_('Subscription is required'), default=True)
pricing_data = models.JSONField(null=True)
min_pricing = models.DecimalField(
_('Minimal pricing'), max_digits=9, decimal_places=2, blank=True, null=True
)
def __str__(self):
return self.label or self.pricing.label
@ -403,11 +427,19 @@ class AgendaPricing(models.Model):
payer_external_id=payer_external_id,
)
pricing, criterias = self.compute_pricing(context=context)
pricing, reduction_rate = self.apply_reduction_rate(
pricing=pricing,
request=request,
context=context,
user_external_id=user_external_id,
payer_external_id=payer_external_id,
)
return {
'pricing': pricing,
'calculation_details': {
'pricing': pricing,
'criterias': criterias,
'reduction_rate': reduction_rate,
'context': context,
},
}
@ -428,12 +460,23 @@ class AgendaPricing(models.Model):
payer_external_id=payer_external_id,
)
pricing, criterias = self.compute_pricing(context=context)
pricing, reduction_rate = self.apply_reduction_rate(
pricing=pricing,
request=request,
context=context,
user_external_id=user_external_id,
payer_external_id=payer_external_id,
)
modifier = self.get_booking_modifier(agenda=agenda, check_status=check_status)
return self.aggregate_pricing_data(
pricing=pricing, criterias=criterias, context=context, modifier=modifier
pricing=pricing,
criterias=criterias,
reduction_rate=reduction_rate,
context=context,
modifier=modifier,
)
def aggregate_pricing_data(self, pricing, criterias, context, modifier):
def aggregate_pricing_data(self, pricing, criterias, reduction_rate, context, modifier):
if modifier['modifier_type'] == 'fixed':
pricing_amount = modifier['modifier_fixed']
else:
@ -443,6 +486,7 @@ class AgendaPricing(models.Model):
'calculation_details': {
'pricing': pricing,
'criterias': criterias,
'reduction_rate': reduction_rate,
'context': context,
},
'booking_details': modifier,
@ -535,6 +579,55 @@ class AgendaPricing(models.Model):
return pricing, criterias
def compute_reduction_rate(self, request, original_context, user_external_id, payer_external_id):
context = RequestContext(request)
context.push(original_context)
context.push({'user_external_id': user_external_id, 'payer_external_id': payer_external_id})
if ':' in user_external_id:
context['user_external_raw_id'] = user_external_id.split(':')[1]
if ':' in payer_external_id:
context['payer_external_raw_id'] = payer_external_id.split(':')[1]
try:
reduction_rate = Template(self.pricing.reduction_rate).render(context)
except (TemplateSyntaxError, VariableDoesNotExist):
raise PricingReductionRateError()
try:
reduction_rate = decimal.Decimal(reduction_rate)
except (decimal.InvalidOperation, ValueError, TypeError):
raise PricingReductionRateFormatError(
details={'reduction_rate': reduction_rate, 'wanted': 'decimal'}
)
if reduction_rate < 0 or reduction_rate > 100:
raise PricingReductionRateValueError(details={'reduction_rate': reduction_rate})
return reduction_rate
def apply_reduction_rate(self, pricing, request, context, user_external_id, payer_external_id):
if not self.pricing.reduction_rate:
return pricing, {}
reduction_rate = self.compute_reduction_rate(
request=request,
original_context=context,
user_external_id=user_external_id,
payer_external_id=payer_external_id,
)
new_pricing = pricing * (100 - reduction_rate) / 100
adjusted_pricing = new_pricing
if self.min_pricing is not None:
adjusted_pricing = max(adjusted_pricing, self.min_pricing)
return adjusted_pricing, {
'computed_pricing': pricing,
'reduction_rate': reduction_rate,
'reduced_pricing': new_pricing,
'min_pricing': self.min_pricing,
'bounded_pricing': adjusted_pricing,
}
def get_booking_modifier(self, agenda, check_status):
status = check_status['status']
if status not in ['error', 'not-booked', 'cancelled', 'presence', 'absence']:

View File

@ -27,6 +27,9 @@
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
<button aria-controls="panel-options" aria-selected="true" id="tab-options" role="tab" tabindex="0">{% trans "Options" %}</button>
{% if object.pricing.reduction_rate %}
<button aria-controls="panel-pricing-options" aria-selected="false" id="tab-pricing-options" role="tab" tabindex="0">{% trans "Pricing options" %}</button>
{% endif %}
{% if object.subscription_required %}
<button aria-controls="panel-agendas" aria-selected="false" id="tab-agendas" role="tab" tabindex="0">{% trans "Agendas" %}</button>
{% endif %}
@ -53,6 +56,17 @@
</ul>
</div>
{% if object.pricing.reduction_rate %}
<div aria-labelledby="tab-pricing-options" hidden id="panel-pricing-options" role="tabpanel" tabindex="0">
<ul>
<li>{% trans "Minimal pricing:" %} {{ object.min_pricing|default_if_none:"" }}</li>
</ul>
<div class="panel--buttons">
<a class="pk-button" rel="popup" href="{% url 'lingo-manager-agenda-pricing-pricingoptions-edit' object.pk %}">{% trans "Edit pricing options" %}</a>
</div>
</div>
{% endif %}
{% if object.subscription_required %}
<div aria-labelledby="tab-agendas" hidden id="panel-agendas" role="tabpanel" tabindex="0">
<ul class="objects-list single-links">

View File

@ -0,0 +1,22 @@
{% extends "lingo/pricing/manager_agenda_pricing_detail.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'lingo-manager-agenda-pricing-pricingoptions-edit' object.pk %}">{% trans "Edit" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Edit pricing options" %}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'lingo-manager-agenda-pricing-detail' object.pk %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -26,13 +26,23 @@
<div class="section">
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
<button aria-controls="panel-variables" aria-selected="true" id="tab-variables" role="tab" tabindex="0">{% trans "Variables" %}</button>
<button aria-controls="panel-options" aria-selected="true" id="tab-options" role="tab" tabindex="0">{% trans "Options" %}</button>
<button aria-controls="panel-variables" aria-selected="false" id="tab-variables" role="tab" tabindex="-1">{% trans "Variables" %}</button>
<button aria-controls="panel-criterias" aria-selected="false" id="tab-criterias" role="tab" tabindex="-1">{% trans "Criterias" %}</button>
<button aria-controls="panel-usage" aria-selected="false" id="tab-usage" role="tab" tabindex="-1">{% trans "Used in agendas" %}</button>
</div>
<div class="pk-tabs--container">
<div aria-labelledby="tab-variables" id="panel-variables" role="tabpanel" tabindex="0">
<div aria-labelledby="tab-options" id="panel-options" role="tabpanel" tabindex="0">
<ul>
<li>{% trans "Reduction rate enabled:"%} {{ object.reduction_rate|yesno }}</li>
{% if object.reduction_rate %}
<li>{% trans "Reduction rate (template):" %} <pre>{{ object.reduction_rate }}</pre></li>
{% endif %}
</ul>
</div>
<div aria-labelledby="tab-variables" hidden="" id="panel-variables" role="tabpanel" tabindex="0">
{% if object.extra_variables %}
<label>{% trans 'Extra variables:' %}</label>
<dl>

View File

@ -192,6 +192,11 @@ urlpatterns = [
views.agenda_pricing_test_tool,
name='lingo-manager-agenda-pricing-test-tool',
),
path(
'agenda-pricing/<int:pk>/pricing-options/',
views.agenda_pricing_pricingoptions_edit,
name='lingo-manager-agenda-pricing-pricingoptions-edit',
),
path(
'agenda-pricing/<int:pk>/agenda/add/',
views.agenda_pricing_agenda_add,

View File

@ -310,7 +310,7 @@ pricing_detail = PricingDetailView.as_view()
class PricingEditView(UpdateView):
template_name = 'lingo/pricing/manager_pricing_form.html'
model = Pricing
fields = ['label', 'slug']
fields = ['label', 'slug', 'reduction_rate']
def get_success_url(self):
return reverse('lingo-manager-pricing-detail', args=[self.object.pk])
@ -396,7 +396,7 @@ class PricingVariableEdit(FormView):
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
return reverse('lingo-manager-pricing-detail', args=[self.object.pk])
return '%s#open:variables' % reverse('lingo-manager-pricing-detail', args=[self.object.pk])
pricing_variable_edit = PricingVariableEdit.as_view()
@ -826,6 +826,23 @@ class AgendaPricingEditView(UpdateView):
agenda_pricing_edit = AgendaPricingEditView.as_view()
class AgendaPricingPricingOptionsEditView(UpdateView):
template_name = 'lingo/pricing/manager_agenda_pricing_pricingoptions_form.html'
model = AgendaPricing
fields = ['min_pricing']
def get_queryset(self):
return super().get_queryset().exclude(pricing__reduction_rate='')
def get_success_url(self):
return '%s#open:pricing-options' % reverse(
'lingo-manager-agenda-pricing-detail', args=[self.object.pk]
)
agenda_pricing_pricingoptions_edit = AgendaPricingPricingOptionsEditView.as_view()
class AgendaPricingDeleteView(DeleteView):
template_name = 'lingo/manager_confirm_delete.html'
model = AgendaPricing

View File

@ -350,6 +350,46 @@ def test_edit_agenda_pricing_billing_date_end(app, admin_user):
resp.form.submit().follow()
def test_edit_agenda_pricing_pricingoptions(app, admin_user):
pricing = Pricing.objects.create(label='Model')
agenda_pricing = AgendaPricing.objects.create(
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2021, month=10, day=1),
)
app = login(app)
assert pricing.reduction_rate == ''
resp = app.get('/manage/pricing/agenda-pricing/%s/' % agenda_pricing.pk)
assert 'Pricing options' not in resp
app.get('/manage/pricing/agenda-pricing/%s/pricing-options/' % agenda_pricing.pk, status=404)
pricing.reduction_rate = 'foo'
pricing.save()
resp = app.get('/manage/pricing/agenda-pricing/%s/' % agenda_pricing.pk)
assert 'Pricing options' in resp
resp = resp.click(href='/manage/pricing/agenda-pricing/%s/pricing-options/' % agenda_pricing.pk)
resp = resp.form.submit()
assert resp.location.endswith(
'/manage/pricing/agenda-pricing/%s/#open:pricing-options' % agenda_pricing.pk
)
agenda_pricing.refresh_from_db()
assert agenda_pricing.min_pricing is None
resp = app.get('/manage/pricing/agenda-pricing/%s/pricing-options/' % agenda_pricing.pk)
resp.form['min_pricing'] = 0
resp = resp.form.submit()
agenda_pricing.refresh_from_db()
assert agenda_pricing.min_pricing == 0
resp = app.get('/manage/pricing/agenda-pricing/%s/pricing-options/' % agenda_pricing.pk)
resp.form['min_pricing'] = 10
resp = resp.form.submit()
agenda_pricing.refresh_from_db()
assert agenda_pricing.min_pricing == 10
def test_detail_agenda_pricing(app, admin_user):
pricing = Pricing.objects.create(label='Model')
agenda_pricing = AgendaPricing.objects.create(

View File

@ -55,6 +55,16 @@ def test_detail_pricing(app, admin_user):
resp = app.get('/manage/pricing/model/%s/' % pricing.pk)
assert resp.text.count('"/manage/pricing/agenda/%s/"' % agenda.pk) == 1
assert pricing.reduction_rate == ''
assert 'Reduction rate enabled: no' in resp
assert 'Reduction rate (template):' not in resp
pricing.reduction_rate = 'foo'
pricing.save()
resp = app.get('/manage/pricing/model/%s/' % pricing.pk)
assert 'Reduction rate enabled: yes' in resp
assert 'Reduction rate (template): <pre>foo</pre>' in resp
def test_edit_pricing(app, admin_user):
pricing = Pricing.objects.create(label='Model 1')
@ -74,6 +84,14 @@ def test_edit_pricing(app, admin_user):
pricing.refresh_from_db()
assert pricing.label == 'Model Foo'
assert pricing.slug == 'foo-bar'
assert pricing.reduction_rate == ''
resp = app.get('/manage/pricing/model/%s/edit/' % pricing.pk)
resp.form['reduction_rate'] = 'foo'
resp = resp.form.submit()
assert resp.location.endswith('/manage/pricing/model/%s/' % pricing.pk)
pricing.refresh_from_db()
assert pricing.reduction_rate == 'foo'
def test_delete_pricing(app, admin_user):

View File

@ -27,6 +27,9 @@ from lingo.pricing.models import (
PricingMatrixCell,
PricingMatrixRow,
PricingMultipleBookingError,
PricingReductionRateError,
PricingReductionRateFormatError,
PricingReductionRateValueError,
PricingUnknownCheckStatusError,
)
@ -52,8 +55,8 @@ class MockedRequestResponse(mock.Mock):
def mocked_requests_send(request, **kwargs):
data = [
{'id': 1, 'fields': {'foo': 'bar', 'bar': False}},
{'id': 2, 'fields': {'foo': 'baz', 'bar': True}},
{'id': 1, 'fields': {'foo': 'bar', 'bar': False, 'rate': 42}},
{'id': 2, 'fields': {'foo': 'baz', 'bar': True, 'rate': 35}},
] # fake result
return MockedRequestResponse(content=json.dumps({'data': data}))
@ -668,6 +671,184 @@ def test_compute_pricing():
)
@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
def test_compute_reduction_rate(mock_send, context):
pricing = Pricing.objects.create(label='Foo bar')
agenda_pricing = AgendaPricing.objects.create(
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2021, month=10, day=1),
)
# empty template
assert pricing.reduction_rate == ''
with pytest.raises(PricingReductionRateFormatError) as e:
agenda_pricing.compute_reduction_rate(
request=context['request'],
original_context={},
user_external_id='child:42',
payer_external_id='parent:35',
)
assert e.value.details == {'reduction_rate': '', 'wanted': 'decimal'}
for value in ['{% for %}', '{{ "foo"|add:user.email }}']:
pricing.reduction_rate = value
pricing.save()
with pytest.raises(PricingReductionRateError):
agenda_pricing.compute_reduction_rate(
request=context['request'],
original_context={},
user_external_id='child:42',
payer_external_id='parent:35',
)
# not a decimal
for value in ['bar', '{{ foo }}']:
pricing.reduction_rate = value
pricing.save()
with pytest.raises(PricingReductionRateFormatError) as e:
agenda_pricing.compute_reduction_rate(
request=context['request'],
original_context={'foo': 'bar'},
user_external_id='child:42',
payer_external_id='parent:35',
)
assert e.value.details == {'reduction_rate': '"bar"', 'wanted': 'decimal'}
# not a good rate
for value in ['-0.01', '{{ min }}', '{{ 0|add:-1 }}', '100.01', '{{ max }}', '{{ 100|add:1 }}']:
pricing.reduction_rate = value
pricing.save()
with pytest.raises(PricingReductionRateValueError) as e:
agenda_pricing.compute_reduction_rate(
request=context['request'],
original_context={'min': '-0.01', 'max': '100.01'},
user_external_id='child:42',
payer_external_id='parent:35',
)
assert e.value.details == {}
for value in ['42', '{{ foo }}', '{{ cards|objects:"foo"|first|get:"fields"|get:"rate" }}']:
pricing.reduction_rate = value
pricing.save()
assert (
agenda_pricing.compute_reduction_rate(
request=context['request'],
original_context={'foo': '42'},
user_external_id='child:42',
payer_external_id='parent:35',
)
== 42
)
# user_external_id and payer_external_id can be used
pricing.reduction_rate = {
'qf': '{{ cards|objects:"qf"|filter_by:"foo"|filter_value:user_external_id|filter_by:"bar"|filter_value:payer_external_id|list }}',
}
pricing.save()
mock_send.reset_mock()
with pytest.raises(PricingReductionRateFormatError):
agenda_pricing.compute_reduction_rate(
request=context['request'],
original_context={},
user_external_id='child:42',
payer_external_id='parent:35',
)
assert 'filter-foo=child%3A42&' in mock_send.call_args_list[0][0][0].url
assert 'filter-bar=parent%3A35&' in mock_send.call_args_list[0][0][0].url
pricing.reduction_rate = {
'qf': '{{ cards|objects:"qf"|filter_by:"foo"|filter_value:user_external_raw_id|filter_by:"bar"|filter_value:payer_external_raw_id|list }}',
}
pricing.save()
mock_send.reset_mock()
with pytest.raises(PricingReductionRateFormatError):
agenda_pricing.compute_reduction_rate(
request=context['request'],
original_context={},
user_external_id='child:42',
payer_external_id='parent:35',
)
assert 'filter-foo=42&' in mock_send.call_args_list[0][0][0].url
assert 'filter-bar=35&' in mock_send.call_args_list[0][0][0].url
def test_apply_reduction_rate(context):
pricing = Pricing.objects.create(label='Foo bar')
agenda_pricing = AgendaPricing.objects.create(
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2021, month=10, day=1),
)
# empty template
assert pricing.reduction_rate == ''
assert agenda_pricing.apply_reduction_rate(
pricing=42,
request=context['request'],
context={'foo': '50'},
user_external_id='child:42',
payer_external_id='parent:35',
) == (42, {})
# template with correct value
pricing.reduction_rate = '{{ foo }}'
pricing.save()
assert agenda_pricing.apply_reduction_rate(
pricing=42,
request=context['request'],
context={'foo': '50'},
user_external_id='child:42',
payer_external_id='parent:35',
) == (
21,
{
'computed_pricing': 42,
'reduction_rate': 50,
'reduced_pricing': 21,
'min_pricing': None,
'bounded_pricing': 21,
},
)
# with a min value
agenda_pricing.min_pricing = 22
agenda_pricing.save()
assert agenda_pricing.apply_reduction_rate(
pricing=42,
request=context['request'],
context={'foo': '50'},
user_external_id='child:42',
payer_external_id='parent:35',
) == (
22,
{
'computed_pricing': 42,
'reduction_rate': 50,
'reduced_pricing': 21,
'min_pricing': 22,
'bounded_pricing': 22,
},
)
agenda_pricing.min_pricing = 21
agenda_pricing.save()
assert agenda_pricing.apply_reduction_rate(
pricing=42,
request=context['request'],
context={'foo': '50'},
user_external_id='child:42',
payer_external_id='parent:35',
) == (
21,
{
'computed_pricing': 42,
'reduction_rate': 50,
'reduced_pricing': 21,
'min_pricing': 21,
'bounded_pricing': 21,
},
)
def test_format_pricing_data():
agenda = Agenda.objects.create(label='Foo bar')
pricing = Pricing.objects.create(label='Foo bar')
@ -1039,6 +1220,7 @@ def test_get_pricing_data(context):
'calculation_details': {
'pricing': 42,
'criterias': {'foo': 'bar'},
'reduction_rate': {},
'context': {'domicile': 'commune', 'qf': '2'},
},
}
@ -1078,6 +1260,7 @@ def test_get_pricing_data_for_event(context):
'calculation_details': {
'pricing': 42,
'criterias': {'foo': 'bar'},
'reduction_rate': {},
'context': {'domicile': 'commune', 'qf': '2'},
},
'booking_details': {
@ -1224,12 +1407,17 @@ def test_aggregate_pricing_data(modifier, pricing_amount):
agenda_pricing.agendas.add(agenda)
assert agenda_pricing.aggregate_pricing_data(
pricing=42, criterias={'foo': 'bar'}, context={'domicile': 'commune', 'qf': 2}, modifier=modifier
pricing=42,
criterias={'foo': 'bar'},
reduction_rate={'foo': 'baz'},
context={'domicile': 'commune', 'qf': 2},
modifier=modifier,
) == {
'pricing': pricing_amount,
'calculation_details': {
'pricing': 42,
'criterias': {'foo': 'bar'},
'reduction_rate': {'foo': 'baz'},
'context': {'domicile': 'commune', 'qf': 2},
},
'booking_details': modifier,