Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow Multi Analysis Results Entry #2114

Merged
merged 24 commits into from
Aug 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Changelog
2.3.0 (unreleased)
------------------

- #2114 Allow Multi Analysis Results Entry
- #2111 Replace header table with customizable sample header viewlet
- #2110 Add a more descriptive message for "Reject" action inside a Worksheet
- #2104 Fix result formatting when result is below LDL or above UDL
Expand Down
26 changes: 18 additions & 8 deletions src/bika/lims/browser/analysisrequest/add2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1680,11 +1680,21 @@ def ajax_submit(self):
# Automatic label printing
setup = api.get_setup()
auto_print = setup.getAutoPrintStickers()
if 'register' in auto_print and ARs:
return {
'success': message,
'stickers': ARs.values(),
'stickertemplate': setup.getAutoStickerTemplate()
}
else:
return {'success': message}
immediate_results_entry = setup.getImmediateResultsEntry()
redirect_to = self.context.absolute_url()
sample_uids = ARs.values()
if "register" in auto_print and sample_uids:
redirect_to = "{}/sticker?autoprint=1&template={}&items={}".format(
self.context.absolute_url(),
setup.getAutoStickerTemplate(),
",".join(sample_uids)
)
elif immediate_results_entry and sample_uids:
redirect_to = "{}/multi_results?uids={}".format(
self.context.absolute_url(),
",".join(sample_uids)
)
return {
"success": message,
"redirect_to": redirect_to,
}
16 changes: 16 additions & 0 deletions src/bika/lims/browser/stickers.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ def __call__(self):

return self.template()

def get_back_url(self):
"""Calculate the Back URL
"""
url = api.get_url(self.context)
portal_type = api.get_portal_type(self.context)
redirect_contexts = ['Client', 'AnalysisRequest', 'Samples', 'Batch']
if portal_type not in redirect_contexts:
parent = api.get_parent(self.context)
url = api.get_url(parent)
# redirect to direct results entry
setup = api.get_setup()
if setup.getImmediateResultsEntry():
url = "{}/multi_results?uids={}".format(
url, ",".join(self.get_uids()))
return url

def get_items(self):
"""Returns a list of SuperModel items
"""
Expand Down
8 changes: 1 addition & 7 deletions src/bika/lims/browser/templates/stickers_preview.pt
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,7 @@
tal:attributes="lang default_language|default;
xml:lang default_language|default;"
i18n:domain="senaite.core"
tal:define="portal_state context/@@plone_portal_state;
portal_url portal_state/portal_url;
plone_view context/@@plone;
portal portal_state/portal;
portal_type python:context.portal_type;
anchor_self python:('Client','AnalysisRequest', 'Samples', 'Batch');
goback_url python:context.absolute_url() if portal_type in anchor_self else context.aq_parent.absolute_url();">
tal:define="goback_url python:view.get_back_url();">
<head>
<div tal:replace="structure provider:plone.htmlhead" />

