Commit 6d9ac10a authored by Romain Courteaud's avatar Romain Courteaud

erp5_core: add Base_reindexAndSenseAlarm script

Trigger activeSense on an alarm after an object is indexed.
The idea is to use this script in an interaction workflow to trigger the alarm.

If called multiple times, try to reduce the number of alarm execution.
parent f39555c5
Pipeline #35507 failed with stage
in 0 seconds
from zExceptions import Unauthorized
if REQUEST is not None:
raise Unauthorized
portal = context.getPortalObject()
alarm_tool = portal.portal_alarms
# Higher than simulable movement priority
# priority=3, to be executed after all reindex, but also execute simulation _expand
PRIORITY = 3
if alarm_tool.isSubscribed() and len(alarm_id_list):
# No alarm tool is not subscribed, respect this choice and do not activate any alarm
tag = None
if must_reindex_context:
tag = "%s-%s" % (script.id, context.getRelativeUrl())
context.reindexObject(activate_kw={'tag': tag})
for alarm_id in alarm_id_list:
alarm = alarm_tool.restrictedTraverse(alarm_id)
deduplication_tag = 'Base_reindexAndSenseAlarm_%s' % alarm_id
if alarm.isEnabled():
# do nothing if the alarm is not enabled
if tag is not None:
activate_kw = {}
activate_kw['activity'] = 'SQLQueue'
activate_kw['after_tag'] = tag
activate_kw['tag'] = deduplication_tag
activate_kw['priority'] = max(1, PRIORITY-1)
# Wait for the context indexation to be finished
alarm_tool.activate(**activate_kw).Base_reindexAndSenseAlarm([alarm_id],
must_reindex_context=False)
elif portal.portal_activities.countMessageWithTag(deduplication_tag) <= 1:
if alarm.isActive():
# If the alarm is active, wait for it
# and try to reduce the number of activities
# to reduce the number of alarm execution
activate_kw = {}
activate_kw['activity'] = 'SQLQueue'
activate_kw['priority'] = PRIORITY
activate_kw['tag'] = deduplication_tag
activate_kw['after_path'] = alarm.getPath()
# Wait for the previous alarm run to be finished
# call on alarm tool to gather and drop with sqldict
alarm_tool.activate(**activate_kw).Base_reindexAndSenseAlarm([alarm_id],
must_reindex_context=False)
else:
# activeSense create an activity in SQLDict
alarm.activeSense()
# Prevent 2 nodes to call activateSense concurrently
alarm.serialize()
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="_reconstructor" module="copy_reg"/>
</klass>
<tuple>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
<global name="object" module="__builtin__"/>
<none/>
</tuple>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string>alarm_id_list, must_reindex_context=True, REQUEST=None</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>Base_reindexAndSenseAlarm</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
# -*- coding: utf-8 -*-
##############################################################################
#
# Copyright (C) 2024 Nexedi SA and Contributors.
#
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
#
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
#
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
#
##############################################################################
import transaction
from zExceptions import Unauthorized
from DateTime import DateTime
from Products.ERP5Type.tests.utils import createZODBPythonScript
from Products.ERP5Type.tests.ERP5TypeTestCase import ERP5TypeTestCase
from time import sleep
class TemporaryAlarmScript(object):
"""
Context manager for temporary alarm python scripts
"""
def __init__(self, portal, script_name, fake_return="", attribute=None):
self.script_name = script_name
self.portal = portal
self.fake_return = fake_return
self.attribute = attribute
def __enter__(self):
if self.script_name in self.portal.portal_skins.custom.objectIds():
raise ValueError('Precondition failed: %s exists in custom' % self.script_name)
if self.attribute is None:
content = """portal_workflow = context.portal_workflow
portal_workflow.doActionFor(context, action='edit_action', comment='Visited by %s')
return %s""" % (self.script_name, self.fake_return)
else:
content = """portal_workflow = context.portal_workflow
context.edit(%s='Visited by %s')
return %s""" % (self.attribute, self.script_name, self.fake_return)
createZODBPythonScript(self.portal.portal_skins.custom,
self.script_name,
'*args, **kwargs',
'# Script body\n' + content)
transaction.commit()
def __exit__(self, exc_type, exc_value, traceback):
if self.script_name in self.portal.portal_skins.custom.objectIds():
self.portal.portal_skins.custom.manage_delObjects(self.script_name)
transaction.commit()
class TestBase_reindexAndSenseAlarm(ERP5TypeTestCase):
def getBusinessTemplateList(self):
"""Business Templates required for this test.
"""
return ('erp5_full_text_mroonga_catalog', 'erp5_simulation_test', )
def afterSetUp(self):
# Ensure the alarms has a workflow history
for alarm_id in ['invoice_builder_alarm',
'packing_list_builder_alarm']:
alarm = self.portal.portal_alarms[alarm_id]
old_comment = alarm.getProperty('comment')
alarm.edit(comment='%s foo' % old_comment)
alarm.edit(comment=old_comment)
return super(TestBase_reindexAndSenseAlarm, self).afterSetUp()
def beforeTearDown(self):
transaction.abort()
def assertLessThan(self, value1, value2):
self.assertTrue(value1 < value2, 'Expected: %s < %s' % (value1, value2))
def getIndexationDate(self, document):
return DateTime(self.portal.portal_catalog(
uid=document.getUid(),
select_list=['indexation_timestamp']
)[0].indexation_timestamp)
def test_reindexAndSenseAlarm_REQUEST_disallowed(self):
document = self.portal.internal_order_module
self.assertRaises(
Unauthorized,
document.Base_reindexAndSenseAlarm,
[],
REQUEST={})
def test_reindexAndSenseAlarm_callAlarmAfterContextReindex(self):
# Check that the alarm is triggered
# only after the context is reindexed
document = self.portal.internal_order_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
previous_indexation_timestamp = self.getIndexationDate(document)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
# Sadly, catalog indexation timestamp has a second precision
# It is needed to wait this 1 second to be able to verify new indexation
sleep(1)
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
next_indexation_timestamp = self.getIndexationDate(document)
edit_timestamp = alarm.getModificationDate()
# check that the document has been reindexed
self.assertLessThan(previous_indexation_timestamp, next_indexation_timestamp)
# check that alarm was called after the object was reindexed
self.assertLessThan(next_indexation_timestamp, edit_timestamp)
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count + 1
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm.workflow_history['edit_workflow'][-1]['comment']
)
def test_reindexAndSenseAlarm_callAlarmWithoutContextReindex(self):
# Check that the alarm is triggered
# without reindexing the context
document = self.portal.internal_order_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'],
must_reindex_context=False)
previous_indexation_timestamp = self.getIndexationDate(document)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
# Sadly, catalog indexation timestamp has a second precision
# It is needed to wait this 1 second to be able to verify new indexation
sleep(1)
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
next_indexation_timestamp = self.getIndexationDate(document)
edit_timestamp = alarm.getModificationDate()
# check that the document was not reindexed
self.assertEqual(previous_indexation_timestamp, next_indexation_timestamp)
# check that alarm was called after the object was reindexed
self.assertLessThan(next_indexation_timestamp, edit_timestamp)
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count + 1
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm.workflow_history['edit_workflow'][-1]['comment']
)
def test_reindexAndSenseAlarm_doesNotReindexIfNoAlarm(self):
# Check that no alarm is triggered
# and the context is not reindexed
document = self.portal.internal_order_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
document.Base_reindexAndSenseAlarm([])
previous_indexation_timestamp = self.getIndexationDate(document)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
# Sadly, catalog indexation timestamp has a second precision
# It is needed to wait this 1 second to be able to verify new indexation
sleep(1)
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
next_indexation_timestamp = self.getIndexationDate(document)
# check that the document was not reindex
self.assertEqual(previous_indexation_timestamp, next_indexation_timestamp)
# check that the alarm was not triggered
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count
)
def test_reindexAndSenseAlarm_twiceInTheSameTransaction(self):
# Check that the alarm is triggered only ONCE
# if the script is called twice in a transaction
document = self.portal.internal_order_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
previous_indexation_timestamp = self.getIndexationDate(document)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
# Sadly, catalog indexation timestamp has a second precision
# It is needed to wait this 1 second to be able to verify new indexation
sleep(1)
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
next_indexation_timestamp = self.getIndexationDate(document)
edit_timestamp = alarm.getModificationDate()
# check that the document has been reindexed
self.assertLessThan(previous_indexation_timestamp, next_indexation_timestamp)
# check that alarm was called ONCE after the object was reindexed
self.assertLessThan(next_indexation_timestamp, edit_timestamp)
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count + 1
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm.workflow_history['edit_workflow'][-1]['comment']
)
def test_reindexAndSenseAlarm_twiceInTheSameTransactionWithoutReindex(self):
# Check that the alarm is triggered only ONCE
# if the script is called twice in a transaction
document = self.portal.internal_order_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'],
must_reindex_context=False)
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'],
must_reindex_context=False)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
# check that alarm was called ONCE
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count + 1
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm.workflow_history['edit_workflow'][-1]['comment']
)
def test_reindexAndSenseAlarm_twiceInTheTwoTransactions(self):
# Check that the alarm is triggered only ONCE
# if the script is called twice in a transaction
document = self.portal.internal_order_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
transaction.commit()
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
previous_indexation_timestamp = self.getIndexationDate(document)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
# Sadly, catalog indexation timestamp has a second precision
# It is needed to wait this 1 second to be able to verify new indexation
sleep(1)
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
next_indexation_timestamp = self.getIndexationDate(document)
edit_timestamp = alarm.getModificationDate()
# check that the document has been reindexed
self.assertLessThan(previous_indexation_timestamp, next_indexation_timestamp)
# check that alarm was called ONCE after the object was reindexed
self.assertLessThan(next_indexation_timestamp, edit_timestamp)
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count + 1
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm.workflow_history['edit_workflow'][-1]['comment']
)
def test_reindexAndSenseAlarm_alarmActive(self):
# Check that the script wait for the alarm to be not activate
# before triggering it again
document = self.portal.internal_order_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
tag = 'foobar'
alarm.activate(tag=tag).getId()
# Call edit, to ensure the last edit contains the comment value
alarm.activate(after_tag=tag, tag=tag+'1').edit(description=alarm.getDescription() + ' ')
alarm.activate(after_tag=tag+'1').edit(description=alarm.getDescription()[:-1])
transaction.commit()
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm'],
must_reindex_context=False)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count + 3
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm.workflow_history['edit_workflow'][-1]['comment']
)
def test_reindexAndSenseAlarm_twoContextSameTransaction(self):
# Check that the script wait for the alarm to be not activate
# before triggering it again
document1 = self.portal.internal_order_module
document2 = self.portal.internal_packing_list_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
document1.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
document2.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
previous_indexation_timestamp1 = self.getIndexationDate(document1)
previous_indexation_timestamp2 = self.getIndexationDate(document2)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
# Sadly, catalog indexation timestamp has a second precision
# It is needed to wait this 1 second to be able to verify new indexation
sleep(1)
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
next_indexation_timestamp1 = self.getIndexationDate(document1)
next_indexation_timestamp2 = self.getIndexationDate(document2)
edit_timestamp = alarm.getModificationDate()
# check that the document has been reindexed
self.assertLessThan(previous_indexation_timestamp1, next_indexation_timestamp1)
self.assertLessThan(previous_indexation_timestamp2, next_indexation_timestamp2)
# check that alarm was called after the object was reindexed
self.assertLessThan(next_indexation_timestamp1, edit_timestamp)
self.assertLessThan(next_indexation_timestamp2, edit_timestamp)
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count + 1
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm.workflow_history['edit_workflow'][-1]['comment']
)
def test_reindexAndSenseAlarm_twoContextDifferentTransaction(self):
# Check that the script wait for the alarm to be not activate
# before triggering it again
document1 = self.portal.internal_order_module
document2 = self.portal.internal_packing_list_module
alarm = self.portal.portal_alarms.invoice_builder_alarm
document1.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
document2.Base_reindexAndSenseAlarm(['invoice_builder_alarm'])
previous_indexation_timestamp1 = self.getIndexationDate(document1)
transaction.commit()
previous_indexation_timestamp2 = self.getIndexationDate(document2)
workflow_history_count = len(alarm.workflow_history['edit_workflow'])
# Sadly, catalog indexation timestamp has a second precision
# It is needed to wait this 1 second to be able to verify new indexation
sleep(1)
with TemporaryAlarmScript(alarm, 'Alarm_buildInvoice'):
self.tic()
next_indexation_timestamp1 = self.getIndexationDate(document1)
next_indexation_timestamp2 = self.getIndexationDate(document2)
edit_timestamp = alarm.getModificationDate()
# check that the document has been reindexed
self.assertLessThan(previous_indexation_timestamp1, next_indexation_timestamp1)
self.assertLessThan(previous_indexation_timestamp2, next_indexation_timestamp2)
# check that alarm was called after the object was reindexed
self.assertLessThan(next_indexation_timestamp1, edit_timestamp)
self.assertLessThan(next_indexation_timestamp2, edit_timestamp)
self.assertEqual(
len(alarm.workflow_history['edit_workflow']),
workflow_history_count + 1
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm.workflow_history['edit_workflow'][-1]['comment']
)
def test_reindexAndSenseAlarm_twoAlarm(self):
# Check that the script wait for the alarm to be not activate
# before triggering it again
document = self.portal.internal_order_module
alarm1 = self.portal.portal_alarms.invoice_builder_alarm
alarm2 = self.portal.portal_alarms.packing_list_builder_alarm
document.Base_reindexAndSenseAlarm(['invoice_builder_alarm',
'packing_list_builder_alarm'])
previous_indexation_timestamp = self.getIndexationDate(document)
workflow_history_count1 = len(alarm1.workflow_history['edit_workflow'])
workflow_history_count2 = len(alarm2.workflow_history['edit_workflow'])
# Sadly, catalog indexation timestamp has a second precision
# It is needed to wait this 1 second to be able to verify new indexation
sleep(1)
with TemporaryAlarmScript(alarm1, 'Alarm_buildInvoice'):
with TemporaryAlarmScript(alarm2, 'Alarm_buildPackingList'):
self.tic()
next_indexation_timestamp = self.getIndexationDate(document)
edit_timestamp1 = alarm1.getModificationDate()
edit_timestamp2 = alarm2.getModificationDate()
self.assertLessThan(previous_indexation_timestamp, next_indexation_timestamp)
# check that alarm was called after the object was reindexed
self.assertLessThan(next_indexation_timestamp, edit_timestamp1)
self.assertLessThan(next_indexation_timestamp, edit_timestamp2)
self.assertEqual(
len(alarm1.workflow_history['edit_workflow']),
workflow_history_count1 + 1
)
self.assertEqual(
'Visited by Alarm_buildInvoice',
alarm1.workflow_history['edit_workflow'][-1]['comment']
)
self.assertEqual(
len(alarm2.workflow_history['edit_workflow']),
workflow_history_count2 + 1
)
self.assertEqual(
'Visited by Alarm_buildPackingList',
alarm2.workflow_history['edit_workflow'][-1]['comment']
)
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="Test Component" module="erp5.portal_type"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>default_reference</string> </key>
<value> <string>testERP5CoreSkins</string> </value>
</item>
<item>
<key> <string>description</string> </key>
<value>
<none/>
</value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>test.erp5.testERP5CoreSkins</string> </value>
</item>
<item>
<key> <string>sid</string> </key>
<value>
<none/>
</value>
</item>
<item>
<key> <string>text_content_error_message</string> </key>
<value>
<tuple/>
</value>
</item>
<item>
<key> <string>text_content_warning_message</string> </key>
<value>
<tuple/>
</value>
</item>
<item>
<key> <string>version</string> </key>
<value> <string>erp5</string> </value>
</item>
<item>
<key> <string>workflow_history</string> </key>
<value>
<persistent> <string encoding="base64">AAAAAAAAAAI=</string> </persistent>
</value>
</item>
</dictionary>
</pickle>
</record>
<record id="2" aka="AAAAAAAAAAI=">
<pickle>
<global name="PersistentMapping" module="Persistence.mapping"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>data</string> </key>
<value>
<dictionary>
<item>
<key> <string>component_validation_workflow</string> </key>
<value>
<persistent> <string encoding="base64">AAAAAAAAAAM=</string> </persistent>
</value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</pickle>
</record>
<record id="3" aka="AAAAAAAAAAM=">
<pickle>
<global name="WorkflowHistoryList" module="Products.ERP5Type.Workflow"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_log</string> </key>
<value>
<list>
<dictionary>
<item>
<key> <string>action</string> </key>
<value> <string>validate</string> </value>
</item>
<item>
<key> <string>validation_state</string> </key>
<value> <string>validated</string> </value>
</item>
</dictionary>
</list>
</value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
test.erp5.testERP5CoreSkins
\ No newline at end of file
erp5_full_text_mroonga_catalog
  • @romain was there a strong reason to add the test in erp5_core and not in erp5_core_test ? to keep erp5_core minimal ( see https://lab.nexedi.com/nexedi/erp5/-/blob/6d9ac10aa680c885f58759eb7038e2315ec7448e/product/ERP5/bootstrap/erp5_core/bt/comment#L3 ) the tests are in erp5_core_test

    The immediate problem seems to be https://erp5js.nexedi.net/#/test_result_module/20240627-5CD6DD6D/96 the diff is https://softinst145611.host.vifib.net/eci-Kn8PyTdC0a/CodingStyleTest-erp5_full_text_myisam_catalog/CodingStyleTest.CodingStyleTest%20erp5_full_text_myisam_catalog.test_rebuild_business_template.diff it seems that because of this dependency, when testing erp5_full_text_myisam_catalog, erp5_full_text_mroonga_catalog becomes installed.

    It seems better to ignore that we could just delete erp5_full_text_mroonga_catalog business template, each time erp5_core grows it's a (small) problem.

    BTW, according to the description of erp5_core, even this Base_reindexAndSenseAlarm should not be in erp5_core (but I don't know what's a better place, this script seems not used)

    Edited by Jérome Perrin
  • ah I just understood the script, that's use case, that's useful. I wrote more or less the same script already.

    For the records, when I did that, I tried to use a short delay, also combining with at_date parameter of activate, to execute the alarm after x minutes, with the idea that if there are many similar user actions in a short time, the alarm is executed only once after user finished, the use case was to refresh an expensive cache for accounting after validating accounts or projects. In practice, I'm not sure this delay really worked, finding the right number for x minutes is impossible, so this is more a comment to explain that using at_date is not really good.

  • @romain was there a strong reason to add the test in erp5_core and not in erp5_core_test ?

    No reason at all. I completely forgot about erp5_core_test. I'll move the test.

    BTW, according to the description of erp5_core, even this Base_reindexAndSenseAlarm should not be in erp5_core (but I don't know what's a better place, this script seems not used)

    This script seems already used by 2 projects, including slapos master. I expect it to be used by other public bt5 some days. I know we should keep erp5_core minimal, but I didn't think of a better bt5 to put this script.

Please register or sign in to reply
erp5_simulation_test
\ No newline at end of file
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