rule.py 25.2 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
# -*- coding: utf-8 -*-
##############################################################################
#
# Copyright (c) 2009 Nexedi SA and Contributors. All Rights Reserved.
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# guarantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
##############################################################################

29
import transaction
30 31
import zope.interface
from AccessControl import ClassSecurityInfo
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
32
from Acquisition import aq_base
33
from Products.ERP5Type import Permissions, interfaces
34
from Products.ERP5Type.Base import Base
35
from Products.ERP5Type.Core.Predicate import Predicate
36 37 38
from Products.ERP5Type.Errors import SimulationError
from Products.ERP5Type.TransactionalVariable import getTransactionalVariable
from Products.ERP5.ExpandPolicy import policy_dict
39 40 41
from Products.ERP5.MovementCollectionDiff import _getPropertyAndCategoryList

from zLOG import LOG
42

Jean-Paul Smets's avatar
Jean-Paul Smets committed
43 44 45 46 47 48
def _compare(tester_list, prevision_movement, decision_movement):
  for tester in tester_list:
    if not tester.compare(prevision_movement, decision_movement):
      return False
  return True

49 50 51 52 53
class MovementGeneratorMixin:
  """
  This class provides a generic implementation of IMovementGenerator
  which can be used together the Rule mixin class bellow. It does not
  have any pretention to provide more than that.
54 55 56 57 58

  TODO:
    - _getInputMovementList is still not well defined. Should input
      be an amount (_getInputAmountList) or a movement? This 
      requires careful thiking.
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
  """
  # Default values
  _applied_rule = None
  _rule = None
  _trade_phase_list = None
  _explanation = None

  def __init__(self, applied_rule, explanation=None, rule=None, trade_phase_list=None):
    self._trade_phase_list = trade_phase_list # XXX-JPS Why a list ?
    self._applied_rule = applied_rule
    if rule is None and applied_rule is not None:
      self._rule = applied_rule.getSpecialiseValue()
    else:
      self._rule = rule # for rule specific stuff
    if explanation is None:
74
      self._explanation = applied_rule
75
    else:
76 77 78 79
      # A good example of explicit explanation can be getRootExplanationLineValue
      # since different lines could have different dates
      # such an explicit root explanation only works if
      # indexing of simulation has already happened
80 81 82 83 84 85
      self._explanation = explanation
    # XXX-JPS handle delay_mode

  # Implementation of IMovementGenerator
  def getGeneratedMovementList(self, movement_list=None, rounding=False):
    """
Jérome Perrin's avatar
Jérome Perrin committed
86
    Returns a list of movements generated by that rule.
87 88 89 90 91 92 93 94 95 96

    movement_list - optional IMovementList which can be passed explicitely

    rounding - boolean argument, which controls if rounding shall be applied on
               generated movements or not

    NOTE:
      - implement rounding appropriately (True or False seems
        simplistic)
    """
Leonardo Rochael Almeida's avatar
typo  
Leonardo Rochael Almeida committed
97
    # Default implementation below can be overriden by subclasses
98 99
    # however it should be generic enough not to be overriden
    # by most classes
100
    # Results will be appended to result
101 102
    result = []
    # Build a list of movement and business path
103 104 105
    input_movement_list = self._getInputMovementList(
                            movement_list=movement_list, rounding=rounding)
    for input_movement in input_movement_list:
106 107 108
      # Merge movement and business path properties (core implementation)
      # Lookup Business Process through composition (NOT UNION)
      business_process = input_movement.asComposedDocument()
109
      explanation = self._applied_rule # We use applied rule as local explanation
110
      trade_phase = self._getTradePhaseList(input_movement, business_process) # XXX-JPS not convenient to handle
111
      update_property_dict = self._getUpdatePropertyDict(input_movement)
112
      result.extend(business_process.getTradePhaseMovementList(explanation, input_movement,
113 114
                                                 trade_phase=trade_phase, delay_mode=None,
                                                 update_property_dict=update_property_dict))
115 116 117 118 119

    # And return list of generated movements
    return result

  def _getUpdatePropertyDict(self, input_movement):
