From 0276a43e75d3ec112bb9205f1d3f85eccbd0321a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 22 Apr 2024 09:59:32 +0200 Subject: [PATCH 1/5] Update metadata version to 1006 --- src/senaite/referral/profiles/default/metadata.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/senaite/referral/profiles/default/metadata.xml b/src/senaite/referral/profiles/default/metadata.xml index e4e2bfe..dfcff1c 100644 --- a/src/senaite/referral/profiles/default/metadata.xml +++ b/src/senaite/referral/profiles/default/metadata.xml @@ -6,7 +6,7 @@ dependencies before installing this add-on own profile. --> - 1005 + 1006 From 68a6910e57580c2bfb9baec12fe5c49b7a12fddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Fri, 7 Jun 2024 11:10:54 +0200 Subject: [PATCH 2/5] Fix retests are not updated with results from reference lab --- .../referral/jsonapi/outboundsample.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/senaite/referral/jsonapi/outboundsample.py b/src/senaite/referral/jsonapi/outboundsample.py index 6287e0c..9db93c2 100644 --- a/src/senaite/referral/jsonapi/outboundsample.py +++ b/src/senaite/referral/jsonapi/outboundsample.py @@ -73,23 +73,34 @@ def process(self): return True # TODO Performance - convert to queue task - # Create a keyword -> analysis mapping - allowed = ["referred"] - analyses = sample.getAnalyses(full_objects=True, review_state=allowed) - analyses = dict([(an.getKeyword(), an) for an in analyses]) + + # Get the analyses that are in a suitable status grouped by keyword + statuses = ["referred", "assigned", "unassigned"] + by_keyword = self.get_analyses_by_keyword(sample, statuses) # Update the analyses passed-in analysis_records = sample_record.get("analyses") for analysis_record in analysis_records: keyword = analysis_record.get("keyword") - analysis = analyses.get(keyword) - try: - self.update_analysis(analysis, analysis_record) - except Exception as e: - raise APIError(500, "{}: {}".format(type(e).__name__, str(e))) + for analysis in by_keyword.get(keyword): + try: + self.update_analysis(analysis, analysis_record) + except Exception as e: + raise APIError(500, "{}: {}".format( + type(e).__name__, str(e))) return True + def get_analyses_by_keyword(self, sample, statuses): + """Returns the analyses of the sample grouped by keyword + """ + groups = {} + analyses = sample.getAnalyses(full_objects=True, review_state=statuses) + for analysis in analyses: + keyword = analysis.getKeyword() + groups.setdefault(keyword, []).append(analysis) + return groups + def get_data(self): out = {} for key in self.data.keys(): From c01664c983e77a94027c81f92df0939cfc160fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Wed, 12 Jun 2024 09:36:48 +0200 Subject: [PATCH 3/5] Remove sample from shipment on recall (#22) * Remove the sample from the shipment on recall * Remove pdb * Remove custom transition recover_sample ("Recover") --- src/senaite/referral/adapters/actions.py | 65 ------------------- src/senaite/referral/adapters/configure.zcml | 9 --- .../referral/browser/outbound/samples.py | 9 --- .../content/outboundsampleshipment.py | 2 +- .../patches/content/analysisrequest.py | 15 +++++ src/senaite/referral/workflow/__init__.py | 28 +------- 6 files changed, 17 insertions(+), 111 deletions(-) delete mode 100644 src/senaite/referral/adapters/actions.py diff --git a/src/senaite/referral/adapters/actions.py b/src/senaite/referral/adapters/actions.py deleted file mode 100644 index b375677..0000000 --- a/src/senaite/referral/adapters/actions.py +++ /dev/null @@ -1,65 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of SENAITE.REFERRAL. -# -# SENAITE.REFERRAL 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 2021-2022 by it's authors. -# Some rights reserved, see README and LICENSE. - -from senaite.referral import messageFactory as _ -from senaite.referral.workflow import recover_sample -from zope.interface import implementer - -from bika.lims import api -from bika.lims.browser.workflow import RequestContextAware -from bika.lims.interfaces import IAnalysisRequest -from bika.lims.interfaces import IWorkflowActionUIDsAdapter - - -@implementer(IWorkflowActionUIDsAdapter) -class RecoverFromShipmentAdapter(RequestContextAware): - """Adapter that handles "recover_from_shipment" action. Removes the - samples from the outbound shipment - """ - - def __call__(self, action, uids): - """Remove the samples from the outbound shipment - """ - - # Remove samples from current context (OutboundShipment) - shipped_samples = self.context.getRawSamples() - - # Bail out those uids that are not present in the OutboundShipment - uids = filter(lambda uid: uid in shipped_samples, uids) - - # Remove the samples from the outbound shipment - shipped_samples = filter(lambda uid: uid not in uids, shipped_samples) - self.context.setSamples(shipped_samples) - - sample_ids = [] - for uid in uids: - sample = api.get_object(uid, default=None) - sample_ids.append(api.get_id(sample)) - if not IAnalysisRequest.providedBy(sample): - continue - - # Recover the sample - recover_sample(sample, shipment=self.context) - - ids = ", ".join(sample_ids) - message = _("These samples have been recovered: {}").format(ids) - self.add_status_message(message=message) - - return self.redirect() diff --git a/src/senaite/referral/adapters/configure.zcml b/src/senaite/referral/adapters/configure.zcml index dd4d7e4..0be947f 100644 --- a/src/senaite/referral/adapters/configure.zcml +++ b/src/senaite/referral/adapters/configure.zcml @@ -37,13 +37,4 @@ factory=".visibility.OutboundShipmentFieldVisibility" name="senaite.referral.visibility.analysisrequest.outboundshipment" /> - - - diff --git a/src/senaite/referral/browser/outbound/samples.py b/src/senaite/referral/browser/outbound/samples.py index 38f174c..edaa80b 100644 --- a/src/senaite/referral/browser/outbound/samples.py +++ b/src/senaite/referral/browser/outbound/samples.py @@ -94,15 +94,6 @@ def __init__(self, context, request): "title": _("All samples"), "contentFilter": {}, "transitions": [], - "custom_transitions": [{ - "id": "recover_from_shipment", - "title": _("Recover"), - "help": _("Recover selected samples from this shipment"), - "url": "workflow_action?action=recover_from_shipment", - }], - "confirm_transitions": [ - "recover_from_shipment", - ], "columns": self.columns.keys(), }] diff --git a/src/senaite/referral/content/outboundsampleshipment.py b/src/senaite/referral/content/outboundsampleshipment.py index a86efb7..fae4d93 100644 --- a/src/senaite/referral/content/outboundsampleshipment.py +++ b/src/senaite/referral/content/outboundsampleshipment.py @@ -211,7 +211,7 @@ def removeSample(self, value): if sample_uid not in uids: return - uids.pop(sample_uid) + uids.remove(sample_uid) self.setSamples(uids) def in_preparation(self): diff --git a/src/senaite/referral/patches/content/analysisrequest.py b/src/senaite/referral/patches/content/analysisrequest.py index dc36c7f..3f9422a 100644 --- a/src/senaite/referral/patches/content/analysisrequest.py +++ b/src/senaite/referral/patches/content/analysisrequest.py @@ -54,8 +54,23 @@ def setOutboundShipment(self, value): obj = api.get_object(value, default=None) if obj and not IOutboundSampleShipment.providedBy(obj): raise ValueError("Type is not supported") + + # check if the value changed + old = self.getOutboundShipment() + if old == obj: + return + + # remove this sample from the old shipment + if old: + old.removeSample(self) + + # assign the shipment to the field self.getField("OutboundShipment").set(self, obj) + # add this sample to the new shipment + if obj: + obj.addSample(self) + def getOutboundShipment(self): """Returns the Outbound Shipment object the AnalysisRequest is assigned to diff --git a/src/senaite/referral/workflow/__init__.py b/src/senaite/referral/workflow/__init__.py index 402067c..644289c 100644 --- a/src/senaite/referral/workflow/__init__.py +++ b/src/senaite/referral/workflow/__init__.py @@ -95,37 +95,11 @@ def ship_sample(sample, shipment): sample.reindexObject() -def recover_sample(sample, shipment=None): - """Recovers a sample from a shipment - """ - sample = api.get_object(sample) - if not IAnalysisRequest.providedBy(sample): - portal_type = api.get_portal_type(sample) - raise ValueError("Type not supported: {}".format(portal_type)) - - if not shipment: - # Extract the shipment from the sample - shipment = sample.getOutboundShipment() - - if api.is_uid(shipment): - shipment = api.get_object(shipment, default=None) - - if IOutboundSampleShipment.providedBy(shipment): - # Remove the sample from the shipment - shipment.removeSample(sample) - - # Restore the status of sample and referred analyses - restore_referred_sample(sample) - - # Reindex the shipment - shipment.reindexObject() - - def restore_referred_sample(sample): """Rolls the status of the referred sample back to the status they had before being referred """ - # Remove the shipment assignment from sample + # Remove the sample from the shipment if it has not been dispatched sample.setOutboundShipment(None) # Transition the sample and analyses to the state before sample was shipped From 722178cf8c0b6d686e320a4bd307602a3b79ba7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Thu, 18 Jul 2024 12:37:49 +0200 Subject: [PATCH 4/5] Add new sample status "Received at reference lab" (#25) * Added received_at_reference status and receive_at_reference transition * Added upgrade step * Add a warning for when modifying the sample in 'shipped' status * Increase metadata version * Better message --- .../referral/adapters/guards/sample.py | 11 ++++++++++ .../referral/adapters/listing/samples.py | 6 ++++- .../referral/jsonapi/outboundsample.py | 13 +++++++++-- .../referral/profiles/default/metadata.xml | 2 +- src/senaite/referral/setuphandlers.py | 22 +++++++++++++++++++ src/senaite/referral/upgrade/v01_00_000.py | 10 +++++++++ src/senaite/referral/upgrade/v01_00_000.zcml | 8 +++++++ src/senaite/referral/workflow/__init__.py | 8 +++---- .../referral/workflow/analysisrequest.py | 13 +++++++++++ 9 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/senaite/referral/adapters/guards/sample.py b/src/senaite/referral/adapters/guards/sample.py index 5f24d50..ee61968 100644 --- a/src/senaite/referral/adapters/guards/sample.py +++ b/src/senaite/referral/adapters/guards/sample.py @@ -68,3 +68,14 @@ def guard_invalidate_at_reference(self): if not lab_code: return False return True + + def guard_receive_at_reference(self): + """Returns true if current request contains a POST with the + 'receive_at_reference' action to ensure this transition is not + performed manually, + """ + request = api.get_request() + lab_code = request.get("lab_code") + if not lab_code: + return False + return True diff --git a/src/senaite/referral/adapters/listing/samples.py b/src/senaite/referral/adapters/listing/samples.py index e309ac8..492f7ae 100644 --- a/src/senaite/referral/adapters/listing/samples.py +++ b/src/senaite/referral/adapters/listing/samples.py @@ -103,7 +103,11 @@ def add_review_states(self): "id": "shipped", "title": _("Referred"), "contentFilter": { - "review_state": ("shipped", "rejected_at_reference"), + "review_state": ( + "shipped", + "rejected_at_reference", + "received_at_reference", + ), "sort_on": "created", "sort_order": "descending"}, "transitions": [], diff --git a/src/senaite/referral/jsonapi/outboundsample.py b/src/senaite/referral/jsonapi/outboundsample.py index 9db93c2..6fe9505 100644 --- a/src/senaite/referral/jsonapi/outboundsample.py +++ b/src/senaite/referral/jsonapi/outboundsample.py @@ -32,6 +32,7 @@ from bika.lims.interfaces import ISubmitted from bika.lims.utils import changeWorkflowState from bika.lims.workflow import doActionFor +from senaite.referral import logger @implementer(IPushConsumer) @@ -64,8 +65,16 @@ def process(self): msg = "No retest found for '%s'" % sample_id raise APIError(500, "ValueError: {}".format(msg)) - # Do not allow to modify the sample if not referred - if api.get_review_status(sample) != "shipped": + # Do not allow to modify the sample if not received at reference + status = api.get_review_status(sample) + if status == "shipped": + # TODO Do not allow the modification of 'shipped' samples + logger.warn("Please upgrade reference instance to latest!!!. The " + "update of samples in 'shipped' statues won't be " + "supported in the near future, but only those in " + "'received_at_reference' status") + + elif status != "received_at_reference": # We don't rise an exception here because maybe the sample was # updated earlier, but the reference lab got a timeout error and # the remote user is now retrying the notification diff --git a/src/senaite/referral/profiles/default/metadata.xml b/src/senaite/referral/profiles/default/metadata.xml index dfcff1c..b3023fd 100644 --- a/src/senaite/referral/profiles/default/metadata.xml +++ b/src/senaite/referral/profiles/default/metadata.xml @@ -6,7 +6,7 @@ dependencies before installing this add-on own profile. --> - 1006 + 1007 diff --git a/src/senaite/referral/setuphandlers.py b/src/senaite/referral/setuphandlers.py index 544be07..ec3869e 100644 --- a/src/senaite/referral/setuphandlers.py +++ b/src/senaite/referral/setuphandlers.py @@ -54,12 +54,24 @@ "transitions": ( "verify", "invalidate_at_reference", + "receive_at_reference", "reject_at_reference", "recall_from_shipment" ), # Sample is read-only "permissions_copy_from": "invalid", }, + "received_at_reference": { + "title": "Received at reference lab", + "description": "Sample received at reference laboratory", + "transitions": ( + "verify", + "reject_at_reference", + "invalidate_at_reference", + ), + # Sample is read-only + "permissions_copy_from": "invalid", + }, "rejected_at_reference": { "title": "Rejected at reference lab", "description": "Sample rejected at reference laboratory", @@ -85,6 +97,16 @@ "guard_expr": "python:here.guard_handler('ship')", } }, + "receive_at_reference": { + "title": "Receive sample (at reference lab)", + "new_state": "received_at_reference", + "action": "Received at reference lab", + "guard": { + "guard_permissions": "", + "guard_roles": "", + "guard_expr": "python:here.guard_handler('receive_at_reference')", + } + }, "reject_at_reference": { "title": "Reject sample (at reference lab)", "new_state": "rejected_at_reference", diff --git a/src/senaite/referral/upgrade/v01_00_000.py b/src/senaite/referral/upgrade/v01_00_000.py index 0469e31..0ef5146 100644 --- a/src/senaite/referral/upgrade/v01_00_000.py +++ b/src/senaite/referral/upgrade/v01_00_000.py @@ -215,3 +215,13 @@ def setup_invalidate_at_reference(tool): setup_workflows(portal) logger.info("Setup transition 'invalidate_at_reference' [DONE]") + + +def setup_receive_at_reference(tool): + logger.info("Setup transition 'receive_at_reference' ...") + portal = tool.aq_inner.aq_parent + + # Setup workflows + setup_workflows(portal) + + logger.info("Setup transition 'receive_at_reference' [DONE]") diff --git a/src/senaite/referral/upgrade/v01_00_000.zcml b/src/senaite/referral/upgrade/v01_00_000.zcml index 9640149..06aea60 100644 --- a/src/senaite/referral/upgrade/v01_00_000.zcml +++ b/src/senaite/referral/upgrade/v01_00_000.zcml @@ -3,6 +3,14 @@ xmlns:genericsetup="http://namespaces.zope.org/genericsetup" i18n_domain="senaite.referral"> + + Date: Thu, 18 Jul 2024 16:50:09 +0200 Subject: [PATCH 5/5] Fix results from hidden (but requested) tests are not updated back (#26) --- src/senaite/referral/content/inboundsample.py | 24 +++++++++++++ .../referral/profiles/default/metadata.xml | 2 +- src/senaite/referral/remotelab.py | 17 ++++----- src/senaite/referral/upgrade/v01_00_000.py | 35 +++++++++++++++++++ src/senaite/referral/upgrade/v01_00_000.zcml | 11 ++++-- .../referral/workflow/inboundsample/events.py | 4 +++ 6 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/senaite/referral/content/inboundsample.py b/src/senaite/referral/content/inboundsample.py index 547f430..b9743aa 100644 --- a/src/senaite/referral/content/inboundsample.py +++ b/src/senaite/referral/content/inboundsample.py @@ -125,6 +125,12 @@ class IInboundSampleSchema(model.Schema): ) ) + # TODO 2.x Replace schema.List by senaite.core.schema.UIDReferenceField + directives.omitted("services") + services = schema.List( + title=_(u"label_inboundsample_services", default=u"Services"), + ) + # TODO 2.x Replace schema.List by senaite.core.schema.UIDReferenceField directives.omitted("sample") sample = schema.List( @@ -311,3 +317,21 @@ def getDateRejected(self): """Returns the datetime when this inbound sample was rejected or None """ return get_action_date(self, "reject_inbound_sample", default=None) + + def setServices(self, value): + """Set the services from the current instance that match with the + analyses that were requested by the referring laboratory + """ + set_uids_field_value(self, "services", value) + + def getServices(self): + """Returns the services from current instance that match with the + analyses that were requested by the referring laboratory + """ + return [api.get_object(uid) for uid in self.getRawServices()] + + def getRawServices(self): + """Returns the UIDs of the services from current instance that match + with the analyses that were requested by the referring laboratory + """ + return get_uids_field_value(self, "services") or [] diff --git a/src/senaite/referral/profiles/default/metadata.xml b/src/senaite/referral/profiles/default/metadata.xml index b3023fd..1a38e8d 100644 --- a/src/senaite/referral/profiles/default/metadata.xml +++ b/src/senaite/referral/profiles/default/metadata.xml @@ -6,7 +6,7 @@ dependencies before installing this add-on own profile. --> - 1007 + 1008 diff --git a/src/senaite/referral/remotelab.py b/src/senaite/referral/remotelab.py index ec69138..5bb7c00 100644 --- a/src/senaite/referral/remotelab.py +++ b/src/senaite/referral/remotelab.py @@ -191,27 +191,24 @@ def update_analyses(self, sample, timeout=5): """ def get_valid_analyses(sample): - # Sort by id descending to prioritize newest results if retests + # Get the analyses from current sample that were requested by the + # referring laboratory, sorted by id descending to prioritize + # newest results if retests + inbound_sample = sample.getInboundSample() + services = inbound_sample.getRawServices() kwargs = { "full_objects": True, + "getServiceUID": services, "sort_on": "id", "sort_order": "ascending", } - # Exclude old, but valid analyses with same keyword (e.g retests), + # exclude old, but valid analyses with same keyword (e.g retests), # cause we want to update the referring lab with the newest result analyses = {} valid = ["verified", "published"] for analysis in sample.getAnalyses(**kwargs): - # Skip analyses for internal use - if IInternalUse.providedBy(analysis): - continue - - # Skip hidden, only interested in final results - if analysis.getHidden(): - continue - # Skip analyses not in a suitable status if api.get_review_status(analysis) not in valid: continue diff --git a/src/senaite/referral/upgrade/v01_00_000.py b/src/senaite/referral/upgrade/v01_00_000.py index 0ef5146..adc8ed3 100644 --- a/src/senaite/referral/upgrade/v01_00_000.py +++ b/src/senaite/referral/upgrade/v01_00_000.py @@ -26,6 +26,7 @@ from senaite.referral.config import PRODUCT_NAME as product from senaite.referral.setuphandlers import setup_catalogs from senaite.referral.setuphandlers import setup_workflows +from senaite.referral.utils import get_services_mapping from bika.lims import api from bika.lims.utils import changeWorkflowState @@ -225,3 +226,37 @@ def setup_receive_at_reference(tool): setup_workflows(portal) logger.info("Setup transition 'receive_at_reference' [DONE]") + + +def setup_inbound_services(tool): + """Updates the inbound samples with the service uids that were requested + by the referring laboratory + """ + logger.info("Setup inbound services ...") + + # Get baseline objects mappings + services = get_services_mapping() + + query = { + "portal_type": "InboundSample", + "review_state": "received", + } + brains = api.search(query, INBOUND_SAMPLE_CATALOG) + total = len(brains) + for num, brain in enumerate(brains): + if num and num % 100 == 0: + logger.info("Processed objects: {}/{}".format(num, total)) + + obj = api.get_object(brain) + if obj.getRawServices(): + # processed already + continue + + # Resolve the service uids that match with the requested analyses + keywords = obj.getAnalyses() or [] + services_uids = map(lambda key: services.get(key), keywords) + services_uids = list(filter(api.is_uid, services_uids)) + obj.setServices(services_uids) + obj._p_deactivate() + + logger.info("Setup inbound services [DONE]") diff --git a/src/senaite/referral/upgrade/v01_00_000.zcml b/src/senaite/referral/upgrade/v01_00_000.zcml index 06aea60..fb69f7e 100644 --- a/src/senaite/referral/upgrade/v01_00_000.zcml +++ b/src/senaite/referral/upgrade/v01_00_000.zcml @@ -1,7 +1,14 @@ + xmlns:genericsetup="http://namespaces.zope.org/genericsetup"> + +