rule.py 25.5 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
32
from Products.ERP5Type.Globals import InitializeClass
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
33
from Acquisition import aq_base
34
from Products.ERP5Type import Permissions, interfaces
35
from Products.ERP5Type.Base import Base
36
from Products.ERP5Type.Core.Predicate import Predicate
37 38 39
from Products.ERP5Type.Errors import SimulationError
from Products.ERP5Type.TransactionalVariable import getTransactionalVariable
from Products.ERP5.ExpandPolicy import policy_dict
40 41 42
from Products.ERP5.MovementCollectionDiff import _getPropertyAndCategoryList

from zLOG import LOG
43

Jean-Paul Smets's avatar
Jean-Paul Smets committed
44 45 46 47 48 49
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

50
class MovementGeneratorMixin(object):
51 52 53 54
  """
  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.
55 56 57

  TODO:
    - _getInputMovementList is still not well defined. Should input
58
      be an amount (_getInputAmountList) or a movement? This
59
      requires careful thiking.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
  """
  # 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:
75
      self._explanation = applied_rule
76
    else:
77 78 79 80
      # 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
81 82 83 84 85 86
      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
87
    Returns a list of movements generated by that rule.
88 89 90 91 92 93 94 95 96 97

    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
98
    # Default implementation below can be overriden by subclasses
99 100
    # however it should be generic enough not to be overriden
    # by most classes
101
    # Results will be appended to result
102 103
    result = []
    # Build a list of movement and business path
104 105 106
    input_movement_list = self._getInputMovementList(
                            movement_list=movement_list, rounding=rounding)
    for input_movement in input_movement_list:
107 108 109
      # Merge movement and business path properties (core implementation)
      # Lookup Business Process through composition (NOT UNION)
      business_process = input_movement.asComposedDocument()
110
      explanation = self._applied_rule # We use applied rule as local explanation
111
      trade_phase = self._getTradePhaseList(input_movement, business_process) # XXX-JPS not convenient to handle
112
      update_property_dict = self._getUpdatePropertyDict(input_movement)
113
      result.extend(business_process.getTradePhaseMovementList(explanation, input_movement,
114 115
                                                 trade_phase=trade_phase, delay_mode=None,
                                                 update_property_dict=update_property_dict))
116 117 118 119 120

    # And return list of generated movements
    return result

  def _getUpdatePropertyDict(self, input_movement):
121 122 123 124
    # 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()}
125 126 127 128

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

136
  def _getInputMovementList(self, movement_list=None, rounding=None): #XXX-JPS should it be amount or movement ?
137 138 139 140 141 142
    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
143
class RuleMixin(Predicate):
144 145
  """
  Provides generic methods and helper methods to implement
146
  IRule and IMovementCollectionUpdater.
147 148 149 150 151 152 153
  """
  # Declarative security
  security = ClassSecurityInfo()
  security.declareObjectProtected(Permissions.AccessContentsInformation)

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

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

160
  # Implementation of IRule
161 162
  security.declareProtected(Permissions.ModifyPortalContent,
                            'constructNewAppliedRule')
163
  def constructNewAppliedRule(self, context, **kw):
164 165 166
    """
    Create a new applied rule in the context.

167
    An applied rule is an instantiation of a Rule. The applied rule is
168 169 170 171 172 173 174 175 176 177 178
    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
    """
179 180
    return context.newContent(portal_type='Applied Rule',
                              specialise_value=self, **kw)
181

Julien Muchembled's avatar
Julien Muchembled committed
182 183 184 185 186 187
  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):
188 189 190
    """
    If no test method is defined, return False, to prevent infinite loop
    """
Julien Muchembled's avatar
Julien Muchembled committed
191 192 193
    if not self.getTestMethodId():
      return False
    return super(RuleMixin, self).test(*args, **kw)
194

195 196
  security.declareProtected(Permissions.ModifyPortalContent,
                            'expand')