120 121 122 123
    # XXX Wouldn't it better to return {} or {'delivery': None} ?
    #     Below code is mainly for root applied rules.
    #     Other movement generators usually want to reset delivery.
    return {'delivery': input_movement.getRelativeUrl()}
124 125 126 127

  def _getTradePhaseList(self, input_movement, business_process): # XXX-JPS WEIRD
    if self._trade_phase_list:
      return self._trade_phase_list
128 129 130 131
    if self._rule is not None:
      trade_phase_list = self._rule.getTradePhaseList()
      if trade_phase_list:
        return trade_phase_list
132 133
    return input_movement.getTradePhaseList() or \
      business_process.getTradePhaseList()
134

135
  def _getInputMovementList(self, movement_list=None, rounding=None): #XXX-JPS should it be amount or movement ?
136 137 138 139 140 141
    raise NotImplementedError
    # Default implementation takes amounts ?
    # Use TradeModelRuleMovementGenerator._getInputMovementList as default implementation
    # and potentially use trade phase for that.... as a way to filter out


Julien Muchembled's avatar
Julien Muchembled committed
142
class RuleMixin(Predicate):
143 144
  """
  Provides generic methods and helper methods to implement
145
  IRule and IMovementCollectionUpdater.
146 147 148 149 150 151 152
  """
  # Declarative security
  security = ClassSecurityInfo()
  security.declareObjectProtected(Permissions.AccessContentsInformation)

  # Declarative interfaces
  zope.interface.implements(interfaces.IRule,
153
                            interfaces.IDivergenceController,
154 155
                            interfaces.IMovementCollectionUpdater,)

156 157 158
  # Portal Type of created children
  movement_type = 'Simulation Movement'

159
  # Implementation of IRule
160
  def constructNewAppliedRule(self, context, **kw):
161 162 163
    """
    Create a new applied rule in the context.

164
    An applied rule is an instantiation of a Rule. The applied rule is
165 166 167 168 169 170 171 172 173 174 175
    linked to the Rule through the `specialise` relation. The newly
    created rule should thus point to self.

    context -- usually, a parent simulation movement of the
               newly created applied rule

    activate_kw -- activity parameters, required to control
                   activity constraints

    kw -- XXX-JPS probably wrong interface specification
    """
176 177
    return context.newContent(portal_type='Applied Rule',
                              specialise_value=self, **kw)
178

Julien Muchembled's avatar
Julien Muchembled committed
179 180 181 182 183 184
  if 0: # XXX-JPS - if people are stupid enough not to configfure predicates,
        # it is not our role to be clever for them
        # Rules have a workflow - make sure applicable rule system works
        # if you wish, add a test here on workflow state to prevent using
        # rules which are no longer applicable
   def test(self, *args, **kw):
185 186 187
    """
    If no test method is defined, return False, to prevent infinite loop
    """
Julien Muchembled's avatar
Julien Muchembled committed
188 189 190
    if not self.getTestMethodId():
      return False
    return super(RuleMixin, self).test(*args, **kw)
191

192
  def expand(self, applied_rule, expand_policy=None, **kw):
193 194 195 196 197
    """
    Expand this applied rule to create new documents inside the
    applied rule.

    At expand time, we must replace or compensate certain
198
    properties. However, if some properties were overwritten
Jérome Perrin's avatar
Jérome Perrin committed
199
    by a decision (ie. a resource is changed), then we
200 201
    should not try to compensate such a decision.
    """
202 203 204 205
    policy_dict[expand_policy](**kw).expand(self, applied_rule)

  def _expandNow(self, maybe_expand, applied_rule):
    # Update moveme-nts
206
    #  NOTE-JPS: it is OK to make rounding a standard parameter of rules
207
    #            although rounding in simulation is not recommended at all
208 209
    self.updateMovementCollection(applied_rule,
      movement_generator=self._getMovementGenerator(applied_rule))
210 211
    # And forward expand
    for movement in applied_rule.getMovementList():
212
      maybe_expand(movement)
213

Julien Muchembled's avatar
Julien Muchembled committed
214 215 216 217 218 219 220 221
  security.declareProtected(Permissions.AccessContentsInformation,
                            'isAccountable')
  def isAccountable(self, movement):
    """Tells wether generated movement needs to be accounted or not.

    Only account movements which are not associated to a delivery;
    Whenever delivery is there, delivery has priority
    """
222
    return not movement.getDelivery()
Julien Muchembled's avatar
Julien Muchembled committed
223

224
  # Implementation of IDivergenceController # XXX-JPS move to IDivergenceController only mixin for 
225 226 227 228 229 230 231
  security.declareProtected( Permissions.AccessContentsInformation,
                            'isDivergent')
  def isDivergent(self, movement, ignore_list=[]):
    """
    Returns true if the Simulation Movement is divergent comparing to
    the delivery value
    """
232
    if not movement.getDelivery():
233
      return False
234
    return bool(self.getDivergenceList(movement))
235 236 237 238 239 240 241 242 243 244 245

  security.declareProtected(Permissions.View, 'getDivergenceList')
  def getDivergenceList(self, movement):
    """
    Returns a list of divergences of the movements provided
    in delivery_or_movement.

    movement -- a movement, a delivery, a simulation movement,
                or a list thereof
    """
    result_list = []
246
    for divergence_tester in self._getDivergenceTesterList(
247 248 249 250 251 252 253
                                          exclude_quantity=False):
      if divergence_tester.test(movement):
        result = divergence_tester.explain(movement)
        if isinstance(result, (list, tuple)): # for compatibility
          result_list.extend(result)
        elif result is not None:
          result_list.append(result)
254 255
    return result_list

256
  # Placeholder for methods to override
257
  def _getMovementGenerator(self, applied_rule):
258 259 260 261 262
    """
    Return the movement generator to use in the expand process
    """
    raise NotImplementedError

263
  def _getMovementGeneratorContext(self, applied_rule):
264 265
    """
    Return the movement generator context to use for expand
266
    XXX-JPS likely useless
267 268 269
    """
    raise NotImplementedError

270
  def _getMovementGeneratorMovementList(self, applied_rule):
271 272 273 274 275
    """
    Return the movement lists to provide to the movement generator
    """
    raise NotImplementedError

276
  def _getDivergenceTesterList(self, exclude_quantity=True):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
277
    """
278
    Return the applicable divergence testers which must
279 280
    be used to test movement divergence. (ie. not all
    divergence testers of the Rule)
281 282 283

     exclude_quantity -- if set to true, do not consider
                         quantity divergence testers
284
    """
285
    if exclude_quantity:
286
      return filter(lambda x:x.isDivergenceProvider() and \
287
                    'quantity' not in x.getTestedPropertyList(), self.objectValues(
288 289
        portal_type=self.getPortalDivergenceTesterTypeList()))
    else:
290
      return filter(lambda x:x.isDivergenceProvider(), self.objectValues(
291
        portal_type=self.getPortalDivergenceTesterTypeList()))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
292

293 294
  def _getMatchingTesterList(self):
    """
295
    Return the applicable divergence testers which must
296 297 298
    be used to match movements and build the diff (ie.
    not all divergence testers of the Rule)
    """
299 300
    return filter(lambda x:x.isMatchingProvider(), self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList()))
301

302
  def _getUpdatingTesterList(self, exclude_quantity=False):
303 304 305 306 307 308
    """
    Return the applicable divergence testers which must be used to
    update movements. (ie. not all divergence testers of the Rule)

    exclude_quantity -- if set to true, do not consider
                        quantity divergence testers
309
    """
310 311 312 313 314 315 316 317
    if exclude_quantity:
      return filter(lambda x:x.isUpdatingProvider() and \
                    'quantity' not in x.getTestedPropertyList(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))
    else:
      return filter(lambda x:x.isUpdatingProvider(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))

318 319
  def _getQuantityTesterList(self):
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
320
    Return the applicable quantity divergence testers.
321
    """
322 323
    tester_list = self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList())
324
    return [x for x in tester_list if 'quantity' in x.getTestedPropertyList()]
325

Jean-Paul Smets's avatar
Jean-Paul Smets committed
326
  def _newProfitAndLossMovement(self, prevision_movement):
327
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
328 329 330
    Returns a new temp simulation movement which can
    be used to represent a profit or loss in relation
    with prevision_movement
331

Jean-Paul Smets's avatar
Jean-Paul Smets committed
332
    prevision_movement -- a simulation movement
333
    """
334
    raise NotImplementedError
335

336
  def _isProfitAndLossMovement(movement): # applied_rule XXX-JPS add this ?
337 338 339 340 341
    """
    Returns True if movement is a profit and loss movement.
    """
    raise NotImplementedError

Jean-Paul Smets's avatar
Jean-Paul Smets committed
342 343 344 345 346 347
  def _extendMovementCollectionDiff(self, movement_collection_diff,
                                    prevision_movement, decision_movement_list):
    """
    Compares a prevision_movement to decision_movement_list which
    are part of the matching group and updates movement_collection_diff
    accordingly
348 349 350 351 352 353 354

    NOTE: this method API implicitely considers that each group of matching 
    movements has 1 prevision_movement (aggregated) for N decision_movement
    It implies that prevision_movement are "more" aggregated than 
    decision_movement.

    TODO:
Jérome Perrin's avatar
Jérome Perrin committed
355
       - is this assumption appropriate ?
Jean-Paul Smets's avatar
Jean-Paul Smets committed
356 357
    """
    # Sample implementation - but it actually looks very generic
358

Jean-Paul Smets's avatar
Jean-Paul Smets committed
359 360 361 362 363
    # Case 1: movements which are not needed
    if prevision_movement is None:
      # decision_movement_list contains simulation movements which must
      # be deleted
      for decision_movement in decision_movement_list:
364 365
        # If not frozen and all children are deletable
        if decision_movement.isDeletable():
Jean-Paul Smets's avatar
Jean-Paul Smets committed
366 367 368 369
          # Delete deletable
          movement_collection_diff.addDeletableMovement(decision_movement)
        else:
          # Compensate non deletable
370 371
          new_movement = decision_movement.asContext(
                            quantity=-decision_movement.getQuantity())
372
          new_movement.setDelivery(None)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
373 374
          movement_collection_diff.addNewMovement(new_movement)
      return
375

376 377 378 379 380
    # Case 2: movements which should be added
    elif len(decision_movement_list) == 0:
      # if decision_movement_list is empty, we can just create a new one.
      movement_collection_diff.addNewMovement(prevision_movement)
      return
381 382 383

    # Case 3: movements which are needed but may need update or
    # compensation_movement_list.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
384 385 386
    #  let us imagine the case of a forward rule
    #  ie. what comes in must either go out or has been lost
    divergence_tester_list = self._getDivergenceTesterList()
387
    profit_tester_list = divergence_tester_list
388
    updating_tester_list = self._getUpdatingTesterList(exclude_quantity=True)
389
    profit_updating_tester_list = updating_tester_list
Jean-Paul Smets's avatar
Jean-Paul Smets committed
390 391 392 393 394 395 396
    quantity_tester_list = self._getQuantityTesterList()
    compensated_quantity = 0.0
    updatable_movement = None
    not_completed_movement = None
    updatable_compensation_movement = None
    prevision_quantity = prevision_movement.getQuantity()
    decision_quantity = 0.0
397
    real_quantity = 0.0
Jean-Paul Smets's avatar
Jean-Paul Smets committed
398 399 400
    # First, we update all properties (exc. quantity) which could be divergent
    # and if we can not, we compensate them
    for decision_movement in decision_movement_list:
401
      real_movement_quantity = decision_movement.getQuantity()
402 403 404
      if decision_movement.isPropertyRecorded('quantity'):
        decision_movement_quantity = decision_movement.getRecordedProperty('quantity')
      else:
405
        decision_movement_quantity = real_movement_quantity
406
      decision_quantity += decision_movement_quantity
407
      real_quantity += real_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
408 409 410 411 412
      if self._isProfitAndLossMovement(decision_movement):
        if decision_movement.isFrozen():
          # Record not completed movements
          if not_completed_movement is None and not decision_movement.isCompleted():
            not_completed_movement = decision_movement
