Commit 73b325c5 authored by Julien Muchembled's avatar Julien Muchembled

Amount Generator: automatic sort based on application/contribution dependencies [2/2]

This implements dependency resolution to sort amount generator lines
so that a base_amount is never contributed after it was applied.

Before, it was required to sort manually using int_index or float_index, which
can be difficult for a human when there are many lines spreaded over different
containers (which are merged by composition). Another problematic case is when
a set of lines is configured by a user (like discounts & fees) and must all be
applied before other lines (taxes) that are installed elsewhere by the
developer: how to reliably make sure the latter have index values that are
already greater than those entered by the user ?

Setting int_index or float_index is now only useful for lines:
- with same reference: only the maching one with lowest index is taken
  into account (commit 68ec6bda)
- applying to intermediate values of some base_amount
  (commit 10be013b)

The difficult part to solve dependencies is that the calculation for a
given base_amount may trigger the application of other base_amount, and so on
recursively. In order to support this case, amount generator lines are first
applied on a dummy amount, and getGeneratedAmountQuantity must be call
unconditionally for all dependent base_amount. So optimizing like

  return 3 <= delivery_amount.getGeneratedAmountQuantity('base_amount/1') \
      or 1 <= delivery_amount.getGeneratedAmountQuantity('base_amount/2')

is wrong except if 'base_amount/2' is only contributed by the movement or if
you sort manually with indices.

