diff --git a/bika/lims/content/abstractanalysis.py b/bika/lims/content/abstractanalysis.py index e34883604f..7740d5d6ff 100644 --- a/bika/lims/content/abstractanalysis.py +++ b/bika/lims/content/abstractanalysis.py @@ -33,6 +33,8 @@ from bika.lims.utils.analysis import create_analysis, format_numeric_result from bika.lims.utils.analysis import get_significant_digits from bika.lims.workflow import doActionFor +from bika.lims.workflow import getTransitionActor +from bika.lims.workflow import getTransitionDate from bika.lims.workflow import isBasicTransitionAllowed from bika.lims.workflow import isTransitionAllowed from bika.lims.workflow import wasTransitionPerformed @@ -108,18 +110,6 @@ 'DetectionLimitOperand' ) -# This is used to calculate turnaround time reports. -# The value is set when the Analysis is published. -Duration = IntegerField( - 'Duration', -) - -# This is used to calculate turnaround time reports. The value is set when the -# Analysis is published. -Earliness = IntegerField( - 'Earliness', -) - # The ID of the logged in user who submitted the result for this Analysis. Analyst = StringField( 'Analyst' @@ -156,8 +146,6 @@ Calculation, DateAnalysisPublished, DetectionLimitOperand, - Duration, - Earliness, # NumberOfRequiredVerifications overrides AbstractBaseClass NumberOfRequiredVerifications, Result, @@ -617,6 +605,67 @@ def getTotalPrice(self): """ return Decimal(self.getPrice()) + Decimal(self.getVATAmount()) + @security.public + def getDuration(self): + """Returns the time in minutes taken for this analysis. + If the analysis is not yet 'ready to process', returns 0 + If the analysis is still in progress (not yet verified), + duration = date_verified - date_start_process + Otherwise: + duration = current_datetime - date_start_process + :return: time in minutes taken for this analysis + :rtype: int + """ + starttime = self.getStartProcessDate() + if not starttime: + # The analysis is not yet ready to be processed + return 0 + + endtime = self.getDateVerified() + if not endtime: + # Assume here the analysis is still in progress, so use the current + # Date and Time + endtime = DateTime() + + # Duration in minutes + duration = (endtime - starttime) * 24 * 60 + return duration + + @security.public + def getEarliness(self): + """The remaining time in minutes for this analysis to be completed. + Returns zero if the analysis is neither 'ready to process' nor a + turnaround time is set. + earliness = duration - max_turnaround_time + The analysis is late if the earliness is negative + :return: the remaining time in minutes before the analysis reaches TAT + :rtype: int + """ + maxtime = self.getMaxTimeAllowed() + if not maxtime: + # No Turnaround time is set for this analysis + return 0 + maxtime_delta = int(maxtime.get('days', 0)) * 86400 + maxtime_delta += int(maxtime.get('hours', 0)) * 3600 + maxtime_delta += int(maxtime.get('minutes', 0)) + duration = self.getDuration() + earliness = maxtime_delta - duration + return earliness + + def isLateAnalysis(self): + """Returns true if the analysis is late in accordance with the maximum + turnaround time. If no maximum turnaround time is set for this analysis + or it is not yet ready to be processed, or there is still time + remaining (earliness), returns False. + :return: true if the analysis is late + :rtype: bool + """ + maxtime = self.getMaxTimeAllowed() + if not maxtime: + # No maximum turnaround time set, assume is not late + return False + return self.getEarliness() < 0 + @security.public def isInstrumentValid(self): """Checks if the instrument selected for this analysis is valid. @@ -1050,32 +1099,35 @@ def getSubmittedBy(self): state of the current analysis is "to_be_verified" or "verified" :return: the user_id of the user who did the last submission of result """ - workflow = getToolByName(self, "portal_workflow") - try: - review_history = workflow.getInfoFor(self, "review_history") - review_history = self.reverseList(review_history) - for event in review_history: - if event.get("action") == "submit": - return event.get("actor") - return '' - except WorkflowException: - return '' + return getTransitionActor(self, 'submit') @security.public def getDateSubmitted(self): """Returns the time the result was submitted. :return: a DateTime object. + :rtype: DateTime """ - workflow = getToolByName(self, "portal_workflow") - try: - review_history = workflow.getInfoFor(self, "review_history") - review_history = self.reverseList(review_history) - for event in review_history: - if event.get("action") == "submit": - return event.get("time") - return '' - except WorkflowException: - return '' + return getTransitionDate(self, 'submit', return_as_datetime=True) + + @security.public + def getDateVerified(self): + """Returns the time the analysis was verified. If the analysis hasn't + been yet verified, returns None + :return: the time the analysis was verified or None + :rtype: DateTime + """ + return getTransitionDate(self, 'verify', return_as_datetime=True) + + @security.public + def getStartProcessDate(self): + """Returns the date time when the analysis is ready to be processed. + It returns the datetime when the object was created, but might be + different depending on the type of analysis (e.g. "Date Received" for + routine analyses): see overriden functions. + :return: Date time when the analysis is ready to be processed. + :rtype: DateTime + """ + return self.created() @security.public def getParentUID(self): @@ -1245,11 +1297,6 @@ def workflow_script_retract(self): def workflow_script_verify(self): events.after_verify(self) - @deprecated('[1705] Use bika.lims.workflow.analysis.events.after_publish') - @security.public - def workflow_script_publish(self): - events.after_publish(self) - @deprecated('[1705] Use bika.lims.workflow.analysis.events.after_cancel') @security.public def workflow_script_cancel(self): diff --git a/bika/lims/content/abstractroutineanalysis.py b/bika/lims/content/abstractroutineanalysis.py index acb2e5c017..0669d73bd3 100644 --- a/bika/lims/content/abstractroutineanalysis.py +++ b/bika/lims/content/abstractroutineanalysis.py @@ -182,6 +182,14 @@ def getDateReceived(self): """ return getTransitionDate(self, 'receive', return_as_datetime=True) + @security.public + def getDatePublished(self): + """Used to populate catalog values. + Returns the date on which the "publish" transition was invoked on this + analysis. + """ + return getTransitionDate(self, 'publish', return_as_datetime=True) + @security.public def getDateSampled(self): """Used to populate catalog values. @@ -189,6 +197,16 @@ def getDateSampled(self): """ return getTransitionDate(self, 'sample', return_as_datetime=True) + @security.public + def getStartProcessDate(self): + """Returns the date time when the analysis was received. If the + analysis hasn't yet been received, returns None + Overrides getStartProcessDateTime from the base class + :return: Date time when the analysis is ready to be processed. + :rtype: DateTime + """ + return self.getDateReceived() + @security.public def getSamplePartitionUID(self): part = self.getSamplePartition() diff --git a/bika/lims/content/analysisrequest.py b/bika/lims/content/analysisrequest.py index 66b1a62fdf..56487bba3b 100644 --- a/bika/lims/content/analysisrequest.py +++ b/bika/lims/content/analysisrequest.py @@ -3143,6 +3143,12 @@ def guard_sample_prep_complete_transition(self): def guard_schedule_sampling_transition(self): return guards.schedule_sampling(self) + @deprecated('[1705] Use guards.publish from ' + 'bika.lims.workflow.analysisrequest') + @security.public + def guard_publish_transition(self): + return guards.publish(self) + @deprecated('[1705] Use events.after_no_sampling_workflow from ' 'bika.lims.workflow.anaysisrequest') @security.public diff --git a/bika/lims/profiles/default/metadata.xml b/bika/lims/profiles/default/metadata.xml index 66073bb560..bddeb30083 100644 --- a/bika/lims/profiles/default/metadata.xml +++ b/bika/lims/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 3.2.0.1706 + 3.2.0.1707 profile-jarn.jsi18n:default profile-Products.ATExtensions:default diff --git a/bika/lims/profiles/default/workflow_csv/bika_ar_workflow.csv b/bika/lims/profiles/default/workflow_csv/bika_ar_workflow.csv index ac94975026..bf03a29468 100644 --- a/bika/lims/profiles/default/workflow_csv/bika_ar_workflow.csv +++ b/bika/lims/profiles/default/workflow_csv/bika_ar_workflow.csv @@ -327,7 +327,7 @@ Target state:,published,,,,,,,,,,,,,,, URL:,,,,,,,,,,,,,,,, Trigger:,User,,,,,,,,,,,,,,, Guard permission:,BIKA: Publish,,,,,,,,,,,,,,, -Guard expression:,python:here.guard_cancelled_object(),,,,,,,,,,,,,,, +Guard expression:,python:here.guard_publish_transition(),,,,,,,,,,,,,,, ,,,,,,,,,,,,,,,, [Transition],,,,,,,,,,,,,,,, Id:,receive,,,,,,,,,,,,,,, diff --git a/bika/lims/subscribers/analysis.py b/bika/lims/subscribers/analysis.py index b13e2bf848..1390c1e55d 100644 --- a/bika/lims/subscribers/analysis.py +++ b/bika/lims/subscribers/analysis.py @@ -51,7 +51,7 @@ def ObjectInitializedEventHandler(instance, event): return def ObjectRemovedEventHandler(instance, event): - + # TODO Workflow - Review all this function and normalize # May need to promote the AR's review_state # if all other analyses are at a higher state than this one was. workflow = getToolByName(instance, 'portal_workflow') diff --git a/bika/lims/upgrade/configure.zcml b/bika/lims/upgrade/configure.zcml index f021aa7d7b..462e03a3f0 100644 --- a/bika/lims/upgrade/configure.zcml +++ b/bika/lims/upgrade/configure.zcml @@ -604,4 +604,11 @@ destination="3.2.0.1706" handler="bika.lims.upgrade.v3_2_0_1706.upgrade" profile="bika.lims:default"/> + + diff --git a/bika/lims/upgrade/v3_2_0_1707.py b/bika/lims/upgrade/v3_2_0_1707.py new file mode 100644 index 0000000000..0190d541e7 --- /dev/null +++ b/bika/lims/upgrade/v3_2_0_1707.py @@ -0,0 +1,61 @@ +# This file is part of Bika LIMS +# +# Copyright 2011-2017 by it's authors. +# Some rights reserved. See LICENSE.txt, AUTHORS.txt. +from Acquisition import aq_inner +from Acquisition import aq_parent + +from bika.lims import logger +from bika.lims.upgrade import upgradestep +from bika.lims.upgrade.utils import UpgradeUtils +from plone.api.portal import get_tool + +from Products.CMFCore.Expression import Expression + +product = 'bika.lims' +version = '3.2.0.1707' + + +@upgradestep(product, version) +def upgrade(tool): + portal = aq_parent(aq_inner(tool)) + ut = UpgradeUtils(portal) + ufrom = ut.getInstalledVersion(product) + if ut.isOlderVersion(product, version): + logger.info("Skipping upgrade of {0}: {1} > {2}".format( + product, ufrom, version)) + # The currently installed version is more recent than the target + # version of this upgradestep + return True + + logger.info("Upgrading {0}: {1} -> {2}".format(product, ufrom, version)) + + # Renames some guard expressions from several transitions + set_guard_expressions(portal) + + logger.info("{0} upgraded to version {1}".format(product, version)) + return True + + +def set_guard_expressions(portal): + """Rename guard expressions of some workflow transitions + """ + logger.info('Renaming guard expressions...') + torename = { + 'bika_ar_workflow.publish': 'python:here.guard_publish_transition()', + } + wtool = get_tool('portal_workflow') + workflowids = wtool.getWorkflowIds() + for wfid in workflowids: + workflow = wtool.getWorkflowById(wfid) + transitions = workflow.transitions + for transid in transitions.objectIds(): + for torenid, newguard in torename.items(): + tokens = torenid.split('.') + if tokens[0] == wfid and tokens[1] == transid: + transition = transitions[transid] + guard = transition.getGuard() + guard.expr = Expression(newguard) + transition.guard = guard + logger.info("Guard from transition '{0}' set to '{1}'" + .format(torenid, newguard)) diff --git a/bika/lims/workflow/__init__.py b/bika/lims/workflow/__init__.py index 72b0f15a6b..bef19d9617 100644 --- a/bika/lims/workflow/__init__.py +++ b/bika/lims/workflow/__init__.py @@ -356,6 +356,19 @@ def getCurrentState(obj, stateflowid='review_state'): return wf.getInfoFor(obj, stateflowid, '') +def getTransitionActor(obj, action_id): + """Returns the actor that performed a given transition. If transition has + not been perormed, or current user has no privileges, returns None + :return: the username of the user that performed the transition passed-in + :type: string + """ + review_history = getReviewHistory(obj) + for event in review_history: + if event.get('action') == action_id: + return event.get('actor') + return None + + def getTransitionDate(obj, action_id, return_as_datetime=False): """ Returns date of action for object. Sometimes we need this date in Datetime @@ -363,12 +376,14 @@ def getTransitionDate(obj, action_id, return_as_datetime=False): """ review_history = getReviewHistory(obj) for event in review_history: - if event['action'] == action_id: + if event.get('action') == action_id: + evtime = event.get('time') if return_as_datetime: - return event['time'] - value = ulocalized_time(event['time'], long_format=True, - time_only=False, context=obj) - return value + return evtime + if evtime: + value = ulocalized_time(evtime, long_format=True, + time_only=False, context=obj) + return value return None diff --git a/bika/lims/workflow/analysis/events.py b/bika/lims/workflow/analysis/events.py index 04e8da1d78..205b5ae124 100644 --- a/bika/lims/workflow/analysis/events.py +++ b/bika/lims/workflow/analysis/events.py @@ -98,34 +98,6 @@ def after_verify(obj): doActionFor(ws, 'verify') -def after_publish(obj): - if skip(obj, "publish"): - return - workflow = getToolByName(obj, "portal_workflow") - state = workflow.getInfoFor(obj, 'cancellation_state', 'active') - if state == "cancelled": - return False - endtime = DateTime() - obj.setDateAnalysisPublished(endtime) - starttime = obj.aq_parent.getDateReceived() - starttime = starttime or obj.created() - maxtime = obj.getMaxTimeAllowed() - # set the instance duration value to default values - # in case of no calendars or max hours - if maxtime: - duration = (endtime - starttime) * 24 * 60 - maxtime_delta = int(maxtime.get("hours", 0)) * 86400 - maxtime_delta += int(maxtime.get("hours", 0)) * 3600 - maxtime_delta += int(maxtime.get("minutes", 0)) * 60 - earliness = duration - maxtime_delta - else: - earliness = 0 - duration = 0 - obj.setDuration(duration) - obj.setEarliness(earliness) - obj.reindexObject() - - def after_cancel(obj): if skip(obj, "cancel"): return diff --git a/bika/lims/workflow/analysis/guards.py b/bika/lims/workflow/analysis/guards.py index 612ea525aa..af73195405 100644 --- a/bika/lims/workflow/analysis/guards.py +++ b/bika/lims/workflow/analysis/guards.py @@ -33,6 +33,15 @@ def receive(obj): def publish(obj): + """ Returns true if the 'publish' transition can be performed to the + analysis passed in. + In accordance with bika_analysis_workflow, 'publish' + transition can only be performed if the state of the analysis is verified, + so this guard only checks if the analysis state is active: there is no need + of additional checks, cause the DC Workflow machinery will already take + care of them. + :returns: true or false + """ return isBasicTransitionAllowed(obj) diff --git a/bika/lims/workflow/analysisrequest/events.py b/bika/lims/workflow/analysisrequest/events.py index 278be9ef0f..392621bf95 100644 --- a/bika/lims/workflow/analysisrequest/events.py +++ b/bika/lims/workflow/analysisrequest/events.py @@ -161,18 +161,14 @@ def after_verify(obj): def after_publish(obj): """Method triggered after an 'publish' transition for the Analysis Request - passed in is performed. + passed in is performed. Performs the 'publish' transition to children. This function is called automatically by bika.lims.workflow.AfterTransitionEventHandler :param obj: Analysis Request affected by the transition :type obj: AnalysisRequest """ - # TODO Workflow, Publish - REQUEST thing inside event? - if "publish all analyses" in obj.REQUEST['workflow_skiplist']: - return - - # Verify all analyses from this Analysis Request, except not requested - ans = obj.getAnalyses(full_objects=True, review_state='verified') + # Transition the children + ans = obj.getAnalyses(full_objects=True) for analysis in ans: doActionFor(analysis, 'publish') diff --git a/bika/lims/workflow/analysisrequest/guards.py b/bika/lims/workflow/analysisrequest/guards.py index ed0dc692c9..9be7cca581 100644 --- a/bika/lims/workflow/analysisrequest/guards.py +++ b/bika/lims/workflow/analysisrequest/guards.py @@ -78,10 +78,14 @@ def verify(obj): Request passed in. This is, returns true if all the analyses that contains have already been verified. Those analyses that are in an inactive state (cancelled, inactive) are dismissed, but at least one analysis must be in - an active state (and verified), otherwise always return False. + an active state (and verified), otherwise always return False. If the + Analysis Request is in inactive state (cancelled/inactive), returns False Note this guard depends entirely on the current status of the children :returns: true or false """ + if not isBasicTransitionAllowed(obj): + return False + analyses = obj.getAnalyses(full_objects=True) invalid = 0 for an in analyses: @@ -110,3 +114,14 @@ def verify(obj): # doesn't make sense to verify an Analysis Request if all the analyses that # contains are rejected or cancelled! return len(analyses) - invalid > 0 + + +def publish(obj): + """Returns True if 'publish' transition can be applied to the Analysis + Request passed in. Returns true if the Analysis Request is active (not in + a cancelled/inactive state). As long as 'publish' transition, in accordance + with its DC workflow can only be performed if its previous state is + verified or published, there is no need of additional validations. + :returns: true or false + """ + return isBasicTransitionAllowed(obj)