413
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
414
          if not _compare(profit_tester_list, prevision_movement, decision_movement):
415 416
            new_movement = decision_movement.asContext(
                                quantity=-decision_movement_quantity)
417
            new_movement.setDelivery(None)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
418
            movement_collection_diff.addNewMovement(new_movement)
419
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
420 421 422 423
        else:
          updatable_compensation_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
424
          for tester in profit_updating_tester_list:
425
            if not tester.compare(prevision_movement, decision_movement):
426
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
427 428 429 430
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
      else:
        if decision_movement.isFrozen():
431
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
432
          if not _compare(divergence_tester_list, prevision_movement, decision_movement):
433 434
            new_movement = decision_movement.asContext(
                                  quantity=-decision_movement_quantity)
435
            new_movement.setDelivery(None)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
436
            movement_collection_diff.addNewMovement(new_movement)
437
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
438 439 440 441
        else:
          updatable_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
442
          for tester in updating_tester_list:
Jérome Perrin's avatar
Jérome Perrin committed
443
            if not tester.compare(prevision_movement, decision_movement):
444
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
Jérome Perrin's avatar
Jérome Perrin committed
445
              # XXX-JPS - there is a risk here that quantity is wrongly updated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
446 447 448 449
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
    # Second, we calculate if the total quantity is the same on both sides
    # after compensation
450 451
    quantity_movement = prevision_movement.asContext(
                            quantity=decision_quantity-compensated_quantity)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
452
    if not _compare(quantity_tester_list, prevision_movement, quantity_movement):
453 454 455
      missing_quantity = ( prevision_quantity
                           - real_quantity
                           + compensated_quantity )
Jean-Paul Smets's avatar
Jean-Paul Smets committed
456 457
      if updatable_movement is not None:
        # If an updatable movement still exists, we update it
458 459
        updatable_movement.setQuantity(
            updatable_movement.getQuantity() + missing_quantity)
460
        updatable_movement.clearRecordedProperty('quantity')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
461
      elif not_completed_movement is not None:
462 463
        # It is still possible to add a new movement some movements are not
        # completed
Jean-Paul Smets's avatar
Jean-Paul Smets committed
464
        new_movement = prevision_movement.asContext(quantity=missing_quantity)
465
        new_movement.setDelivery(None)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
466 467 468 469
        movement_collection_diff.addNewMovement(new_movement)
      elif updatable_compensation_movement is not None:
        # If not, it means that all movements are completed
        # but we can still update a profit and loss movement_collection_diff
470 471
        updatable_compensation_movement.setQuantity(
            updatable_compensation_movement.getQuantity() + missing_quantity)
472
        updatable_compensation_movement.clearRecordedProperty('quantity')
473
      else:
Jean-Paul Smets's avatar
Jean-Paul Smets committed
474 475 476
        # We must create a profit and loss movement
        new_movement = self._newProfitAndLossMovement(prevision_movement)
        movement_collection_diff.addNewMovement(new_movement)
Jérome Perrin's avatar
Jérome Perrin committed
477

478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500

class SimulableMixin(Base):

  def updateSimulation(self, **kw):
    """Create/update related simulation trees by activity

    This method is used to maintain related objects in simulation trees:
    - hiding complexity of activity dependencies
    - avoiding duplicate work

    Repeated calls of this method for the same delivery will result in a single
    call to _updateSimulation. Grouping may happen at the end of the transaction
    or by the grouping method.

    See _updateSimulation for accepted parameters.
    """
    tv = getTransactionalVariable()
    key = 'SimulableMixin.updateSimulation', self.getUid()
    item_list = kw.items()
    try:
      kw, ignore = tv[key]
      kw.update(item_list)
    except KeyError:
501 502
      ignore_key = key + ('ignore',)
      ignore = tv.pop(ignore_key, set())
503 504 505 506 507 508 509 510 511
      tv[key] = kw, ignore
      def before_commit():
        if kw:
          path = self.getPath()
          if aq_base(self.unrestrictedTraverse(path, None)) is aq_base(self):
            self.activate(
              activity='SQLQueue',
              group_method_id='portal_rules/updateSimulation',
              tag='expand:' + path,
512 513 514
              # Now that we don't rely on catalog anymore, this after_tag could
              # moved to _localBuild, which currently only depends on 'expand:'.
              after_tag='built:'+ path,
515 516
              priority=3,
              )._updateSimulation(**kw)
