pricing: get pricing data for event & users (#64562)
This commit is contained in:
parent
f0e8197cd6
commit
301d280c9e
|
@ -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
|
||||
|
|
|
@ -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'},
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue