pricing: get pricing data for event & users (#64562)

This commit is contained in:
Lauréline Guérin 2022-04-28 15:24:10 +02:00
parent f0e8197cd6
commit 301d280c9e
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
2 changed files with 360 additions and 2 deletions

View File

@ -14,14 +14,39 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import decimal
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.template import Context, Template, TemplateSyntaxError
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from chrono.utils.misc import generate_slug
class PricingError(Exception):
def __init__(self, details=None):
self.details = details or {}
super().__init__()
class AgendaPricingNotFound(PricingError):
pass
class CriteriaConditionNotFound(PricingError):
pass
class PricingDataError(PricingError):
pass
class PricingDataFormatError(PricingError):
pass
class CriteriaCategory(models.Model):
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
@ -75,6 +100,13 @@ class Criteria(models.Model):
def base_slug(self):
return slugify(self.label)
def compute_condition(self, context):
try:
template = Template('{%% if %s %%}OK{%% endif %%}' % self.condition)
except TemplateSyntaxError:
return False
return template.render(Context(context)) == 'OK'
class Pricing(models.Model):
label = models.CharField(_('Label'), max_length=150)
@ -129,3 +161,69 @@ class AgendaPricing(models.Model):
date_start = models.DateField()
date_end = models.DateField()
pricing_data = JSONField(null=True)
@staticmethod
def get_pricing_data(event, user_external_id, adult_external_id):
agenda_pricing = AgendaPricing.get_agenda_pricing(event)
context = agenda_pricing.get_pricing_context(event, user_external_id, adult_external_id)
pricing, criterias = agenda_pricing.compute_pricing(context)
return {
'pricing': pricing,
'criterias': criterias,
'context': context,
}
@staticmethod
def get_agenda_pricing(event):
agenda = event.agenda
try:
return agenda.agendapricing_set.get(
date_start__lte=event.start_datetime,
date_end__gt=event.start_datetime,
)
except (AgendaPricing.DoesNotExist, AgendaPricing.MultipleObjectsReturned):
raise AgendaPricingNotFound
def get_pricing_context(self, event, user_external_id, adult_external_id):
# FIXME: compute agenda pricing variables, add event data
return {
'qf': 2,
'domicile': 'commune',
}
def compute_pricing(self, context):
criterias = {}
categories = []
# for each category (ordered)
for category in self.pricing.categories.all().order_by('pricingcriteriacategory__order'):
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):
condition = criteria.compute_condition(context)
if condition:
criterias[category.slug] = criteria.slug
break
# 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'}
)
key = '%s:%s' % (category, criteria)
if key not in pricing_data:
raise PricingDataError(details={'category': category, 'criteria': criteria})
pricing_data = pricing_data[key]
try:
pricing = decimal.Decimal(pricing_data)
except (decimal.InvalidOperation, ValueError, TypeError):
raise PricingDataFormatError(details={'pricing': pricing_data, 'wanted': 'decimal'})
return pricing, criterias

View File

