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)