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 @@ + + + + + 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 @@ + + + + +