From 39b7c8a777d0caee5b03103e53ee38dc10de1e87 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sat, 14 Dec 2019 12:40:41 +0100 Subject: [PATCH 01/22] Implemented Types and added Setup Routines --- bika/lims/browser/configure.zcml | 1 + bika/lims/browser/dynamic_analysisspec.py | 72 +++++++++++ bika/lims/browser/dynamic_analysisspec.zcml | 17 +++ bika/lims/content/analysisspec.py | 15 +++ bika/lims/content/dynamic_analysisspec.py | 68 +++++++++++ bika/lims/controlpanel/configure.zcml | 10 ++ .../controlpanel/dynamic_analysisspecs.py | 113 ++++++++++++++++++ bika/lims/profiles/default/controlpanel.xml | 9 ++ bika/lims/profiles/default/types.xml | 4 + .../default/types/DynamicAnalysisSpec.xml | 89 ++++++++++++++ .../default/types/DynamicAnalysisSpecs.xml | 87 ++++++++++++++ bika/lims/profiles/default/workflows.xml | 5 + bika/lims/setuphandlers.py | 34 ++++++ bika/lims/upgrade/v01_03_003.py | 9 +- 14 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 bika/lims/browser/dynamic_analysisspec.py create mode 100644 bika/lims/browser/dynamic_analysisspec.zcml create mode 100644 bika/lims/content/dynamic_analysisspec.py create mode 100644 bika/lims/controlpanel/dynamic_analysisspecs.py create mode 100644 bika/lims/profiles/default/types/DynamicAnalysisSpec.xml create mode 100644 bika/lims/profiles/default/types/DynamicAnalysisSpecs.xml diff --git a/bika/lims/browser/configure.zcml b/bika/lims/browser/configure.zcml index eb723fdc13..2a07f179f4 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/content/analysisspec.py b/bika/lims/content/analysisspec.py index e7c2894e0b..95ecb8382c 100644 --- a/bika/lims/content/analysisspec.py +++ b/bika/lims/content/analysisspec.py @@ -58,6 +58,21 @@ ), ), + UIDReferenceField( + 'DynamicAnalysisSpec', + allowed_types=('DynamicAnalysisSpec',), + required=1, + 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..6fc825eed7 --- /dev/null +++ b/bika/lims/content/dynamic_analysisspec.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +from StringIO import StringIO + +from bika.lims import _ +from bika.lims.catalog import SETUP_CATALOG +from openpyxl.reader.excel import load_workbook +from plone.dexterity.content import Item +from plone.namedfile import field as namedfile +from plone.supermodel import model +from zope.interface import implementer + + +class IDynamicAnalysisSpec(model.Schema): + """Dynamic Analysis Specification + """ + + specs_file = namedfile.NamedBlobFile( + title=_(u"Specification File"), + description=_(u"Only Excel files supported"), + required=True) + + +@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 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..707c010cfb --- /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 sampling rounds + """ + + 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/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 @@ + + + + + Dynamic Analysis Specification + + + + string:${portal_url}/++resource++bika.lims.images/analysisspec.png + + + DynamicAnalysisSpec + + + string:${folder_url}/++add++DynamicAnalysisSpec + + + view + + + False + + + True + + + + + False + + + view + + + + + False + + + cmf.AddPortalContent + + + bika.lims.content.dynamic_analysisspec.IDynamicAnalysisSpec + bika.lims.content.dynamic_analysisspec.DynamicAnalysisSpec + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + Dynamic Analysis Specifications + + + + string:${portal_url}/++resource++bika.lims.images/analysisspec.png + + + DynamicAnalysisSpecs + + + string:${folder_url}/++add++DynamicAnalysisSpecs + + + view + + + True + + + True + + + + + + False + + + view + + + + + False + + + cmf.AddPortalContent + + + bika.lims.controlpanel.dynamic_analysisspecs.IDynamicAnalysisSpecs + bika.lims.controlpanel.dynamic_analysisspecs.DynamicAnalysisSpecs + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + Date: Sun, 15 Dec 2019 17:31:31 +0100 Subject: [PATCH 06/22] Better docstrings --- bika/lims/adapters/dynamicresultsrange.py | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/bika/lims/adapters/dynamicresultsrange.py b/bika/lims/adapters/dynamicresultsrange.py index 00efc9019d..ca8e16e126 100644 --- a/bika/lims/adapters/dynamicresultsrange.py +++ b/bika/lims/adapters/dynamicresultsrange.py @@ -10,8 +10,6 @@ @implementer(IDynamicResultsRange) class DynamicResultsRange(object): """Default Dynamic Results Range Adapter - - Matches all columns against the Analysis field values with the same names. """ def __init__(self, analysis): @@ -23,13 +21,26 @@ def __init__(self, analysis): self.dynamicspec = self.specification.getDynamicAnalysisSpec() def convert(self, value): + # convert referenced UIDs to the Title if api.is_uid(value): obj = api.get_object_by_uid(value) return api.get_title(obj) return value def get_match_data(self): + """Returns a fieldname -> 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) @@ -37,17 +48,30 @@ def get_match_data(self): 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() # Iterate over the rows and return the first where all values match From db6a62f4850f47dca5c4dc95f171c778806ff982 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 15 Dec 2019 20:58:50 +0100 Subject: [PATCH 07/22] Removed duplicate line --- bika/lims/upgrade/v01_03_003.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bika/lims/upgrade/v01_03_003.py b/bika/lims/upgrade/v01_03_003.py index 042d3fba6f..d32568a28f 100644 --- a/bika/lims/upgrade/v01_03_003.py +++ b/bika/lims/upgrade/v01_03_003.py @@ -258,7 +258,6 @@ def upgrade(tool): # Add the dynamic analysisspecs folder setup.runImportStepFromProfile(profile, "typeinfo") - setup.runImportStepFromProfile(profile, "typeinfo") add_dexterity_setup_items(portal) logger.info("{0} upgraded to version {1}".format(product, version)) From f965e7efa2bff59f46f4831797aac178ad6254a7 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Tue, 17 Dec 2019 15:43:46 +0100 Subject: [PATCH 08/22] Minor change --- bika/lims/browser/widgets/analysisspecificationwidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bika/lims/browser/widgets/analysisspecificationwidget.py b/bika/lims/browser/widgets/analysisspecificationwidget.py index 256eb0936a..2677b1e533 100644 --- a/bika/lims/browser/widgets/analysisspecificationwidget.py +++ b/bika/lims/browser/widgets/analysisspecificationwidget.py @@ -261,7 +261,7 @@ class AnalysisSpecificationWidget(TypesWidget): """ _properties = TypesWidget._properties.copy() _properties.update({ - 'macro': "bika_widgets/analysisspecificationwidget", + "macro": "bika_widgets/analysisspecificationwidget", }) security = ClassSecurityInfo() From 3884475f7d188b761b08dd069015bce63fc8772c Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Tue, 17 Dec 2019 15:43:58 +0100 Subject: [PATCH 09/22] Return immediately if now services were selected --- bika/lims/browser/widgets/analysisspecificationwidget.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bika/lims/browser/widgets/analysisspecificationwidget.py b/bika/lims/browser/widgets/analysisspecificationwidget.py index 2677b1e533..03d1b225f4 100644 --- a/bika/lims/browser/widgets/analysisspecificationwidget.py +++ b/bika/lims/browser/widgets/analysisspecificationwidget.py @@ -283,15 +283,15 @@ 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: + return values, {} + # dynamic analysis specification dynamic_spec = {} if instance.getDynamicAnalysisSpec(): dynamic_spec = instance.getDynamicAnalysisSpec().get_by_keyword() - if not service_uids: - # Inject empty fields for the validator - values = [dict.fromkeys(field.getSubfields())] - for uid in service_uids: s_min = self._get_spec_value(form, uid, "min") s_max = self._get_spec_value(form, uid, "max") From ef77046087724dc5d66e71f0ae6d49aa8927759a Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Wed, 18 Dec 2019 15:51:01 +0100 Subject: [PATCH 10/22] Lookup range keys from the schema --- bika/lims/adapters/dynamicresultsrange.py | 52 ++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/bika/lims/adapters/dynamicresultsrange.py b/bika/lims/adapters/dynamicresultsrange.py index ca8e16e126..6aca4e9467 100644 --- a/bika/lims/adapters/dynamicresultsrange.py +++ b/bika/lims/adapters/dynamicresultsrange.py @@ -6,6 +6,18 @@ marker = object() +DEFAULT_RANGE_KEYS = [ + "min", + "warn_min", + "min_operator", + "minpanic", + "max", + "warn_max", + "max", + "maxpanic", + "error", +] + @implementer(IDynamicResultsRange) class DynamicResultsRange(object): @@ -20,6 +32,21 @@ def __init__(self, analysis): if self.specification: self.dynamicspec = self.specification.getDynamicAnalysisSpec() + @property + def keyword(self): + """Analysis Keyword + """ + return self.analysis.getKeyword() + + @property + def range_keys(self): + """The keys of the result range dict + """ + if not self.specification: + return DEFAULT_RANGE_KEYS + # return the subfields of the specification + return self.specification.getField("ResultsRange").subfields + def convert(self, value): # convert referenced UIDs to the Title if api.is_uid(value): @@ -74,17 +101,30 @@ def get_results_range(self): # 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: - match = True for k, v in match_data.items(): + # continue if the values do not match if v != spec[k]: - match = False - break - if match: - return spec - return {} + 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() From 56fe0e4de9d88d4488f0ecd33b301679afd417cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Thu, 19 Dec 2019 10:26:23 +0100 Subject: [PATCH 11/22] Make DynamicSpecs field non-required --- bika/lims/content/analysisspec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bika/lims/content/analysisspec.py b/bika/lims/content/analysisspec.py index 95ecb8382c..2675ca9c1f 100644 --- a/bika/lims/content/analysisspec.py +++ b/bika/lims/content/analysisspec.py @@ -61,7 +61,7 @@ UIDReferenceField( 'DynamicAnalysisSpec', allowed_types=('DynamicAnalysisSpec',), - required=1, + required=0, widget=ReferenceWidget( label=_("Dynamic Analysis Specification"), showOn=True, From 4e303683572d114729c8bbd3e24e93e584173331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Thu, 19 Dec 2019 10:26:43 +0100 Subject: [PATCH 12/22] Subfield validators from other add-ons are dismissed, as well as their values --- .../widgets/analysisspecificationwidget.py | 17 +++++++++++++++-- bika/lims/validators.py | 13 +++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/bika/lims/browser/widgets/analysisspecificationwidget.py b/bika/lims/browser/widgets/analysisspecificationwidget.py index 03d1b225f4..96be9ceb24 100644 --- a/bika/lims/browser/widgets/analysisspecificationwidget.py +++ b/bika/lims/browser/widgets/analysisspecificationwidget.py @@ -322,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, @@ -334,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/validators.py b/bika/lims/validators.py index 2dc5088302..09c2d4c41e 100644 --- a/bika/lims/validators.py +++ b/bika/lims/validators.py @@ -777,21 +777,26 @@ def __call__(self, value, *args, **kwargs): fieldname = kwargs['field'].getName() # This value in request prevents running once per subfield value. - key = '{}{}'.format(instance.getId(), fieldname) + # self.name returns the name of the validator. This allows other + # subfield validators to be called if defined (eg. in other add-ons) + key = '{}-{}-{}'.format(self.name, instance.getId(), fieldname) if instance.REQUEST.get(key, False): return True # Walk through all AS UIDs and validate each parameter for that AS - services = request.get('service', [{}])[0] - for uid, service_name in services.items(): + service_uids = request.get("uids", []) + for uid in service_uids: err_msg = self.validate_service(request, uid) if not err_msg: continue # Validation failed + service = api.get_object_by_uid(uid) + title = api.get_title(service) + err_msg = "{}: {}".format(_("Validation for '{}' failed"), _(err_msg)) - err_msg = err_msg.format(service_name) + err_msg = err_msg.format(title) translate = api.get_tool('translation_service').translate instance.REQUEST[key] = to_utf8(translate(safe_unicode(err_msg))) return instance.REQUEST[key] From b7538e50f7a85662ddd5766f58c9f77b2d89b61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Thu, 19 Dec 2019 15:34:19 +0100 Subject: [PATCH 13/22] Better formatting of specs validators messages --- bika/lims/validators.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bika/lims/validators.py b/bika/lims/validators.py index 09c2d4c41e..29fff8ef39 100644 --- a/bika/lims/validators.py +++ b/bika/lims/validators.py @@ -794,9 +794,7 @@ def __call__(self, value, *args, **kwargs): service = api.get_object_by_uid(uid) title = api.get_title(service) - err_msg = "{}: {}".format(_("Validation for '{}' failed"), - _(err_msg)) - err_msg = err_msg.format(title) + err_msg = "{}: {}".format(title, _(err_msg)) translate = api.get_tool('translation_service').translate instance.REQUEST[key] = to_utf8(translate(safe_unicode(err_msg))) return instance.REQUEST[key] From 994fd839fac192e04f82177b90b4f9d9fbd6c498 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sat, 25 Jan 2020 23:13:15 +0100 Subject: [PATCH 14/22] Fixed specification matching Match all columns of the dynamic analysis specification correctly --- bika/lims/adapters/dynamicresultsrange.py | 38 ++++++++++++++--------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/bika/lims/adapters/dynamicresultsrange.py b/bika/lims/adapters/dynamicresultsrange.py index 6aca4e9467..853bf2f8ec 100644 --- a/bika/lims/adapters/dynamicresultsrange.py +++ b/bika/lims/adapters/dynamicresultsrange.py @@ -103,26 +103,34 @@ def get_results_range(self): rr = {} - # Iterate over the rows and return the first where all values match + # 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(): - # continue if the values do not match + # 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 - # 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 + # set the range value + rr[key] = value + # return the updated result range + return rr return rr From 12439f301ae6533ccadaa0218873623a374fb5af Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sat, 25 Jan 2020 23:18:06 +0100 Subject: [PATCH 15/22] Added doctest --- .../tests/doctests/DynamicAnalysisSpec.rst | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 bika/lims/tests/doctests/DynamicAnalysisSpec.rst diff --git a/bika/lims/tests/doctests/DynamicAnalysisSpec.rst b/bika/lims/tests/doctests/DynamicAnalysisSpec.rst new file mode 100644 index 0000000000..b7f3466a72 --- /dev/null +++ b/bika/lims/tests/doctests/DynamicAnalysisSpec.rst @@ -0,0 +1,195 @@ +Dynamic Analysis Specifications +=============================== + +A *Dynamic Analysis Specification* can be assigned to *Analysis Specifications*. + +When retrieving the result ranges (specification) for an Analysis, a lookup is +done on the *Dynamic Analysis Specification*. + +Example +------- + +Given is an Excel with the following minimal set of columns: + +| Keyword | Method | min | max | +|---------|----------|-----|-----| +| Ca | Method A | 1 | 2 | +| Ca | Method B | 3 | 4 | +| Mg | Method A | 5 | 6 | +| Mg | Method B | 7 | 8 | + +This Excel is uploaded to an *Dynamic Analysis Specification* object, which is +linked to an Analysis Specification for the Sample Type "Water". + +A new "Water" Sample is created with an containing `H2O` analysis to be tested +with `Method-2`. The results range selected will be `[3;4]`. + + +Running this test from the buildout directory: + + bin/test test_textual_doctests -t DynamicAnalysisSpec.rst + +Test Setup +---------- + +Needed imports: + + >>> from DateTime import DateTime + >>> from StringIO import StringIO + >>> from bika.lims import api + >>> from bika.lims.utils.analysisrequest import create_analysisrequest + >>> from bika.lims.workflow import doActionFor as do_action_for + >>> from openpyxl import Workbook + >>> from openpyxl.writer.excel import save_virtual_workbook + >>> from plone.app.testing import TEST_USER_ID + >>> from plone.app.testing import setRoles + >>> from plone.namedfile.file import NamedBlobFile + >>> import csv + +Some Variables: + + >>> portal = self.portal + >>> request = self.request + >>> setup = api.get_setup() + +Functional Helpers: + + >>> def new_sample(services, specification=None, results_ranges=None): + ... values = { + ... 'Client': client.UID(), + ... 'Contact': contact.UID(), + ... 'DateSampled': DateTime().strftime("%Y-%m-%d"), + ... 'SampleType': sampletype.UID(), + ... 'Analyses': map(api.get_uid, services), + ... 'Specification': specification or None } + ... + ... ar = create_analysisrequest(client, request, values) + ... transitioned = do_action_for(ar, "receive") + ... return ar + +Privileges: + + >>> setRoles(portal, TEST_USER_ID, ['Manager',]) + + +Creating a Dynamic Analysis Specification +----------------------------------------- + +Dynamic Analysis Specifications are actually only small wrappers around an Excel +file, where result ranges are defined per row. + +Let's create first a small helper function that generates an Excel for us: + + >>> def to_excel(data): + ... workbook = Workbook() + ... first_sheet = workbook.get_active_sheet() + ... reader = csv.reader(StringIO(data)) + ... for row in reader: + ... first_sheet.append(row) + ... return NamedBlobFile(save_virtual_workbook(workbook)) + +Then we create the data according to the example given above: + + >>> data = """Keyword,Method,min,max + ... Ca,Method A,1,2 + ... Ca,Method B,3,4 + ... Mg,Method A,5,6 + ... Mg,Method B,7,8""" + +Now we can create a Dynamic Analysis Specification Object: + + >>> ds = api.create(setup.dynamic_analysisspecs, "DynamicAnalysisSpec") + >>> ds.specs_file = to_excel(data) + +We can get now directly the parsed header: + + >>> header = ds.get_header() + >>> header + [u'Keyword', u'Method', u'min', u'max'] + +And the result ranges: + + >>> rr = ds.get_specs() + >>> map(lambda r: [r.get(k) for k in header], rr) + [[u'Ca', u'Method A', 1, 2], [u'Ca', u'Method B', 3, 4], [u'Mg', u'Method A', 5, 6], [u'Mg', u'Method B', 7, 8]] + +We can also get the specs by Keyword: + + >>> mg_rr = ds.get_by_keyword()["Mg"] + >>> map(lambda r: [r.get(k) for k in header], mg_rr) + [[u'Mg', u'Method A', 5, 6], [u'Mg', u'Method B', 7, 8]] + + +Hooking in a Dynamic Analysis Specification +------------------------------------------- + +Dynamic Analysis Specifications can only be assigned to a default Analysis Specification. + +First we build some basic setup structure: + + >>> client = api.create(portal.clients, "Client", Name="Happy Hills", ClientID="HH", MemberDiscountApplies=True) + >>> contact = api.create(client, "Contact", Firstname="Rita", Lastname="Mohale") + >>> labcontact = api.create(setup.bika_labcontacts, "LabContact", Firstname="Lab", Lastname="Manager") + >>> department = api.create(setup.bika_departments, "Department", title="Chemistry", Manager=labcontact) + >>> category = api.create(setup.bika_analysiscategories, "AnalysisCategory", title="Metals", Department=department) + + >>> method_a = api.create(portal.methods, "Method", title="Method A") + >>> method_b = api.create(portal.methods, "Method", title="Method B") + + >>> Ca = api.create(setup.bika_analysisservices, "AnalysisService", title="Calcium", Keyword="Ca", Category=category, Method=method_a) + >>> Mg = api.create(setup.bika_analysisservices, "AnalysisService", title="Magnesium", Keyword="Mg", Category=category, Method=method_a) + +Then we create a default Analysis Specification: + + >>> rr1 = {"keyword": "Ca", "min": 10, "max": 20, "warn_min": 9, "warn_max": 21} + >>> rr2 = {"keyword": "Mg", "min": 10, "max": 20, "warn_min": 9, "warn_max": 21} + >>> sampletype = api.create(setup.bika_sampletypes, "SampleType", title="Water", Prefix="H2O") + >>> specification = api.create(setup.bika_analysisspecs, "AnalysisSpec", title="Lab Water Spec", SampleType=sampletype.UID(), ResultsRange=[rr1, rr2]) + +And create a new sample with the given Analyses and the Specification: + + >>> services = [Ca, Mg] + >>> sample = new_sample(services, specification=specification) + >>> ca, mg = sample["Ca"], sample["Mg"] + +The specification is according to the values we have set before: + + >>> ca_spec = ca.getResultsRange() + >>> ca_spec["min"], ca_spec["max"] + (10, 20) + + >>> mg_spec = mg.getResultsRange() + >>> mg_spec["min"], mg_spec["max"] + (10, 20) + +Now we hook in our Dynamic Analysis Specification to the standard Specification: + + >>> specification.setDynamicAnalysisSpec(ds) + +The specification of the `Ca` Analysis with the Method `Method A`: + + >>> ca_spec = ca.getResultsRange() + >>> ca_spec["min"], ca_spec["max"] + (1, 2) + +Now let's change the `Ca` Analysis Method to `Method B`: + + >>> ca.setMethod(method_b) + +And get the results range again: + + >>> ca_spec = ca.getResultsRange() + >>> ca_spec["min"], ca_spec["max"] + (3, 4) + +The same now with the `Mg` Analysis in one run: + + >>> mg_spec = mg.getResultsRange() + >>> mg_spec["min"], mg_spec["max"] + (5, 6) + + >>> mg.setMethod(method_b) + + >>> mg_spec = mg.getResultsRange() + >>> mg_spec["min"], mg_spec["max"] + (7, 8) From 069a3545dad1e51654f2894e7d0d4dd0a9d0a19c Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sat, 25 Jan 2020 23:21:33 +0100 Subject: [PATCH 16/22] Try to convert the markdown table --- bika/lims/tests/doctests/DynamicAnalysisSpec.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bika/lims/tests/doctests/DynamicAnalysisSpec.rst b/bika/lims/tests/doctests/DynamicAnalysisSpec.rst index b7f3466a72..c64d7126e5 100644 --- a/bika/lims/tests/doctests/DynamicAnalysisSpec.rst +++ b/bika/lims/tests/doctests/DynamicAnalysisSpec.rst @@ -11,12 +11,14 @@ Example Given is an Excel with the following minimal set of columns: + | Keyword | Method | min | max | |---------|----------|-----|-----| | Ca | Method A | 1 | 2 | | Ca | Method B | 3 | 4 | | Mg | Method A | 5 | 6 | | Mg | Method B | 7 | 8 | + This Excel is uploaded to an *Dynamic Analysis Specification* object, which is linked to an Analysis Specification for the Sample Type "Water". From cc18f43d13b28052ecbe338bfd2a61b31b922dac Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sat, 25 Jan 2020 23:26:11 +0100 Subject: [PATCH 17/22] Use RST Table format --- bika/lims/tests/doctests/DynamicAnalysisSpec.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bika/lims/tests/doctests/DynamicAnalysisSpec.rst b/bika/lims/tests/doctests/DynamicAnalysisSpec.rst index c64d7126e5..9b2ba417fa 100644 --- a/bika/lims/tests/doctests/DynamicAnalysisSpec.rst +++ b/bika/lims/tests/doctests/DynamicAnalysisSpec.rst @@ -11,14 +11,14 @@ Example Given is an Excel with the following minimal set of columns: - -| Keyword | Method | min | max | -|---------|----------|-----|-----| -| Ca | Method A | 1 | 2 | -| Ca | Method B | 3 | 4 | -| Mg | Method A | 5 | 6 | -| Mg | Method B | 7 | 8 | - +======= ======== === === +Keyword Method min max +======= ======== === === +Ca Method A 1 2 +Ca Method B 3 4 +Mg Method A 5 6 +Mg Method B 7 8 +======= ======== === === This Excel is uploaded to an *Dynamic Analysis Specification* object, which is linked to an Analysis Specification for the Sample Type "Water". From 8e9476b6e4688db2a68efd9e47cfa13352906230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 27 Jan 2020 00:34:26 +0100 Subject: [PATCH 18/22] Display Dynamic Analysis Spec link at legacy's controlpanel --- bika/lims/upgrade/v01_03_003.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bika/lims/upgrade/v01_03_003.py b/bika/lims/upgrade/v01_03_003.py index abfc3b388f..ca14abc86e 100644 --- a/bika/lims/upgrade/v01_03_003.py +++ b/bika/lims/upgrade/v01_03_003.py @@ -272,7 +272,9 @@ def upgrade(tool): setup_form_controller_actions(portal) # Add the dynamic analysisspecs folder + # https://github.com/senaite/senaite.core/pull/1492 setup.runImportStepFromProfile(profile, "typeinfo") + setup.runImportStepFromProfile(profile, "controlpanel") add_dexterity_setup_items(portal) logger.info("{0} upgraded to version {1}".format(product, version)) From d65f27b5f45cc71336b695fa026e4f1937e72d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 27 Jan 2020 00:50:27 +0100 Subject: [PATCH 19/22] misspelling --- bika/lims/controlpanel/dynamic_analysisspecs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bika/lims/controlpanel/dynamic_analysisspecs.py b/bika/lims/controlpanel/dynamic_analysisspecs.py index 707c010cfb..5bce5cfbb3 100644 --- a/bika/lims/controlpanel/dynamic_analysisspecs.py +++ b/bika/lims/controlpanel/dynamic_analysisspecs.py @@ -21,7 +21,7 @@ class IDynamicAnalysisSpecs(model.Schema): class DynamicAnalysisSpecsView(ListingView): - """Displays all system's sampling rounds + """Displays all system's dynamic analysis specifications """ def __init__(self, context, request): From 63d0e5aa7be5bccbed4227dd40cfdcb5aa8d6a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Tue, 28 Jan 2020 12:01:24 +0100 Subject: [PATCH 20/22] Add a viewlet for when a spec has a dynamic spec assigned --- bika/lims/browser/viewlets/configure.zcml | 14 +++++++++ bika/lims/browser/viewlets/dynamic_specs.py | 31 +++++++++++++++++++ .../templates/dynamic_specs_viewlet.pt | 25 +++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 bika/lims/browser/viewlets/dynamic_specs.py create mode 100644 bika/lims/browser/viewlets/templates/dynamic_specs_viewlet.pt 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..b259911b36 --- /dev/null +++ b/bika/lims/browser/viewlets/dynamic_specs.py @@ -0,0 +1,31 @@ +# -*- 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-2019 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 +from bika.lims import api + + +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. + +

+
+
+
From 14c81ad715aca6ea34c111e24f31d495ae65c8a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Tue, 28 Jan 2020 12:36:30 +0100 Subject: [PATCH 21/22] Add column Dynamic Specification in Analysis Specs listing --- bika/lims/controlpanel/bika_analysisspecs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From ab15d24cb73eeb113ce34d8cf2e9d8f05db17afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Tue, 28 Jan 2020 12:38:40 +0100 Subject: [PATCH 22/22] Imports cleanup --- bika/lims/browser/viewlets/dynamic_specs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bika/lims/browser/viewlets/dynamic_specs.py b/bika/lims/browser/viewlets/dynamic_specs.py index b259911b36..06c878ebec 100644 --- a/bika/lims/browser/viewlets/dynamic_specs.py +++ b/bika/lims/browser/viewlets/dynamic_specs.py @@ -15,12 +15,11 @@ # this program; if not, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # -# Copyright 2018-2019 by it's authors. +# 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 -from bika.lims import api class DynamicSpecsViewlet(ViewletBase):