197
  def expand(self, applied_rule, expand_policy=None, **kw):
198 199 200 201 202
    """
    Expand this applied rule to create new documents inside the
    applied rule.

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

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

Julien Muchembled's avatar
Julien Muchembled committed
219 220 221 222 223 224 225 226
  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
    """
227
    return not movement.getDelivery()
Julien Muchembled's avatar
Julien Muchembled committed
228

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

  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 = []
251
    for divergence_tester in self._getDivergenceTesterList(
252 253 254 255 256 257 258
                                          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)
259 260
    return result_list

261
  # Placeholder for methods to override
262
  def _getMovementGenerator(self, applied_rule):
263 264 265 266 267
    """
    Return the movement generator to use in the expand process
    """
    raise NotImplementedError

268
  def _getMovementGeneratorMovementList(self, applied_rule):
269 270 271 272
    """
    Return the movement lists to provide to the movement generator
    """

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

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

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

299
  def _getUpdatingTesterList(self, exclude_quantity=False):
300 301 302 303 304 305
    """
    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
306
    """
307 308 309 310 311 312 313 314
    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()))

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

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

Jean-Paul Smets's avatar
Jean-Paul Smets committed
329
    prevision_movement -- a simulation movement
330
    """
331
    raise NotImplementedError
332

333
  def _isProfitAndLossMovement(self, movement): # applied_rule XXX-JPS add this ?
334 335 336 337 338
    """
    Returns True if movement is a profit and loss movement.
    """
    raise NotImplementedError

Jean-Paul Smets's avatar
Jean-Paul Smets committed
339 340 341 342 343 344
  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
345

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

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

Jean-Paul Smets's avatar
Jean-Paul Smets committed
356 357 358 359 360
    # 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:
361 362
        # If not frozen and all children are deletable
        if decision_movement.isDeletable():
Jean-Paul Smets's avatar
Jean-Paul Smets committed
363 364
          # Delete deletable
          movement_collection_diff.addDeletableMovement(decision_movement)
365 366 367 368 369 370 371 372 373 374 375
          continue
        quantity = decision_movement.getQuantity()
        if quantity:
          if decision_movement.isFrozen():
            # Compensate
            new_movement = decision_movement.asContext(quantity=-quantity)
            new_movement.setDelivery(None)
            movement_collection_diff.addNewMovement(new_movement)
          else:
            movement_collection_diff.addUpdatableMovement(decision_movement,
                                                          {'quantity': 0})
Jean-Paul Smets's avatar
Jean-Paul Smets committed
376
      return
377

378 379 380 381 382
    # 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
383 384 385

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

480
InitializeClass(RuleMixin)
481 482

class SimulableMixin(Base):
483
  security = ClassSecurityInfo()
484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504

  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:
505 506
      ignore_key = key + ('ignore',)
      ignore = tv.pop(ignore_key, set())
507 508 509 510 511 512 513 514
      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',
515
              tag='build:' + path,
516 517
              priority=3,
              )._updateSimulation(**kw)
518 519 520
        del tv[key]
        ignore.update(kw)
        tv[ignore_key] = ignore
521 522 523 524 525 526 527 528 529 530 531 532 533 534 535
      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
536 537
      expand_related -- expand related simulation movements
      index_related  -- reindex related simulation movements (recursively)
538 539 540 541 542 543
    """
    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
544
    activate_kw = {'tag': 'build:'+self.getPath()}
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560
    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)

561 562
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getRuleReference')
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 600 601 602
  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
603 604 605 606
        # 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.
607 608 609 610 611
        applied_rule.immediateReindexObject()
        self.serialize() # prevent duplicate root Applied Rule
        return applied_rule
      raise SimulationError("No such rule as %r is found" % rule_reference)

612
  security.declarePrivate('manage_beforeDelete')
613 614 615 616 617
  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)
618

619
InitializeClass(SimulableMixin)