Expand Down
33 changes: 33 additions & 0 deletions src/bika/lims/content/bikasetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,22 @@ def getCounterTypes(self, instance=None):
"configured in individual Analysis Services."),
)
),
BooleanField(
"ImmediateResultsEntry",
schemata="Analyses",
default=False,
widget=BooleanWidget(
label=_("label_bikasetup_immediateresultsentry",
default=u"Immediate results entry"),
description=_(
"description_bikasetup_immediateresultsentry",
default=u"Allow the user to directly enter results after "
"sample creation, e.g. to enter field results immediately, or "
"lab results, when the automatic sample reception is "
"activated."
),
),
),
BooleanField(
'EnableAnalysisRemarks',
schemata="Analyses",
Expand Down Expand Up @@ -1032,5 +1048,22 @@ def setEnableGlobalAuditlog(self, value):
if setup:
setup.setEnableGlobalAuditlog(value)

def getImmediateResultsEntry(self):
"""Get the value from the senaite setup
"""
setup = api.get_senaite_setup()
# setup is `None` during initial site content structure installation
if setup:
return setup.getImmediateResultsEntry()
return False

def setImmediateResultsEntry(self, value):
"""Set the value in the senaite setup
"""
setup = api.get_senaite_setup()
# setup is `None` during initial site content structure installation
if setup:
setup.setImmediateResultsEntry(value)


registerType(BikaSetup, PROJECTNAME)
1 change: 1 addition & 0 deletions src/bika/lims/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
TransitionDispatchSample = "senaite.core: Transition: Dispatch Sample"
TransitionRestoreSample = "senaite.core: Transition: Restore Sample"
TransitionCreatePartitions = "senaite.core: Transition: Create Partitions"
TransitionMultiResults = "senaite.core: Transition: Multi Results"


# Type-specific permissions
Expand Down
1 change: 1 addition & 0 deletions src/bika/lims/permissions.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
<permission id="senaite.core.permissions.TransitionDispatchSample" title="senaite.core: Transition: Dispatch Sample"/>
<permission id="senaite.core.permissions.TransitionRestoreSample" title="senaite.core: Transition: Restore Sample"/>
<permission id="senaite.core.permissions.TransitionCreatePartitions" title="senaite.core: Transition: Create Partitions"/>
<permission id="senaite.core.permissions.TransitionMultiResults" title="senaite.core: Transition: Multi Results"/>

# Object-specific permissions
# ---------------------------
Expand Down
6 changes: 6 additions & 0 deletions src/bika/lims/workflow/analysisrequest/guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,9 @@ def guard_restore(sample):
"""Checks if the restore transition is allowed
"""
return True


def guard_multi_results(sample):
"""Checks if the multi results action is allowed
"""
return True
12 changes: 12 additions & 0 deletions src/senaite/core/adapters/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@
provides="bika.lims.interfaces.IWorkflowActionAdapter"
permission="zope.Public" />

<!-- Sample: "multi_results"
Note this applies wide, cause at the moment, this action only exists
for Analysis Requests and we always want this adapter to be in charge,
regardless of the context (Analysis Requests listing, Client folder, etc.) -->
<adapter
name="workflow_action_multi_results"
for="*
zope.publisher.interfaces.browser.IBrowserRequest"
factory=".sample.WorkflowActionMultiResultsAdapter"
provides="bika.lims.interfaces.IWorkflowActionAdapter"
permission="zope.Public" />

<!-- Dynamic calculation of local roles for a given context and principal -->
<adapter factory=".localroles.DynamicLocalRoleAdapter" />

Expand Down
13 changes: 13 additions & 0 deletions src/senaite/core/adapters/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,16 @@ def __call__(self, action, uids):
url = "{}/dispatch_samples?uids={}".format(
api.get_url(self.context), ",".join(uids))
return self.redirect(redirect_url=url)


@implementer(IWorkflowActionUIDsAdapter)
class WorkflowActionMultiResultsAdapter(RequestContextAware):
"""Redirects to multi results view
"""

def __call__(self, action, uids):
"""Redirects the user to the multi results form
"""
url = "{}/multi_results?uids={}".format(
api.get_url(self.context), ",".join(uids))
return self.redirect(redirect_url=url)
18 changes: 15 additions & 3 deletions src/senaite/core/browser/contentmenu/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
from zope.browsermenu.interfaces import IBrowserMenu
from zope.component import getUtility

HIDE_WF_ITEMS = [
"workflow-transition-advanced"
]


class ContentMenuProvider(Base):
"""Content menu provider for the "view" tab: displays the menu
Expand All @@ -41,12 +45,20 @@ def fiddle_menu_item(self, item):
Unfortunately, this can not be done more elegant w/o overrides.zcml.
https://stackoverflow.com/questions/11904155/disable-advanced-in-workflow-status-menu-in-plone
"""
def is_wf_item_visible(item):
"""Checks if the WF items is visible or not
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can achieve this by changing the "category" from the transition from "workflow" to anything else (e.g. "listing"). Via ZMI:

Captura de 2022-08-17 17-00-47

Or in transition definition:

<transition transition_id="listing_multi_results" title="Multi Results" new_state="" trigger="USER"
              allowed="False" before_script="" after_script="" i18n:attributes="title">
    <action url="" category="listing" icon="">Multi Results</action>
    ...

Not sure about side effects though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, that might work as well, let me give it a try. Maybe I try first how a default Plone site renders a transition with this category in...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like any other category than workflow is skipped already:
https://github.com/plone/plone.app.contentmenu/blob/master/plone/app/contentmenu/menu.py#L840

"""
extra = item.get("extra", {})
wfid = extra.get("id")
if wfid in HIDE_WF_ITEMS:
return False
return True

action = item.get("action")
if action.endswith("content_status_history"):
# remove the "Advanced ..." submenu
submenu = filter(
lambda m: m.get("title") != "label_advanced",
item.get("submenu", []) or [])
submenu = item.get("submenu", []) or []
submenu = filter(is_wf_item_visible, submenu)
item["submenu"] = submenu
return item

Expand Down
7 changes: 7 additions & 0 deletions src/senaite/core/browser/samples/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
permission="senaite.core.permissions.TransitionDispatchSample"
layer="senaite.core.interfaces.ISenaiteCore" />

<!-- Multi results view -->
<browser:page
for="*"
name="multi_results"
class=".multi_results.MultiResultsView"
permission="senaite.core.permissions.TransitionMultiResults"
layer="senaite.core.interfaces.ISenaiteCore" />

<!-- Manage Sample Fields -->
<browser:page
Expand Down
125 changes: 125 additions & 0 deletions src/senaite/core/browser/samples/multi_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-

import collections

import six

from bika.lims import api
from bika.lims.browser import BrowserView
from bika.lims.interfaces import IAnalysisRequest
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
from senaite.core import logger

OFF_VALUES = ["0", "off", "no"]


class MultiResultsView(BrowserView):
"""Allows to edit results of multiple samples
"""
template = ViewPageTemplateFile("templates/multi_results.pt")

def __init__(self, context, request):
super(MultiResultsView, self).__init__(context, request)
self.context = context
self.request = request
self.portal = api.get_portal()

def __call__(self):
return self.template()

@property
def context_state(self):
return api.get_view(
"plone_context_state",
context=self.context, request=self.request)

def contents_table(self, sample, poc):
view_name = "table_{}_analyses".format(poc)
view = api.get_view(view_name, context=sample, request=self.request)
# Inject additional hidden field in the listing form for redirect
# https://github.com/senaite/senaite.app.listing/pull/80
view.additional_hidden_fields = [{
"name": "redirect_url",
"value": self.context_state.current_page_url(),
}]
view.update()
view.before_render()
return view.contents_table()

def show_lab_analyses(self, sample):
"""Show/Hide lab analyses
"""
analyses = sample.getAnalyses(getPointOfCapture="lab")
if len(analyses) == 0:
return False
lab_analyses = self.request.get("lab_analyses")
if lab_analyses in OFF_VALUES:
return False
return True

def show_field_analyses(self, sample):
"""Show/Hide field analyses
"""
analyses = sample.getAnalyses(getPointOfCapture="field")
if len(analyses) == 0:
return False
field_analyses = self.request.get("field_analyses", True)
if field_analyses in OFF_VALUES:
return False
return True

def get_samples(self):
"""Extract the samples from the request UIDs

This might be either a samples container or a sample context
"""

# fetch objects from request
objs = self.get_objects_from_request()

samples = []

for obj in objs:
# when coming from the samples listing
if IAnalysisRequest.providedBy(obj):
samples.append(obj)

# when coming from the WF menu inside a sample
if IAnalysisRequest.providedBy(self.context):
samples.append(self.context)

return self.uniquify_items(samples)

def uniquify_items(self, items):
"""Uniquify the items with sort order
"""
unique = []
for item in items:
if item in unique:
continue
unique.append(item)
return unique
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change required, just a reminder that the removal of duplicates while keeping the order can also be accomplished with:

return list(collections.OrderedDict.fromkeys(items))

not sure about which is the recommended / more performant approach though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably collections.OrderedDict.fromkeys(items).keys() would work as well.
However, given the fact that you have to create a new dictionary instance in memory next to the list and iterate twice through the items, I believe the first might be the more performant way (although probably marginal).


def get_objects_from_request(self):
"""Returns a list of objects coming from the "uids" request parameter
"""
unique_uids = self.get_uids_from_request()
return filter(None, map(self.get_object_by_uid, unique_uids))

def get_uids_from_request(self):
"""Return a list of uids from the request
"""
uids = self.request.form.get("uids", "")
if isinstance(uids, six.string_types):
uids = uids.split(",")
unique_uids = collections.OrderedDict().fromkeys(uids).keys()
return filter(api.is_uid, unique_uids)

def get_object_by_uid(self, uid):
"""Get the object by UID
"""
logger.debug("get_object_by_uid::UID={}".format(uid))
obj = api.get_object_by_uid(uid, None)
if obj is None:
logger.warn("!! No object found for UID #{} !!")
return obj
Loading