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..19ba4057ae 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/#2877
class AnalysisSpecs(ATFolder):
implements(IAnalysisSpecs, IHideActionsMenu)
displayContentsTab = False
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"
/>
-
-
-
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 @@
-
-
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 @@
-
-
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/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):
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 @@
+
+
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 @@
+
+
diff --git a/src/senaite/core/upgrade/v02_07_000.py b/src/senaite/core/upgrade/v02_07_000.py
index 6e7a7ff1dd..3881afb662 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,142 @@ 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")
+
+ 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()
+ if not api.is_at_content(analysisspec):
+ logger.info("[{}/{}] Already migrated: {}".format(
+ num, total, api.get_path(analysisspec)))
+ continue
+
+ migrate_analysisspec_to_dx(analysisspec)
+
+ 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 "")
+ target.setSampleType(src.getSampleType())
+
+ # 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">
+
+