diff --git a/CHANGES.rst b/CHANGES.rst index 1ae167010d..7fe091b147 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,7 @@ Changelog **Fixed** +- #593 Fixed Price/Spec/Interim not set in AR Manage Analyses - #585 Empty value for Analysis Request column in aggregated list of analyses - #578 Fix translation for review state titles in listings - #580 Fix calculations using built-ins diff --git a/bika/lims/browser/analysisrequest/templates/ar_add2.pt b/bika/lims/browser/analysisrequest/templates/ar_add2.pt index 8b0d18679a..3d3163579a 100644 --- a/bika/lims/browser/analysisrequest/templates/ar_add2.pt +++ b/bika/lims/browser/analysisrequest/templates/ar_add2.pt @@ -534,7 +534,7 @@ class="err" size="5" placeholder="err%" i18n:attributes="placeholder" - tal:attributes="name string:${fieldname}.err:records; + tal:attributes="name string:${fieldname}.error:records; value python:service_spec.get('error');"/> @@ -711,7 +711,7 @@ class="err" size="5" placeholder="err%" i18n:attributes="placeholder" - tal:attributes="name string:${fieldname}.err:records; + tal:attributes="name string:${fieldname}.error:records; value python:service_spec.get('error');"/> diff --git a/bika/lims/browser/fields/aranalysesfield.py b/bika/lims/browser/fields/aranalysesfield.py index 675166e0a8..4287bdb270 100644 --- a/bika/lims/browser/fields/aranalysesfield.py +++ b/bika/lims/browser/fields/aranalysesfield.py @@ -5,50 +5,55 @@ # Copyright 2018 by it's authors. # Some rights reserved. See LICENSE.rst, CONTRIBUTORS.rst. +import itertools + from AccessControl import ClassSecurityInfo -from Products.Archetypes.Registry import registerField -from Products.Archetypes.public import * -from Products.Archetypes.utils import shasattr -from Products.CMFCore.utils import getToolByName -from bika.lims import api, logger +from bika.lims import api, deprecated, logger from bika.lims.catalog import CATALOG_ANALYSIS_LISTING -from bika.lims.interfaces import IARAnalysesField, IAnalysisService, IAnalysis -from bika.lims.interfaces.analysis import IRequestAnalysis +from bika.lims.interfaces import IAnalysis, IAnalysisService, IARAnalysesField from bika.lims.permissions import ViewRetractedAnalyses from bika.lims.utils.analysis import create_analysis -from plone.api.portal import get_tool +from bika.lims.workflow import wasTransitionPerformed +from Products.Archetypes.public import Field, ObjectField +from Products.Archetypes.Registry import registerField +from Products.Archetypes.utils import shasattr +from Products.CMFCore.utils import getToolByName from zope.interface import implements +"""Field to manage Analyses on ARs + +Please see the assigned doctest at tests/doctests/ARAnalysesField.rst + +Run this test from the buildout directory: + + bin/test test_textual_doctests -t ARAnalysesField +""" + class ARAnalysesField(ObjectField): """A field that stores Analyses instances - - get() returns the list of Analyses contained inside the AnalysesRequest - set() converts a sequence of UIDS to Analysis instances in the AR """ implements(IARAnalysesField) + security = ClassSecurityInfo() _properties = Field._properties.copy() _properties.update({ 'type': 'analyses', 'default': None, }) - security = ClassSecurityInfo() - security.declarePrivate('get') def get(self, instance, **kwargs): - """ get() returns the list of contained analyses - By default, return a list of catalog brains. - - If you want objects, pass full_objects = True + """Returns a list of Analyses assigned to this AR - If you want to override "ViewRetractedAnalyses", - pass retracted=True - - other kwargs are passed to bika_analysis_catalog + Return a list of catalog brains unless `full_objects=True` is passed. + Overrides "ViewRetractedAnalyses" when `retracted=True` is passed. + Other keyword arguments are passed to bika_analysis_catalog + :param instance: Analysis Request object + :param kwargs: Keyword arguments to be passed to control the output + :returns: A list of Analysis Objects/Catalog Brains """ full_objects = False @@ -59,6 +64,7 @@ def get(self, instance, **kwargs): if 'full_objects' in kwargs: full_objects = kwargs['full_objects'] del kwargs['full_objects'] + if 'get_reflexed' in kwargs: get_reflexed = kwargs['get_reflexed'] del kwargs['get_reflexed'] @@ -68,15 +74,15 @@ def get(self, instance, **kwargs): del kwargs['retracted'] else: mtool = getToolByName(instance, 'portal_membership') - retracted = mtool.checkPermission(ViewRetractedAnalyses, - instance) + retracted = mtool.checkPermission( + ViewRetractedAnalyses, instance) bac = getToolByName(instance, CATALOG_ANALYSIS_LISTING) contentFilter = dict([(k, v) for k, v in kwargs.items() if k in bac.indexes()]) contentFilter['portal_type'] = "Analysis" contentFilter['sort_on'] = "getKeyword" - contentFilter['path'] = {'query': "/".join(instance.getPhysicalPath()), + contentFilter['path'] = {'query': api.get_path(instance), 'level': 0} analyses = bac(contentFilter) if not retracted or full_objects or not get_reflexed: @@ -99,158 +105,236 @@ def get(self, instance, **kwargs): security.declarePrivate('set') def set(self, instance, items, prices=None, specs=None, **kwargs): - """Set the 'Analyses' field value, by creating and removing Analysis - objects from the AR. + """Set/Assign Analyses to this AR + + :param items: List of Analysis objects/brains, AnalysisService + objects/brains and/or Analysis Service uids + :type items: list + :param prices: Mapping of AnalysisService UID -> price + :type prices: dict + :param specs: List of AnalysisService UID -> Result Range Record mappings + :type specs: list + :returns: list of new assigned Analyses + """ - items is a list that contains the items to be set: - The list can contain Analysis objects/brains, AnalysisService - objects/brains and/or Analysis Service uids. + # This setter returns a list of new set Analyses + new_analyses = [] - prices is a dictionary: - key = AnalysisService UID - value = price + # Prevent removing all Analyses + if not items: + logger.warn("Not allowed to remove all Analyses from AR.") + return new_analyses - specs is a dictionary: - key = AnalysisService UID - value = dictionary: defined in ResultsRange field definition + # Bail out if the items is not a list type + if not isinstance(items, (list, tuple)): + raise TypeError( + "Items parameter must be a tuple or list, got '{}'".format( + type(items))) - """ - if not items: - return + # Bail out if the AR in frozen state + if self._is_frozen(instance): + raise ValueError( + "Analyses can not be modified for inactive/verified ARs") - assert isinstance(items, - (list, tuple)), "items must be a list or a tuple" - - # Convert the items list to a list of service uids and remove empties - service_uids = map(self._get_service_uid, items) - service_uids = filter(None, service_uids) - - bsc = getToolByName(instance, 'bika_setup_catalog') - workflow = getToolByName(instance, 'portal_workflow') - - # one can only edit Analyses up to a certain state. - ar_state = workflow.getInfoFor(instance, 'review_state', '') - assert ar_state in ('sample_registered', 'sampled', - 'to_be_sampled', 'to_be_preserved', - 'sample_due', 'sample_received', - 'attachment_due', 'to_be_verified') - - # - Modify existing AR specs with new form values for selected analyses. - # - new analysis requests are also using this function, so ResultsRange - # may be undefined. in this case, specs= will contain the entire - # AR spec. - rr = instance.getResultsRange() - specs = specs if specs else [] - for s in specs: - s_in_rr = False - for i, r in enumerate(rr): - if s['keyword'] == r['keyword']: - rr[i].update(s) - s_in_rr = True - if not s_in_rr: - rr.append(s) - instance.setResultsRange(rr) + # Convert the items to a valid list of AnalysisServices + services = filter(None, map(self._to_service, items)) - new_analyses = [] - proxies = bsc(UID=service_uids) - for proxy in proxies: - service = proxy.getObject() + # Calculate dependencies + # FIXME Infinite recursion error possible here, if the formula includes + # the Keyword of the Service that includes the Calculation + dependencies = map(lambda s: s.getServiceDependencies(), services) + dependencies = list(itertools.chain.from_iterable(dependencies)) + + # Merge dependencies and services + services = set(services + dependencies) + + # Service UIDs + service_uids = map(api.get_uid, services) + + # Modify existing AR specs with new form values of selected analyses. + self._update_specs(instance, specs) + + for service in services: keyword = service.getKeyword() - # analysis->InterimFields - calc = service.getCalculation() - interim_fields = calc and list(calc.getInterimFields()) or [] - - # override defaults from service->InterimFields - service_interims = service.getInterimFields() - sif = dict([(x['keyword'], x.get('value', '')) - for x in service_interims]) - for i, i_f in enumerate(interim_fields): - if i_f['keyword'] in sif: - interim_fields[i]['value'] = sif[i_f['keyword']] - service_interims = [x for x in service_interims - if x['keyword'] != i_f['keyword']] - # Add remaining service interims to the analysis - for v in service_interims: - interim_fields.append(v) - - # create the analysis if it doesn't exist + # Create the Analysis if it doesn't exist if shasattr(instance, keyword): analysis = instance._getOb(keyword) else: + # TODO Entry point for interims assignment and Calculation + # decoupling from Analysis. See comments PR#593 analysis = create_analysis(instance, service) + # TODO Remove when the `create_analysis` function supports this + # Set the interim fields only for new created Analysis + self._update_interims(analysis, service) new_analyses.append(analysis) - for i, r in enumerate(rr): - if r['keyword'] == analysis.getKeyword(): - r['uid'] = analysis.UID() + + # Set the price of the Analysis + self._update_price(analysis, service, prices) # delete analyses delete_ids = [] for analysis in instance.objectValues('Analysis'): service_uid = analysis.getServiceUID() - if service_uid not in service_uids: - # If it is verified or published, don't delete it. - state = workflow.getInfoFor(analysis, 'review_state') - if state in ('verified', 'published'): - continue - # If it is assigned to a worksheet, unassign it before deletion. - state = workflow.getInfoFor(analysis, - 'worksheetanalysis_review_state') - if state == 'assigned': - ws = analysis.getBackReferences("WorksheetAnalysis")[0] - ws.removeAnalysis(analysis) - # Unset the partition reference - analysis.edit(SamplePartition=None) - delete_ids.append(analysis.getId()) + + # Skip assigned Analyses + if service_uid in service_uids: + continue + + # Skip Analyses in frozen states + if self._is_frozen(analysis): + logger.warn("Inactive/verified Analyses can not be removed.") + continue + + # If it is assigned to a worksheet, unassign it before deletion. + if self._is_assigned_to_worksheet(analysis): + backrefs = self._get_assigned_worksheets(analysis) + ws = backrefs[0] + ws.removeAnalysis(analysis) + + # Unset the partition reference + analysis.edit(SamplePartition=None) + delete_ids.append(analysis.getId()) if delete_ids: # Note: subscriber might promote the AR instance.manage_delObjects(ids=delete_ids) - return new_analyses - security.declarePublic('Vocabulary') + return new_analyses - def Vocabulary(self, content_instance=None): - """ Create a vocabulary from analysis services + def _get_services(self, full_objects=False): + """Fetch and return analysis service objects """ - vocab = [] - for service in self.Services(): - vocab.append((service.UID(), service.Title())) - return vocab + bsc = api.get_tool('bika_setup_catalog') + brains = bsc(portal_type='AnalysisService') + if full_objects: + return map(api.get_object, brains) + return brains - security.declarePublic('Services') + def _to_service(self, thing): + """Convert to Analysis Service - def Services(self): - """ Return analysis services + :param thing: UID/Catalog Brain/Object/Something + :returns: Analysis Service object or None """ - bsc = get_tool('bika_setup_catalog') - brains = bsc(portal_type='AnalysisService') - return [proxy.getObject() for proxy in brains] - def _get_service_uid(self, item): - if api.is_uid(item): - return item + # Convert UIDs to objects + if api.is_uid(thing): + thing = api.get_object_by_uid(thing, None) - if not api.is_object(item): - logger.warn("Not an UID: {}".format(item)) + # Bail out if the thing is not a valid object + if not api.is_object(thing): + logger.warn("'{}' is not a valid object!".format(repr(thing))) return None - obj = api.get_object(item) + # Ensure we have an object here and not a brain + obj = api.get_object(thing) + if IAnalysisService.providedBy(obj): - return api.get_uid(obj) + return obj - if IAnalysis.providedBy(obj) and IRequestAnalysis.providedBy(obj): - return obj.getServiceUID() + if IAnalysis.providedBy(obj): + return obj.getAnalysisService() # An object, but neither an Analysis nor AnalysisService? # This should never happen. msg = "ARAnalysesField doesn't accept objects from {} type. " \ - "The object will be dismissed." - logger.warn(msg.format(api.get_portal_type(obj))) + "The object will be dismissed.".format(api.get_portal_type(obj)) + logger.warn(msg) return None + def _is_frozen(self, brain_or_object): + """Check if the passed in object is frozen + + :param obj: Analysis or AR Brain/Object + :returns: True if the object is frozen + """ + obj = api.get_object(brain_or_object) + active = api.is_active(obj) + verified = wasTransitionPerformed(obj, 'verify') + return not active or verified + + def _get_assigned_worksheets(self, analysis): + """Return the assigned worksheets of this Analysis + + :param analysis: Analysis Brain/Object + :returns: Worksheet Backreferences + """ + analysis = api.get_object(analysis) + return analysis.getBackReferences("WorksheetAnalysis") + + def _is_assigned_to_worksheet(self, analysis): + """Check if the Analysis is assigned to a worksheet + + :param analysis: Analysis Brain/Object + :returns: True if the Analysis is assigned to a WS + """ + analysis = api.get_object(analysis) + state = api.get_workflow_status_of( + analysis, state_var='worksheetanalysis_review_state') + return state == "assigned" + + def _update_interims(self, analysis, service): + """Update Interim Fields of the Analysis + + :param analysis: Analysis Object + :param service: Analysis Service Object + """ + service_interims = service.getInterimFields() + analysis.setInterimFields(service_interims) + + def _update_price(self, analysis, service, prices): + """Update the Price of the Analysis + + :param analysis: Analysis Object + :param service: Analysis Service Object + :param prices: Price mapping + """ + prices = prices or {} + price = prices.get(service.UID(), service.getPrice()) + analysis.setPrice(price) + + def _update_specs(self, instance, specs): + """Update AR specifications + + :param instance: Analysis Request + :param specs: List of Specification Records + """ + + if specs is None: + return + + rr = {item["keyword"]: item for item in instance.getResultsRange()} + for spec in specs: + keyword = spec.get("keyword") + if keyword in rr: + rr[keyword].update(spec) + else: + rr[keyword] = spec + return instance.setResultsRange(rr.values()) + + # DEPRECATED: The following code should not be in the field's domain + + security.declarePublic('Vocabulary') + + @deprecated("Please refactor, this method will be removed in senaite.core 1.5") + def Vocabulary(self, content_instance=None): + """Create a vocabulary from analysis services + """ + vocab = [] + for service in self._get_services(): + vocab.append((api.get_uid(service), api.get_title(service))) + return vocab + + security.declarePublic('Services') + + @deprecated("Please refactor, this method will be removed in senaite.core 1.5") + def Services(self): + """Fetch and return analysis service objects + """ + return self._get_services(full_objects=True) + registerField(ARAnalysesField, - title='Analyses', - description='Used for Analysis instances' - ) + title="Analyses", + description="Manages Analyses of ARs") diff --git a/bika/lims/browser/js/bika.lims.site.js b/bika/lims/browser/js/bika.lims.site.js index 9fe94317e8..3bb7904b7b 100644 --- a/bika/lims/browser/js/bika.lims.site.js +++ b/bika/lims/browser/js/bika.lims.site.js @@ -698,10 +698,12 @@ /* * Eventhandler when the user pressed a key inside a numeric field. */ - var isAllowedKey, key; + var $el, el, isAllowedKey, key; console.debug("°°° SiteView::on_numeric_field_keypress °°°"); + el = event.currentTarget; + $el = $(el); key = event.which; - isAllowedKey = this.allowedKeys.join(',').match(new RegExp(key)); + isAllowedKey = this.allowed_keys.join(',').match(new RegExp(key)); if (!key || 48 <= key && key <= 57 || isAllowedKey) { window.setTimeout((function() { $el.val($el.val().replace(',', '.')); diff --git a/bika/lims/browser/js/coffee/bika.lims.site.coffee b/bika/lims/browser/js/coffee/bika.lims.site.coffee index cc58af9057..9d47b43826 100644 --- a/bika/lims/browser/js/coffee/bika.lims.site.coffee +++ b/bika/lims/browser/js/coffee/bika.lims.site.coffee @@ -719,9 +719,11 @@ class window.SiteView ### console.debug "°°° SiteView::on_numeric_field_keypress °°°" + el = event.currentTarget + $el = $(el) key = event.which - isAllowedKey = @allowedKeys.join(',').match(new RegExp(key)) + isAllowedKey = @allowed_keys.join(',').match(new RegExp(key)) # IE doesn't support indexOf # Some browsers just don't raise events for control keys. Easy. e.g. Safari backspace. diff --git a/bika/lims/tests/doctests/ARAnalysesField.rst b/bika/lims/tests/doctests/ARAnalysesField.rst new file mode 100644 index 0000000000..0cb25db884 --- /dev/null +++ b/bika/lims/tests/doctests/ARAnalysesField.rst @@ -0,0 +1,643 @@ +AR Analyses Field +================= + +This field manages Analyses for Analysis Requests. + +It is capable to perform the following tasks: + + - Create Analyses from Analysis Services + - Delete assigned Analyses + - Update Prices of assigned Analyses + - Update Specifications of assigned Analyses + - Update Interim Fields of assigned Analyses + +Running this test from the buildout directory:: + + bin/test test_textual_doctests -t ARAnalysesField + + +Test Setup +---------- + +Imports: + + >>> import transaction + >>> from operator import methodcaller + >>> from DateTime import DateTime + >>> from plone import api as ploneapi + + >>> from bika.lims import api + >>> from bika.lims.utils.analysisrequest import create_analysisrequest + +Functional Helpers: + + >>> def start_server(): + ... from Testing.ZopeTestCase.utils import startZServer + ... ip, port = startZServer() + ... return "http://{}:{}/{}".format(ip, port, portal.id) + + >>> def timestamp(format="%Y-%m-%d"): + ... return DateTime().strftime(format) + +Variables:: + + >>> date_now = timestamp() + >>> portal = self.portal + >>> request = self.request + >>> setup = portal.bika_setup + >>> calculations = setup.bika_calculations + >>> sampletypes = setup.bika_sampletypes + >>> samplepoints = setup.bika_samplepoints + >>> analysiscategories = setup.bika_analysiscategories + >>> analysisspecs = setup.bika_analysisspecs + >>> analysisservices = setup.bika_analysisservices + >>> labcontacts = setup.bika_labcontacts + >>> worksheets = setup.worksheets + >>> storagelocations = setup.bika_storagelocations + >>> samplingdeviations = setup.bika_samplingdeviations + >>> sampleconditions = setup.bika_sampleconditions + >>> portal_url = portal.absolute_url() + >>> setup_url = portal_url + "/bika_setup" + +Test User: + + >>> from plone.app.testing import TEST_USER_ID + >>> from plone.app.testing import setRoles + >>> setRoles(portal, TEST_USER_ID, ['Manager',]) + + +Prepare Test Environment +------------------------ + +Create Client: + + >>> clients = self.portal.clients + >>> client = api.create(clients, "Client", Name="Happy Hills", ClientID="HH") + >>> client + + +Create some Contact(s): + + >>> contact1 = api.create(client, "Contact", Firstname="Client", Surname="One") + >>> contact1 + + + >>> contact2 = api.create(client, "Contact", Firstname="Client", Surname="Two") + >>> contact2 + + +Create a Sample Type: + + >>> sampletype = api.create(sampletypes, "SampleType", Prefix="water", MinimumVolume="100 ml") + >>> sampletype + + +Create a Sample Point: + + >>> samplepoint = api.create(samplepoints, "SamplePoint", title="Lake Python") + >>> samplepoint + + +Create an Analysis Category: + + >>> analysiscategory = api.create(analysiscategories, "AnalysisCategory", title="Water") + >>> analysiscategory + + +Create Analysis Service for PH (Keyword: `PH`): + + >>> analysisservice1 = api.create(analysisservices, "AnalysisService", title="PH", ShortTitle="ph", Category=analysiscategory, Keyword="PH", Price="10") + >>> analysisservice1 + + +Create Analysis Service for Magnesium (Keyword: `MG`): + + >>> analysisservice2 = api.create(analysisservices, "AnalysisService", title="Magnesium", ShortTitle="mg", Category=analysiscategory, Keyword="MG", Price="20") + >>> analysisservice2 + + +Create Analysis Service for Calcium (Keyword: `CA`): + + >>> analysisservice3 = api.create(analysisservices, "AnalysisService", title="Calcium", ShortTitle="ca", Category=analysiscategory, Keyword="CA", Price="30") + >>> analysisservice3 + + +Create Analysis Service for Total Hardness (Keyword: `THCaCO3`): + + >>> analysisservice4 = api.create(analysisservices, "AnalysisService", title="Total Hardness", ShortTitle="Tot. Hard", Category=analysiscategory, Keyword="THCaCO3", Price="40") + >>> analysisservice4 + + +Create some Calculations with Formulas referencing existing AS keywords: + + >>> calc1 = api.create(calculations, "Calculation", title="Round") + >>> calc1.setFormula("round(12345, 2)") + + >>> calc2 = api.create(calculations, "Calculation", title="A in ppt") + >>> calc2.setFormula("[A] * 1000") + + >>> calc3 = api.create(calculations, "Calculation", title="B in ppt") + >>> calc3.setFormula("[B] * 1000") + + >>> calc4 = api.create(calculations, "Calculation", title="Total Hardness") + >>> calc4.setFormula("[CA] + [MG]") + +Assign the calculations to the Analysis Services: + + >>> analysisservice1.setCalculation(calc1) + >>> analysisservice2.setCalculation(calc2) + >>> analysisservice3.setCalculation(calc3) + >>> analysisservice4.setCalculation(calc4) + +Create an Analysis Specification for `Water`: + + >>> sampletype_uid = api.get_uid(sampletype) + + >>> rr1 = {"keyword": "PH", "min": 5, "max": 7, "error": 10, "hidemin": "", "hidemax": "", "rangecomment": "Lab PH Spec"} + >>> rr2 = {"keyword": "MG", "min": 5, "max": 7, "error": 10, "hidemin": "", "hidemax": "", "rangecomment": "Lab MG Spec"} + >>> rr3 = {"keyword": "CA", "min": 5, "max": 7, "error": 10, "hidemin": "", "hidemax": "", "rangecomment": "Lab CA Spec"} + >>> rr = [rr1, rr2, rr3] + + >>> analysisspec1 = api.create(analysisspecs, "AnalysisSpec", title="Lab Water Spec", SampleType=sampletype_uid, ResultsRange=rr) + +Create an Analysis Request: + + >>> values = { + ... 'Client': client.UID(), + ... 'Contact': contact1.UID(), + ... 'CContact': contact2.UID(), + ... 'SamplingDate': date_now, + ... 'DateSampled': date_now, + ... 'SampleType': sampletype.UID(), + ... 'Priority': '1', + ... } + + >>> service_uids = [analysisservice1.UID()] + >>> ar = create_analysisrequest(client, request, values, service_uids) + >>> ar + + + +ARAnalysesField +--------------- + +This field maintains `Analyses` within `AnalysesRequests`: + + >>> field = ar.getField("Analyses") + >>> field.type + 'analyses' + + >>> from bika.lims.interfaces import IARAnalysesField + >>> IARAnalysesField.providedBy(field) + True + + +Getting Analyses +................ + +The `get` method returns a list of assined analyses brains: + + >>> field.get(ar) + [] + +The full objects can be obtained by passing in `full_objects=True`: + + >>> field.get(ar, full_objects=True) + [] + +The analysis `PH` is now contained in the AR: + + >>> ar.objectValues("Analysis") + [] + + +Setting Analyses +................ + +The `set` method returns a list of new created analyses. + +The field takes the following parameters: + + - items is a list that contains the items to be set: + The list can contain Analysis objects/brains, AnalysisService + objects/brains and/or Analysis Service uids. + + - prices is a dictionary: + key = AnalysisService UID + value = price + + - specs is a list of dictionaries: + key = AnalysisService UID + value = dictionary: defined in ResultsRange field definition + +Pass in all prior created Analysis Services: + + >>> all_services = [analysisservice1, analysisservice2, analysisservice3] + >>> new_analyses = field.set(ar, all_services) + +We expect to have now the `CA` and `MG` Analyses as well: + + >>> sorted(new_analyses, key=methodcaller('getId')) + [, ] + +In the Analyis Request should be now three Analyses: + + >>> len(ar.objectValues("Analysis")) + 3 + +Removing Analyses is done by omitting those from the `items` list: + + >>> new_analyses = field.set(ar, [analysisservice1]) + >>> sorted(new_analyses, key=methodcaller('getId')) + [] + +Now there should be again only one Analysis assigned: + + >>> len(ar.objectValues("Analysis")) + 1 + +We expect to have just the `PH` Analysis again: + + >>> ar.objectValues("Analysis") + [] + +Removing all Analyses is prevented, because it can not be empty: + + >>> new_analyses = field.set(ar, []) + >>> ar.objectValues("Analysis") + [] + +The field can also handle UIDs of Analyses Services: + + >>> service_uids = map(api.get_uid, all_services) + >>> new_analyses = field.set(ar, service_uids) + +We expect again to have the `CA` and `MG` Analyses as well: + + >>> sorted(new_analyses, key=methodcaller('getId')) + [, ] + +And all the three Analyses in total: + + >>> sorted(ar.objectValues("Analysis"), key=methodcaller("getId")) + [, , ] + +Set again only the `PH` Analysis: + + >>> new_analyses = field.set(ar, [analysisservice1]) + >>> ar.objectValues("Analysis") + [] + +The field should also handle catalog brains: + + >>> brains = api.search({"portal_type": "AnalysisService", "getKeyword": "CA"}) + >>> brains + [] + + >>> brain = brains[0] + >>> api.get_title(brain) + 'Calcium' + + >>> new_analyses = field.set(ar, [brain]) + +We expect now to have just the `CA` analysis assigned: + + >>> ar.objectValues("Analysis") + [] + +Now let's try int mixed, one catalog brain and one object: + + >>> new_analyses = field.set(ar, [analysisservice1, brain]) + +We expect now to have now `PH` and `CA`: + + >>> sorted(ar.objectValues("Analysis"), key=methodcaller("getId")) + [, ] + +Finally, we test it with an `Analysis` object: + + >>> analysis1 = ar["PH"] + >>> new_analyses = field.set(ar, [analysis1]) + + >>> sorted(ar.objectValues("Analysis"), key=methodcaller("getId")) + [] + + +Setting Analysis Specifications +............................... + +Specifications are defined on the `ResultsRange` field of an Analysis Request. +It is a dictionary with the following keys and values: + + - keyword: The Keyword of the Analysis Service + - min: The minimum allowed value + - max: The maximum allowed value + - error: The error percentage + - hidemin: ? + - hidemax: ? + - rangecomment: ? + +Each Analysis can request its own Specification (Result Range): + + >>> new_analyses = field.set(ar, all_services) + + >>> analysis1 = ar[analysisservice1.getKeyword()] + >>> analysis2 = ar[analysisservice2.getKeyword()] + >>> analysis3 = ar[analysisservice3.getKeyword()] + +The precedence of Specification lookup is AR -> Client -> Lab. Therefore, we +expect to get the prior added Water Specification of the Lab for each Analysis. + + >>> spec1 = analysis1.getResultsRange() + >>> spec1.get("rangecomment") + 'Lab PH Spec' + + >>> spec2 = analysis2.getResultsRange() + >>> spec2.get("rangecomment") + 'Lab MG Spec' + + >>> spec3 = analysis3.getResultsRange() + >>> spec3.get("rangecomment") + 'Lab CA Spec' + +Now we will set the analyses with custom specifications through the +ARAnalysesField. This should set the custom Specifications on the Analysis +Request and have precedence over the lab specifications: + + >>> arr1 = {"keyword": "PH", "min": 5.5, "max": 7.5, "error": 5, "hidemin": "", "hidemax": "", "rangecomment": "My PH Spec"} + >>> arr2 = {"keyword": "MG", "min": 5.5, "max": 7.5, "error": 5, "hidemin": "", "hidemax": "", "rangecomment": "My MG Spec"} + >>> arr3 = {"keyword": "CA", "min": 5.5, "max": 7.5, "error": 5, "hidemin": "", "hidemax": "", "rangecomment": "My CA Spec"} + >>> arr = [arr1, arr2, arr3] + + >>> all_analyses = [analysis1, analysis2, analysis3] + >>> new_analyses = field.set(ar, all_analyses, specs=arr) + + >>> myspec1 = analysis1.getResultsRange() + >>> myspec1.get("rangecomment") + 'My PH Spec' + + >>> myspec2 = analysis2.getResultsRange() + >>> myspec2.get("rangecomment") + 'My MG Spec' + + >>> myspec3 = analysis3.getResultsRange() + >>> myspec3.get("rangecomment") + 'My CA Spec' + +All Result Ranges are set on the AR: + + >>> sorted(map(lambda r: r.get("rangecomment"), ar.getResultsRange())) + ['My CA Spec', 'My MG Spec', 'My PH Spec'] + +Now we simulate the form input data of the ARs "Manage Analysis" form, so that +the User only selected the `PH` service and gave some custom specifications for +this Analysis. + +The specifications get applied if the keyword matches: + + >>> ph_specs = {"keyword": analysis1.getKeyword(), "min": 5.2, "max": 7.9, "error": 3} + >>> new_analyses = field.set(ar, [analysis1], specs=[ph_specs]) + +We expect to have now just one Analysis set: + + >>> analyses = field.get(ar, full_objects=True) + >>> analyses + [] + +And the specification should be according to the values we have set + + >>> ph = analyses[0] + >>> phspec = ph.getResultsRange() + + >>> phspec.get("min") + 5.2 + + >>> phspec.get("max") + 7.9 + + >>> phspec.get("error") + 3 + + +Setting Analyses Prices +....................... + +Prices are primarily defined on Analyses Services: + + >>> analysisservice1.getPrice() + '10.00' + + >>> analysisservice2.getPrice() + '20.00' + + >>> analysisservice3.getPrice() + '30.00' + +Created Analyses inherit that price: + + >>> new_analyses = field.set(ar, all_services) + + >>> analysis1 = ar[analysisservice1.getKeyword()] + >>> analysis2 = ar[analysisservice2.getKeyword()] + >>> analysis3 = ar[analysisservice3.getKeyword()] + + >>> analysis1.getPrice() + '10.00' + + >>> analysis2.getPrice() + '20.00' + + >>> analysis3.getPrice() + '30.00' + +The `setter` also allows to set custom prices for the Analyses: + + >>> prices = { + ... analysisservice1.UID(): "100", + ... analysisservice2.UID(): "200", + ... analysisservice3.UID(): "300", + ... } + +Now we set the field with all analyses services and new prices: + + >>> new_analyses = field.set(ar, all_services, prices=prices) + +The Analyses have now the new prices: + + >>> analysis1.getPrice() + '100.00' + + >>> analysis2.getPrice() + '200.00' + + >>> analysis3.getPrice() + '300.00' + +The Services should retain the old prices: + + >>> analysisservice1.getPrice() + '10.00' + + >>> analysisservice2.getPrice() + '20.00' + + >>> analysisservice3.getPrice() + '30.00' + + +Calculations and Interim Fields +............................... + +When an Analysis is assigned to an AR, it inherits its Calculation and Interim Fields. + +Create some interim fields: + + >>> interim1 = {"keyword": "A", "title": "Interim A", "value": 1, "hidden": False, "type": "int", "unit": "x"} + >>> interim2 = {"keyword": "B", "title": "Interim B", "value": 2, "hidden": False, "type": "int", "unit": "x"} + >>> interim3 = {"keyword": "C", "title": "Interim C", "value": 3, "hidden": False, "type": "int", "unit": "x"} + >>> interim4 = {"keyword": "D", "title": "Interim D", "value": 4, "hidden": False, "type": "int", "unit": "x"} + +Append interim field `A` to the `Total Hardness` Calculation: + + >>> calc4.setInterimFields([interim1]) + >>> map(lambda x: x["keyword"], calc4.getInterimFields()) + ['A'] + +Append interim field `B` to the `Total Hardness` Analysis Service: + + >>> analysisservice4.setInterimFields([interim2]) + >>> map(lambda x: x["keyword"], analysisservice4.getInterimFields()) + ['B'] + +Now we assign the `Total Hardness` Analysis Service: + + >>> new_analyses = field.set(ar, [analysisservice4]) + >>> analysis = new_analyses[0] + >>> analysis + + +The created Analysis has the same Calculation attached, as the Analysis Service: + + >>> analysis_calc = analysis.getCalculation() + >>> analysis_calc + + +And therofore, also the same Interim Fields as the Calculation: + + >>> map(lambda x: x["keyword"], analysis_calc.getInterimFields()) + ['A'] + +The Analysis also inherits the Interim Fields of the Analysis Service: + + >>> map(lambda x: x["keyword"], analysis.getInterimFields()) + ['B'] + +But what happens if the Interim Fields of either the Analysis Service or of the +Calculation change and the AR is updated with the same Analysis Service? + +Change the Interim Field of the Calculation to `C`: + + >>> calc4.setInterimFields([interim3]) + >>> map(lambda x: x["keyword"], calc4.getInterimFields()) + ['C'] + +Change the Interim Fields of the Analysis Service to `D`: + + >>> analysisservice4.setInterimFields([interim4]) + >>> map(lambda x: x["keyword"], analysisservice4.getInterimFields()) + ['D'] + +Update the AR with the new Analysis Service: + + >>> new_analyses = field.set(ar, [analysisservice4]) + +Since no new Analyses were created, the field should return an empty list: + + >>> new_analyses + [] + +The Analysis should be still there: + + >>> analysis = ar[analysisservice4.getKeyword()] + >>> analysis + + +The calculation should be still there: + + >>> analysis_calc = analysis.getCalculation() + >>> analysis_calc + + +And therefore, also the same Interim Fields as the Calculation: + + >>> map(lambda x: x["keyword"], analysis_calc.getInterimFields()) + ['C'] + +The existing Analysis retains the initial Interim Fields of the Analysis Service: + + >>> map(lambda x: x["keyword"], analysis.getInterimFields()) + ['B'] + + +Worksheets +.......... + +If the an Analysis is assigned to a worksheet, it should be detached before it +is removed from an Analysis Request. + +Assign the `PH` Analysis: + + >>> new_analyses = field.set(ar, [analysisservice1]) + >>> new_analyses + [] + +Create a new Worksheet and assign the Analysis to it: + + >>> ws = api.create(worksheets, "Worksheet", "WS") + >>> analysis = new_analyses[0] + >>> ws.addAnalysis(analysis) + +The analysis should be now in the 'assigned' state: + + >>> api.get_workflow_status_of(analysis, state_var='worksheetanalysis_review_state') + 'assigned' + +The worksheet has now the Analysis assigned: + + >>> ws.getAnalyses() + [] + +Removing the analysis from the AR also unassignes it from the worksheet: + + >>> new_analyses = field.set(ar, [analysisservice2]) + >>> new_analyses + [] + + >>> ws.getAnalyses() + [] + + +Dependencies +............ + +The Analysis Service `Total Hardness` uses the `Total Hardness` Calculation: + + >>> analysisservice4.getCalculation() + + +The Calculation is dependent on the `CA` and `MG` Services through its Formula: + + >>> analysisservice4.getCalculation().getFormula() + '[CA] + [MG]' + +Get the dependent services: + + >>> sorted(analysisservice4.getServiceDependencies(), key=methodcaller('getId')) + [, ] + +We expect that dependent services get automatically set: + + >>> new_analyses = field.set(ar, [analysisservice4], debug=1) + + >>> sorted(ar.objectValues("Analysis"), key=methodcaller('getId')) + [, , ]