From 7186848660bcd3f02925bce88d83ce5a01dd2a0c Mon Sep 17 00:00:00 2001 From: Tim-IA <95612081+Tim-IA@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:51:44 +0200 Subject: [PATCH 1/5] Add AnalysisSpec DX content --- .../browser/client/views/analysisspecs.py | 2 +- .../lims/controlpanel/bika_analysisspecs.py | 108 +---- .../lims/profiles/default/factorytool.xml | 1 - .../default/structure/bika_setup/.preserve | 1 - .../bika_setup/bika_analysisspecs/.properties | 4 - src/bika/lims/profiles/default/types.xml | 2 - .../profiles/default/types/AnalysisSpec.xml | 48 --- .../profiles/default/types/AnalysisSpecs.xml | 29 -- .../controlpanel/analysisspecs/__init__.py | 19 + .../controlpanel/analysisspecs/configure.zcml | 12 + .../controlpanel/analysisspecs/view.py | 144 +++++++ .../core/browser/controlpanel/configure.zcml | 1 + .../widgets/analysisspec_services_widget.py | 148 +++++++ .../core/browser/widgets/configure.zcml | 9 + src/senaite/core/content/analysisspec.py | 392 ++++++++++++++++++ src/senaite/core/content/analysisspecs.py | 38 ++ src/senaite/core/interfaces/__init__.py | 10 + src/senaite/core/profiles/default/types.xml | 4 + .../profiles/default/types/AnalysisSpec.xml | 86 ++++ .../profiles/default/types/AnalysisSpecs.xml | 90 ++++ 20 files changed, 955 insertions(+), 193 deletions(-) delete mode 100644 src/bika/lims/profiles/default/structure/bika_setup/bika_analysisspecs/.properties delete mode 100644 src/bika/lims/profiles/default/types/AnalysisSpec.xml delete mode 100644 src/bika/lims/profiles/default/types/AnalysisSpecs.xml create mode 100644 src/senaite/core/browser/controlpanel/analysisspecs/__init__.py create mode 100644 src/senaite/core/browser/controlpanel/analysisspecs/configure.zcml create mode 100644 src/senaite/core/browser/controlpanel/analysisspecs/view.py create mode 100644 src/senaite/core/browser/widgets/analysisspec_services_widget.py create mode 100644 src/senaite/core/content/analysisspec.py create mode 100644 src/senaite/core/content/analysisspecs.py create mode 100644 src/senaite/core/profiles/default/types/AnalysisSpec.xml create mode 100644 src/senaite/core/profiles/default/types/AnalysisSpecs.xml diff --git a/src/bika/lims/browser/client/views/analysisspecs.py b/src/bika/lims/browser/client/views/analysisspecs.py index 41de1e87af..b05243dd82 100644 --- a/src/bika/lims/browser/client/views/analysisspecs.py +++ b/src/bika/lims/browser/client/views/analysisspecs.py @@ -20,7 +20,7 @@ from bika.lims import api from bika.lims import bikaMessageFactory as _ -from bika.lims.controlpanel.bika_analysisspecs import AnalysisSpecsView +from senaite.core.browser.controlpanel.analysisspecs.view import AnalysisSpecsView from senaite.core.permissions import AddAnalysisSpec diff --git a/src/bika/lims/controlpanel/bika_analysisspecs.py b/src/bika/lims/controlpanel/bika_analysisspecs.py index 04dbfa86bb..57b12e53ed 100644 --- a/src/bika/lims/controlpanel/bika_analysisspecs.py +++ b/src/bika/lims/controlpanel/bika_analysisspecs.py @@ -18,15 +18,8 @@ # Copyright 2018-2025 by it's authors. # Some rights reserved, see README and LICENSE. -import collections - -from bika.lims import api -from bika.lims import bikaMessageFactory as _ -from senaite.core.browser.controlpanel.listing import ControlPanelListingView from bika.lims.config import PROJECTNAME from bika.lims.interfaces import IAnalysisSpecs -from senaite.core.permissions import AddAnalysisSpec -from bika.lims.utils import get_link from plone.app.folder.folder import ATFolder from plone.app.folder.folder import ATFolderSchema from Products.Archetypes import atapi @@ -35,108 +28,9 @@ from zope.interface.declarations import implements -# TODO: Separate content and view into own modules! - - -class AnalysisSpecsView(ControlPanelListingView): - - def __init__(self, context, request): - super(AnalysisSpecsView, self).__init__(context, request) - - self.catalog = "senaite_catalog_setup" - - self.contentFilter = { - "portal_type": "AnalysisSpec", - "sort_on": "sortable_title", - "sort_order": "ascending", - "path": { - "query": api.get_path(context), - "level": 0} - } - - self.context_actions = { - _("Add"): { - "url": "createObject?type_name=AnalysisSpec", - "permission": AddAnalysisSpec, - "icon": "++resource++bika.lims.images/add.png"} - } - - self.title = self.context.translate(_("Analysis Specifications")) - self.icon = "{}/{}".format( - self.portal_url, - "/++resource++bika.lims.images/analysisspec_big.png" - ) - - self.show_select_row = False - self.show_select_column = True - self.pagesize = 25 - - self.columns = collections.OrderedDict(( - ("Title", { - "title": _("Analysis Specification"), - "index": "sortable_title"}), - ("SampleType", { - "title": _("Sample Type"), - "index": "sampletype_title"}), - ("DynamicSpec", { - "title": _("Dynamic Specification"), - "sortable": False, - }) - )) - - self.review_states = [ - { - "id": "default", - "title": _("Active"), - "contentFilter": {"is_active": True}, - "transitions": [{"id": "deactivate"}, ], - "columns": self.columns.keys(), - }, { - "id": "inactive", - "title": _("Inactive"), - "contentFilter": {'is_active': False}, - "transitions": [{"id": "activate"}, ], - "columns": self.columns.keys(), - }, { - "id": "all", - "title": _("All"), - "contentFilter": {}, - "columns": self.columns.keys(), - }, - ] - - def folderitem(self, obj, item, index): - """Service triggered each time an item is iterated in folderitems. - The use of this service prevents the extra-loops in child objects. - :obj: the instance of the class to be foldered - :item: dict containing the properties of the object to be used by - the template - :index: current index of the item - """ - obj = api.get_object(obj) - title = obj.Title() - url = obj.absolute_url() - - item["replace"]["Title"] = get_link(url, value=title) - - sampletype = obj.getSampleType() - if sampletype: - title = sampletype.Title() - url = sampletype.absolute_url() - item["replace"]["SampleType"] = get_link(url, value=title) - - dynamic_spec = obj.getDynamicAnalysisSpec() - if dynamic_spec: - title = dynamic_spec.Title() - url = api.get_url(dynamic_spec) - item["replace"]["DynamicSpec"] = get_link(url, value=title) - - return item - - schema = ATFolderSchema.copy() - +# TODO: Migrated to DX - https://github.com/senaite/senaite.core/pull/ class AnalysisSpecs(ATFolder): implements(IAnalysisSpecs, IHideActionsMenu) displayContentsTab = False diff --git a/src/bika/lims/profiles/default/factorytool.xml b/src/bika/lims/profiles/default/factorytool.xml index ed6dd27fa9..a18c1eddba 100644 --- a/src/bika/lims/profiles/default/factorytool.xml +++ b/src/bika/lims/profiles/default/factorytool.xml @@ -6,7 +6,6 @@ - diff --git a/src/bika/lims/profiles/default/structure/bika_setup/.preserve b/src/bika/lims/profiles/default/structure/bika_setup/.preserve index ed1435bd66..e7850f1f4f 100644 --- a/src/bika/lims/profiles/default/structure/bika_setup/.preserve +++ b/src/bika/lims/profiles/default/structure/bika_setup/.preserve @@ -1,6 +1,5 @@ auditlog bika_analysisservices -bika_analysisspecs bika_artemplates bika_instruments bika_labcontacts diff --git a/src/bika/lims/profiles/default/structure/bika_setup/bika_analysisspecs/.properties b/src/bika/lims/profiles/default/structure/bika_setup/bika_analysisspecs/.properties deleted file mode 100644 index ff1d408071..0000000000 --- a/src/bika/lims/profiles/default/structure/bika_setup/bika_analysisspecs/.properties +++ /dev/null @@ -1,4 +0,0 @@ -[DEFAULT] -description = -title = Analysis Specifications - diff --git a/src/bika/lims/profiles/default/types.xml b/src/bika/lims/profiles/default/types.xml index 08782e075a..0110e8203d 100644 --- a/src/bika/lims/profiles/default/types.xml +++ b/src/bika/lims/profiles/default/types.xml @@ -9,8 +9,6 @@ - - diff --git a/src/bika/lims/profiles/default/types/AnalysisSpec.xml b/src/bika/lims/profiles/default/types/AnalysisSpec.xml deleted file mode 100644 index 84a37cb36c..0000000000 --- a/src/bika/lims/profiles/default/types/AnalysisSpec.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - Analysis Specification - - senaite_theme/icon/analysisspec - AnalysisSpec - bika.lims - addAnalysisSpec - - - False - True - - False - False - - - - - - - - - - - - - - diff --git a/src/bika/lims/profiles/default/types/AnalysisSpecs.xml b/src/bika/lims/profiles/default/types/AnalysisSpecs.xml deleted file mode 100644 index d8a2f3e012..0000000000 --- a/src/bika/lims/profiles/default/types/AnalysisSpecs.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - Analysis Specifications - - senaite_theme/icon/analysisspec - AnalysisSpecs - bika.lims - addAnalysisSpecs - - - False - True - - - - False - False - - - - - - - diff --git a/src/senaite/core/browser/controlpanel/analysisspecs/__init__.py b/src/senaite/core/browser/controlpanel/analysisspecs/__init__.py new file mode 100644 index 0000000000..8e2f7d3fc6 --- /dev/null +++ b/src/senaite/core/browser/controlpanel/analysisspecs/__init__.py @@ -0,0 +1,19 @@ +# -*- 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-2025 by it's authors. +# Some rights reserved, see README and LICENSE. diff --git a/src/senaite/core/browser/controlpanel/analysisspecs/configure.zcml b/src/senaite/core/browser/controlpanel/analysisspecs/configure.zcml new file mode 100644 index 0000000000..962ad080fa --- /dev/null +++ b/src/senaite/core/browser/controlpanel/analysisspecs/configure.zcml @@ -0,0 +1,12 @@ + + + + + diff --git a/src/senaite/core/browser/controlpanel/analysisspecs/view.py b/src/senaite/core/browser/controlpanel/analysisspecs/view.py new file mode 100644 index 0000000000..ab6df0f811 --- /dev/null +++ b/src/senaite/core/browser/controlpanel/analysisspecs/view.py @@ -0,0 +1,144 @@ +# -*- 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-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +import collections + +from bika.lims import api +from bika.lims import senaiteMessageFactory as _ +from bika.lims.utils import get_link +from senaite.core.browser.controlpanel.listing import ControlPanelListingView +from senaite.core.catalog import SETUP_CATALOG +from senaite.core.i18n import translate +from senaite.core.permissions import AddAnalysisSpec + + +class AnalysisSpecsView(ControlPanelListingView): + """Displays all system's dynamic analysis specifications + """ + + def __init__(self, context, request): + super(AnalysisSpecsView, self).__init__(context, request) + + self.catalog = SETUP_CATALOG + + self.contentFilter = { + "portal_type": "AnalysisSpec", + "sort_on": "sortable_title", + "sort_order": "ascending", + "path": { + "query": api.get_path(self.context), + "depth": 0, + }, + } + + self.context_actions = { + _("listing_analysisspec_action_add", default="Add"): { + "url": "++add++AnalysisSpec", + "permission": AddAnalysisSpec, + "icon": "senaite_theme/icon/plus" + } + } + + self.icon = api.get_icon("AnalysisSpecs", html_tag=False) + + self.title = translate(_( + u"listing_analysisspecs_title", + default=u"Analysis Specifications") + ) + self.description = self.context.Description() + self.show_select_column = True + self.pagesize = 25 + + self.columns = collections.OrderedDict(( + ("Title", { + "title": _( + u"listing_analysisspecs_column_title", + default=u"Analysis Specification" + ), + "index": "sortable_title"}), + ("SampleType", { + "title": _( + u"listing_analysisspecs_column_sampletype", + default=u"SampleType" + ), + "index": "sampletype_title"}), + ("DynamicSpec", { + "title": _( + u"listing_analysisspecs_column_dynamic_specification", + default=u"Dynamic Specification"), + "sortable": False, + }) + )) + + self.review_states = [ + { + "id": "default", + "title": _( + u"listing_analysisspecs_state_active", + default=u"Active" + ), + "contentFilter": {"is_active": True}, + "columns": self.columns.keys(), + }, { + "id": "inactive", + "title": _( + u"listing_analysisspecs_state_inactive", + default=u"Inactive" + ), + "contentFilter": {"is_active": False}, + "columns": self.columns.keys(), + }, { + "id": "all", + "title": _( + u"listing_analysisspecs_state_all", + default=u"All" + ), + "contentFilter": {}, + "columns": self.columns.keys(), + }, + ] + + def folderitem(self, obj, item, index): + """Service triggered each time an item is iterated in folderitems. + The use of this service prevents the extra-loops in child objects. + :obj: the instance of the class to be foldered + :item: dict containing the properties of the object to be used by + the template + :index: current index of the item + """ + obj = api.get_object(obj) + title = obj.Title() + url = obj.absolute_url() + + item["replace"]["Title"] = get_link(url, value=title) + + sampletype = obj.getSampleType() + if sampletype: + title = sampletype.Title() + url = sampletype.absolute_url() + item["replace"]["SampleType"] = get_link(url, value=title) + + dynamic_spec = obj.getDynamicAnalysisSpec() + if dynamic_spec: + title = dynamic_spec.Title() + url = api.get_url(dynamic_spec) + item["replace"]["DynamicSpec"] = get_link(url, value=title) + + return item diff --git a/src/senaite/core/browser/controlpanel/configure.zcml b/src/senaite/core/browser/controlpanel/configure.zcml index 1e1dc78590..1260dbda25 100644 --- a/src/senaite/core/browser/controlpanel/configure.zcml +++ b/src/senaite/core/browser/controlpanel/configure.zcml @@ -22,6 +22,7 @@ + diff --git a/src/senaite/core/browser/widgets/analysisspec_services_widget.py b/src/senaite/core/browser/widgets/analysisspec_services_widget.py new file mode 100644 index 0000000000..e4ed3e5bcc --- /dev/null +++ b/src/senaite/core/browser/widgets/analysisspec_services_widget.py @@ -0,0 +1,148 @@ +# -*- 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-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +import collections + +from bika.lims import bikaMessageFactory as _ +from bika.lims.config import MAX_OPERATORS +from bika.lims.config import MIN_OPERATORS +from bika.lims.utils import to_choices +from senaite.core.permissions import FieldEditSpecification +from bika.lims.api.security import check_permission + +from .services_widget import ServicesWidget + + +class AnalysisSpecServicesWidget(ServicesWidget): + """Listing widget for Analysis Specification Services + """ + + def update(self): + super(AnalysisSpecServicesWidget, self).update() + self.columns = collections.OrderedDict(( + ("Title", { + "title": _("Service"), + "index": "sortable_title", + "sortable": False + }), + ("Keyword", { + "title": _("Keyword"), + "sortable": False + }), + ("Methods", { + "title": _("Methods"), + "sortable": False + }), + ("Unit", { + "title": _("Unit"), + "sortable": False + }), + ("warn_min", { + "title": _("Min warn"), + "sortable": False, + "type": "numeric", + }), + ("min", { + "title": _("Min"), + "sortable": False, + "type": "numeric", + }), + ("min_operator", { + "title": _("Min operator"), + "type": "choices", + "sortable": False, + }), + ("max", { + "title": _("Max"), + "sortable": False, + "type": "numeric", + }), + ("warn_max", { + "title": _("Max warn"), + "sortable": False, + "type": "numeric", + }), + ("max_operator", { + "title": _("Max operator"), + "type": "choices", + "sortable": False, + }), + ("hidemin", { + "title": _("< Min"), + "sortable": False, + "type": "numeric", + }), + ("hidemax", { + "title": _("> Max"), + "sortable": False, + "type": "numeric", + }), + ("rangecomment", { + "title": _(u"Out of range comment"), + "sortable": False, + "type": "string", + }), + )) + + self.review_states[0]["columns"] = self.columns.keys() + + def folderitem(self, obj, item, index): + item = super(AnalysisSpecServicesWidget, + self).folderitem(obj, item, index) + + uid = item.get("uid") + + # Get existing record data from the field (results_range) + # Note: self.records is populated + # by DefaultListingWidget from the field value + record = self.records.get(uid, {}) + + item["min"] = record.get("min", "") + item["max"] = record.get("max", "") + item["warn_min"] = record.get("warn_min", "") + item["warn_max"] = record.get("warn_max", "") + item["hidemin"] = record.get("hidemin", "") + item["hidemax"] = record.get("hidemax", "") + item["rangecomment"] = record.get("rangecomment", "") + + if "choices" not in item: + item["choices"] = {} + item["choices"]["min_operator"] = to_choices(MIN_OPERATORS) + item["choices"]["max_operator"] = to_choices(MAX_OPERATORS) + + max_op = record.get("max_operator", "leq") + min_op = record.get("min_operator", "geq") + + can_edit = check_permission(FieldEditSpecification, self.context) + + if can_edit: + item["max_operator"] = max_op + item["min_operator"] = min_op + item["allow_edit"] = [ + "min", "max", "warn_min", "warn_max", + "hidemin", "hidemax", "rangecomment", + "min_operator", "max_operator" + ] + else: + item["max_operator"] = MAX_OPERATORS.getValue(max_op) + item["min_operator"] = MIN_OPERATORS.getValue(min_op) + item["allow_edit"] = [] + + return item diff --git a/src/senaite/core/browser/widgets/configure.zcml b/src/senaite/core/browser/widgets/configure.zcml index f3f72c0f34..f8d82efdb1 100644 --- a/src/senaite/core/browser/widgets/configure.zcml +++ b/src/senaite/core/browser/widgets/configure.zcml @@ -29,6 +29,15 @@ layer="senaite.core.interfaces.ISenaiteCore" /> + + + Max"), + required=False, + default=u"", + ) + rangecomment = schema.TextLine( + title=_(u"label_resultsrange_rangecomment", + default=u"Out of range comment"), + required=False, + default=u"", + ) + +class IAnalysisSpecSchema(model.Schema): + """Analysis Specification Schema""" + + directives.widget( + "sample_type", + UIDReferenceWidgetFactory, + catalog=SETUP_CATALOG, + query={ + "portal_type": "SampleType", + "is_active": True, + "sort_on": "title", + "sort_order": "ascending", + }, + limit=5, + ) + sample_type = UIDReferenceField( + title=_( + u"label_analysisspec_sampletype", + default=u"Sample Type" + ), + description=_( + u"description_analysisspec_sampletype", + default=u"Select the sample type for this specification" + ), + allowed_types=("SampleType", ), + multi_valued=False, + required=True, + ) + + + directives.widget( + "dynamic_analysis_spec", + UIDReferenceWidgetFactory, + catalog=SETUP_CATALOG, + query={ + "portal_type": "DynamicAnalysisSpec", + "is_active": True, + "sort_on": "title", + "sort_order": "ascending", + }, + limit=5, + ) + dynamic_analysis_spec = UIDReferenceField( + title=_( + u"label_analysisspec_dynamicspec", + default=u"Dynamic Analysis Specification" + ), + description=_( + u"description_analysisspec_dynamicspec", + default=u"Link dynamic analysis specification" + ), + allowed_types=("DynamicAnalysisSpec", ), + multi_valued=False, + required=False, + ) + + title = schema.TextLine( + title=_( + "title_containertype_title", + default="Name" + ), + required=True, + ) + + description = schema.Text( + title=_( + "title_containertype_description", + default="Description" + ), + required=False, + ) + + directives.widget("results_range", + ListingWidgetFactory, + listing_view="analysisspec_services_widget") + results_range = schema.List( + title=_( + u"title_analysisspec_results_range", + default=u"Specifications" + ), + description=_( + u"description_analysisspec_results_range", + default=u"'Min' and 'Max' values indicate a valid results " + u"range. Any result outside this results range will " + u"raise an alert.
" + u"'Min warn' and 'Max warn' values indicate a " + u"shoulder range. Any result outside the results " + u"range but within the shoulder range will raise a " + u"less severe alert.
" + u"If the result is out of range, the value set for " + u"'< Min' or '> Max' will be displayed in lists " + u"and results reports instead of the real result. In " + u"such case, the value set for 'Out of range comment' " + u"will be displayed in results report as well" + ), + value_type=DataGridRow(schema=IResultsRangeRecord), + default=[], + required=True, + ) + + +@implementer(IAnalysisSpec, IAnalysisSpecSchema, IDeactivable) +class AnalysisSpec(Container, ClientAwareMixin): + """Analysis Specification content type + """ + _catalogs = [SETUP_CATALOG] + security = ClassSecurityInfo() + + def Title(self): + title = self.title or self.getSampleTypeTitle() or "" + return safe_unicode(title).encode("utf-8") + + @security.protected(permissions.View) + def contextual_title(self): + """Returns the title with the context (Lab or Client) + """ + parent = api.get_parent(self) + portal_type = api.get_portal_type(parent) + if portal_type == "Client": + context = translate(_(u"Client")) + else: + context = translate(_(u"Lab")) + return u"{} ({})".format(safe_unicode(self.title), context) + + @security.protected(permissions.View) + def getResultsRange(self): + return self.getRawServices() + + @security.protected(permissions.ModifyPortalContent) + def setResultsRange(self, value, keep_inactive=True): + return self.setServices(value, keep_inactive) + + ResultsRange = property(getResultsRange, setResultsRange) + + @security.protected(permissions.View) + def getRawSampleType(self): + accessor = self.accessor("sample_type", raw=True) + return accessor(self) + + @security.protected(permissions.View) + def getSampleType(self): + accessor = self.accessor("sample_type") + return accessor(self) + + @security.protected(permissions.ModifyPortalContent) + def setSampleType(self, value): + mutator = self.mutator("sample_type") + mutator(self, value) + + @security.protected(permissions.View) + def getSampleTypeUID(self): + return self.getRawSampleType() + + SampleType = property(getSampleType, setSampleType) + + @security.protected(permissions.View) + def getSampleTypeTitle(self): + st = self.getSampleType() + return api.get_title(st) + + @security.protected(permissions.View) + def getRawDynamicAnalysisSpec(self): + accessor = self.accessor("dynamic_analysis_spec", raw=True) + return accessor(self) + + @security.protected(permissions.View) + def getDynamicAnalysisSpec(self): + accessor = self.accessor("dynamic_analysis_spec") + return accessor(self) + + @security.protected(permissions.ModifyPortalContent) + def setDynamicAnalysisSpec(self, value): + mutator = self.mutator("dynamic_analysis_spec") + mutator(self, value) + + DynamicAnalysisSpec = property(getDynamicAnalysisSpec, setDynamicAnalysisSpec) + + @security.protected(permissions.View) + def getRawServices(self): + """Return the raw value of the services field + """ + accessor = self.accessor("results_range") + services = accessor(self) + if services: + return [s.get("uid") for s in services] + return [] + + @security.protected(permissions.View) + def getServices(self, active_only=True): + """Returns a list of service objects + + >>> self.getServices() + [, , ...] + + :returns: List of analysis service objects + """ + services = map(api.get_object, self.getRawServiceUIDs()) + if active_only: + # filter out inactive services + services = filter(api.is_active, services) + return list(services) + + @security.protected(permissions.ModifyPortalContent) + def setServices(self, value, keep_inactive=True): + """Set services for the analysis specification + """ + if not isinstance(value, (list, dict)): + raise TypeError( + "Expected a dict or list, got %r" % type(value)) + if isinstance(value, dict): + value = [value] + + records = [] + for v in value: + if api.is_object(v): + uid = api.get_uid(v) + v = {"uid": uid} + elif api.is_uid(v): + uid = v + v = {"uid": uid} + + uid = v.get("uid", "") + warn_min = v.get("warn_min", "") + min_val = v.get("min", "") + min_operator = v.get("min_operator", "geq") + max_val = v.get("max", "") + warn_max = v.get("warn_max", "") + max_operator = v.get("max_operator", "leq") + hidemin = v.get("hidemin", "") + hidemax = v.get("hidemax", "") + rangecomment = v.get("rangecomment", "") + + if uid: + uid = api.get_uid(uid) + + records.append({ + "uid": uid, + "warn_min": warn_min, + "min": min_val, + "min_operator": min_operator, + "max": max_val, + "warn_max": warn_max, + "max_operator": max_operator, + "hidemin": hidemin, + "hidemax": hidemax, + "rangecomment": rangecomment, + }) + + if keep_inactive: + uids = [record.get("uid") for record in records] + for record in self.getRawServices(): + uid = record.get("uid") + if uid in uids: + continue + obj = api.get_object(uid) + if not api.is_active(obj): + records.append(record) + + mutator = self.mutator("results_range") + mutator(self, records) + + @security.protected(permissions.View) + def getServiceUIDs(self, active_only=True): + """Returns a list of UIDs for the referenced AnalysisService objects + + :param active_only: If True, only UIDs of active services are returned + :returns: A list of unique identifiers (UIDs) + """ + if active_only: + services = self.getServices(active_only=active_only) + return list(map(api.get_uid, services)) + return self.getRawServiceUIDs() + + @security.protected(permissions.View) + def getRawServiceUIDs(self): + """Returns the list of UIDs stored as raw data in the 'Services' field + + :returns: A list of UIDs extracted from the raw 'Services' data. + """ + services = self.getRawServices() + return list(map(lambda record: record.get("uid"), services)) diff --git a/src/senaite/core/content/analysisspecs.py b/src/senaite/core/content/analysisspecs.py new file mode 100644 index 0000000000..e91e92c8dc --- /dev/null +++ b/src/senaite/core/content/analysisspecs.py @@ -0,0 +1,38 @@ +# -*- 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-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +from bika.lims.interfaces import IDoNotSupportSnapshots +from plone.dexterity.content import Container +from plone.supermodel import model +from senaite.core.interfaces import IAnalysisSpecs +from senaite.core.interfaces import IHideActionsMenu +from zope.interface import implementer + + +class IAnalysisSpecsSchema(model.Schema): + """Schema interface + """ + + +@implementer(IAnalysisSpecs, IAnalysisSpecs, + IDoNotSupportSnapshots, IHideActionsMenu) +class IAnalysisSpecs(Container): + """A container for analysis specifications + """ diff --git a/src/senaite/core/interfaces/__init__.py b/src/senaite/core/interfaces/__init__.py index 6525ddbbb4..1351040b0d 100644 --- a/src/senaite/core/interfaces/__init__.py +++ b/src/senaite/core/interfaces/__init__.py @@ -603,3 +603,13 @@ def remove(uids): :param uids: Set or list of UIDs to remove """ + + +class IAnalysisSpec(Interface): + """Marker interface for Analysis Specificition + """ + + +class IAnalysisSpecs(Interface): + """Marker interface for Analysis Specificitions + """ diff --git a/src/senaite/core/profiles/default/types.xml b/src/senaite/core/profiles/default/types.xml index 42502113a4..04e4bdd40a 100644 --- a/src/senaite/core/profiles/default/types.xml +++ b/src/senaite/core/profiles/default/types.xml @@ -130,4 +130,8 @@ + + + + diff --git a/src/senaite/core/profiles/default/types/AnalysisSpec.xml b/src/senaite/core/profiles/default/types/AnalysisSpec.xml new file mode 100644 index 0000000000..cc97c29ce4 --- /dev/null +++ b/src/senaite/core/profiles/default/types/AnalysisSpec.xml @@ -0,0 +1,86 @@ + + + + + AnalysisSpecification + + + + senaite_theme/icon/analysisspec + + + AnalysisSpec + + + string:${folder_url}/++add++AnalysisSpec + + + view + + + False + + + True + + + + + False + + + view + + + + + False + + + cmf.AddPortalContent + + + senaite.core.content.analysisspec.IAnalysisSpecSchema + senaite.core.content.analysisspec.AnalysisSpec + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/senaite/core/profiles/default/types/AnalysisSpecs.xml b/src/senaite/core/profiles/default/types/AnalysisSpecs.xml new file mode 100644 index 0000000000..f34b8b2dee --- /dev/null +++ b/src/senaite/core/profiles/default/types/AnalysisSpecs.xml @@ -0,0 +1,90 @@ + + + + + AnalysisSpecifications + + + + senaite_theme/icon/analysisspec + + + AnalysisSpecs + + + string:${folder_url}/++add++AnalysisSpecs + + + view + + + True + + + True + + + + + + False + + + view + + + + + False + + + cmf.AddPortalContent + + + senaite.core.content.analysisspecs.IAnalysisSpecsSchema + senaite.core.content.analysisspecs.AnalysisSpecs + + + + + + + + + + + + + + + + + + + + + + + + + From 721721276530e2d3901dc3a9194f4888dbe70ce8 Mon Sep 17 00:00:00 2001 From: Tim-IA <95612081+Tim-IA@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:20:42 +0200 Subject: [PATCH 2/5] remove bika analysisspec listing widget --- src/bika/lims/controlpanel/configure.zcml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/bika/lims/controlpanel/configure.zcml b/src/bika/lims/controlpanel/configure.zcml index dcccdb4505..552845da59 100644 --- a/src/bika/lims/controlpanel/configure.zcml +++ b/src/bika/lims/controlpanel/configure.zcml @@ -28,14 +28,6 @@ layer="bika.lims.interfaces.IBikaLIMS" /> - - Date: Fri, 10 Apr 2026 14:45:27 +0200 Subject: [PATCH 3/5] Replace resolve_service() with load_analysisspec_services() --- .../lims/controlpanel/bika_analysisspecs.py | 2 +- .../core/catalog/indexer/analysisspec.py | 2 +- .../core/exportimport/setupdata/__init__.py | 117 ++++++++++-------- 3 files changed, 69 insertions(+), 52 deletions(-) diff --git a/src/bika/lims/controlpanel/bika_analysisspecs.py b/src/bika/lims/controlpanel/bika_analysisspecs.py index 57b12e53ed..19ba4057ae 100644 --- a/src/bika/lims/controlpanel/bika_analysisspecs.py +++ b/src/bika/lims/controlpanel/bika_analysisspecs.py @@ -30,7 +30,7 @@ schema = ATFolderSchema.copy() -# TODO: Migrated to DX - https://github.com/senaite/senaite.core/pull/ +# TODO: Migrated to DX - https://github.com/senaite/senaite.core/pull/#2877 class AnalysisSpecs(ATFolder): implements(IAnalysisSpecs, IHideActionsMenu) displayContentsTab = False diff --git a/src/senaite/core/catalog/indexer/analysisspec.py b/src/senaite/core/catalog/indexer/analysisspec.py index 5a511f66c4..8b121d8349 100644 --- a/src/senaite/core/catalog/indexer/analysisspec.py +++ b/src/senaite/core/catalog/indexer/analysisspec.py @@ -19,7 +19,7 @@ # Some rights reserved, see README and LICENSE. from bika.lims import api -from bika.lims.interfaces import IAnalysisSpec +from senaite.core.interfaces import IAnalysisSpec from plone.indexer import indexer from senaite.core.interfaces import ISetupCatalog diff --git a/src/senaite/core/exportimport/setupdata/__init__.py b/src/senaite/core/exportimport/setupdata/__init__.py index 72f5855c0a..61f75edb0b 100644 --- a/src/senaite/core/exportimport/setupdata/__init__.py +++ b/src/senaite/core/exportimport/setupdata/__init__.py @@ -1718,66 +1718,83 @@ def Import(self): class Analysis_Specifications(WorksheetImporter): - def resolve_service(self, row): + def load_analysisspec_services(self): + self.spec_services = {} + for sheetname in ("Analysis Specification Services", + "Analysis Specifications", + "Results Range"): + worksheet = self.workbook.get(sheetname) + if worksheet: + break + else: + return bsc = getToolByName(self.context, SETUP_CATALOG) - service = bsc( - portal_type="AnalysisService", - title=safe_unicode(row["service"]) - ) - if not service: - service = bsc( - portal_type="AnalysisService", - getKeyword=safe_unicode(row["service"]) + for row in self.get_rows(3, worksheet=worksheet): + title = row.get("AnalysisSpec_title") or row.get("Title") or row.get("title") + if not title: + continue + service = self.get_object( + bsc, + "AnalysisService", + row.get("service") ) - service = service[0].getObject() - return service + if not service: + continue + if title not in self.spec_services: + self.spec_services[title] = [] + self.spec_services[title].append({ + "uid": service.UID(), + "min": row.get("min") or "0", + "max": row.get("max") or "0", + }) def Import(self): - bucket = {} + self.load_analysisspec_services() client_catalog = getToolByName(self.context, CLIENT_CATALOG) setup_catalog = getToolByName(self.context, SETUP_CATALOG) - # collect up all values into the bucket + for row in self.get_rows(3): - title = row.get("Title", False) + title = row.get("Title") or row.get("title") if not title: - title = row.get("title", False) - if not title: - continue + continue + parent = row["Client_title"] if row["Client_title"] else "lab" st = row["SampleType_title"] if row["SampleType_title"] else "" - service = self.resolve_service(row) - - if parent not in bucket: - bucket[parent] = {} - if title not in bucket[parent]: - bucket[parent][title] = {"sampletype": st, "resultsrange": []} - bucket[parent][title]["resultsrange"].append({ - "keyword": service.getKeyword(), - "min": row["min"] if row["min"] else "0", - "max": row["max"] if row["max"] else "0", - }) - # write objects. - for parent in bucket.keys(): - for title in bucket[parent]: - if parent == "lab": - folder = self.context.bika_setup.bika_analysisspecs - else: - proxy = client_catalog( - portal_type="Client", getName=safe_unicode(parent))[0] - folder = proxy.getObject() - st = bucket[parent][title]["sampletype"] - resultsrange = bucket[parent][title]["resultsrange"] - if st: - st_uid = setup_catalog( - portal_type="SampleType", title=safe_unicode(st))[0].UID - obj = _createObjectByType("AnalysisSpec", folder, tmpID()) - obj.edit(title=title) - obj.setResultsRange(resultsrange) - if st: - obj.setSampleType(st_uid) - obj.unmarkCreationFlag() - renameAfterCreation(obj) - notify(ObjectInitializedEvent(obj)) + + if parent == "lab": + folder = self.context.setup.analysisspecs + else: + proxy = client_catalog( + portal_type="Client", getName=safe_unicode(parent)) + if not proxy: + logger.warning( + "Analysis_Specifications: client not found: %s, " + "skipping spec '%s'" % (parent, title) + ) + continue + folder = proxy[0].getObject() + + if not st: + logger.warning( + "Analysis_Specifications: 'SampleType_title' missing " + "for spec '%s', skipping" % title + ) + continue + + st_brains = setup_catalog( + portal_type="SampleType", title=safe_unicode(st)) + if not st_brains: + logger.warning( + "Analysis_Specifications: SampleType not found: %s, " + "skipping spec '%s'" % (st, title) + ) + continue + + st_uid = st_brains[0].UID + + obj = api.create(folder, "AnalysisSpec", title=title, + sample_type=st_uid) + obj.setResultsRange(self.spec_services.get(title, [])) class Analysis_Profiles(WorksheetImporter): From ea5da4dcaab84d772414b063b424eb8585bb2f9d Mon Sep 17 00:00:00 2001 From: Tim-IA <95612081+Tim-IA@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:39:38 +0200 Subject: [PATCH 4/5] migrate --- src/senaite/core/upgrade/v02_07_000.py | 162 +++++++++++++++++++++++ src/senaite/core/upgrade/v02_07_000.zcml | 8 ++ 2 files changed, 170 insertions(+) diff --git a/src/senaite/core/upgrade/v02_07_000.py b/src/senaite/core/upgrade/v02_07_000.py index 6e7a7ff1dd..f473a9748f 100644 --- a/src/senaite/core/upgrade/v02_07_000.py +++ b/src/senaite/core/upgrade/v02_07_000.py @@ -73,6 +73,8 @@ profile = "profile-{0}:default".format(product) REMOVE_AT_TYPES = [ + "AnalysisSpec", + "AnalysisSpecs", "ARReport", "Contact", "Calculation", @@ -548,6 +550,166 @@ def get_destination_folder(folder_id): return folder +@upgradestep(product, version) +def migrate_analysisspecs_to_dx(tool): + """Converts existing Analysis Specifications to Dexterity + """ + logger.info("Convert Analysis Specifications to Dexterity ...") + + # ensure old AT types are flushed first + remove_at_portal_types(tool, REMOVE_AT_TYPES) + + # run required import steps + tool.runImportStepFromProfile(profile, "typeinfo") + tool.runImportStepFromProfile(profile, "workflow") + + setup = api.get_senaite_setup() + + origin = api.get_setup().get("bika_analysisspecs") + if origin: + destination = get_setup_folder("analysisspecs") + uncatalog_object(origin) + objects = list(origin.objectValues()) + for num, src in enumerate(objects, start=1): + migrate_analysisspec_to_dx(src, destination) + if num % 100 == 0: + transaction.savepoint() + copy_snapshots(origin, destination) + if len(origin) == 0: + delete_object(origin) + else: + logger.warn("Cannot remove {}. Is not empty".format(origin)) + else: + logger.info("bika_analysisspecs not found, skipping lab specs") + + # migrate client-level specs (each client folder) + query = {"portal_type": "AnalysisSpec"} + brains = api.search(query, SETUP_CATALOG) + total = len(brains) + logger.info("Found {} AnalysisSpec objects to migrate".format(total)) + + for num, brain in enumerate(brains, start=1): + if num % 100 == 0: + logger.info("Progress: {}/{} specs migrated".format(num, total)) + transaction.savepoint() + src = api.get_object(brain) + if not api.is_at_content(src): + logger.info("[{}/{}] Already migrated: {}".format( + num, total, api.get_path(src))) + continue + # client-level specs live inside the client folder — migrate in place + migrate_analysisspec_to_dx(src) + + logger.info("Convert Analysis Specifications to Dexterity [DONE]") + + +def migrate_analysisspec_to_dx(src, destination=None): + """Migrate an AT AnalysisSpec to DX in the destination folder + + :param src: The source AT object + :param destination: The destination folder. If `None`, the parent folder of + the source object is taken + """ + portal_type = "AnalysisSpec" + + if api.get_portal_type(src) != portal_type: + logger.error("Not a '{}' object: {}".format(portal_type, src)) + return + + # check if we migrate within the same folder + if destination is None: + target_id = tmpID() + destination = api.get_parent(src) + else: + target_id = src.getId() + + target = destination.get(target_id) + if not target: + # Don't use api.create to skip auto-id generation + target = createContent(portal_type, id=target_id) + destination._setObject(target_id, target) + target = destination._getOb(target_id) + + # Manually set the fields + # NOTE: always convert string values to unicode for dexterity fields! + target.title = api.safe_unicode(src.Title() or "") + target.description = api.safe_unicode(src.Description() or "") + + # sample_type: AT stores a UID via getRawSampleType() + raw_st = src.getRawSampleType() + if raw_st: + target.sample_type = raw_st if isinstance(raw_st, list) else [raw_st] + + # dynamic_analysis_spec: AT stores a UID via getRawDynamicAnalysisSpec() + raw_dyn = src.getRawDynamicAnalysisSpec() \ + if hasattr(src, "getRawDynamicAnalysisSpec") else None + if raw_dyn: + target.dynamic_analysis_spec = \ + raw_dyn if isinstance(raw_dyn, list) else [raw_dyn] + + # results_range: AT stores list of dicts keyed by uid/keyword + results_range = [] + for record in (src.getResultsRange() or []): + uid = record.get("uid", "") + # AT legacy records may use keyword instead of uid — resolve it + if not uid: + keyword = record.get("keyword", "") + brains = api.search( + {"portal_type": "AnalysisService", "getKeyword": keyword}, + SETUP_CATALOG) + uid = brains[0].UID if brains else "" + if not uid: + continue + results_range.append({ + "uid": uid, + "min": record.get("min", ""), + "max": record.get("max", ""), + "warn_min": record.get("warn_min", ""), + "warn_max": record.get("warn_max", ""), + "min_operator": record.get("min_operator", "geq"), + "max_operator": record.get("max_operator", "leq"), + "hidemin": record.get("hidemin", ""), + "hidemax": record.get("hidemax", ""), + "rangecomment": record.get("rangecomment", ""), + }) + target.results_range = results_range + + # Migrate the contents from AT to DX + migrator = getMultiAdapter((src, target), interface=IContentMigrator) + + # copy all (raw) attributes from the source object to the target + migrator.copy_attributes(src, target) + + # copy the UID + migrator.copy_uid(src, target) + + # copy auditlog + migrator.copy_snapshots(src, target) + + # copy creators + migrator.copy_creators(src, target) + + # copy workflow history + migrator.copy_workflow_history(src, target) + + # copy marker interfaces + migrator.copy_marker_interfaces(src, target) + + # copy dates + migrator.copy_dates(src, target) + + # uncatalog the source object + migrator.uncatalog_object(src) + + # delete the old object + migrator.delete_object(src) + + # change the ID *after* the original object was removed + migrator.copy_id(src, target) + + logger.info("Migrated AnalysisSpec from %s -> %s" % (src, target)) + + @upgradestep(product, version) def migrate_contacts_to_dx(tool): """Migrate Contact objects from Archetypes to Dexterity diff --git a/src/senaite/core/upgrade/v02_07_000.zcml b/src/senaite/core/upgrade/v02_07_000.zcml index 7d4ce02ec0..4507e1784f 100644 --- a/src/senaite/core/upgrade/v02_07_000.zcml +++ b/src/senaite/core/upgrade/v02_07_000.zcml @@ -2,6 +2,14 @@ xmlns="http://namespaces.zope.org/zope" xmlns:genericsetup="http://namespaces.zope.org/genericsetup"> + + Date: Wed, 13 May 2026 09:45:45 +0200 Subject: [PATCH 5/5] migrator --- src/senaite/core/upgrade/v02_07_000.py | 36 +++++--------------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/senaite/core/upgrade/v02_07_000.py b/src/senaite/core/upgrade/v02_07_000.py index f473a9748f..3881afb662 100644 --- a/src/senaite/core/upgrade/v02_07_000.py +++ b/src/senaite/core/upgrade/v02_07_000.py @@ -563,42 +563,22 @@ def migrate_analysisspecs_to_dx(tool): tool.runImportStepFromProfile(profile, "typeinfo") tool.runImportStepFromProfile(profile, "workflow") - setup = api.get_senaite_setup() - - origin = api.get_setup().get("bika_analysisspecs") - if origin: - destination = get_setup_folder("analysisspecs") - uncatalog_object(origin) - objects = list(origin.objectValues()) - for num, src in enumerate(objects, start=1): - migrate_analysisspec_to_dx(src, destination) - if num % 100 == 0: - transaction.savepoint() - copy_snapshots(origin, destination) - if len(origin) == 0: - delete_object(origin) - else: - logger.warn("Cannot remove {}. Is not empty".format(origin)) - else: - logger.info("bika_analysisspecs not found, skipping lab specs") - - # migrate client-level specs (each client folder) query = {"portal_type": "AnalysisSpec"} brains = api.search(query, SETUP_CATALOG) total = len(brains) logger.info("Found {} AnalysisSpec objects to migrate".format(total)) for num, brain in enumerate(brains, start=1): + analysisspec = api.get_object(brain) if num % 100 == 0: logger.info("Progress: {}/{} specs migrated".format(num, total)) transaction.savepoint() - src = api.get_object(brain) - if not api.is_at_content(src): + if not api.is_at_content(analysisspec): logger.info("[{}/{}] Already migrated: {}".format( - num, total, api.get_path(src))) + num, total, api.get_path(analysisspec))) continue - # client-level specs live inside the client folder — migrate in place - migrate_analysisspec_to_dx(src) + + migrate_analysisspec_to_dx(analysisspec) logger.info("Convert Analysis Specifications to Dexterity [DONE]") @@ -634,11 +614,7 @@ def migrate_analysisspec_to_dx(src, destination=None): # NOTE: always convert string values to unicode for dexterity fields! target.title = api.safe_unicode(src.Title() or "") target.description = api.safe_unicode(src.Description() or "") - - # sample_type: AT stores a UID via getRawSampleType() - raw_st = src.getRawSampleType() - if raw_st: - target.sample_type = raw_st if isinstance(raw_st, list) else [raw_st] + target.setSampleType(src.getSampleType()) # dynamic_analysis_spec: AT stores a UID via getRawDynamicAnalysisSpec() raw_dyn = src.getRawDynamicAnalysisSpec() \