517 518 519
        del tv[key]
        ignore.update(kw)
        tv[ignore_key] = ignore
520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599
      transaction.get().addBeforeCommitHook(before_commit)
    for k, v in item_list:
      if not v:
        ignore.add(k)
      elif k not in ignore:
        continue
      del kw[k]

  def _updateSimulation(self, create_root=0, expand_root=0,
                              expand_related=0, index_related=0):
    """
    Depending on set parameters, this method will:
      create_root    -- if a root applied rule is missing, create and expand it
      expand_root    -- expand related root applied rule,
                        create it before if missing
      expand_related -- reindex related simulation movements (recursively)
      index_related  -- expand related simulation movements
    """
    if create_root or expand_root:
      applied_rule = self._getRootAppliedRule()
      if applied_rule is None:
        applied_rule = self._createRootAppliedRule()
        expand_root = applied_rule is not None
    activate_kw = {'tag': 'expand:'+self.getPath()}
    if expand_root:
      applied_rule.expand(activate_kw=activate_kw)
    else:
      applied_rule = None
    if expand_related:
      for movement in self._getAllRelatedSimulationMovementList():
        movement = movement.getObject()
        if not movement.aq_inContextOf(applied_rule):
          # XXX: make sure this will also reindex of all sub-objects recursively
          movement.expand(activate_kw=activate_kw)
    elif index_related:
      for movement in self._getAllRelatedSimulationMovementList():
        movement = movement.getObject()
        if not movement.aq_inContextOf(applied_rule):
          movement.recursiveReindexObject(activate_kw=activate_kw)

  def getRuleReference(self):
    """Returns an appropriate rule reference

    XXX: Using reference to select a rule (for a root applied rule) is wrong
         and should be replaced by predicate and workflow state.
    """
    method = self._getTypeBasedMethod('getRuleReference')
    if method is None:
      raise SimulationError("Missing type-based 'getRuleReference' script for "
                            + repr(self))
    return method()

  def _getRootAppliedRule(self):
    """Get related root applied rule if it exists"""
    applied_rule_list = self.getCausalityRelatedValueList(
        portal_type='Applied Rule')
    if len(applied_rule_list) == 1:
      return applied_rule_list[0]
    elif applied_rule_list:
      raise SimulationError('%r has more than one applied rule.' % self)

  def _createRootAppliedRule(self):
    """Create a root applied rule"""
    # XXX: Consider moving this first test to Delivery
    if self.isSimulated():
      # No need to have a root applied rule
      # if we are already in the simulation process
      return
    rule_reference = self.getRuleReference()
    if rule_reference:
      portal = self.getPortalObject()
      rule_list = portal.portal_catalog.unrestrictedSearchResults(
        portal_type=portal.getPortalRuleTypeList(),
        validation_state="validated", reference=rule_reference,
        sort_on='version', sort_order='descending')
      if rule_list:
        applied_rule = rule_list[0].constructNewAppliedRule(
          portal.portal_simulation, is_indexable=False)
        applied_rule._setCausalityValue(self)
        del applied_rule.isIndexable
600 601 602 603
        # To prevent duplicate root Applied Rule, we reindex immediately and
        # lock ZODB, and we rely on the fact that ZODB is committed after
        # catalog. This way, we guarantee the catalog is up-to-date as soon as
        # ZODB is unlocked.
604 605 606 607 608 609 610 611 612 613
        applied_rule.immediateReindexObject()
        self.serialize() # prevent duplicate root Applied Rule
        return applied_rule
      raise SimulationError("No such rule as %r is found" % rule_reference)

  def manage_beforeDelete(self, item, container):
    """Delete related Applied Rule"""
    for o in self.getCausalityRelatedValueList(portal_type='Applied Rule'):
      o.getParentValue().deleteContent(o.getId())
    super(SimulableMixin, self).manage_beforeDelete(item, container)