Dependency resolution has precedence over indices. But since the sort is stable,
lines will remain sorted by index if it respects dependencies.
parent dd10a334
......@@ -26,6 +26,7 @@
#
##############################################################################
from collections import defaultdict, deque
import random
import zope.interface
from AccessControl import ClassSecurityInfo
......@@ -82,8 +83,10 @@ class BaseAmountDict(Implicit):
if variation_category_list:
base_amount = (base_amount,) + variation_category_list
raise ValueError("Can not contribute to %r because this base_amount is"
" already applied. Order of Amount Generator Lines is"
" wrong." % (base_amount,))
" already applied. This should only happen in you have custom"
" calculation for some base_amount and your code does not call"
" getGeneratedAmountQuantity unconditionally for all base_amount"
" it depends on." % (base_amount,))
self._dict[variated_base_amount] = \
self._getQuantity(variated_base_amount) + value
......@@ -140,6 +143,11 @@ class BaseAmountDict(Implicit):
if variated_base_amount in self._frozen:
return self._getQuantity(variated_base_amount)
self._frozen.add(variated_base_amount)
value = self._dict[variated_base_amount] = \
self._getGeneratedAmountQuantity(base_amount, variation_category_list)
return value
def _getGeneratedAmountQuantity(self, base_amount, variation_category_list):
try:
method = self._cache[base_amount]
except KeyError:
......@@ -155,9 +163,59 @@ class BaseAmountDict(Implicit):
variation_category_list=variation_category_list)
else:
kw = self._method_kw
value = method(self, base_amount, **kw)
self._dict[variated_base_amount] = value
return value
return method(self, base_amount, **kw)
class BaseAmountResolver(BaseAmountDict):
_dummy_property_dict = {'_index': 0},
def __init__(self, cache, method_kw):
self._dict = cache.setdefault(None, {})
self._cache = cache
self._method_kw = method_kw
def __call__(self, delivery_amount, property_dict_list):
if property_dict_list:
recurseApplicationDependencies = \
self.__of__(delivery_amount).getGeneratedAmountQuantity
contribution_dict = defaultdict(list)
for property_dict in property_dict_list:
self._amount_generator_line = property_dict[None]
self._resolving = property_dict['_index'] = set()
for variated_base_amount in property_dict['_application']:
recurseApplicationDependencies(*variated_base_amount)
for variated_base_amount in property_dict['_contribution']:
contribution_dict[variated_base_amount].append(property_dict)
del self._resolving
contribution_dict.default_factory = lambda: self._dummy_property_dict
def sort_key(property_dict):
score = property_dict['_index']
if type(score) is set:
score = property_dict['_index'] = 1 + max(sort_key(x)
for x in score
for x in contribution_dict[x])
return score
property_dict_list.sort(key=sort_key)
for property_dict in property_dict_list:
del property_dict['_index']
def getBaseAmountList(self):
return ()
def _getQuantity(self, variated_base_amount):
return 0
def getGeneratedAmountQuantity(self, base_amount, variation_category_list=()):
variated_base_amount = base_amount, variation_category_list
resolving = self._resolving
if variated_base_amount not in self._dict:
self._resolving = self._dict[variated_base_amount] = set(
(variated_base_amount,))
self._getGeneratedAmountQuantity(base_amount, variation_category_list)
self._resolving = resolving
resolving |= self._dict[variated_base_amount]
return 0
class AmountGeneratorMixin:
......@@ -233,36 +291,48 @@ class AmountGeneratorMixin:
return (line.getFloatIndex() if int_index is None else int_index,
random.random())
def accumulateAmountList(self):
"""Browse recursively the amount generator lines
and accumulate applicable values
"""
if 1:
amount_generator_line_list = self.contentValues(
is_mapped_value = isinstance(self, MappedValue)
recurse_queue = deque()
resolver = BaseAmountResolver(*args)
for base_amount in base_amount_list:
delivery_amount = base_amount.getObject()
recurse_queue.append(self if is_mapped_value
else delivery_amount.asComposedDocument(amount_generator_type_list))
property_dict_list = []
# If several amount generator lines have same reference, the first
# (sorted by int_index or float_index) matching one will mask the others.
reference_set = set()
while recurse_queue:
self = recurse_queue.popleft()
amount_generator_line_list = self.objectValues(
portal_type=amount_generator_line_type_list)
# Recursively feed base_amount
if amount_generator_line_list:
# First sort so that a line can mask other of same reference.
# We will sort again later to satisfy dependencies between
# base_application & base_contribution.
amount_generator_line_list.sort(key=getLineSortKey)
for amount_generator_line in amount_generator_line_list:
accumulateAmountList(amount_generator_line)
return
elif (self.getPortalType() not in amount_generator_line_type_list):
return
target_method = self.isTargetDelivery() and 'isDelivery' or default_target
recurse_queue += amount_generator_line_list
continue
if self.getPortalType() not in amount_generator_line_type_list:
continue
target_method = 'isDelivery' if self.isTargetDelivery() \
else default_target
if target_method and not getattr(delivery_amount, target_method)():
return
continue
if not self.test(delivery_amount):
return
continue
self = self.asPredicate()
reference = self.getReference()
if reference:
if reference in reference_set:
return
continue
reference_set.add(reference)
# Try to collect cells and aggregate their mapped properties
# using resource + variation as aggregation key or base_application
# for intermediate lines.
amount_generator_cell_list = [self] + self.contentValues(
amount_generator_cell_list = [self] + self.objectValues(
portal_type=amount_generator_cell_type_list)
cell_aggregate = {} # aggregates final line information
......@@ -273,11 +343,12 @@ class AmountGeneratorMixin:
if not cell.test(delivery_amount):
continue
cell = cell.asPredicate()
key = cell.getCellAggregateKey()
aggregate_key = cell.getCellAggregateKey()
try:
property_dict = cell_aggregate[key]
property_dict = cell_aggregate[aggregate_key]
except KeyError:
cell_aggregate[key] = property_dict = {
cell_aggregate[aggregate_key] = property_dict = {
None: self,
'base_application_set': set(base_application_list),
'base_contribution_set': set(base_contribution_list),
'category_list': [],
......@@ -300,7 +371,9 @@ class AmountGeneratorMixin:
cell.getMappedValueBaseCategoryList(), base=1)
property_dict['category_list'] += category_list
property_dict['resource'] = cell.getResource()
if cell is not self:
if cell is self:
self_key = aggregate_key
else:
# cells inherit base_application and base_contribution from line
property_dict['base_application_set'].update(
cell.getBaseApplicationList())
......@@ -308,8 +381,6 @@ class AmountGeneratorMixin:
cell.getBaseContributionList())
property_dict['causality_value_list'].append(cell)
base_amount.setAmountGeneratorLine(self)
for property_dict in cell_aggregate.itervalues():
# Ignore line (i.e. self) if cells produce unrelated amounts.
# With Transformed Resource (Transformation), line is considered in
# order to gather common properties and cells are used to describe
......@@ -317,23 +388,40 @@ class AmountGeneratorMixin:
# In cases like trade, payroll or assorted resources,
# we want to ignore the line if they are cells.
# See also implementations of 'getCellAggregateKey'
causality_value = property_dict['causality_value_list'][-1]
if causality_value is self and len(cell_aggregate) > 1:
continue
base_application_set = property_dict['base_application_set']
# allow a single base_application to be variated
variation_category_list = tuple(sorted([x for x in base_application_set
if x[:12] != 'base_amount/']))
if variation_category_list:
base_application_set.difference_update(variation_category_list)
# Before we ignored 'quantity=0' amount here for better performance,
# but it makes expand unstable (e.g. when the first expand causes
# non-zero quantity and then quantity becomes zero).
# Ignore only if there's no base_application.
if not base_application_set:
continue
if len(cell_aggregate) > 1 and \
len(cell_aggregate[self_key]['causality_value_list']) == 1:
del cell_aggregate[self_key]
# Allow base_application & base_contribution to be variated.
for property_dict in cell_aggregate.itervalues():
base_amount_set = property_dict['base_application_set']
variation_list = tuple(sorted(x for x in base_amount_set
if not x.startswith('base_amount/')))
base_amount_set.difference_update(variation_list)
# Before we ignored 'quantity=0' amount here for better performance,
# but it makes expand unstable (e.g. when the first expand causes
# non-zero quantity and then quantity becomes zero).
# Ignore only if there's no base_application.
if not base_amount_set:
continue
property_dict['_application'] = [(x, variation_list)
for x in base_amount_set]
base_amount_set = property_dict['base_contribution_set']
variation_list = tuple(sorted(x for x in base_amount_set
if not x.startswith('base_amount/')))
property_dict['_contribution'] = [(x, variation_list)
for x in base_amount_set.difference(variation_list)]
property_dict_list.append(property_dict)
# Sort amount generators according to
# base_application & base_contribution dependencies.
resolver(delivery_amount, property_dict_list)
# Accumulate applicable values.
for property_dict in property_dict_list:
self = property_dict.pop(None)
base_amount.setAmountGeneratorLine(self)
contribution_list = property_dict.pop('_contribution')
# property_dict may include
# resource - VAT service or a Component in MRP
# (if unset, the amount will only be used for reporting)
......@@ -349,9 +437,8 @@ class AmountGeneratorMixin:
# for future simulation of efficiencies.
# If no quantity is provided, we consider that the value is 1.0
# (XXX is it OK ?) XXX-JPS Need careful review with taxes
quantity = float(sum(base_amount.getGeneratedAmountQuantity(
base_application, variation_category_list)
for base_application in base_application_set))
quantity = float(sum(base_amount.getGeneratedAmountQuantity(*x)
for x in property_dict.pop('_application')))
for key in 'quantity', 'price', 'efficiency':
if property_dict.get(key, 0) in (None, ''):
del property_dict[key]
......@@ -363,7 +450,8 @@ class AmountGeneratorMixin:
# Create an Amount object
amount = newTempAmount(portal,
# we only want the id to be unique so we pick a random causality
causality_value.getRelativeUrl().replace('/', '_'),
property_dict['causality_value_list'][-1]
.getRelativeUrl().replace('/', '_'),
notify_workflow=False)
amount._setCategoryList(property_dict.pop('category_list', ()))
if amount.getQuantityUnit():
......@@ -388,26 +476,10 @@ class AmountGeneratorMixin:
quantity /= property_dict.get('efficiency', 1)
except ZeroDivisionError:
quantity *= float('inf')
base_contribution_set = property_dict['base_contribution_set']
# allow a single base_contribution to be variated
variation_category_list = tuple(sorted([x for x in base_contribution_set
if x[:12] != 'base_amount/']))
if variation_category_list:
base_contribution_set.difference_update(variation_category_list)
for base_contribution in base_contribution_set:
for base_contribution, variation_category_list in contribution_list:
base_amount.contribute(base_contribution, variation_category_list,
quantity)
is_mapped_value = isinstance(self, MappedValue)
for base_amount in base_amount_list:
delivery_amount = base_amount.getObject()
if not is_mapped_value:
self = delivery_amount.asComposedDocument(amount_generator_type_list)
# If several amount generator lines have same reference, the first
# (sorted by int_index or float_index) matching one will mask the others.
reference_set = set()
accumulateAmountList(self)
return result
security.declareProtected(Permissions.AccessContentsInformation,
......
......@@ -100,7 +100,6 @@ class TestComplexTradeModelLineUseCase(TestTradeModelLineMixin):
dict(title='Total Price Without VAT',
reference='TOTAL_PRICE_WITHOUT_VAT',
price=1,
int_index=10,
target_delivery=True,
base_application_list=('base_amount/discount_amount_of_non_vat_taxable',
'base_amount/discount_amount_of_vat_taxable',
......@@ -110,7 +109,6 @@ class TestComplexTradeModelLineUseCase(TestTradeModelLineMixin):
dict(title='Total Price Of VAT Taxable',
reference='TOTAL_PRICE_OF_VAT_TAXABLE',
price=1,
int_index=10,
target_delivery=True,
base_application_list=('base_amount/discount_amount_of_vat_taxable',
'base_amount/vat_taxable'),
......@@ -120,7 +118,6 @@ class TestComplexTradeModelLineUseCase(TestTradeModelLineMixin):
resource_value=self.service_discount,
price=1,
trade_phase='default/invoicing',
int_index=10,
target_delivery=True,
base_application_list=('base_amount/discount_amount_of_vat_taxable',
'base_amount/discount_amount_of_non_vat_taxable'),
......@@ -130,7 +127,6 @@ class TestComplexTradeModelLineUseCase(TestTradeModelLineMixin):
resource_value=self.service_vat,
price=0.05,
trade_phase='default/invoicing',
int_index=10,
target_delivery=True,
base_application_list=('base_amount/discount_amount_of_vat_taxable',
'base_amount/vat_taxable'),
......@@ -138,7 +134,6 @@ class TestComplexTradeModelLineUseCase(TestTradeModelLineMixin):
dict(title='Total Price With VAT',
reference='TOTAL_PRICE_WITH_VAT',
price=1,
int_index=20,
target_delivery=True,
base_application_list=('base_amount/vat_amount',
'base_amount/total_price_without_vat'),
......@@ -182,7 +177,6 @@ return getBaseAmountQuantity""")
dict(reference='SPECIAL_DISCOUNT_3CD_LINEAR',
resource_value=self.service_discount,
price=-0.1,
int_index=0,
target_delivery=True,
base_application=special_discount,
base_contribution='base_amount/discount_amount_of_vat_taxable'),
......@@ -238,7 +232,6 @@ return lambda delivery_amount, base_application, **kw: \\
resource_value=self.service_discount,
price=-1,
quantity=500,
int_index=0,
target_delivery=True,
base_application=special_discount,
base_contribution='base_amount/discount_amount_of_vat_taxable'),
......@@ -289,6 +282,9 @@ def getBaseAmountQuantity(delivery_amount, base_application, **kw):
if base_application in movement.getBaseContributionList()])
if total_quantity < 3:
return 0
# Following expression should be evaluated during dependency resolution
# but it's ok to optimize here when total_quantity < 3 because
# 'total_price_of_ordered_items' is only contributed by movements.
return delivery_amount.getGeneratedAmountQuantity(
'base_amount/total_price_of_ordered_items')
return getBaseAmountQuantity""")
......@@ -298,7 +294,6 @@ return getBaseAmountQuantity""")
dict(reference='SPECIAL_DISCOUNT_3CD_LINEAR',
resource_value=self.service_discount,
price=-0.1,
int_index=0,
target_delivery=True,
base_application=special_discount,
base_contribution='base_amount/discount_amount_of_vat_taxable'),
......@@ -352,17 +347,19 @@ return getBaseAmountQuantity"""
'poster_present_3cd', total_quantity)
special_discount = self.setBaseAmountQuantityMethod(
'special_discount', """\
return lambda delivery_amount, base_application, **kw: \\
3 <= delivery_amount.getGeneratedAmountQuantity(%r) or \\
1 <= delivery_amount.getGeneratedAmountQuantity(%r)"""
% (poster_present_3cd, poster_present_1dvd))
def getBaseAmountQuantity(delivery_amount, base_application, **kw):
# Compute B even if A < 3 for dependency resolution.
# But like in test_usecase3, we could optimize.
A = delivery_amount.getGeneratedAmountQuantity(%r)
B = delivery_amount.getGeneratedAmountQuantity(%r)
return 3 <= A or 1 <= B
return getBaseAmountQuantity""" % (poster_present_3cd, poster_present_1dvd))
trade_condition = self.createTradeCondition(
self.trade_condition, (
dict(reference='SPECIAL_DISCOUNT_3CD_OR_1DVD_FIXED',
resource_value=self.poster,
price=0,
int_index=0,
target_delivery=True,
base_application=special_discount),
))
......@@ -433,7 +430,6 @@ return getBaseAmountQuantity""")
dict(reference='SPECIAL_DISCOUNT_3CD',
resource_value=self.service_discount,
price=-0.15,
int_index=0,
target_delivery=True,
base_application=special_discount,
base_contribution='base_amount/discount_amount_of_vat_taxable'),
......@@ -476,7 +472,6 @@ return lambda *args, **kw: 1""")
dict(reference='SHIPPING_FEE',
resource_value=self.service_discount,
quantity=500,
int_index=0,
target_delivery=True,
base_application=fixed_quantity,
base_contribution_list=('base_amount/additional_charge',
......
......@@ -1187,7 +1187,6 @@ class TestPayrollMixin(TestTradeModelLineMixin, ERP5ReportTestCase):
model = sequence.get('model')
model_line = self.createModelLine(model)
model_line.edit(title='intermediate line',
int_index = 10,
reference='intermediate_line',
price=0.2,
base_contribution_list=['base_amount/payroll/base/income_tax'],
......@@ -1201,7 +1200,6 @@ class TestPayrollMixin(TestTradeModelLineMixin, ERP5ReportTestCase):
model = sequence.get('model')
model_line = self.createModelLine(model)
model_line.edit(title='line applied on intermediate line',
int_index = 50,
trade_phase='payroll/france/urssaf',
resource_value=sequence.get('urssaf_service'),
reference='line_applied_on_intermediate_line',
......@@ -1928,7 +1926,7 @@ class TestPayrollMixin(TestTradeModelLineMixin, ERP5ReportTestCase):
sequence=None, **kw):
model = sequence.get('model')
paysheet = sequence.get('paysheet')
property_list = ('title', 'description', 'int_index')
property_list = 'title', 'description'
for model_line in model.contentValues(portal_type='Pay Sheet Model Line'):
model_line_resource = model_line.getResource()
line_found = False
......@@ -1949,12 +1947,10 @@ class TestPayrollMixin(TestTradeModelLineMixin, ERP5ReportTestCase):
def stepSetProperiesOnModelLines(self, sequence=None, **kw):
model = sequence.get('model')
index = 0
for model_line in model.contentValues(portal_type='Pay Sheet Model Line'):
for index, model_line in enumerate(model.contentValues(
portal_type='Pay Sheet Model Line')):
model_line.setTitle('Model line title %s' % index)
model_line.setDescription('Model line description %s' % index)
model_line.setIntIndex(index)
index += 1
def checkPrecisionOfListBox(self, report_section, precision):
here = report_section.getObject(self.portal)
......@@ -3421,8 +3417,8 @@ class TestPayroll(TestPayrollMixin):
sequence_list.play(self)
def test_propertiesAreSetOnPaysheetLines(self):
'''check porperties from model line (like description, int_index,
title, ...) are copied on the paysheet lines'''
'''check properties from model line (like description, title, ...)
are copied on the paysheet lines'''
sequence_list = SequenceList()
sequence_string = self.COMMON_BASIC_DOCUMENT_CREATION_SEQUENCE_STRING + """
SetProperiesOnModelLines
......
......@@ -172,9 +172,7 @@ class TestTradeModelLineMixin(TestBPMMixin, UserDict):
title=self.id(),
specialise_value_list=specialise_value_list,
**kw)
for int_index, line_kw in enumerate(trade_model_line_list):
kw = dict(int_index=int_index)
kw.update(line_kw)
for kw in trade_model_line_list:
self.createTradeModelLine(trade_condition, **kw)
return trade_condition
......@@ -509,14 +507,12 @@ class TestTradeModelLine(TestTradeModelLineMixin):
base_contribution='base_amount/tax',
trade_phase='default/discount',
resource_value=self.createServiceDiscount(),
reference='discount',
int_index=10),
reference='discount'),
dict(price=self.default_tax_ratio,
base_application='base_amount/tax',
trade_phase='default/tax',
resource_value=self.createServiceTax(),
reference='tax',
int_index=20),
reference='tax'),
))
order = self.createOrder(trade_condition, (
dict(price=1, quantity=2, id='taxed',
......@@ -540,12 +536,6 @@ class TestTradeModelLine(TestTradeModelLineMixin):
self.tic()
if not build:
# Check amount_generator refuses to produce amounts
# if lines are not ordered correctly.
self['trade_model_line/tax'].setIntIndex(0)
self.assertRaises(ValueError, order.getGeneratedAmountList)
self.abort()
for movement in (order, order['taxed'], order['discounted'],
order['taxed_discounted']):
self.checkComposition(movement, [trade_condition], {
......@@ -729,35 +719,30 @@ class TestTradeModelLine(TestTradeModelLineMixin):
base_contribution='base_amount/total_tax',
trade_phase='default/tax',
resource_value=service_tax,
reference='service_tax',
int_index=10),
reference='service_tax'),
dict(price=0.32,
base_application='base_amount/discount',
base_contribution='base_amount/total_discount',
trade_phase='default/discount',
resource_value=service_discount,
reference='total_dicount_2',
int_index=10),
reference='total_dicount_2'),
dict(price=0.2,
base_application='base_amount/tax',
base_contribution='base_amount/total_tax',
trade_phase='default/tax',
resource_value=service_tax,
reference='service_tax_2',
int_index=10),
reference='service_tax_2'),
dict(price=0.12,
base_application='base_amount/total_tax',
base_contribution='base_amount/total_discount',
trade_phase='default/tax',
resource_value=service_tax,
reference='tax_3',
int_index=20),
reference='tax_3'),
dict(price=0.8,
base_application='base_amount/total_discount',
trade_phase='default/discount',
resource_value=service_discount,
reference='total_discount',
int_index=30),
reference='total_discount'),
]
random.shuffle(line_list)
trade_condition = self.createTradeCondition(business_process, line_list)
......@@ -812,16 +797,13 @@ return getBaseAmountQuantity""")
trade_condition = self.createTradeCondition(business_process, (
dict(price=0.3,
base_application=base_amount,
reference='tax1',
int_index=10),
reference='tax1'),
dict(base_application=base_amount,
base_contribution='base_amount/total_tax',
reference='tax2',
int_index=20),
reference='tax2'),
dict(base_application='base_amount/total_tax',
base_contribution='base_amount/total',
reference='tax3',
int_index=30),
reference='tax3'),
))
def createCells(line, matrix, base_application=(), base_contribution=()):
range_list = [set() for x in iter(matrix).next()]
......@@ -899,6 +881,37 @@ return context""" % (base_amount, base_amount))
self.assertAlmostEqual(total_price * total_ratio,
sum((x.getTotalPrice() for x in amount_list), total_price))
def test_05_dependencyResolution(self):
from Products.ERP5Type.Document import newTempAmount, newTempTradeModelLine
from Products.ERP5.mixin.amount_generator import BaseAmountResolver
resolver = BaseAmountResolver({}, {})
trade_model_line = newTempTradeModelLine(self.portal, '')
trade_model_line.getBaseAmountQuantity = \
lambda delivery_amount, base_amount: sum(map(
delivery_amount.getGeneratedAmountQuantity,
application_dict.get(base_amount, base_amount)))
application_dict = dict(B='bf', C='c', E='Bef')
property_dict_list = [{
None: trade_model_line,
'index': index,
'_application': [(x, ()) for x in application],
'_contribution': [(x, ()) for x in contribution],
} for index, application, contribution in (
(2, 'C', 'e'),
(3, 'dE', ''),
(0, 'a', 'b'),
(1, 'B', 'cd'),
)]
delivery_amount = newTempAmount(self.portal, '')
resolver(delivery_amount, property_dict_list)
self.assertEqual(range(len(property_dict_list)),
[x['index'] for x in property_dict_list])
# Retry with cache already filled.
property_dict_list.reverse()
resolver(delivery_amount, property_dict_list)
self.assertEqual(range(len(property_dict_list)),
[x['index'] for x in property_dict_list])
def test_tradeModelLineWithFixedPrice(self):
"""
Check it's possible to have fixed quantity on lines. Sometimes we want
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment