diff --git a/bika/lims/adapters/configure.zcml b/bika/lims/adapters/configure.zcml
index d5a491fca1..8527a7fb56 100644
--- a/bika/lims/adapters/configure.zcml
+++ b/bika/lims/adapters/configure.zcml
@@ -5,6 +5,14 @@
xmlns:monkey="http://namespaces.plone.org/monkey"
i18n_domain="senaite.core">
+
+
+
value mapping of context data
+
+ The fieldnames are selected from the column names of the dynamic
+ specifications file. E.g. the column "Method" of teh specifications
+ file will lookup the value (title) of the Analysis and added to the
+ mapping like this: `{"Method": "Method-1"}`.
+
+ :returns: fieldname -> value mapping
+ :rtype: dict
+ """
+ data = {}
+
+ # Lookup the column names on the Analysis and the Analysis Request
+ for column in self.dynamicspec.get_header():
+ an_value = getattr(self.analysis, column, marker)
+ ar_value = getattr(self.analysisrequest, column, marker)
+ if an_value is not marker:
+ data[column] = self.convert(an_value)
+ elif ar_value is not marker:
+ data[column] = self.convert(ar_value)
+
+ return data
+
+ def get_results_range(self):
+ """Return the dynamic results range
+
+ The returning dicitionary should containe at least the `min` and `max`
+ values to override the ResultsRangeDict data.
+
+ :returns: An `IResultsRangeDict` compatible dict
+ :rtype: dict
+ """
+ if self.dynamicspec is None:
+ return {}
+ # A matching Analysis Keyword is mandatory for any further matches
+ keyword = self.analysis.getKeyword()
+ by_keyword = self.dynamicspec.get_by_keyword()
+ # Get all specs (rows) from the Excel with the same Keyword
+ specs = by_keyword.get(keyword)
+ if not specs:
+ return {}
+
+ # Generate a match data object, which match both the column names and
+ # the field names of the Analysis.
+ match_data = self.get_match_data()
+
+ rr = {}
+
+ # Iterate over the rows and return the first where **all** values match
+ # with the analysis' values
+ for spec in specs:
+ skip = False
+
+ for k, v in match_data.items():
+ # break if the values do not match
+ if v != spec[k]:
+ skip = True
+ break
+
+ # skip the whole specification row
+ if skip:
+ continue
+
+ # at this point we have a match, update the results range dict
+ for key in self.range_keys:
+ value = spec.get(key, marker)
+ # skip if the range key is not set in the Excel
+ if value is marker:
+ continue
+ # skip if the value is not floatable
+ if not api.is_floatable(value):
+ continue
+ # set the range value
+ rr[key] = value
+ # return the updated result range
+ return rr
+
+ return rr
+
+ def __call__(self):
+ return self.get_results_range()
diff --git a/bika/lims/browser/configure.zcml b/bika/lims/browser/configure.zcml
index 569aa77cdd..f7bcd77559 100644
--- a/bika/lims/browser/configure.zcml
+++ b/bika/lims/browser/configure.zcml
@@ -20,6 +20,7 @@
+
diff --git a/bika/lims/browser/dynamic_analysisspec.py b/bika/lims/browser/dynamic_analysisspec.py
new file mode 100644
index 0000000000..b9b3f04dfe
--- /dev/null
+++ b/bika/lims/browser/dynamic_analysisspec.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+
+import collections
+
+from bika.lims import _
+from bika.lims import api
+from senaite.core.listing.view import ListingView
+
+
+class DynamicAnalysisSpecView(ListingView):
+ """A listing view that shows the contents of the Excel
+ """
+ def __init__(self, context, request):
+ super(DynamicAnalysisSpecView, self).__init__(context, request)
+
+ self.pagesize = 50
+ self.context_actions = {}
+ self.title = api.get_title(self.context)
+ self.description = api.get_description(self.context)
+ self.show_search = False
+ self.show_column_toggles = False
+
+ if self.context.specs_file:
+ filename = self.context.specs_file.filename
+ self.description = _("Contents of the file {}".format(filename))
+
+ self.specs = self.context.get_specs()
+ self.total = len(self.specs)
+
+ self.columns = collections.OrderedDict()
+ for title in self.context.get_header():
+ self.columns[title] = {
+ "title": title,
+ "toggle": True}
+
+ self.review_states = [
+ {
+ "id": "default",
+ "title": _("All"),
+ "contentFilter": {},
+ "transitions": [],
+ "custom_transitions": [],
+ "columns": self.columns.keys()
+ }
+ ]
+
+ def update(self):
+ super(DynamicAnalysisSpecView, self).update()
+
+ def before_render(self):
+ super(DynamicAnalysisSpecView, self).before_render()
+
+ def make_empty_item(self, **kw):
+ """Create a new empty item
+ """
+ item = {
+ "uid": None,
+ "before": {},
+ "after": {},
+ "replace": {},
+ "allow_edit": [],
+ "disabled": False,
+ "state_class": "state-active",
+ }
+ item.update(**kw)
+ return item
+
+ def folderitems(self):
+ items = []
+ for record in self.specs:
+ items.append(self.make_empty_item(**record))
+ return items
diff --git a/bika/lims/browser/dynamic_analysisspec.zcml b/bika/lims/browser/dynamic_analysisspec.zcml
new file mode 100644
index 0000000000..719a8791bc
--- /dev/null
+++ b/bika/lims/browser/dynamic_analysisspec.zcml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/bika/lims/browser/images/dynamic_analysisspec.png b/bika/lims/browser/images/dynamic_analysisspec.png
new file mode 100644
index 0000000000..4ab6a36e80
Binary files /dev/null and b/bika/lims/browser/images/dynamic_analysisspec.png differ
diff --git a/bika/lims/browser/images/dynamic_analysisspec_big.png b/bika/lims/browser/images/dynamic_analysisspec_big.png
new file mode 100644
index 0000000000..e70e742de5
Binary files /dev/null and b/bika/lims/browser/images/dynamic_analysisspec_big.png differ
diff --git a/bika/lims/browser/viewlets/configure.zcml b/bika/lims/browser/viewlets/configure.zcml
index cf87d4d597..4a988c283c 100644
--- a/bika/lims/browser/viewlets/configure.zcml
+++ b/bika/lims/browser/viewlets/configure.zcml
@@ -245,4 +245,18 @@
layer="bika.lims.interfaces.IBikaLIMS"
/>
+
+
+
diff --git a/bika/lims/browser/viewlets/dynamic_specs.py b/bika/lims/browser/viewlets/dynamic_specs.py
new file mode 100644
index 0000000000..06c878ebec
--- /dev/null
+++ b/bika/lims/browser/viewlets/dynamic_specs.py
@@ -0,0 +1,30 @@
+# -*- 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-2020 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from plone.app.layout.viewlets import ViewletBase
+
+
+class DynamicSpecsViewlet(ViewletBase):
+ """ Displays an informative message when the specification has a dynamic
+ specification assigned, so ranges might be overriden by the ranges provided
+ in the xls file from the Dynamic Specification
+ """
+ template = ViewPageTemplateFile("templates/dynamic_specs_viewlet.pt")
diff --git a/bika/lims/browser/viewlets/templates/dynamic_specs_viewlet.pt b/bika/lims/browser/viewlets/templates/dynamic_specs_viewlet.pt
new file mode 100644
index 0000000000..84353a2ae5
--- /dev/null
+++ b/bika/lims/browser/viewlets/templates/dynamic_specs_viewlet.pt
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+ This Analysis Specification has a Dynamic Specification assigned
+
+
+
+ Be aware that the ranges provided in the spreadsheet file from the
+ dynamic specification might override the ranges defined in the
+ Specifications list below.
+
+
+
+
+
diff --git a/bika/lims/browser/widgets/analysisspecificationwidget.py b/bika/lims/browser/widgets/analysisspecificationwidget.py
index 20dd25fb38..96be9ceb24 100644
--- a/bika/lims/browser/widgets/analysisspecificationwidget.py
+++ b/bika/lims/browser/widgets/analysisspecificationwidget.py
@@ -135,6 +135,7 @@ def update(self):
super(AnalysisSpecificationView, self).update()
self.allow_edit = self.is_edit_allowed()
self.specification = self.context.getResultsRangeDict()
+ self.dynamic_spec = self.context.getDynamicAnalysisSpec()
@view.memoize
def is_edit_allowed(self):
@@ -161,6 +162,12 @@ def get_required_columns(self):
columns = []
return columns
+ @view.memoize
+ def get_dynamic_analysisspecs(self):
+ if not self.dynamic_spec:
+ return {}
+ return self.dynamic_spec.get_by_keyword()
+
def folderitems(self):
"""TODO: Refactor to non-classic mode
"""
@@ -184,6 +191,16 @@ def folderitem(self, obj, item, index):
title = api.get_title(obj)
keyword = obj.getKeyword()
+ # dynamic analysisspecs
+ dspecs = self.get_dynamic_analysisspecs()
+ dspec = dspecs.get(keyword)
+ # show the dynamic specification icon next to the Keyword
+ if dspec:
+ item["before"]["Keyword"] = get_image(
+ "dynamic_analysisspec.png",
+ title=_("Found Dynamic Analysis Specification for '{}' in '{}'"
+ .format(keyword, self.dynamic_spec.Title())))
+
# get the category
if self.show_categories_enabled():
category = obj.getCategoryTitle()
@@ -199,6 +216,7 @@ def folderitem(self, obj, item, index):
item["required"] = self.get_required_columns()
spec = self.specification.get(keyword, {})
+
item["selected"] = spec and True or False
item["min_operator"] = spec.get("min_operator", "geq")
item["min"] = spec.get("min", "")
@@ -243,7 +261,7 @@ class AnalysisSpecificationWidget(TypesWidget):
"""
_properties = TypesWidget._properties.copy()
_properties.update({
- 'macro': "bika_widgets/analysisspecificationwidget",
+ "macro": "bika_widgets/analysisspecificationwidget",
})
security = ClassSecurityInfo()
@@ -265,20 +283,31 @@ def process_form(self, instance, field, form, empty_marker=None,
# selected services
service_uids = form.get("uids", [])
+ # return immediately if now services were selected
if not service_uids:
- # Inject empty fields for the validator
- values = [dict.fromkeys(field.getSubfields())]
+ return values, {}
+
+ # dynamic analysis specification
+ dynamic_spec = {}
+ if instance.getDynamicAnalysisSpec():
+ dynamic_spec = instance.getDynamicAnalysisSpec().get_by_keyword()
for uid in service_uids:
s_min = self._get_spec_value(form, uid, "min")
s_max = self._get_spec_value(form, uid, "max")
if not s_min and not s_max:
- # If user has not set value neither for min nor max, omit this
- # record. Otherwise, since 'min' and 'max' are defined as
- # mandatory subfields, the following message will appear after
- # submission: "Specifications is required, please correct."
- continue
+ service = api.get_object_by_uid(uid)
+ keyword = service.getKeyword()
+ if not dynamic_spec.get(keyword):
+ # If user has not set value neither for min nor max, omit
+ # this record. Otherwise, since 'min' and 'max' are defined
+ # as mandatory subfields, the following message will appear
+ # after submission: "Specifications is required, please
+ # correct."
+ continue
+ s_min = 0
+ s_max = 0
# TODO: disallow this case in the UI
if s_min and s_max:
@@ -293,7 +322,7 @@ def process_form(self, instance, field, form, empty_marker=None,
form, uid, "max_operator", check_floatable=False)
service = api.get_object_by_uid(uid)
- values.append({
+ subfield_values = {
"keyword": service.getKeyword(),
"uid": uid,
"min_operator": min_operator,
@@ -305,7 +334,20 @@ def process_form(self, instance, field, form, empty_marker=None,
"hidemin": self._get_spec_value(form, uid, "hidemin"),
"hidemax": self._get_spec_value(form, uid, "hidemax"),
"rangecomment": self._get_spec_value(form, uid, "rangecomment",
- check_floatable=False)})
+ check_floatable=False)
+ }
+
+ # Include values from other subfields that might be added
+ # by other add-ons independently via SchemaModifier
+ for subfield in field.subfields:
+ if subfield not in subfield_values.keys():
+ subfield_values.update({
+ subfield: self._get_spec_value(form, uid, subfield)
+ })
+
+ values.append(subfield_values)
+
+
return values, {}
def _get_spec_value(self, form, uid, key, check_floatable=True,
diff --git a/bika/lims/content/abstractroutineanalysis.py b/bika/lims/content/abstractroutineanalysis.py
index 8e160b40d7..0cb8aca0cb 100644
--- a/bika/lims/content/abstractroutineanalysis.py
+++ b/bika/lims/content/abstractroutineanalysis.py
@@ -33,6 +33,7 @@
from bika.lims.content.reflexrule import doReflexRuleAction
from bika.lims.interfaces import IAnalysis
from bika.lims.interfaces import ICancellable
+from bika.lims.interfaces import IDynamicResultsRange
from bika.lims.interfaces import IRoutineAnalysis
from bika.lims.interfaces.analysis import IRequestAnalysis
from bika.lims.workflow import getTransitionDate
@@ -355,7 +356,12 @@ def getResultsRange(self):
ar_ranges = analysis_request.getResultsRange()
# Get the result range that corresponds to this specific analysis
an_range = [rr for rr in ar_ranges if rr.get('keyword', '') == keyword]
- return an_range and an_range[0] or specs
+ rr = an_range and an_range[0] or specs
+ # dynamic results range adapter
+ adapter = IDynamicResultsRange(self, None)
+ if adapter:
+ rr.update(adapter())
+ return rr
@security.public
def getSiblings(self, retracted=False):
diff --git a/bika/lims/content/analysisspec.py b/bika/lims/content/analysisspec.py
index e7c2894e0b..2675ca9c1f 100644
--- a/bika/lims/content/analysisspec.py
+++ b/bika/lims/content/analysisspec.py
@@ -58,6 +58,21 @@
),
),
+ UIDReferenceField(
+ 'DynamicAnalysisSpec',
+ allowed_types=('DynamicAnalysisSpec',),
+ required=0,
+ widget=ReferenceWidget(
+ label=_("Dynamic Analysis Specification"),
+ showOn=True,
+ catalog_name=SETUP_CATALOG,
+ base_query=dict(
+ sort_on="sortable_title",
+ sort_order="ascending"
+ ),
+ ),
+ ),
+
)) + BikaSchema.copy() + Schema((
RecordsField(
diff --git a/bika/lims/content/dynamic_analysisspec.py b/bika/lims/content/dynamic_analysisspec.py
new file mode 100644
index 0000000000..45b0b76b53
--- /dev/null
+++ b/bika/lims/content/dynamic_analysisspec.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+
+from collections import defaultdict
+from StringIO import StringIO
+
+from bika.lims import _
+from bika.lims.catalog import SETUP_CATALOG
+from openpyxl.reader.excel import load_workbook
+from openpyxl.shared.exc import InvalidFileException
+from plone.dexterity.content import Item
+from plone.namedfile import field as namedfile
+from plone.supermodel import model
+from zope.interface import Invalid
+from zope.interface import implementer
+from zope.interface import invariant
+
+REQUIRED_COLUMNS = [
+ "Keyword", # The Analysis Keyword
+ "min", # Lower Limit
+ "max", # Upper Limit
+]
+
+
+class IDynamicAnalysisSpec(model.Schema):
+ """Dynamic Analysis Specification
+ """
+
+ specs_file = namedfile.NamedBlobFile(
+ title=_(u"Specification File"),
+ description=_(u"Only Excel files supported"),
+ required=True)
+
+ @invariant
+ def validate_sepecs_file(data):
+ """Checks the Excel file contains the required header columns
+ """
+ fd = StringIO(data.specs_file.data)
+ try:
+ xls = load_workbook(fd)
+ except (InvalidFileException, TypeError):
+ raise Invalid(_(
+ "Invalid specifications file detected. "
+ "Please upload an Excel spreadsheet with at least "
+ "the following columns defined: '{}'"
+ .format(", ".join(REQUIRED_COLUMNS))))
+ try:
+ header = map(lambda c: c.value, xls.worksheets[0].rows[0])
+ except IndexError:
+ raise Invalid(
+ _("First sheet does not contain a valid column definition"))
+ for col in REQUIRED_COLUMNS:
+ if col not in header:
+ raise Invalid(_("Column '{}' is missing".format(col)))
+
+
+@implementer(IDynamicAnalysisSpec)
+class DynamicAnalysisSpec(Item):
+ """Dynamic Analysis Specification
+ """
+ _catalogs = [SETUP_CATALOG]
+
+ def get_workbook(self):
+ specs_file = self.specs_file
+ if not specs_file:
+ return None
+ data = StringIO(specs_file.data)
+ return load_workbook(data)
+
+ def get_worksheets(self):
+ wb = self.get_workbook()
+ if wb is None:
+ return []
+ return wb.worksheets
+
+ def get_primary_sheet(self):
+ sheets = self.get_worksheets()
+ if len(sheets) == 0:
+ return None
+ return sheets[0]
+
+ def get_header(self):
+ ps = self.get_primary_sheet()
+ if ps is None:
+ return []
+ return map(lambda cell: cell.value, ps.rows[0])
+
+ def get_specs(self):
+ ps = self.get_primary_sheet()
+ if ps is None:
+ return []
+ keys = self.get_header()
+ specs = []
+ for num, row in enumerate(ps.rows):
+ # skip the header
+ if num == 0:
+ continue
+ values = map(lambda cell: cell.value, row)
+ data = dict(zip(keys, values))
+ specs.append(data)
+ return specs
+
+ def get_by_keyword(self):
+ specs = self.get_specs()
+ groups = defaultdict(list)
+ for spec in specs:
+ groups[spec.get("Keyword")].append(spec)
+ return groups
diff --git a/bika/lims/controlpanel/bika_analysisspecs.py b/bika/lims/controlpanel/bika_analysisspecs.py
index bd8afd3732..1aad59ecd7 100644
--- a/bika/lims/controlpanel/bika_analysisspecs.py
+++ b/bika/lims/controlpanel/bika_analysisspecs.py
@@ -77,6 +77,10 @@ def __init__(self, context, request):
("SampleType", {
"title": _("Sample Type"),
"index": "sampletype_title"}),
+ ("DynamicSpec", {
+ "title": _("Dynamic Specification"),
+ "sortable": False,
+ })
))
self.review_states = [
@@ -125,6 +129,12 @@ def folderitem(self, obj, item, index):
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/bika/lims/controlpanel/configure.zcml b/bika/lims/controlpanel/configure.zcml
index 7f716a03aa..3bdb4ef6e1 100644
--- a/bika/lims/controlpanel/configure.zcml
+++ b/bika/lims/controlpanel/configure.zcml
@@ -294,4 +294,14 @@
layer="bika.lims.interfaces.IBikaLIMS"
/>
+
+
+
+
diff --git a/bika/lims/controlpanel/dynamic_analysisspecs.py b/bika/lims/controlpanel/dynamic_analysisspecs.py
new file mode 100644
index 0000000000..5bce5cfbb3
--- /dev/null
+++ b/bika/lims/controlpanel/dynamic_analysisspecs.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+
+import collections
+
+from bika.lims import _
+from bika.lims.catalog import SETUP_CATALOG
+from plone.dexterity.content import Container
+from plone.supermodel import model
+from senaite.core.listing import ListingView
+from zope import schema
+from zope.interface import implements
+
+
+class IDynamicAnalysisSpecs(model.Schema):
+ """Dynamic Analysis Specifications
+ """
+ title = schema.TextLine(
+ title=_(u"Title"),
+ description=_(u"Title of the Folder"),
+ required=True)
+
+
+class DynamicAnalysisSpecsView(ListingView):
+ """Displays all system's dynamic analysis specifications
+ """
+
+ def __init__(self, context, request):
+ super(DynamicAnalysisSpecsView, self).__init__(context, request)
+
+ self.catalog = SETUP_CATALOG
+
+ self.contentFilter = {
+ "portal_type": "DynamicAnalysisSpec",
+ "sort_on": "created",
+ "sort_order": "descending",
+ }
+
+ self.context_actions = {
+ _("Add"): {
+ "url": "++add++DynamicAnalysisSpec",
+ "permission": "cmf.AddPortalContent",
+ "icon": "++resource++bika.lims.images/add.png"}
+ }
+
+ self.icon = "{}/{}/{}".format(
+ self.portal_url,
+ "/++resource++bika.lims.images",
+ "analysisspec_big.png"
+ )
+
+ self.title = self.context.Title()
+ self.description = self.context.Description()
+ self.show_select_column = True
+ self.pagesize = 25
+
+ self.columns = collections.OrderedDict((
+ ("Title", {
+ "title": _("Title"),
+ "replace_url": "absolute_url",
+ "index": "sortable_title"}),
+ ("Description", {
+ "title": _("Description"),
+ "index": "Description"}),
+ ))
+
+ self.review_states = [
+ {
+ "id": "default",
+ "title": _("Active"),
+ "contentFilter": {"is_active": True},
+ "transitions": [],
+ "columns": self.columns.keys(),
+ }, {
+ "id": "inactive",
+ "title": _("Inactive"),
+ "contentFilter": {'is_active': False},
+ "transitions": [],
+ "columns": self.columns.keys(),
+ }, {
+ "id": "all",
+ "title": _("All"),
+ "contentFilter": {},
+ "columns": self.columns.keys(),
+ },
+ ]
+
+ def update(self):
+ """Update hook
+ """
+ super(DynamicAnalysisSpecsView, self).update()
+
+ def before_render(self):
+ """Before template render hook
+ """
+ super(DynamicAnalysisSpecsView, self).before_render()
+ # Don't allow any context actions
+ self.request.set("disable_border", 1)
+
+ 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
+ """
+ return item
+
+
+class DynamicAnalysisSpecs(Container):
+ """Dynamic Analysis Specifications Folder
+ """
+ implements(IDynamicAnalysisSpecs)
diff --git a/bika/lims/interfaces/__init__.py b/bika/lims/interfaces/__init__.py
index 8dc5b218d3..df17f80691 100644
--- a/bika/lims/interfaces/__init__.py
+++ b/bika/lims/interfaces/__init__.py
@@ -395,6 +395,11 @@ class IAnalysisSpecs(Interface):
"""
+class IDynamicResultsRange(Interface):
+ """Marker interface for Dynamic Result Range
+ """
+
+
class IAnalysisProfile(Interface):
"""Marker interface for an Analysis Profile
"""
diff --git a/bika/lims/profiles/default/controlpanel.xml b/bika/lims/profiles/default/controlpanel.xml
index 6a056bcca3..ce29a60a5d 100644
--- a/bika/lims/profiles/default/controlpanel.xml
+++ b/bika/lims/profiles/default/controlpanel.xml
@@ -311,4 +311,13 @@
senaite.core: Manage Bika
+
+ senaite.core: Manage Bika
+
+
diff --git a/bika/lims/profiles/default/types.xml b/bika/lims/profiles/default/types.xml
index 6f061295a0..2c3121765f 100644
--- a/bika/lims/profiles/default/types.xml
+++ b/bika/lims/profiles/default/types.xml
@@ -107,4 +107,8 @@
+
+
+
+
diff --git a/bika/lims/profiles/default/types/DynamicAnalysisSpec.xml b/bika/lims/profiles/default/types/DynamicAnalysisSpec.xml
new file mode 100644
index 0000000000..409499c7f7
--- /dev/null
+++ b/bika/lims/profiles/default/types/DynamicAnalysisSpec.xml
@@ -0,0 +1,89 @@
+
+
diff --git a/bika/lims/profiles/default/types/DynamicAnalysisSpecs.xml b/bika/lims/profiles/default/types/DynamicAnalysisSpecs.xml
new file mode 100644
index 0000000000..a5b53c7e80
--- /dev/null
+++ b/bika/lims/profiles/default/types/DynamicAnalysisSpecs.xml
@@ -0,0 +1,87 @@
+
+
diff --git a/bika/lims/profiles/default/workflows.xml b/bika/lims/profiles/default/workflows.xml
index bcc564a150..e27d2369a9 100644
--- a/bika/lims/profiles/default/workflows.xml
+++ b/bika/lims/profiles/default/workflows.xml
@@ -412,6 +412,11 @@
+
+
+
+
+