Skip to content

Commit

Permalink
Merge branch '2.x' into sample-batch-reference-show-title-or-id
Browse files Browse the repository at this point in the history
  • Loading branch information
ramonski authored Dec 10, 2024
2 parents 9a31643 + 426827a commit 12c0e6c
Show file tree
Hide file tree
Showing 23 changed files with 591 additions and 36 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Changelog
------------------

- #2654 Show Batch title or ID in Sample reference field
- #2657 Methods from analyses are not updated on instrument change in worksheet
- #2656 Fix AnalysisProfile keyword validator fail with non-ascii value
- #2646 Add SelectOtherField and SelectOtherWidget
- #2649 Fix event subscribers are not triggered on analysis initialization
- #2650 Fix AttributeError on analysis update with interim values via jsonapi
- #2648 Increase the default width for field labels to min 150px
Expand Down
30 changes: 21 additions & 9 deletions src/bika/lims/content/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
from senaite.core.catalog import SETUP_CATALOG
from senaite.core.exportimport import instruments
from senaite.core.p3compat import cmp
from zope.deprecation import deprecate
from zope.interface import implements

schema = BikaFolderSchema.copy() + BikaSchema.copy() + Schema((
Expand Down Expand Up @@ -160,15 +161,21 @@
),

UIDReferenceField(
'Methods',
vocabulary='_getAvailableMethods',
allowed_types=('Method',),
relationship='InstrumentMethods',
"Methods",
vocabulary="_getAvailableMethods",
allowed_types=("Method",),
relationship="InstrumentMethods",
required=0,
multiValued=1,
widget=PicklistWidget(
size=10,
label=_("Methods"),
label=_(u"label_instrument_methods",
default=u"Supported methods"),
description=_(
u"description_instrument_methods",
default=u"Methods that are supported by this analytical "
u"instrument"
)
),
),

Expand Down Expand Up @@ -414,11 +421,16 @@ def getMaintenanceTypesList(self):
def getCalibrationAgentsList(self):
return getCalibrationAgents(self)

def getRawMethods(self):
"""Returns the UIDs of the methods supported by this instrument
:returns: Method UIDs
"""
return self.getField("Methods").getRaw(self)

@deprecate("Use getRawMethods instead")
def getMethodUIDs(self):
uids = []
if self.getMethods():
uids = [m.UID() for m in self.getMethods()]
return uids
return self.getRawMethods()

def _getAvailableMethods(self):
""" Returns the available (active) methods.
Expand Down
57 changes: 34 additions & 23 deletions src/bika/lims/content/worksheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -1194,36 +1194,47 @@ def getNumberOfRegularSamples(self):
return len(set(samples))

def setInstrument(self, instrument, override_analyses=False):
""" Sets the specified instrument to the Analysis from the
Worksheet. Only sets the instrument if the Analysis
allows it, according to its Analysis Service and Method.
If an analysis has already assigned an instrument, it won't
be overriden.
The Analyses that don't allow the instrument specified will
not be modified.
Returns the number of analyses affected
"""Assigns the specified analytical instrument to the analyses in this
worksheet that are compatible with the instrument. The system will
attempt to assign the first method supported by the instrument that is
also compatible with each analysis.
By default, the instrument and method assigned to the analysis won't be
replaced unless the analysis does not have an instrument assigned yet
or the parameter override_analyses is set to True. Analyses that are
incompatible with the specified instrument will remain unchanged.
"""
analyses = [an for an in self.getAnalyses()
if (not an.getInstrument() or override_analyses) and
an.isInstrumentAllowed(instrument)]
analyses = self.getAnalyses()
instrument = api.get_object(instrument, default=None)

# find out the methods supported by the instrument, if any
supported_methods = instrument.getRawMethods() if instrument else []

total = 0
for an in analyses:
# An analysis can be done using differents Methods.
# Un method can be supported by more than one Instrument,
# but not all instruments support one method.
# We must force to set the instrument's method too. Otherwise,
# the WS manage results view will display the an's default
# method and its instruments displaying, only the instruments
# for the default method in the picklist.
instr_methods = instrument.getMethods()
meth = instr_methods[0] if instr_methods else None
if meth and an.isMethodAllowed(meth):
if an.getMethod() not in instr_methods:
an.setMethod(meth)

if not override_analyses and an.getRawInstrument():
# skip, no overwrite analysis if an instrument is set
continue

if not an.isInstrumentAllowed(instrument):
# skip, instrument cannot run this analysis
continue

# assign the instrument
an.setInstrument(instrument)
total += 1

if an.getRawMethod() in supported_methods:
# the analysis method is supported by this instrument
continue

# reset and try to assign the first supported method
allowed = an.getRawAllowedMethods()
methods = list(filter(lambda m: m in allowed, supported_methods))
method = methods[0] if methods else None
an.setMethod(method)

self.getField('Instrument').set(self, instrument)
return total

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/senaite/core/browser/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@
from .queryselect import QuerySelectWidget # noqa F401
from .recordswidget import RecordsWidget # noqa F401
from .referencewidget import ReferenceWidget # noqa F401
from .selectotherwidget import SelectOtherWidget # noqa F401
92 changes: 92 additions & 0 deletions src/senaite/core/browser/widgets/selectotherwidget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-

import json
from bika.lims import _
from Products.Archetypes.Registry import registerWidget
from Products.Archetypes.Widget import StringWidget
from senaite.core.i18n import translate as t
from senaite.core.z3cform.widgets.selectother.widget import OTHER_OPTION_VALUE


class SelectOtherWidget(StringWidget):
"""Select Other Widget for AT fields
"""
# CSS class that is picked up by the ReactJS component
klass = u"senaite-selectother-widget-input"

_properties = StringWidget._properties.copy()
_properties.update({
"macro": "senaite_widgets/selectotherwidget",
})

def process_form(self, instance, field, form, empty_marker=None,
emptyReturnsMarker=False, validating=True):
value = form.get(field.getName(), "")
if isinstance(value, list):
if value[0] == OTHER_OPTION_VALUE:
return value[1], {}
value = value[0]

return value, {}

def get_input_widget_attributes(self, context, field, value):
"""Return input widget attributes for the ReactJS component
This method get called from the page template to populate the
attributes that are used by the ReactJS widget component.
:param context: The current context of the field
:param field: The current field of the widget
:param value: The current field value
"""
option = ""
other = ""

# find out if the value is a predefined option
choices = self.get_choices(context, field)
options = dict(choices).keys()
if value in options:
option = value
elif value:
option = OTHER_OPTION_VALUE
other = value

attributes = {
"data-id": field.getName(),
"data-name": field.getName(),
"data-choices": choices,
"data-option": option,
"data-option_other": OTHER_OPTION_VALUE,
"data-other": other,
}

# convert all attributes to JSON
for key, value in attributes.items():
attributes[key] = json.dumps(value)

return attributes

def get_vocabulary(self, context, field):
func = getattr(field, "Vocabulary", None)
if callable(func):
return func(context)
return None

def get_choices(self, context, field):
"""Returns the predefined options for this field
"""
# generate a list of tuples (value, text) from vocabulary
vocabulary = self.get_vocabulary(context, field)
choices = list(vocabulary.items()) if vocabulary else []

# insert the empty option
choices.insert(0, ("", ""))

# append the "Other..." choice
other = (OTHER_OPTION_VALUE, t(_("Other...")))
choices.append(other)

return choices


registerWidget(SelectOtherWidget, title="Select Other Widget")
2 changes: 1 addition & 1 deletion src/senaite/core/content/analysisprofile.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def validate_profile_key(data):
return
query = {
"portal_type": "AnalysisProfile",
"profile_key": profile_key,
"profile_key": api.to_utf8(profile_key),
}
results = api.search(query, catalog=SETUP_CATALOG)
if len(results) > 0:
Expand Down
3 changes: 3 additions & 0 deletions src/senaite/core/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
from .phonefield import IPhoneField
from .phonefield import PhoneField
from .richtextfield import RichTextField
from .selectotherfield import ISelectOtherField
from .selectotherfield import SelectOtherField
from .uidreferencefield import IUIDReferenceField
from .uidreferencefield import UIDReferenceField

Expand All @@ -49,4 +51,5 @@
classImplementsFirst(IntField, IIntField)
classImplementsFirst(PhoneField, IPhoneField)
classImplementsFirst(RichTextField, IRichTextField)
classImplementsFirst(SelectOtherField, ISelectOtherField)
classImplementsFirst(UIDReferenceField, IUIDReferenceField)
5 changes: 5 additions & 0 deletions src/senaite/core/schema/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,8 @@ class IDurationField(ITimedelta):
class IGPSCoordinatesField(IDict):
"""Senaite GPS Coordinated field
"""


class ISelectOtherField(INativeString):
"""Senaite SelectOther field
"""
14 changes: 14 additions & 0 deletions src/senaite/core/schema/selectotherfield.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-

from senaite.core.schema.fields import BaseField
from senaite.core.schema.interfaces import ISelectOtherField
from zope.interface import implementer
from zope.schema import Choice


@implementer(ISelectOtherField)
class SelectOtherField(Choice, BaseField):
"""A field that handles a value from a predefined vocabulary or custom
"""
def _validate(self, value):
super(SelectOtherField, self)._validate(value)
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:tal="http://xml.zope.org/namespaces/tal"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
metal:use-macro="here/main_template/macros/master"
i18n:domain="senaite.core">
<body>

<!-- VIEW MACRO -->
<metal:view_macro define-macro="view">
</metal:view_macro>

<!-- EDIT MACRO -->
<metal:edit_macro define-macro="edit">
<tal:values define="value python:field.getEditAccessor(here)() or [];
session_value python:here.session_restore_value(fieldName, value);
cached_value python:request.get(fieldName, session_value);
value python:cached_value or value;
required field/required|nothing;
required python: required and 'required' or None;
error_id python:errors.get(fieldName);
i18n_domain field/widget/i18n_domain|context/i18n_domain|string:plone;">

<tal:ifLabel tal:condition="not: widget/render_own_label | nothing">
<label class="formQuestion"
tal:attributes="for python:fieldName">
<span tal:replace="python:widget.Label(here)"
i18n:translate=""></span>
<span class="required"
tal:condition="field/required"
title="Required"
i18n:attributes="title title_required;">&nbsp;</span>
<span class="formHelp"
tal:define="description python:widget.Description(here)"
tal:content="structure description"
tal:attributes="id string:${fieldName}_help"
i18n:translate="">
Help
</span>
</label>
</tal:ifLabel>

<div tal:define="widget_attrs python:field.widget.get_input_widget_attributes(context, field, value);"
tal:attributes="id python:fieldName;
class python: test(error_id, 'field error ' + 'Archetypes' + widget.getName(), 'field ' + 'Archetypes' + widget.getName()) + ' ' + widget.klass;
data-required python:required and '1' or '0';
python:widget_attrs;">
<!-- ReactJS controlled component -->
</div>

<div class="fieldErrorBox" tal:condition="required"></div>
<div class="fieldErrorBox" tal:content="error_id" i18n:translate=""></div>
</tal:values>

</metal:edit_macro>

<metal:search_macro define-macro="search">
<div metal:use-macro="context/widgets/string/macros/edit"></div>
</metal:search_macro>

</body>
6 changes: 6 additions & 0 deletions src/senaite/core/z3cform/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,9 @@ class IDurationWidget(IWidget):
class IListingWidget(IWidget):
"""Listing view widget
"""


class ISelectOtherWidget(IWidget):
"""Allows to select a pre-populated option from a select element along with
manual introduction when the built-in option 'Other' is selected
"""
1 change: 1 addition & 0 deletions src/senaite/core/z3cform/widgets/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<include package=".gpscoordinates" />
<include package=".listing" />
<include package=".queryselect" />
<include package=".selectother" />
<include package=".uidreference" />

<include file="address.zcml" />
Expand Down
1 change: 1 addition & 0 deletions src/senaite/core/z3cform/widgets/selectother/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# -*- coding: utf-8 -*-
Loading

0 comments on commit 12c0e6c

Please sign in to comment.