diff --git a/CHANGES.rst b/CHANGES.rst
index e4b138e716..1ecbfbf08d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,9 +4,10 @@ Changelog
2.6.0 (unreleased)
------------------
+- #2632 Refactor Catalog Indexing
- #2630 Fix references from sample templates are not kept when partitioning
- #2634 Pin et-xmlfile to a Python 2 compatible version
-- #2631 Fix for sending email attachment if filename contain spaces
+- #2631 Fix for sending email attachment if filename contain spaces
- #2633 Fix DateTimeError when using API's to_DT and to_dt functions
- #2629 Fix default sticker template based on sample type is not rendered
- #2627 Skip workflow transition for temporary analyses
diff --git a/src/bika/lims/api/__init__.py b/src/bika/lims/api/__init__.py
index 996cbf00d0..9e0bfb441c 100644
--- a/src/bika/lims/api/__init__.py
+++ b/src/bika/lims/api/__init__.py
@@ -384,10 +384,11 @@ def move_object(obj, destination, check_constraints=True):
return obj
-def uncatalog_object(obj):
+def uncatalog_object(obj, recursive=False):
"""Un-catalog the object from all catalogs
:param obj: object to un-catalog
+ :param recursive: recursively uncatalog all child objects
:type obj: ATContentType/DexterityContentType
"""
# un-catalog from registered catalogs
@@ -399,11 +400,16 @@ def uncatalog_object(obj):
url = "/".join(obj.getPhysicalPath()[2:])
uid_catalog.uncatalog_object(url)
+ if recursive:
+ for child in obj.objectValues():
+ uncatalog_object(child, recursive=recursive)
-def catalog_object(obj):
+
+def catalog_object(obj, recursive=False):
"""Re-catalog the object
- :param obj: object to un-catalog
+ :param obj: object to catalog
+ :param recursive: recursively catalog all child objects
:type obj: ATContentType/DexterityContentType
"""
if is_at_content(obj):
@@ -416,6 +422,10 @@ def catalog_object(obj):
uc.catalog_object(obj, url)
obj.reindexObject()
+ if recursive:
+ for child in obj.objectValues():
+ catalog_object(child, recursive=recursive)
+
def delete(obj, check_permissions=True, suppress_events=False):
"""Deletes the given object
diff --git a/src/bika/lims/utils/analysisrequest.py b/src/bika/lims/utils/analysisrequest.py
index cc6281cc6c..9bd2816073 100644
--- a/src/bika/lims/utils/analysisrequest.py
+++ b/src/bika/lims/utils/analysisrequest.py
@@ -158,7 +158,7 @@ def create_analysisrequest(client, request, values, analyses=None,
# unmark the sample as temporary
api.unmark_temporary(ar)
# explicit reindexing after sample finalization
- reindex(ar)
+ api.catalog_object(ar)
# notify object initialization (also creates a snapshot)
event.notify(ObjectInitializedEvent(ar))
@@ -169,18 +169,6 @@ def create_analysisrequest(client, request, values, analyses=None,
return ar
-def reindex(obj, recursive=False):
- """Reindex the object
-
- :param obj: The object to reindex
- :param recursive: If true, all child objects are reindexed recursively
- """
- obj.reindexObject()
- if recursive:
- for child in obj.objectValues():
- reindex(child)
-
-
def receive_sample(sample, check_permission=False, date_received=None):
"""Receive the sample without transition
"""
diff --git a/src/senaite/core/catalog/catalog_multiplex_processor.py b/src/senaite/core/catalog/catalog_multiplex_processor.py
index 8c06e3b0e9..1f94eada53 100644
--- a/src/senaite/core/catalog/catalog_multiplex_processor.py
+++ b/src/senaite/core/catalog/catalog_multiplex_processor.py
@@ -46,7 +46,11 @@ def is_global_auditlog_enabled(self):
return setup.getEnableGlobalAuditlog()
def get_catalogs_for(self, obj):
- catalogs = getattr(obj, "_catalogs", [])
+ """Get a list of catalog IDs for the given object
+ """
+ # get a list of catalog IDs that are mapped to the object
+ catalogs = list(map(lambda x: x.id, api.get_catalogs_for(obj)))
+
for rc in REQUIRED_CATALOGS:
if rc in catalogs:
continue
@@ -61,10 +65,11 @@ def get_catalogs_for(self, obj):
def supports_multi_catalogs(self, obj):
"""Check if the Multi Catalog Behavior is enabled
"""
- if IMultiCatalogBehavior(obj, None) is None:
- return False
if api.is_temporary(obj):
return False
+ if api.is_dexterity_content(obj) and \
+ IMultiCatalogBehavior(obj, None) is None:
+ return False
return True
def index(self, obj, attributes=None):
diff --git a/src/senaite/core/idserver/idserver.py b/src/senaite/core/idserver/idserver.py
index 5076115307..363369d076 100644
--- a/src/senaite/core/idserver/idserver.py
+++ b/src/senaite/core/idserver/idserver.py
@@ -156,7 +156,11 @@ def get_partition_count(context, default=0):
if not parent:
return default
- return len(parent.getDescendants())
+ # XXX: we need to count one up because the new partition only shows up in
+ # parent.getDescendants() *after* it has been renamed, because
+ # temporary objects don't get indexed!
+ # https://github.com/senaite/senaite.core/pull/2632
+ return len(parent.getDescendants()) + 1
def get_secondary_count(context, default=0):
diff --git a/src/senaite/core/patches/__init__.py b/src/senaite/core/patches/__init__.py
index 45d926b147..b0d48c9ed7 100644
--- a/src/senaite/core/patches/__init__.py
+++ b/src/senaite/core/patches/__init__.py
@@ -17,16 +17,3 @@
#
# Copyright 2018-2024 by it's authors.
# Some rights reserved, see README and LICENSE.
-
-from bika.lims import api
-from Products.Archetypes import utils
-
-
-def isFactoryContained(obj):
- """Are we inside the portal_factory?
- """
- return api.is_temporary(obj)
-
-
-# https://pypi.org/project/collective.monkeypatcher/#patching-module-level-functions
-utils.isFactoryContained = isFactoryContained
diff --git a/src/senaite/core/patches/archetypes/README.md b/src/senaite/core/patches/archetypes/README.md
new file mode 100644
index 0000000000..48aea0f0bd
--- /dev/null
+++ b/src/senaite/core/patches/archetypes/README.md
@@ -0,0 +1,92 @@
+# Archetypes Patches
+
+This package contains patches for Archetype based content types.
+
+## Catalog Multiplex
+
+The module `catalog_multiplex` contains patches for the class
+`Products.Archetpyes.CatalogMultiplex.CatalogMultiplex`, which is a mixin for
+`BaseContent` and controls how to index, unindex, reindex these content and is
+used when e.g. `obj.reindexObject` is called.
+
+### Patches
+
+The following methods are patched:
+
+- `indexObject`
+- `unindexObject`
+- `reindexObject`
+
+### Reason
+
+The patches ensure that temporary objects are not indexed and delegate the
+operation to the respective method of the catalog itself.
+
+Due to the fact that SENAITE catalogs inherit from `Products.CMFPlone.CatalogTool.CatalogTool`,
+which is a subclass of `Products.CMFCore.CatalogTool.CatalogTool`, this operation uses the
+`IndexQueue` defined in `Products.CMFCore.indexing.IndexQueue` to optimize indexing.
+
+### Notes
+
+The index queue always looks up all registered `IIndexQueueProcessor` utilities
+to further delegate the operation.
+
+Since the `PortalCatalogProcessor` utility is registered there as well, a
+patching is required to avoid indexing of, e.g. Samples or Analyses there as
+they should be only indexed in their primary catalog, e.g.
+`seanite_catalog_sample` or `senaite_catalog_analysis`.
+
+Please see `senaite.core.patches.cmfcore.portal_catalog_processor` for details.
+
+Furthermore, changes in `senaite.core.catalog.catalog_multiplex_processor.CatalogMultiplexProcessor`
+were required to handle AT based contens as well.
+
+Please see https://github.com/senaite/senaite.core/pull/2632 for details.
+
+💡 It might make sense to define for each catalog its own `IIndexQueueProcessor`.
+A simple check by content type would could decide if a content should be indexed or not.
+
+
+## UID Catalog indexing
+
+The module `referencable` contains patches for the class `Products.Archetypes.Referencable.Referencable`,
+which is a mixin for `BaseObject` and controls AT native referencable behavior
+(not used) and the indexing in the UID Catalog (used and needed for UID
+references and more).
+
+### Patches
+
+The following methods are patched:
+
+- `_catalogUID_`
+- `uncatalogUID`
+
+### Reason
+
+The patches ensure that temporary objects are not indexed.
+
+### Notes
+
+As soon as we have migrated all contents to Dexterity, we should provide a
+custom `senaite_catalog_uid` to keep track of the UIDs and maybe references.
+
+
+## Base Object
+
+The module `base_objects` contains patches for the class `Products.Archetypes.BaseObject.BaseObject`,
+which is the base class for our AT based contents.
+
+### Patches
+
+The following methods are patched:
+
+- `getLabels`
+- `isTemporary`
+
+### Reason
+
+Provide a similar methods for AT contents as for DX contents.
+
+**getLabels**: Get SENAITE labels (dynamically extended fields)
+
+**isTemporary**: Checks if an object contains a temporary ID to avoid further indexing/processing
diff --git a/src/senaite/core/patches/archetypes/__init__.py b/src/senaite/core/patches/archetypes/__init__.py
new file mode 100644
index 0000000000..1521b36835
--- /dev/null
+++ b/src/senaite/core/patches/archetypes/__init__.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.CORE.
+#
+# SENAITE.CORE 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, version 2.
+#
+# 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.
+#
+# Copyright 2018-2024 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+
+from bika.lims import api
+from Products.Archetypes import utils
+
+
+def isFactoryContained(obj):
+ """Are we inside the portal_factory?
+ """
+ return api.is_temporary(obj)
+
+
+# https://pypi.org/project/collective.monkeypatcher/#patching-module-level-functions
+utils.isFactoryContained = isFactoryContained
diff --git a/src/senaite/core/patches/archetypes.py b/src/senaite/core/patches/archetypes/base_object.py
similarity index 100%
rename from src/senaite/core/patches/archetypes.py
rename to src/senaite/core/patches/archetypes/base_object.py
diff --git a/src/senaite/core/patches/archetypes/catalog_multiplex.py b/src/senaite/core/patches/archetypes/catalog_multiplex.py
new file mode 100644
index 0000000000..599da93cd4
--- /dev/null
+++ b/src/senaite/core/patches/archetypes/catalog_multiplex.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.CORE.
+#
+# SENAITE.CORE 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, version 2.
+#
+# 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.
+#
+# Copyright 2018-2024 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+from bika.lims import api
+from Products.Archetypes.Referenceable import Referenceable
+from Products.Archetypes.utils import shasattr
+from senaite.core.catalog import AUDITLOG_CATALOG
+
+
+def is_auditlog_enabled():
+ setup = api.get_senaite_setup()
+ if not setup:
+ return False
+ return setup.getEnableGlobalAuditlog()
+
+
+def indexObject(self):
+ """Handle indexing for AT based objects
+ """
+ # Never index temporary AT objects
+ if api.is_temporary(self):
+ return
+
+ # get all registered catalogs
+ catalogs = api.get_catalogs_for(self)
+
+ for catalog in catalogs:
+ # skip auditlog_catalog if global auditlogging is deactivated
+ if catalog.id == AUDITLOG_CATALOG and not is_auditlog_enabled():
+ continue
+ # always use catalog tool queuing system
+ catalog.indexObject(self)
+
+
+def unindexObject(self):
+ """Handle unindexing for AT based objects
+ """
+ # Never unindex temporary AT objects
+ if api.is_temporary(self):
+ return
+
+ # get all registered catalogs
+ catalogs = api.get_catalogs_for(self)
+
+ for catalog in catalogs:
+ # skip auditlog_catalog if global auditlogging is deactivated
+ if catalog.id == AUDITLOG_CATALOG and not is_auditlog_enabled():
+ continue
+ # always use catalog tool queuing system
+ catalog.unindexObject(self)
+
+
+def reindexObject(self, idxs=None):
+ """Reindex all AT based contents with the catalog queuing system
+ """
+ # Never reindex temporary AT objects
+ if api.is_temporary(self):
+ return
+
+ if idxs is None:
+ idxs = []
+
+ # Copy (w/o knowig if this is required of not x_X)
+ if idxs == [] and shasattr(self, "notifyModified"):
+ # Archetypes default setup has this defined in ExtensibleMetadata
+ # mixin. note: this refreshes the 'etag ' too.
+ self.notifyModified()
+ self.http__refreshEtag()
+ # /Paste
+
+ catalogs = api.get_catalogs_for(self)
+
+ for catalog in catalogs:
+ # skip auditlog_catalog if global auditlogging is deactivated
+ if catalog.id == AUDITLOG_CATALOG and not is_auditlog_enabled():
+ continue
+ # We want the intersection of the catalogs idxs
+ # and the incoming list.
+ lst = idxs
+ indexes = catalog.indexes()
+ if idxs:
+ lst = [i for i in idxs if i in indexes]
+ # use catalog tool queuing system
+ catalog.reindexObject(self, idxs=lst)
+
+ # Copy (w/o knowig if this is required of not x_X)
+ #
+ # We only make this call if idxs is not passed.
+ #
+ # manage_afterAdd/manage_beforeDelete from Referenceable take
+ # care of most of the issues, but some places still expect to
+ # call reindexObject and have the uid_catalog updated.
+ # TODO: fix this so we can remove the following lines.
+ if not idxs:
+ if isinstance(self, Referenceable):
+ isCopy = getattr(self, '_v_is_cp', None)
+ if isCopy is None:
+ self._catalogUID(self)
+ # /Paste
diff --git a/src/senaite/core/patches/archetypes/configure.zcml b/src/senaite/core/patches/archetypes/configure.zcml
new file mode 100644
index 0000000000..9adb5468c6
--- /dev/null
+++ b/src/senaite/core/patches/archetypes/configure.zcml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/senaite/core/patches/archetypes/referenceable.py b/src/senaite/core/patches/archetypes/referenceable.py
new file mode 100644
index 0000000000..cac04bc6f5
--- /dev/null
+++ b/src/senaite/core/patches/archetypes/referenceable.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.CORE.
+#
+# SENAITE.CORE 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, version 2.
+#
+# 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.
+#
+# Copyright 2018-2024 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+from bika.lims import api
+from Products.Archetypes.config import UID_CATALOG
+
+
+def _catalogUID(self, aq, uc=None):
+ # skip indexing of temporary objects
+ if api.is_temporary(self):
+ return
+ if not uc:
+ uc = api.get_tool(UID_CATALOG)
+ url = self._getURL()
+ uc.catalog_object(self, url)
+
+
+def _uncatalogUID(self, aq, uc=None):
+ # skip indexing of temporary objects
+ if api.is_temporary(self):
+ return
+ if not uc:
+ uc = api.get_tool(UID_CATALOG)
+ url = self._getURL()
+ # XXX This is an ugly workaround. This method shouldn't be called
+ # twice for an object in the first place, so we don't have to check
+ # if it is still cataloged.
+ rid = uc.getrid(url)
+ if rid is not None:
+ uc.uncatalog_object(url)
diff --git a/src/senaite/core/patches/catalog.py b/src/senaite/core/patches/catalog.py
deleted file mode 100644
index ee974ddf2a..0000000000
--- a/src/senaite/core/patches/catalog.py
+++ /dev/null
@@ -1,125 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# This file is part of SENAITE.CORE.
-#
-# SENAITE.CORE 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, version 2.
-#
-# 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.
-#
-# Copyright 2018-2024 by it's authors.
-# Some rights reserved, see README and LICENSE.
-
-from bika.lims import api
-from senaite.core.interfaces import IMultiCatalogBehavior
-from plone.indexer.interfaces import IIndexableObject
-from Products.ZCatalog.ZCatalog import ZCatalog
-from senaite.core import logger
-from senaite.core.catalog import AUDITLOG_CATALOG
-from senaite.core.setuphandlers import CATALOG_MAPPINGS
-from zope.component import queryMultiAdapter
-
-PORTAL_CATALOG = "portal_catalog"
-CATALOG_MAP = dict(CATALOG_MAPPINGS)
-
-
-def is_auditlog_enabled():
- setup = api.get_senaite_setup()
- if not setup:
- return False
- return setup.getEnableGlobalAuditlog()
-
-
-def catalog_object(self, object, uid=None, idxs=None,
- update_metadata=1, pghandler=None):
-
- # Never catalog temporary objects
- if api.is_temporary(object):
- return
-
- # skip indexing auditlog catalog if disabled
- if self.id == AUDITLOG_CATALOG:
- if not is_auditlog_enabled():
- return
-
- if idxs is None:
- idxs = []
- self._increment_counter()
-
- w = object
- if not IIndexableObject.providedBy(object):
- # This is the CMF 2.2 compatible approach, which should be used
- # going forward
- wrapper = queryMultiAdapter((object, self), IIndexableObject)
- if wrapper is not None:
- w = wrapper
-
- ZCatalog.catalog_object(self, w, uid, idxs,
- update_metadata, pghandler=pghandler)
-
-
-def in_portal_catalog(obj):
- """Check if the given object should be indexed in portal catalog
- """
- # catalog objects appeared here?
- if not api.is_object(obj):
- return False
-
- # already handled in our catalog multiplex processor
- if IMultiCatalogBehavior.providedBy(obj):
- # BBB: Fallback for unset catalogs mapping, e.g. for DataBoxes
- catalogs = getattr(obj, "_catalogs", [])
- if len(catalogs) == 0:
- return True
- return False
-
- # check our static mapping from setuphandlers
- portal_type = api.get_portal_type(obj)
- catalogs = CATALOG_MAP.get(portal_type)
- if isinstance(catalogs, list) and PORTAL_CATALOG not in catalogs:
- return False
-
- # check archetype tool if we have an AT content type
- if api.is_at_type(obj):
- att = api.get_tool("archetype_tool", default=None)
- catalogs = att.catalog_map.get(portal_type) if att else None
- if isinstance(catalogs, list) and PORTAL_CATALOG not in catalogs:
- return False
-
- # all other contents (folders etc.) can be indexed in portal_catalog
- return True
-
-
-def portal_catalog_index(self, obj, attributes=None):
- if not in_portal_catalog(obj):
- return
- path = api.get_path(obj)
- logger.info("Indexing object on path '%s' in portal_catalog" % path)
- pc = api.get_tool("portal_catalog")
- pc._indexObject(obj)
-
-
-def portal_catalog_reindex(self, obj, attributes=None, update_metadata=1):
- if not in_portal_catalog(obj):
- return
- path = api.get_path(obj)
- logger.info("Reindexing object on path '%s' in portal_catalog" % path)
- pc = api.get_tool("portal_catalog")
- pc._reindexObject(obj, idxs=attributes, update_metadata=update_metadata)
-
-
-def portal_catalog_unindex(self, obj):
- if not in_portal_catalog(obj):
- return
- path = api.get_path(obj)
- logger.info("Unindexing object on path '%s' in portal_catalog" % path)
- pc = api.get_tool("portal_catalog")
- pc._unindexObject(obj)
diff --git a/src/senaite/core/patches/cmfcore/README.md b/src/senaite/core/patches/cmfcore/README.md
new file mode 100644
index 0000000000..304a43995c
--- /dev/null
+++ b/src/senaite/core/patches/cmfcore/README.md
@@ -0,0 +1,51 @@
+# CMFCore Patches
+
+This package contains patches for `Products.CMFCore`.
+
+## Portal Catalog Processor
+
+The module `portal_catalog_processor` contains patches for the class
+`Products.CMFCore.indexing.PortalCatalogProcessor` which is registered as the
+default `IIndexQueueProcessor` utility for the `portal_catalog`.
+
+
+### Patches
+
+The following methods are patched:
+
+- `index`
+- `unindex`
+- `reindex`
+
+### Reason
+
+The patches ensure that AT based SENAITE content types are not additionally
+indexed in `portal_catalog` if they have a primary catalog assigned, e.g.
+Samples -> `senaite_catalog_sample`.
+
+### Notes
+
+Currently, we only keep the root folders like `Clients`, `Methods`, `Samples` etc. in `portal_catalog`.
+
+
+## Workflow Tool
+
+The modules `workflowtool` contains patches for the class `Products.CMFCore.WorkflowTool.WorkflowTool`,
+which provides workflow related methods like e.g. the `doActionFor` method to
+transition from one workflow state to the other.
+
+### Patches
+
+The following methods are patched:
+
+- `_reindexWorkflowVariables`
+
+### Reason
+
+Please see docstring and https://github.com/senaite/senaite.core/pull/2593 for details.
+
+### Notes
+
+Removing this patch made the test `WorkflowAnalysisUnassign` fail, which is an unexpected side-effect.
+
+TODO: We need to investigate the reason of this behavior!
diff --git a/src/senaite/core/patches/cmfcore/__init__.py b/src/senaite/core/patches/cmfcore/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/senaite/core/patches/cmfcore/configure.zcml b/src/senaite/core/patches/cmfcore/configure.zcml
new file mode 100644
index 0000000000..79cce76195
--- /dev/null
+++ b/src/senaite/core/patches/cmfcore/configure.zcml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/senaite/core/patches/cmfcore/portal_catalog_processor.py b/src/senaite/core/patches/cmfcore/portal_catalog_processor.py
new file mode 100644
index 0000000000..f9e66f5852
--- /dev/null
+++ b/src/senaite/core/patches/cmfcore/portal_catalog_processor.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.CORE.
+#
+# SENAITE.CORE 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, version 2.
+#
+# 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.
+#
+# Copyright 2018-2024 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+from bika.lims import api
+
+PORTAL_CATALOG = "portal_catalog"
+
+
+def index_in_portal_catalog(obj):
+ portal_catalog = api.get_tool(PORTAL_CATALOG)
+ catalogs = api.get_catalogs_for(obj)
+ if portal_catalog not in catalogs:
+ return False
+ return True
+
+
+def index(self, obj, attributes=None):
+ if not index_in_portal_catalog(obj):
+ return
+ catalog = api.get_tool(PORTAL_CATALOG)
+ if catalog is not None:
+ catalog._indexObject(obj)
+
+
+def reindex(self, obj, attributes=None, update_metadata=1):
+ if not index_in_portal_catalog(obj):
+ return
+ catalog = api.get_tool(PORTAL_CATALOG)
+ if catalog is not None:
+ catalog._reindexObject(
+ obj,
+ idxs=attributes,
+ update_metadata=update_metadata)
+
+
+def unindex(self, obj):
+ if not index_in_portal_catalog(obj):
+ return
+ catalog = api.get_tool(PORTAL_CATALOG)
+ if catalog is not None:
+ catalog._unindexObject(obj)
diff --git a/src/senaite/core/patches/workflow.py b/src/senaite/core/patches/cmfcore/workflowtool.py
similarity index 58%
rename from src/senaite/core/patches/workflow.py
rename to src/senaite/core/patches/cmfcore/workflowtool.py
index 4ef01f23b1..7c304f387f 100644
--- a/src/senaite/core/patches/workflow.py
+++ b/src/senaite/core/patches/cmfcore/workflowtool.py
@@ -1,4 +1,22 @@
# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.CORE.
+#
+# SENAITE.CORE 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, version 2.
+#
+# 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.
+#
+# Copyright 2018-2024 by it's authors.
+# Some rights reserved, see README and LICENSE.
from bika.lims import api
diff --git a/src/senaite/core/patches/configure.zcml b/src/senaite/core/patches/configure.zcml
index d51bd1463b..dcd9d8b8ce 100644
--- a/src/senaite/core/patches/configure.zcml
+++ b/src/senaite/core/patches/configure.zcml
@@ -3,77 +3,9 @@
xmlns:monkey="http://namespaces.plone.org/monkey"
i18n_domain="senaite.core">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
diff --git a/src/senaite/core/patches/dexterity/README.md b/src/senaite/core/patches/dexterity/README.md
new file mode 100644
index 0000000000..9de0083e65
--- /dev/null
+++ b/src/senaite/core/patches/dexterity/README.md
@@ -0,0 +1,23 @@
+# Dexterity Patches
+
+This package contains patches for Dexterity based content types.
+
+## Dexterity Content
+
+The module `dexterity_content` contains patches for the class `plone.dexterity.content.DexterityContent`,
+which is the base class for our `Item` and `Container` based contents.
+
+### Patches
+
+The following methods are patched:
+
+- `getLabels`
+- `isTemporary`
+
+### Reason
+
+Provide a similar methods for DX contents as for AT contents.
+
+**getLabels**: Get SENAITE labels (dynamically extended fields)
+
+**isTemporary**: Checks if an object contains a temporary ID to avoid further indexing/processing
diff --git a/src/senaite/core/patches/dexterity/__init__.py b/src/senaite/core/patches/dexterity/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/senaite/core/patches/dexterity/configure.zcml b/src/senaite/core/patches/dexterity/configure.zcml
new file mode 100644
index 0000000000..1c9d66d2b1
--- /dev/null
+++ b/src/senaite/core/patches/dexterity/configure.zcml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/senaite/core/patches/dexterity.py b/src/senaite/core/patches/dexterity/dexterity_content.py
similarity index 100%
rename from src/senaite/core/patches/dexterity.py
rename to src/senaite/core/patches/dexterity/dexterity_content.py
diff --git a/src/senaite/core/tests/doctests/CatalogIndexing.rst b/src/senaite/core/tests/doctests/CatalogIndexing.rst
new file mode 100644
index 0000000000..9fd185a4e0
--- /dev/null
+++ b/src/senaite/core/tests/doctests/CatalogIndexing.rst
@@ -0,0 +1,361 @@
+Catalog Indexing
+----------------
+
+Running this test from the buildout directory:
+
+ bin/test test_textual_doctests -t CatalogIndexing
+
+
+Test Setup
+..........
+
+Needed Imports:
+
+ >>> import os
+ >>> from bika.lims import api
+ >>> from bika.lims.utils.analysisrequest import create_analysisrequest
+ >>> from bika.lims.workflow import doActionFor as do_action_for
+ >>> from DateTime import DateTime
+ >>> from plone.app.testing import setRoles
+ >>> from plone.app.testing import TEST_USER_ID
+ >>> from plone.app.testing import TEST_USER_PASSWORD
+ >>> from Products.CMFCore.indexing import processQueue
+
+ >>> from senaite.core.catalog import ANALYSIS_CATALOG
+ >>> from senaite.core.catalog import AUDITLOG_CATALOG
+ >>> from senaite.core.catalog import CLIENT_CATALOG
+ >>> from senaite.core.catalog import CONTACT_CATALOG
+ >>> from senaite.core.catalog import LABEL_CATALOG
+ >>> from senaite.core.catalog import REPORT_CATALOG
+ >>> from senaite.core.catalog import SAMPLE_CATALOG
+ >>> from senaite.core.catalog import SENAITE_CATALOG
+ >>> from senaite.core.catalog import SETUP_CATALOG
+ >>> from senaite.core.catalog import WORKSHEET_CATALOG
+
+Functional Helpers:
+
+ >>> def start_server():
+ ... from Testing.ZopeTestCase.utils import startZServer
+ ... ip, port = startZServer()
+ ... return "http://{}:{}/{}".format(ip, port, portal.id)
+
+ >>> def new_sample(services):
+ ... values = {
+ ... 'Client': client.UID(),
+ ... 'Contact': contact.UID(),
+ ... 'DateSampled': date_now,
+ ... 'SampleType': sampletype.UID()}
+ ... service_uids = map(api.get_uid, services)
+ ... sample = create_analysisrequest(client, request, values, service_uids)
+ ... return sample
+
+ >>> def is_indexed(obj, *catalogs, **kw):
+ ... """Checks if the passed in object is indexed in the catalogs
+ ... """
+ ... formatted = kw.get("formatted", True)
+ ... query = {"UID": api.get_uid(obj)}
+ ... results = []
+ ... summary = []
+ ... for catalog in catalogs:
+ ... cat = api.get_tool(catalog)
+ ... res = cat(query)
+ ... results.append((catalog, len(res)))
+ ... text = "%s: %s (found %s)" % (catalog, "YES" if len(res) > 0 else "NO", len(res))
+ ... summary.append(text)
+ ... if formatted:
+ ... print("\n".join(summary))
+ ... return
+ ... return results
+
+Variables:
+
+ >>> portal = self.portal
+ >>> request = self.request
+ >>> setup = portal.setup
+ >>> bikasetup = portal.bika_setup
+ >>> date_now = DateTime().strftime("%Y-%m-%d")
+ >>> date_future = (DateTime() + 5).strftime("%Y-%m-%d")
+ >>> ALL_SENAITE_CATALOGS = [
+ ... ANALYSIS_CATALOG,
+ ... AUDITLOG_CATALOG,
+ ... CLIENT_CATALOG,
+ ... CONTACT_CATALOG,
+ ... LABEL_CATALOG,
+ ... REPORT_CATALOG,
+ ... SAMPLE_CATALOG,
+ ... SENAITE_CATALOG,
+ ... SETUP_CATALOG,
+ ... WORKSHEET_CATALOG,
+ ... ]
+
+ >>> UID_CATALOG = "uid_catalog"
+ >>> PORTAL_CATALOG = "portal_catalog"
+
+We need to create some basic objects for the test:
+
+ >>> setRoles(portal, TEST_USER_ID, ['LabManager', 'Sampler'])
+ >>> client = api.create(portal.clients, "Client", Name="Happy Hills", ClientID="HH", MemberDiscountApplies=True)
+ >>> contact = api.create(client, "Contact", Firstname="Rita", Lastname="Mohale")
+ >>> sampletype = api.create(setup.sampletypes, "SampleType", title="Water", Prefix="W")
+ >>> labcontact = api.create(bikasetup.bika_labcontacts, "LabContact", Firstname="Lab", Lastname="Manager")
+ >>> department = api.create(setup.departments, "Department", title="Chemistry", Manager=labcontact)
+ >>> category = api.create(setup.analysiscategories, "AnalysisCategory", title="Metals", Department=department)
+ >>> Cu = api.create(bikasetup.bika_analysisservices, "AnalysisService", title="Copper", Keyword="Cu", Price="15", Category=category.UID(), Accredited=True)
+ >>> Fe = api.create(bikasetup.bika_analysisservices, "AnalysisService", title="Iron", Keyword="Fe", Price="10", Category=category.UID())
+ >>> Au = api.create(bikasetup.bika_analysisservices, "AnalysisService", title="Gold", Keyword="Au", Price="20", Category=category.UID())
+
+
+Test catalog indexing of Samples
+................................
+
+Be sure the queue is processed:
+
+ >>> processing = processQueue()
+
+Set testmod on:
+
+ >>> # os.environ["TESTMOD"] = "1"
+
+NOTE: The TESTMOD environment variable can be used to conditionally set some
+debug statements in the code, e.g.::
+
+ import os
+ if os.getenv("TESTMOD", False):
+ print "ZCatalog.Catalog.catalogObject: catalog=%s object=%s" % (
+ self.id, repr(object))
+
+Create a new sample:
+
+ >>> sample = new_sample([Cu])
+ >>> api.get_workflow_status_of(sample)
+ 'sample_due'
+
+The sample should be indexed in the `senaite_catalog_sample`:
+
+ >>> is_indexed(sample, SAMPLE_CATALOG)
+ senaite_catalog_sample: YES (found 1)
+
+It should not be indexed in the other catalogs:
+
+ >>> is_indexed(sample, *list(filter(lambda x: x != SAMPLE_CATALOG, ALL_SENAITE_CATALOGS)))
+ senaite_catalog_analysis: NO (found 0)
+ senaite_catalog_auditlog: NO (found 0)
+ senaite_catalog_client: NO (found 0)
+ senaite_catalog_contact: NO (found 0)
+ senaite_catalog_label: NO (found 0)
+ senaite_catalog_report: NO (found 0)
+ senaite_catalog: NO (found 0)
+ senaite_catalog_setup: NO (found 0)
+ senaite_catalog_worksheet: NO (found 0)
+
+It should be indexed in the `uid_catalog`:
+
+ >>> is_indexed(sample, UID_CATALOG)
+ uid_catalog: YES (found 1)
+
+But not in the `portal_catalog`:
+
+ >>> is_indexed(sample, PORTAL_CATALOG)
+ portal_catalog: NO (found 0)
+
+
+Test catalog indexing of Analyses
+.................................
+
+The analyses should be indexed in the `senaite_catalog_analysis`:
+
+ >>> is_indexed(sample.Cu, ANALYSIS_CATALOG)
+ senaite_catalog_analysis: YES (found 1)
+
+It should not be indexed in the other catalogs:
+
+ >>> is_indexed(sample.Cu, *list(filter(lambda x: x != ANALYSIS_CATALOG, ALL_SENAITE_CATALOGS)))
+ senaite_catalog_auditlog: NO (found 0)
+ senaite_catalog_client: NO (found 0)
+ senaite_catalog_contact: NO (found 0)
+ senaite_catalog_label: NO (found 0)
+ senaite_catalog_report: NO (found 0)
+ senaite_catalog_sample: NO (found 0)
+ senaite_catalog: NO (found 0)
+ senaite_catalog_setup: NO (found 0)
+ senaite_catalog_worksheet: NO (found 0)
+
+It should be indexed in the `uid_catalog`:
+
+ >>> is_indexed(sample.Cu, UID_CATALOG)
+ uid_catalog: YES (found 1)
+
+But not in the `portal_catalog`:
+
+ >>> is_indexed(sample.Cu, PORTAL_CATALOG)
+ portal_catalog: NO (found 0)
+
+
+Test catalog indexing of Setup items
+....................................
+
+Setup items, e.g. `AnalysisService`, should be indexed in `senaite_catalog_setup`:
+
+ >>> is_indexed(Cu, SETUP_CATALOG)
+ senaite_catalog_setup: YES (found 1)
+
+It should not be indexed in the other catalogs:
+
+ >>> is_indexed(Cu, *list(filter(lambda x: x != SETUP_CATALOG, ALL_SENAITE_CATALOGS)))
+ senaite_catalog_analysis: NO (found 0)
+ senaite_catalog_auditlog: NO (found 0)
+ senaite_catalog_client: NO (found 0)
+ senaite_catalog_contact: NO (found 0)
+ senaite_catalog_label: NO (found 0)
+ senaite_catalog_report: NO (found 0)
+ senaite_catalog_sample: NO (found 0)
+ senaite_catalog: NO (found 0)
+ senaite_catalog_worksheet: NO (found 0)
+
+It should be indexed in the `uid_catalog`:
+
+ >>> is_indexed(Cu, UID_CATALOG)
+ uid_catalog: YES (found 1)
+
+But not in the `portal_catalog`:
+
+ >>> is_indexed(Cu, PORTAL_CATALOG)
+ portal_catalog: NO (found 0)
+
+
+Test catalog indexing of Clients
+................................
+
+Clients should be indexed in `senaite_catalog_client`:
+
+ >>> is_indexed(client, CLIENT_CATALOG)
+ senaite_catalog_client: YES (found 1)
+
+It should not be indexed in the other catalogs:
+
+ >>> is_indexed(client, *list(filter(lambda x: x != CLIENT_CATALOG, ALL_SENAITE_CATALOGS)))
+ senaite_catalog_analysis: NO (found 0)
+ senaite_catalog_auditlog: NO (found 0)
+ senaite_catalog_contact: NO (found 0)
+ senaite_catalog_label: NO (found 0)
+ senaite_catalog_report: NO (found 0)
+ senaite_catalog_sample: NO (found 0)
+ senaite_catalog: NO (found 0)
+ senaite_catalog_setup: NO (found 0)
+ senaite_catalog_worksheet: NO (found 0)
+
+It should be indexed in the `uid_catalog`:
+
+ >>> is_indexed(client, UID_CATALOG)
+ uid_catalog: YES (found 1)
+
+But not in the `portal_catalog`:
+
+ >>> is_indexed(client, PORTAL_CATALOG)
+ portal_catalog: NO (found 0)
+
+
+Test catalog indexing of Contacts
+.................................
+
+Contacts should be indexed in `senaite_catalog_contact`:
+
+ >>> is_indexed(contact, CONTACT_CATALOG)
+ senaite_catalog_contact: YES (found 1)
+
+It should not be indexed in the other catalogs:
+
+ >>> is_indexed(contact, *list(filter(lambda x: x != CONTACT_CATALOG, ALL_SENAITE_CATALOGS)))
+ senaite_catalog_analysis: NO (found 0)
+ senaite_catalog_auditlog: NO (found 0)
+ senaite_catalog_client: NO (found 0)
+ senaite_catalog_label: NO (found 0)
+ senaite_catalog_report: NO (found 0)
+ senaite_catalog_sample: NO (found 0)
+ senaite_catalog: NO (found 0)
+ senaite_catalog_setup: NO (found 0)
+ senaite_catalog_worksheet: NO (found 0)
+
+It should be indexed in the `uid_catalog`:
+
+ >>> is_indexed(contact, UID_CATALOG)
+ uid_catalog: YES (found 1)
+
+But not in the `portal_catalog`:
+
+ >>> is_indexed(contact, PORTAL_CATALOG)
+ portal_catalog: NO (found 0)
+
+
+Test catalog indexing of Worksheets
+...................................
+
+Create a new worksheet:
+
+ >>> ws = api.create(portal.worksheets, "Worksheet")
+ >>> for analysis in sample.getAnalyses(full_objects=True):
+ ... ws.addAnalysis(analysis)
+
+Worksheets should be indexed in `senaite_catalog_worksheet`:
+
+ >>> is_indexed(ws, WORKSHEET_CATALOG)
+ senaite_catalog_worksheet: YES (found 1)
+
+It should not be indexed in the other catalogs:
+
+ >>> is_indexed(ws, *list(filter(lambda x: x != WORKSHEET_CATALOG, ALL_SENAITE_CATALOGS)))
+ senaite_catalog_analysis: NO (found 0)
+ senaite_catalog_auditlog: NO (found 0)
+ senaite_catalog_client: NO (found 0)
+ senaite_catalog_contact: NO (found 0)
+ senaite_catalog_label: NO (found 0)
+ senaite_catalog_report: NO (found 0)
+ senaite_catalog_sample: NO (found 0)
+ senaite_catalog: NO (found 0)
+ senaite_catalog_setup: NO (found 0)
+
+It should be indexed in the `uid_catalog`:
+
+ >>> is_indexed(ws, UID_CATALOG)
+ uid_catalog: YES (found 1)
+
+But not in the `portal_catalog`:
+
+ >>> is_indexed(ws, PORTAL_CATALOG)
+ portal_catalog: NO (found 0)
+
+
+Test catalog indexing of Batches
+................................
+
+Create a new batch:
+
+ >>> batch = api.create(portal.batches, "Batch", title="Test batch")
+
+Batches should be indexed in `senaite_catalog`:
+
+ >>> is_indexed(batch, SENAITE_CATALOG)
+ senaite_catalog: YES (found 1)
+
+It should not be indexed in the other catalogs:
+
+ >>> is_indexed(batch, *list(filter(lambda x: x != SENAITE_CATALOG, ALL_SENAITE_CATALOGS)))
+ senaite_catalog_analysis: NO (found 0)
+ senaite_catalog_auditlog: NO (found 0)
+ senaite_catalog_client: NO (found 0)
+ senaite_catalog_contact: NO (found 0)
+ senaite_catalog_label: NO (found 0)
+ senaite_catalog_report: NO (found 0)
+ senaite_catalog_sample: NO (found 0)
+ senaite_catalog_setup: NO (found 0)
+ senaite_catalog_worksheet: NO (found 0)
+
+It should be indexed in the `uid_catalog`:
+
+ >>> is_indexed(batch, UID_CATALOG)
+ uid_catalog: YES (found 1)
+
+But not in the `portal_catalog`:
+
+ >>> is_indexed(batch, PORTAL_CATALOG)
+ portal_catalog: NO (found 0)