@ -1,6 +1,20 @@
import pytest
import datetime
from chrono.pricing.models import Criteria, CriteriaCategory, Pricing, PricingCriteriaCategory
import pytest
from django.utils.timezone import make_aware
from chrono.agendas.models import Agenda, Event
from chrono.pricing.models import (
AgendaPricing,
AgendaPricingNotFound,
Criteria,
CriteriaCategory,
CriteriaConditionNotFound,
Pricing,
PricingCriteriaCategory,
PricingDataError,
PricingDataFormatError,
)
pytestmark = pytest.mark.django_db
@ -120,3 +134,249 @@ def test_pricing_category_criteria_duplicate_orders():
assert pcc.order == 2
pcc = PricingCriteriaCategory.objects.create(pricing=pricing, category=category3)
assert pcc.order == 3
def test_get_agenda_pricing():
agenda = Agenda.objects.create(label='Foo bar', kind='events')
pricing = Pricing.objects.create(label='Foo bar')
event = Event.objects.create(
agenda=agenda, start_datetime=make_aware(datetime.datetime(2021, 9, 15, 12, 00)), places=10
)
# not found
with pytest.raises(AgendaPricingNotFound):
AgendaPricing.get_agenda_pricing(event)
# ok
agenda_pricing = AgendaPricing.objects.create(
agenda=agenda,
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2021, month=10, day=1),
)
assert AgendaPricing.get_agenda_pricing(event) == agenda_pricing
# more than one matching
AgendaPricing.objects.create(
agenda=agenda,
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=14),
date_end=datetime.date(year=2021, month=9, day=16),
)
with pytest.raises(AgendaPricingNotFound):
AgendaPricing.get_agenda_pricing(event)
@pytest.mark.parametrize(
'event_date, found',
[
# just before first day
((2021, 8, 31, 12, 00), False),
# first day
((2021, 9, 1, 12, 00), True),
# last day
((2021, 9, 30, 12, 00), True),
# just after last day
((2021, 10, 1, 12, 00), False),
],
)
def test_get_agenda_pricing_event_date(event_date, found):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
pricing = Pricing.objects.create(label='Foo bar')
event = Event.objects.create(
agenda=agenda, start_datetime=make_aware(datetime.datetime(*event_date)), places=10
)
agenda_pricing = AgendaPricing.objects.create(
agenda=agenda,
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2021, month=10, day=1),
)
if found:
assert AgendaPricing.get_agenda_pricing(event) == agenda_pricing
else:
with pytest.raises(AgendaPricingNotFound):
AgendaPricing.get_agenda_pricing(event)
def test_get_pricing_data():
agenda = Agenda.objects.create(label='Foo bar', kind='events')
event = Event.objects.create(
agenda=agenda, start_datetime=make_aware(datetime.datetime(2021, 9, 15, 12, 00)), places=10
)
category = CriteriaCategory.objects.create(label='Foo', slug='foo')
criteria = Criteria.objects.create(label='Bar', slug='bar', condition='True', category=category)
pricing = Pricing.objects.create(label='Foo bar')
pricing.criterias.add(criteria)
pricing.categories.add(category, through_defaults={'order': 1})
AgendaPricing.objects.create(
agenda=agenda,
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2021, month=10, day=1),
pricing_data={
'foo:bar': 42,
},
)
assert AgendaPricing.get_pricing_data(event, 'child:42', 'parent:35') == {
'pricing': 42,
'criterias': {'foo': 'bar'},
'context': {'domicile': 'commune', 'qf': 2},
}
def test_get_pricing_context():
agenda = Agenda.objects.create(label='Foo bar', kind='events')
event = Event.objects.create(
agenda=agenda, start_datetime=make_aware(datetime.datetime(2021, 9, 15, 12, 00)), places=10
)
pricing = Pricing.objects.create(label='Foo bar')
agenda_pricing = AgendaPricing.objects.create(
agenda=agenda,
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2021, month=10, day=1),
)
assert agenda_pricing.get_pricing_context(event, 'child:42', 'parent:35') == {
'domicile': 'commune',
'qf': 2,
}
@pytest.mark.parametrize(
'condition, context, result',
[
('qf < 1', {}, False),
('qf < 1', {'qf': 'foo'}, False),
('qf < 1', {'qf': 1}, False),
('qf < 1', {'qf': 0.9}, True),
('1 <= qf and qf < 2', {'qf': 0}, False),
('1 <= qf and qf < 2', {'qf': 2}, False),
('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),
],
)
def test_compute_condition(condition, context, result):
category = CriteriaCategory.objects.create(label='QF', slug='qf')
criteria = Criteria.objects.create(label='FOO', condition=condition, category=category)
assert criteria.compute_condition(context) == result
def test_compute_pricing():
agenda = Agenda.objects.create(label='Foo bar', kind='events')
category = CriteriaCategory.objects.create(label='QF', slug='qf')
pricing = Pricing.objects.create(label='Foo bar')
pricing.categories.add(category, through_defaults={'order': 1})
agenda_pricing = AgendaPricing.objects.create(
agenda=agenda,
pricing=pricing,
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2021, month=10, day=1),
)
# no criteria defined on agenda_pricing
with pytest.raises(CriteriaConditionNotFound) as e:
agenda_pricing.compute_pricing(context={'qf': 2})
assert e.value.details == {'category': 'qf'}
# conditions are not set
criteria1 = Criteria.objects.create(label='QF < 1', slug='qf-0', category=category)
criteria2 = Criteria.objects.create(label='QF >= 1', slug='qf-1', category=category)
pricing.criterias.add(criteria1)
pricing.criterias.add(criteria2)
with pytest.raises(CriteriaConditionNotFound) as e:
agenda_pricing.compute_pricing(context={'qf': 2})
assert e.value.details == {'category': 'qf'}
# conditions set, but no match
criteria1.condition = 'qf < 1'
criteria1.save()
criteria2.condition = 'False'
criteria2.save()
with pytest.raises(CriteriaConditionNotFound) as e:
agenda_pricing.compute_pricing(context={'qf': 2})
assert e.value.details == {'category': 'qf'}
# criteria found, but agenda_pricing.pricing_data is not defined
criteria1.condition = 'qf < 1'
criteria1.save()
criteria2.condition = 'qf >= 1'
criteria2.save()
with pytest.raises(PricingDataFormatError) as e:
agenda_pricing.compute_pricing(context={'qf': 2})
assert e.value.details == {'category': 'qf', 'pricing': None, 'wanted': 'dict'}
# criteria not found in pricing_data
agenda_pricing.pricing_data = {
'qf:qf-0': 42,
}
agenda_pricing.save()
with pytest.raises(PricingDataError) as e:
agenda_pricing.compute_pricing(context={'qf': 2})
assert e.value.details == {'category': 'qf', 'criteria': 'qf-1'}
# criteria found, but value is wrong
for value in ['foo', [], {}]:
agenda_pricing.pricing_data = {
'qf:qf-0': 42,
'qf:qf-1': value,
}
agenda_pricing.save()
with pytest.raises(PricingDataFormatError) as e:
agenda_pricing.compute_pricing(context={'qf': 2})
assert e.value.details == {'pricing': value, 'wanted': 'decimal'}
# correct value (decimal)
agenda_pricing.pricing_data = {
'qf:qf-0': 42,
'qf:qf-1': 52,
}
agenda_pricing.save()
assert agenda_pricing.compute_pricing(context={'qf': 2}) == (52, {'qf': 'qf-1'})
# 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
)
criteria2 = Criteria.objects.create(
label='Hors commune', slug='dom-1', condition='domicile != "commune"', category=category2
)
pricing.categories.add(category2, through_defaults={'order': 2})
pricing.criterias.add(criteria1)
pricing.criterias.add(criteria2)
# wrong definition
agenda_pricing.pricing_data = {
'domicile:dom-0': {
'qf:qf-0': 3,
'qf:qf-1': 5,
},
'domicile:dom-1': {
'qf:qf-0': 7,
'qf:qf-1': 10,
},
}
agenda_pricing.save()
with pytest.raises(PricingDataError) as e:
agenda_pricing.compute_pricing(context={'qf': 2, 'domicile': 'commune'})
# reorder categories, so the definition is correct
PricingCriteriaCategory.objects.filter(pricing=pricing, category=category).update(order=2)
PricingCriteriaCategory.objects.filter(pricing=pricing, category=category2).update(order=1)
assert agenda_pricing.compute_pricing(context={'qf': 2, 'domicile': 'commune'}) == (
5,
{'domicile': 'dom-0', 'qf': 'qf-1'},
)
assert agenda_pricing.compute_pricing(context={'qf': 0, 'domicile': 'commune'}) == (
3,
{'domicile': 'dom-0', 'qf': 'qf-0'},
)
assert agenda_pricing.compute_pricing(context={'qf': 2, 'domicile': 'ext'}) == (
10,
{'domicile': 'dom-1', 'qf': 'qf-1'},
)
assert agenda_pricing.compute_pricing(context={'qf': 0, 'domicile': 'ext'}) == (
7,
{'domicile': 'dom-1', 'qf': 'qf-0'},
)