From 2b06196caa150124558ce15d4d57c27604e45e4d Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 11:10:23 +0200 Subject: [PATCH 01/25] Add ManageLabels and ViewLabels permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManageLabels gates adding/removing labels through the Manage Labels modal and the REST endpoints (LabClerk, LabManager, Manager). ViewLabels gates seeing label chips and using the click-to-filter mechanism so client users do not see internal lab labels on shared samples (Analyst, LabClerk, LabManager, Manager, Preserver, Publisher, RegulatoryInspector, Sampler, SamplingCoordinator, Verifier — not Client / ClientGuest). --- src/senaite/core/permissions/__init__.py | 2 ++ src/senaite/core/permissions/configure.zcml | 2 ++ src/senaite/core/profiles/default/rolemap.xml | 17 +++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/src/senaite/core/permissions/__init__.py b/src/senaite/core/permissions/__init__.py index 47d4cdda53..0fa983ffba 100644 --- a/src/senaite/core/permissions/__init__.py +++ b/src/senaite/core/permissions/__init__.py @@ -175,6 +175,8 @@ ManageSenaite = "senaite.core: Manage Bika" ManageAnalysisRequests = "senaite.core: Manage Analysis Requests" ManageInvoices = "senaite.core: Manage Invoices" +ManageLabels = "senaite.core: Manage Labels" +ViewLabels = "senaite.core: View Labels" ManageLoginDetails = "senaite.core: Manage Login Details" ManageReference = "senaite.core: Manage Reference" ViewResults = "senaite.core: View Results" diff --git a/src/senaite/core/permissions/configure.zcml b/src/senaite/core/permissions/configure.zcml index 7a73c720a2..78357fd04a 100644 --- a/src/senaite/core/permissions/configure.zcml +++ b/src/senaite/core/permissions/configure.zcml @@ -97,6 +97,8 @@ + + diff --git a/src/senaite/core/profiles/default/rolemap.xml b/src/senaite/core/profiles/default/rolemap.xml index aa768bb04b..b13abf33fb 100644 --- a/src/senaite/core/profiles/default/rolemap.xml +++ b/src/senaite/core/profiles/default/rolemap.xml @@ -688,6 +688,23 @@ + + + + + + + + + + + + + + + + + From 508bdc9046f7b3e1abc99577ac654455f4a80cdd Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 11:10:32 +0200 Subject: [PATCH 02/25] Add z3c.form ColorWidget for hex color fields ColorField subclasses TextLineField and validates that the value is a 6-digit hex string (#rrggbb) or empty. ColorWidget renders the native HTML5 picker with a hex-code preview next to it, and is fixed at a 2.25rem square footprint so it does not stretch to the full row width. Follows the existing PhoneWidget/PhoneField pattern: separate IColorField / IColorWidget interfaces, an adapter factory, and input / display / hidden widget templates in a dedicated package under z3cform/widgets/color/. --- src/senaite/core/schema/colorfield.py | 49 +++++++++++++++++ src/senaite/core/schema/interfaces.py | 5 ++ src/senaite/core/z3cform/interfaces.py | 5 ++ .../core/z3cform/widgets/color/__init__.py | 19 +++++++ .../core/z3cform/widgets/color/configure.zcml | 30 +++++++++++ .../core/z3cform/widgets/color/display.pt | 20 +++++++ .../core/z3cform/widgets/color/hidden.pt | 10 ++++ .../core/z3cform/widgets/color/input.pt | 26 +++++++++ .../core/z3cform/widgets/color/widget.py | 54 +++++++++++++++++++ .../core/z3cform/widgets/configure.zcml | 1 + 10 files changed, 219 insertions(+) create mode 100644 src/senaite/core/schema/colorfield.py create mode 100644 src/senaite/core/z3cform/widgets/color/__init__.py create mode 100644 src/senaite/core/z3cform/widgets/color/configure.zcml create mode 100644 src/senaite/core/z3cform/widgets/color/display.pt create mode 100644 src/senaite/core/z3cform/widgets/color/hidden.pt create mode 100644 src/senaite/core/z3cform/widgets/color/input.pt create mode 100644 src/senaite/core/z3cform/widgets/color/widget.py diff --git a/src/senaite/core/schema/colorfield.py b/src/senaite/core/schema/colorfield.py new file mode 100644 index 0000000000..d72e450ca0 --- /dev/null +++ b/src/senaite/core/schema/colorfield.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE 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 2018-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +import re + +from senaite.core.schema.interfaces import IColorField +from senaite.core.schema.textlinefield import TextLineField +from zope.interface import implementer +from zope.schema import ValidationError + + +HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") + + +class InvalidColorValue(ValidationError): + __doc__ = u"Color value must be a 6-digit hex string (#rrggbb)." + + +@implementer(IColorField) +class ColorField(TextLineField): + """A field storing a 6-digit hex color string `#rrggbb`. + + Renders as the native HTML5 `` picker in the + edit form. An empty value is allowed when `required=False`. + """ + + def _validate(self, value): + super(ColorField, self)._validate(value) + if not value: + return + if not HEX_COLOR_RE.match(value): + raise InvalidColorValue(value) diff --git a/src/senaite/core/schema/interfaces.py b/src/senaite/core/schema/interfaces.py index 61b678f143..e9be963c0d 100644 --- a/src/senaite/core/schema/interfaces.py +++ b/src/senaite/core/schema/interfaces.py @@ -85,6 +85,11 @@ class IPhoneField(ITextLine): """ +class IColorField(ITextLine): + """Color picker field — value is a 6-digit hex string ``#rrggbb``. + """ + + class IDurationField(ITimedelta): """Senaite Duration field """ diff --git a/src/senaite/core/z3cform/interfaces.py b/src/senaite/core/z3cform/interfaces.py index 978acc9dc6..76d0ea4da1 100644 --- a/src/senaite/core/z3cform/interfaces.py +++ b/src/senaite/core/z3cform/interfaces.py @@ -65,6 +65,11 @@ class IPhoneWidget(IWidget): """ +class IColorWidget(IWidget): + """Input type "color" widget — renders the native HTML5 picker. + """ + + class IQuerySelectWidget(IWidget): """Allows to search and select a value """ diff --git a/src/senaite/core/z3cform/widgets/color/__init__.py b/src/senaite/core/z3cform/widgets/color/__init__.py new file mode 100644 index 0000000000..8e2f7d3fc6 --- /dev/null +++ b/src/senaite/core/z3cform/widgets/color/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE 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 2018-2025 by it's authors. +# Some rights reserved, see README and LICENSE. diff --git a/src/senaite/core/z3cform/widgets/color/configure.zcml b/src/senaite/core/z3cform/widgets/color/configure.zcml new file mode 100644 index 0000000000..4c90ca04e7 --- /dev/null +++ b/src/senaite/core/z3cform/widgets/color/configure.zcml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/src/senaite/core/z3cform/widgets/color/display.pt b/src/senaite/core/z3cform/widgets/color/display.pt new file mode 100644 index 0000000000..fb1f035489 --- /dev/null +++ b/src/senaite/core/z3cform/widgets/color/display.pt @@ -0,0 +1,20 @@ + + + + + + (no color) + + diff --git a/src/senaite/core/z3cform/widgets/color/hidden.pt b/src/senaite/core/z3cform/widgets/color/hidden.pt new file mode 100644 index 0000000000..5e4e942256 --- /dev/null +++ b/src/senaite/core/z3cform/widgets/color/hidden.pt @@ -0,0 +1,10 @@ + + + + diff --git a/src/senaite/core/z3cform/widgets/color/input.pt b/src/senaite/core/z3cform/widgets/color/input.pt new file mode 100644 index 0000000000..f53bb4d670 --- /dev/null +++ b/src/senaite/core/z3cform/widgets/color/input.pt @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/src/senaite/core/z3cform/widgets/color/widget.py b/src/senaite/core/z3cform/widgets/color/widget.py new file mode 100644 index 0000000000..2b9bc4fafd --- /dev/null +++ b/src/senaite/core/z3cform/widgets/color/widget.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE 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 2018-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +import zope.interface +from senaite.core.interfaces import ISenaiteFormLayer +from senaite.core.schema.interfaces import IColorField +from senaite.core.z3cform.interfaces import IColorWidget +from z3c.form import interfaces +from z3c.form.browser import text +from z3c.form.browser import widget +from z3c.form.widget import FieldWidget +from zope.component import adapter +from zope.interface import implementer_only + + +@implementer_only(IColorWidget) +class ColorWidget(text.TextWidget): + """Renders an HTML5 `` picker. + """ + klass = u"senaite-color-widget" + value = u"" + + def update(self): + super(ColorWidget, self).update() + widget.addFieldClass(self) + # NOTE: deliberately do NOT add `form-control` here — it + # forces `width: 100%` and stretches the color picker + # across the full row. The square footprint comes from the + # input template. + + +@adapter(IColorField, ISenaiteFormLayer) +@zope.interface.implementer(interfaces.IFieldWidget) +def ColorWidgetFactory(field, request): + """IFieldWidget widget factory for ColorWidget. + """ + return FieldWidget(field, ColorWidget(request)) diff --git a/src/senaite/core/z3cform/widgets/configure.zcml b/src/senaite/core/z3cform/widgets/configure.zcml index 4fb3e37c5b..11b7fd19e4 100644 --- a/src/senaite/core/z3cform/widgets/configure.zcml +++ b/src/senaite/core/z3cform/widgets/configure.zcml @@ -3,6 +3,7 @@ i18n_domain="senaite.core"> + From f86b099a85c59d604e5e436dc7f845e7e9e59ad1 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 11:10:42 +0200 Subject: [PATCH 03/25] Add color to the Label content type and restyle its view as a chip Adds an optional ColorField 'color' to ILabelSchema. The field is hidden on the display form via directives.mode(IDisplayForm); a new LabelColorViewlet on IBelowContentTitle restyles the page's

.documentFirstHeading element so the heading itself looks like the chip the Label represents. The Labels control-panel listing renders each row's Title as a colored chip (linking to the Label's view) instead of a plain title link, so admins can see the configured palette at a glance. --- .../core/browser/controlpanel/labels/view.py | 32 ++++++++----- .../core/browser/viewlets/configure.zcml | 10 ++++ .../core/browser/viewlets/label_color.py | 48 +++++++++++++++++++ .../browser/viewlets/templates/label_color.pt | 20 ++++++++ src/senaite/core/content/label.py | 21 ++++++++ 5 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 src/senaite/core/browser/viewlets/label_color.py create mode 100644 src/senaite/core/browser/viewlets/templates/label_color.pt diff --git a/src/senaite/core/browser/controlpanel/labels/view.py b/src/senaite/core/browser/controlpanel/labels/view.py index 79eaca3a74..330faf38a7 100644 --- a/src/senaite/core/browser/controlpanel/labels/view.py +++ b/src/senaite/core/browser/controlpanel/labels/view.py @@ -19,10 +19,11 @@ # Some rights reserved, see README and LICENSE. import collections +from cgi import escape as html_escape from bika.lims import api from bika.lims import bikaMessageFactory as _ -from bika.lims.utils import get_link_for +from senaite.core.api import label as label_api from senaite.core.browser.controlpanel.listing import ControlPanelListingView from senaite.core.catalog import SETUP_CATALOG @@ -84,18 +85,27 @@ def __init__(self, context, request): ] def folderitem(self, obj, item, index): - """Service triggered each time an item is iterated in folderitems. - The use of this service prevents the extra-loops in child objects. - :obj: the instance of the class to be foldered - :item: dict containing the properties of the object to be used by - the template - :index: current index of the item - """ + # Always wake the object: we need Title / URL / Description + # anyway, so the brain-metadata shortcut would not save a wake. obj = api.get_object(obj) - - item["replace"]["Title"] = get_link_for(obj) + color = getattr(obj, "color", u"") or u"" + if not label_api.is_safe_color(color): + color = u"" + + title = api.safe_unicode(api.get_title(obj)) + url = api.safe_unicode(api.get_url(obj)) + style = label_api.chip_style(color) + style_attr = u' style="{}"'.format(style) if style else u"" + + item["replace"]["Title"] = ( + u'' + u'{title}' + ).format( + url=html_escape(url, quote=True), + style=style_attr, + title=html_escape(title), + ) item["Description"] = api.get_description(obj) - return item def folderitems(self): diff --git a/src/senaite/core/browser/viewlets/configure.zcml b/src/senaite/core/browser/viewlets/configure.zcml index c51213e1cd..aef1c6a2a0 100644 --- a/src/senaite/core/browser/viewlets/configure.zcml +++ b/src/senaite/core/browser/viewlets/configure.zcml @@ -14,6 +14,16 @@ layer="senaite.core.interfaces.ISenaiteCore" /> + + + ` heading to look like the + colored chip it represents. + + Renders nothing visible — only an inline ` + diff --git a/src/senaite/core/content/label.py b/src/senaite/core/content/label.py index 56cc535d9d..edf458f422 100644 --- a/src/senaite/core/content/label.py +++ b/src/senaite/core/content/label.py @@ -19,12 +19,15 @@ # Some rights reserved, see README and LICENSE. from bika.lims import senaiteMessageFactory as _ +from plone.autoform.interfaces import IDisplayForm +from plone.autoform import directives from plone.supermodel import model from senaite.core.catalog import SETUP_CATALOG from senaite.core.content.base import Container from senaite.core.interfaces import IHideActionsMenu from senaite.core.api import label as label_api from senaite.core.interfaces import ILabel +from senaite.core.schema.colorfield import ColorField from zope import schema from zope.interface import Invalid from zope.interface import implementer @@ -50,6 +53,24 @@ class ILabelSchema(model.Schema): required=False, ) + # Hide the color row on the display view — a viewlet renders the + # swatch next to the title instead. The field is still editable + # in the add / edit forms via the ColorField widget. + directives.mode(IDisplayForm, color="hidden") + color = ColorField( + title=_( + u"title_label_color", + default=u"Color" + ), + description=_( + u"description_label_color", + default=u"Hex color code used for the chip " + u"(e.g. #0d6efd). Leave empty for the default style." + ), + required=False, + default=u"", + ) + @invariant def validate_title(data): """Checks if the title is unique From efa76211bb3555ec61006eca1394b026b9650339 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 11:10:55 +0200 Subject: [PATCH 04/25] Extend label API and catalogs for chip color and click-to-filter Adds a 'labels' KeywordIndex and 'getLabels' metadata column to senaite_catalog_sample so sample listings can filter by label without waking objects. Adds 'color' as a metadata column on senaite_catalog_setup so chip-color lookups read brain metadata. Extends senaite.core.api.label with the helpers needed by every consumer of the chip rendering: - HEX_COLOR_RE, is_safe_color(value), chip_style(color): single source of truth for the hex whitelist and the CSS style string. Callers go through chip_style so user-supplied color values cannot break out of the style attribute. - get_label_colors(names=None): returns a {label_name: color} map from the setup catalog without waking Label objects. - parse_label_csv(raw): parses request-shaped values (str, list, or list-of-str) into a sorted unique list of unicode names. Coerces to unicode because the setup catalog 'title' FieldIndex rejects utf-8 byte strings post-#2901. Fixes get_label_by_name() to coerce 'name' to unicode before querying the title index, which otherwise raises UnicodeDecodeError on any non-ASCII label. --- src/senaite/core/api/label.py | 81 +++++++++++++++++++++- src/senaite/core/catalog/sample_catalog.py | 2 + src/senaite/core/catalog/setup_catalog.py | 1 + 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/senaite/core/api/label.py b/src/senaite/core/api/label.py index 17d4c32e4e..fceb92d35f 100644 --- a/src/senaite/core/api/label.py +++ b/src/senaite/core/api/label.py @@ -18,6 +18,8 @@ # Copyright 2018-2025 by it's authors. # Some rights reserved, see README and LICENSE. +import re + from bika.lims import api from bika.lims import logger from bika.lims.api import create @@ -40,6 +42,38 @@ LABEL_STORAGE = "senaite.core.labels" BEHAVIOR_ID = ICanHaveLabels.__identifier__ +# 6-digit hex color string `#rrggbb`. Used to validate the optional +# `color` attribute of a Label and to filter user-supplied values +# before they are inlined into `style=""` attributes anywhere chips +# are rendered. +HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") + + +def is_safe_color(value): + """Return True if `value` is a 6-digit hex color (`#rrggbb`). + + Empty / None values are treated as unsafe so callers can fall + back to the default chip styling. + """ + if not value: + return False + return bool(HEX_COLOR_RE.match(value)) + + +def chip_style(color): + """Inline CSS for a colored label chip, or empty string. + + Returned string contains `;` separators, so callers that embed + it via Chameleon `tal:attributes` must go through a view method + (semicolons collide with TAL's attribute separator) rather than + inlining the expression directly. + """ + if not is_safe_color(color): + return u"" + return ( + u"background-color: {c}; border-color: {c}; color: #fff" + ).format(c=color) + def get_storage(obj, default=None): """Get label storage for the given object @@ -84,7 +118,11 @@ def get_label_by_name(name, inactive=True): :param name: Name of the label :returns: Label object or None """ - found = query_labels(title=name) + # The setup catalog's `title` FieldIndex stores unicode keys + # (post #2901 rebuild). Form-submitted names arrive as utf-8 + # byte strings, so coerce before querying or the index raises + # `UnicodeDecodeError` on ASCII comparison. + found = query_labels(title=api.safe_unicode(name)) if len(found) == 0: return None elif len(found) > 1: @@ -202,6 +240,28 @@ def add_obj_labels(obj, labels): return get_obj_labels(obj) +def parse_label_csv(raw): + """Parse a comma-separated label string (or list of strings) into a + sorted unique list of unicode names. Whitespace is stripped, + empties dropped. + + Accepts the two shapes that come over HTTP: + - a single string `"foo, bar"` + - a list of strings `["foo", "bar,baz"]` (repeated query params) + + Values are coerced to unicode because the setup catalog's `title` + FieldIndex expects unicode keys; comparing utf-8 bytes against + unicode raises `UnicodeDecodeError` inside `PluginIndexes`. + """ + if isinstance(raw, (list, tuple)): + values = [] + for entry in raw: + values.extend(api.safe_unicode(entry or u"").split(u",")) + else: + values = api.safe_unicode(raw or u"").split(u",") + return sorted({v.strip() for v in values if v and v.strip()}) + + def del_obj_labels(obj, labels): """Remove labels from the object @@ -219,6 +279,25 @@ def del_obj_labels(obj, labels): return get_obj_labels(obj) +def get_label_colors(names=None): + """Return a `{label_name: color}` map for the given label names. + + When `names` is None, returns the map for all active labels. + Colors are read from the brain `color` metadata column to avoid + waking the Label objects. Empty / missing colors are omitted. + """ + query = {"portal_type": "Label"} + if names: + query["title"] = list(names) + brains = search(query, catalog=SETUP_CATALOG) + out = {} + for brain in brains: + color = getattr(brain, "color", None) + if color: + out[api.safe_unicode(brain.Title)] = api.safe_unicode(color) + return out + + def search_objects_by_label(label, **kw): """Search for objects having one or more of the given labels diff --git a/src/senaite/core/catalog/sample_catalog.py b/src/senaite/core/catalog/sample_catalog.py index eb1a6ce2d3..bd59d3f8c4 100644 --- a/src/senaite/core/catalog/sample_catalog.py +++ b/src/senaite/core/catalog/sample_catalog.py @@ -51,6 +51,7 @@ ("getSamplingDate", "", "DateIndex"), ("isRootAncestor", "", "BooleanIndex"), ("is_received", "", "BooleanIndex"), + ("labels", "", "KeywordIndex"), # https://zope.readthedocs.io/en/latest/zopebook/SearchingZCatalog.html ("listing_searchable_text", "", "ZCTextIndex"), ("modified", "", "DateIndex"), @@ -79,6 +80,7 @@ "getDueDate", "getInternalUse", "getInvoiceExclude", + "getLabels", "getPrinted", "getPrioritySortkey", "getProfilesTitleStr", diff --git a/src/senaite/core/catalog/setup_catalog.py b/src/senaite/core/catalog/setup_catalog.py index c40665b4ec..f00b6ba170 100644 --- a/src/senaite/core/catalog/setup_catalog.py +++ b/src/senaite/core/catalog/setup_catalog.py @@ -56,6 +56,7 @@ # attribute name "Description", "Type", + "color", # Label color (used by chip rendering) "description", "getCategoryUID", "getClientUID", From ac5fc81624cfec2f80ff93483e7158e8a1fc607e Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 11:11:09 +0200 Subject: [PATCH 05/25] Add label chip rendering and filter support to ListingView The senaite.core ListingView base now renders label chips inline under the primary column of any listing whose context type carries labels: - labels_visible(): gated by ViewLabels. Default True for the listing's catalog; transposed views (worksheet manage view) return False because their 'after' slot is not a sample identifier. - labels_filterable(): True when labels_visible AND the listing's catalog has a 'labels' index. When False, chips render as plain spans; when True they render as click-to-filter spans with the 'is-filterable' class (navigation is wired client-side by senaite.app.listing). - get_request_labels(): reads from request.form first, falls back to parsing QUERY_STRING directly because the AJAX subpath POST (/view/folderitems) carries the query in QUERY_STRING but Zope's publisher does not populate request.form for the subpath JSON request. - render_label_chips(): HTML-escapes every interpolated value; user-supplied colors pass is_safe_color before any inline style is emitted. Removal is done through the manage-labels modal, so chips carry no inline X. - _attach_label_chips(): wraps the cell value and chips inside a single block-level div via 'replace' so chips break to a new line below the primary value. Raw cell text from the catalog metadata column is HTML-escaped before being inlined. Subclasses control where chips land with the new label_target_column class attribute (defaults to the first column in PRIMARY_COLUMN_CANDIDATES that exists in self.columns). --- src/senaite/core/browser/listing/base.py | 237 ++++++++++++++++++++++- 1 file changed, 231 insertions(+), 6 deletions(-) diff --git a/src/senaite/core/browser/listing/base.py b/src/senaite/core/browser/listing/base.py index fd9f3c3930..38d6046b2a 100644 --- a/src/senaite/core/browser/listing/base.py +++ b/src/senaite/core/browser/listing/base.py @@ -18,10 +18,41 @@ # Copyright 2018-2025 by it's authors. # Some rights reserved, see README and LICENSE. +from cgi import escape as html_escape +from urlparse import parse_qs + +from bika.lims import api from bika.lims.api.security import check_permission +from plone.memoize import view from Products.CMFCore.permissions import ModifyPortalContent from Products.CMFPlone.utils import safe_unicode from senaite.app.listing import ListingView as BaseListingView +from senaite.core.api import label as label_api +from senaite.core.permissions import ManageLabels +from senaite.core.permissions import ViewLabels + +LABEL_INDEX = "labels" +LABEL_COLUMN = "getLabels" +PRIMARY_COLUMN_CANDIDATES = ("Title", "title", "getId") + + +def _read_labels(obj): + """Return labels for the given brain/object without unnecessary wake. + + Prefers the `getLabels` brain metadata column when present; + otherwise falls back to waking the object and calling + `getLabels()`. + """ + value = getattr(obj, LABEL_COLUMN, None) + if value is not None: + # brain metadata is the bare value; never a method here + return value or () + live = api.get_object(obj, default=None) + if live is None: + return () + if hasattr(live, "getLabels"): + return live.getLabels() or () + return () class ListingView(BaseListingView): @@ -32,31 +63,225 @@ class ListingView(BaseListingView): object's edit form inside a Bootstrap modal showing only the edit form content (#content or #content-core). - Subclasses may set ``edit_icon_column`` to the column key that should + Subclasses may set `edit_icon_column` to the column key that should receive the icon. When not set, the first non-empty key among - ``("Title", "title", "getId")`` is used. + `("Title", "title", "getId")` is used. - Subclasses may set ``edit_view`` to control which view is opened: - - ``"edit"`` (default) — appends ``/edit`` to the object URL; the modal + Subclasses may set `edit_view` to control which view is opened: + - `"edit"` (default) — appends `/edit` to the object URL; the modal auto-closes when the form navigates away after save/cancel. - - ``""`` — opens the object's base URL (e.g. the SENAITE sample view + - `""` — opens the object's base URL (e.g. the SENAITE sample view with inline editing); the modal stays open until dismissed manually. + + Label chips are rendered under the primary column automatically when + the listing's catalog carries a `labels` KeywordIndex and a + `getLabels` metadata column. Subclasses may override + `label_target_column` to pin chips to a specific column. """ edit_icon_column = None edit_view = "edit" + # Column key that receives label chips. `None` resolves to the first + # non-empty key among `PRIMARY_COLUMN_CANDIDATES`. + label_target_column = None + def folderitems(self): items = super(ListingView, self).folderitems() if not check_permission(ModifyPortalContent, self.context): return items return [self._add_iframe_edit_link(item) for item in items] + def before_render(self): + super(ListingView, self).before_render() + if self.labels_filterable(): + labels = self.get_request_labels() + if labels: + self.contentFilter["labels"] = { + "query": labels, + "operator": "and", + } + + def folderitem(self, obj, item, index): + item = super(ListingView, self).folderitem(obj, item, index) + if not item: + return item + if self.labels_visible(): + self._attach_label_chips(obj, item) + return item + + # ------------------------------------------------------------------ + # Label support + # ------------------------------------------------------------------ + + @view.memoize + def labels_visible(self): + """Whether this listing renders label chips. + + Gated by the `ViewLabels` permission so that client users do + not see internal lab labels on shared samples. Transposed + listings (e.g. worksheet manage view) are also skipped because + their `after` slot is not a sample identifier. + """ + if getattr(self, "transposed_view", False): + return False + return self.can_view_labels() + + @view.memoize + def can_view_labels(self): + return check_permission(ViewLabels, self.context) + + @view.memoize + def labels_filterable(self): + """Whether chips link to the `?labels=` filter. + + Requires the listing's catalog to carry a `labels` index + AND the current user to have `ViewLabels`. + """ + if not self.can_view_labels(): + return False + catalog_id = getattr(self, "catalog", None) + if not catalog_id: + return False + catalog = api.get_tool(catalog_id, default=None) + if catalog is None: + return False + return LABEL_INDEX in catalog.indexes() + + @view.memoize + def can_manage_labels(self): + return check_permission(ManageLabels, self.context) + + def get_request_labels(self): + """Parse the `labels` query string into a sorted unique list. + + Reads `request.form` first (initial page render) and falls + back to parsing `QUERY_STRING` directly because the AJAX + subpath POST (`/view/folderitems`) carries the query in + `QUERY_STRING` but Zope's publisher does not populate + `request.form` for the subpath JSON request. + """ + raw = self.request.form.get("labels") + if not raw: + qs = self.request.get("QUERY_STRING") or "" + raw = parse_qs(qs).get("labels", []) + return label_api.parse_label_csv(raw) + + def get_label_target_column(self, item): + if self.label_target_column: + return self.label_target_column + for key in PRIMARY_COLUMN_CANDIDATES: + if key in self.columns: + return key + return None + + def render_label_chips(self, obj): + """Render the inline chip block for the given brain/object. + + Every interpolated value is HTML-escaped before being + concatenated. Label titles are free-text content on the Label + type and the brain `color` value is admin-supplied, so both + must be treated as untrusted in this context — color values + pass through `is_safe_color` before any inline style is built. + + Chips render as plain `` when the listing's catalog + does not support label filtering (no `labels` index); as + clickable filter chips (`is-filterable` class, navigation + wired client-side) otherwise. Label removal is done through + the manage-labels modal, so the chip body carries no `×`. + """ + labels = _read_labels(obj) + if not labels: + return u"" + + filterable = self.labels_filterable() + active = set(self.get_request_labels()) if filterable else set() + uid_attr = self._chip_uid_attr(obj) + colors = self._get_label_colors_map() + + chips = [ + self._render_chip(label, colors.get(safe_unicode(label)), + active, filterable) + for label in labels + ] + return ( + u'
{chips}
' + ).format(uid=uid_attr, chips=u"".join(chips)) + + def _chip_uid_attr(self, obj): + uid = getattr(obj, "UID", None) + if callable(uid): + uid = uid() + return html_escape(safe_unicode(uid or u""), quote=True) + + def _render_chip(self, label, color, active_set, filterable): + label_u = safe_unicode(label) + label_attr = html_escape(label_u, quote=True) + label_text = html_escape(label_u) + css_classes = [u"sample-label"] + if label in active_set: + css_classes.append(u"active") + if filterable: + css_classes.append(u"is-filterable") + style = label_api.chip_style(color) + style_attr = u' style="{}"'.format(style) if style else u"" + return ( + u'' + u'{label_text}' + u'' + ).format( + cls=u" ".join(css_classes), + label_attr=label_attr, + label_text=label_text, + style=style_attr, + ) + + @view.memoize + def _get_label_colors_map(self): + """Per-render cache of `{label_name: color}` from the setup catalog. + """ + return label_api.get_label_colors() + + def _attach_label_chips(self, obj, item): + """Stack chips under the primary column value. + + Going through `replace` avoids the `` + wrapper used by the `after` slot, which forces inline flow + and prevents chips from breaking to a new line. + + Pre-existing `replace[target]` content (e.g. a `` + link the consumer view installed) is trusted as HTML. When + there is no such entry, the raw cell value comes from the + catalog metadata column and is escaped before being inlined, + so a sample id containing `<` cannot inject markup. + """ + target = self.get_label_target_column(item) + if not target: + return + chips = self.render_label_chips(obj) + if not chips: + return + replace_map = item.setdefault("replace", {}) + existing_html = replace_map.get(target) + if existing_html is None: + raw_value = safe_unicode(item.get(target, u"") or u"") + existing_html = html_escape(raw_value) + replace_map[target] = ( + u'
' + u'
{value}
' + u'{chips}' + u'
' + ).format(value=safe_unicode(existing_html), chips=chips) + + # ------------------------------------------------------------------ + # Edit icon + # ------------------------------------------------------------------ + def _get_edit_icon_column(self, item): """Return the column key to attach the edit icon to.""" if self.edit_icon_column: return self.edit_icon_column - for key in ("Title", "title", "getId"): + for key in PRIMARY_COLUMN_CANDIDATES: if item.get(key): return key return None From 964192e3d1fed29f560aacd30e51f40c4adf96f4 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 11:11:25 +0200 Subject: [PATCH 06/25] Add Manage Labels modal and wire the Labels transition into SamplesView ManageLabelsModal is registered as @@manage_labels_modal and follows the senaite.core.browser.modals.Modal pattern used by the Create Worksheet modal. The form is the 'what should the labels be after submit' editor: - Available labels render as a clickable chip grid pre-selected by the union of labels currently set on the selected samples. Toggling a pre-selected chip schedules it for removal; toggling a non-pre-selected chip schedules it for addition. - A free-text input plus a color row (curated preset swatches + native HTML5 color picker + random button) creates a new Label via senaite.core.api.label.create_label on the fly. If the name already exists, only the color is updated. - handle_submit() computes add and remove sets by diffing the posted 'selected_labels' against the hidden 'initial_labels' it captured at render time, applies them per sample via add_obj_labels / del_obj_labels, and reindexes the 'labels' column on the sample catalog. Three companion REST endpoints land in the browser/label/ package, all gated by senaite.core: Manage Labels (add_label, remove_label) or senaite.core: View Labels (available_labels): - @@add_label: POST; accepts free-text names and auto-creates the corresponding Label in setup.labels if missing. - @@remove_label: POST; removes label(s) from the context object. - @@available_labels: GET; returns the full {name, color, description} catalogue used by app.listing to color the active filter chips. SamplesView is wired up via two two-line additions: - label_target_column = 'getId' so chips render under the Sample ID rather than the default Title fallback. - add_custom_transitions() appends a 'Labels' custom-transition entry pointing to @@manage_labels_modal, gated by can_manage_labels(). --- src/senaite/core/browser/label/configure.zcml | 21 ++ .../core/browser/label/manage_labels.py | 124 ++++++++++ .../core/browser/modals/configure.zcml | 8 + .../core/browser/modals/manage_labels.py | 214 ++++++++++++++++++ .../browser/modals/templates/manage_labels.pt | 165 ++++++++++++++ src/senaite/core/browser/samples/view.py | 13 ++ 6 files changed, 545 insertions(+) create mode 100644 src/senaite/core/browser/label/manage_labels.py create mode 100644 src/senaite/core/browser/modals/manage_labels.py create mode 100644 src/senaite/core/browser/modals/templates/manage_labels.pt diff --git a/src/senaite/core/browser/label/configure.zcml b/src/senaite/core/browser/label/configure.zcml index d17efd387c..8866851d89 100644 --- a/src/senaite/core/browser/label/configure.zcml +++ b/src/senaite/core/browser/label/configure.zcml @@ -10,4 +10,25 @@ permission="senaite.core.permissions.ManageBika" layer="senaite.core.interfaces.ISenaiteCore"/> + + + + + + diff --git a/src/senaite/core/browser/label/manage_labels.py b/src/senaite/core/browser/label/manage_labels.py new file mode 100644 index 0000000000..65f71de2e4 --- /dev/null +++ b/src/senaite/core/browser/label/manage_labels.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE 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 2018-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +import json + +from bika.lims import api +from Products.Five.browser import BrowserView +from senaite.core.api import label as label_api + + +SAMPLE_LABEL_REINDEX = ["labels"] + + +def _parse_labels(request): + """Return the labels submitted with the request as a sorted list. + + Accepts both `label=foo&label=bar` and `labels=foo,bar` forms. + """ + values = [] + single = request.form.get("label") + if isinstance(single, (list, tuple)): + values.extend(single) + elif single: + values.append(single) + multi = request.form.get("labels") + if multi: + values.append(multi) + return label_api.parse_label_csv(values) + + +def _json_response(request, payload): + request.response.setHeader("Content-Type", "application/json") + return json.dumps(payload) + + +class AddLabelView(BrowserView): + """POST endpoint that adds one or more labels to the context. + + Free-text labels are accepted: a submitted name that does not match + an existing `Label` in `setup.labels` is created on the fly. + Protected by the `senaite.core: Manage Labels` permission via ZCML. + """ + + def __call__(self): + labels = _parse_labels(self.request) + if not labels: + return _json_response(self.request, { + "success": False, + "error": "No labels submitted", + "labels": list(label_api.get_obj_labels(self.context)), + }) + + for name in labels: + if label_api.get_label_by_name(name) is None: + label_api.create_label(name) + + new_labels = label_api.add_obj_labels(self.context, labels) + self.context.reindexObject(idxs=SAMPLE_LABEL_REINDEX) + return _json_response(self.request, { + "success": True, + "labels": list(new_labels), + }) + + +class RemoveLabelView(BrowserView): + """POST endpoint that removes one or more labels from the context. + + Protected by the `senaite.core: Manage Labels` permission via ZCML. + """ + + def __call__(self): + labels = _parse_labels(self.request) + if not labels: + return _json_response(self.request, { + "success": False, + "error": "No labels submitted", + "labels": list(label_api.get_obj_labels(self.context)), + }) + + new_labels = label_api.del_obj_labels(self.context, labels) + self.context.reindexObject(idxs=SAMPLE_LABEL_REINDEX) + return _json_response(self.request, { + "success": True, + "labels": list(new_labels), + }) + + +class AvailableLabelsView(BrowserView): + """GET endpoint returning all active labels as JSON. + + Returns name, color and description for every label so the listing + can render chip colors consistently. Available to any authenticated + user (chip coloring is read-only and consumed both by the + chip-color sync on the page header and by the inline label picker). + """ + + def __call__(self): + brains = label_api.query_labels() + labels = [] + for brain in brains: + color = getattr(brain, "color", u"") or u"" + labels.append({ + "name": api.safe_unicode(brain.Title), + "color": api.safe_unicode(color), + "description": api.safe_unicode(brain.Description or ""), + }) + return _json_response(self.request, {"labels": labels}) diff --git a/src/senaite/core/browser/modals/configure.zcml b/src/senaite/core/browser/modals/configure.zcml index fb265cdb9f..14704f9956 100644 --- a/src/senaite/core/browser/modals/configure.zcml +++ b/src/senaite/core/browser/modals/configure.zcml @@ -41,4 +41,12 @@ layer="senaite.core.interfaces.ISenaiteCore" /> + + diff --git a/src/senaite/core/browser/modals/manage_labels.py b/src/senaite/core/browser/modals/manage_labels.py new file mode 100644 index 0000000000..c4edccf4ed --- /dev/null +++ b/src/senaite/core/browser/modals/manage_labels.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE 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 2018-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +from bika.lims import api +from bika.lims import senaiteMessageFactory as _ +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from senaite.core.api import label as label_api +from senaite.core.browser.modals import Modal + + +SAMPLE_LABEL_REINDEX = ["labels"] + +# Curated preset palette — picked for medium saturation so white text +# stays readable. Names are i18n-stable English keys; titles come from +# the template (they are tooltip-only and can be translated there). +LABEL_COLOR_PRESETS = [ + ("Red", u"#d33a3a"), + ("Orange", u"#e8852b"), + ("Yellow", u"#d4a017"), + ("Green", u"#2f9e44"), + ("Teal", u"#0d9488"), + ("Blue", u"#0d6efd"), + ("Purple", u"#7c3aed"), + ("Pink", u"#db2777"), + ("Slate", u"#475569"), +] + + +def _normalize_color(value): + if not value: + return u"" + value = api.safe_unicode(value).strip() + if label_api.is_safe_color(value): + return value + return u"" + + +class ManageLabelsModal(Modal): + """Modal that adds labels to the selected samples. + + Existing labels can be picked from a clickable chip grid; new + labels can be entered as free text with a color (preset / native + picker / random). New labels are created in `setup.labels` via + `senaite.core.api.label.create_label` before assignment. + """ + template = ViewPageTemplateFile("templates/manage_labels.pt") + + def __call__(self): + if self.request.form.get("submitted", False): + return self.handle_submit() + return self.template() + + def add_status_message(self, message, level="info"): + return self.context.plone_utils.addPortalMessage(message, level) + + def get_color_presets(self): + """Returns `[{name, color}, ...]` for the preset palette.""" + return [{"name": name, "color": color} + for name, color in LABEL_COLOR_PRESETS] + + def chip_style(self, color): + """Return a CSS `style` value for a colored chip. + + Built here (not inline in the template) because the CSS + semicolons would collide with Chameleon's `tal:attributes` + separator. Returns an empty string for invalid / missing + colors so chips fall back to the default pill styling. + """ + return label_api.chip_style(color) + + def swatch_style(self, color): + if not label_api.is_safe_color(color): + return u"" + return u"background-color: {c}".format(c=color) + + def get_available_labels(self): + """Returns `[{name, color}, ...]` for all active labels. + """ + brains = label_api.query_labels() + colors = label_api.get_label_colors() or {} + out = [] + for brain in brains: + title = api.safe_unicode(brain.Title) + out.append({ + "name": title, + "color": colors.get(title, u""), + }) + return out + + def get_selected_objects(self): + return [api.get_object(uid) for uid in self.uids] + + def get_shared_labels(self): + objects = self.get_selected_objects() + if not objects: + return [] + shared = set(label_api.get_obj_labels(objects[0])) + for obj in objects[1:]: + shared &= set(label_api.get_obj_labels(obj)) + return sorted(shared) + + def get_union_labels(self): + """Labels currently set on at least one of the selected objects. + + Toggling these in the chip grid lets the user remove them; any + chip that the user leaves un-selected is interpreted as + "should not be on the samples after submit". + """ + union = set() + for obj in self.get_selected_objects(): + union.update(label_api.get_obj_labels(obj)) + return sorted(union) + + def _parse_selected_labels(self): + raw = self.request.form.get("selected_labels", "") + return label_api.parse_label_csv(raw) + + def _parse_initial_labels(self): + raw = self.request.form.get("initial_labels", "") + return label_api.parse_label_csv(raw) + + def _parse_new_label(self): + raw = self.request.form.get("new_label", u"") or u"" + # Coerce to unicode so downstream `get_label_by_name` / + # `create_label` queries hit the setup catalog's unicode + # title index without a `UnicodeDecodeError`. + name = api.safe_unicode(raw).strip() + color = _normalize_color(self.request.form.get("new_label_color")) + return name, color + + def handle_submit(self): + selected = set(self._parse_selected_labels()) + initial = set(self._parse_initial_labels()) + new_name, new_color = self._parse_new_label() + + # Build the final desired set: selected ones the user kept, + # plus the new free-text label if any. + if new_name: + existing = label_api.get_label_by_name(new_name) + if existing is None: + kwargs = {} + if new_color: + kwargs["color"] = new_color + label_api.create_label(new_name, **kwargs) + elif new_color: + if getattr(existing, "color", None) != new_color: + existing.color = new_color + existing.reindexObject() + selected.add(new_name) + + # Diff against the initial state to derive add/remove sets. + to_add = sorted(selected - initial) + to_remove = sorted(initial - selected) + + if not self.uids or (not to_add and not to_remove): + return self.template() + + affected = 0 + for obj in self.get_selected_objects(): + if to_remove: + label_api.del_obj_labels(obj, to_remove) + if to_add: + label_api.add_obj_labels(obj, to_add) + obj.reindexObject(idxs=SAMPLE_LABEL_REINDEX) + affected += 1 + + if to_add and to_remove: + message = _( + u"labels_changed", + default=u"Applied ${added} added and ${removed} " + u"removed across ${affected} sample(s)", + mapping={ + "added": len(to_add), + "removed": len(to_remove), + "affected": affected, + }, + ) + elif to_add: + message = _( + u"labels_added", + default=u"Added ${count} label(s) to ${affected} sample(s)", + mapping={ + "count": len(to_add), + "affected": affected, + }, + ) + else: + message = _( + u"labels_removed", + default=u"Removed ${count} label(s) from ${affected} sample(s)", + mapping={ + "count": len(to_remove), + "affected": affected, + }, + ) + self.add_status_message(message) + return self.template() diff --git a/src/senaite/core/browser/modals/templates/manage_labels.pt b/src/senaite/core/browser/modals/templates/manage_labels.pt new file mode 100644 index 0000000000..6b103d7a58 --- /dev/null +++ b/src/senaite/core/browser/modals/templates/manage_labels.pt @@ -0,0 +1,165 @@ + diff --git a/src/senaite/core/browser/samples/view.py b/src/senaite/core/browser/samples/view.py index aa3ddfa5af..c30ae7115b 100644 --- a/src/senaite/core/browser/samples/view.py +++ b/src/senaite/core/browser/samples/view.py @@ -83,6 +83,8 @@ class SamplesView(ListingView): edit_icon_column = "getId" # Open the SENAITE sample view (not the generic /edit form) edit_view = "" + # Pin label chips under the Sample ID column + label_target_column = "getId" def __init__(self, context, request): super(SamplesView, self).__init__(context, request) @@ -720,6 +722,17 @@ def add_custom_transitions(self): "help": _("Create a new worksheet for the selected samples") }) + # Allow to add labels to the selected samples + if self.can_manage_labels(): + custom_transitions.append({ + "id": "modal_manage_labels", + "title": _("Labels"), + "url": "{}/manage_labels_modal".format( + api.get_url(self.context)), + "css_class": "btn btn-outline-secondary", + "help": _("Add or remove labels on the selected samples"), + }) + for rv in self.review_states: rv.setdefault("custom_transitions", []).extend(custom_transitions) From 3121ce05564695760799799bf9b7071de39b47a6 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 11:11:37 +0200 Subject: [PATCH 07/25] Add upgrade step (2745 -> 2746), extend label doctest, and update CHANGES The new setup_sample_labels upgrade step registers the two new permissions (re-imports rolemap), adds the 'labels' KeywordIndex and 'getLabels' column to senaite_catalog_sample, adds the 'color' column to senaite_catalog_setup, and refreshes Label brains so the color metadata column populates on existing installs. The API_label doctest is extended with: - is_safe_color whitelist behavior (safe, 3-digit reject, name, empty, None, and XSS-shaped input). - chip_style returning the CSS string or empty when unsafe. - parse_label_csv over the three input shapes plus a non-ASCII utf-8 round-trip (Furth -> u'F\xfcrth') so a regression on the byte/unicode boundary fails fast. - Setting Label.color and reading it back via get_label_colors, with the no-color filter behavior of the global map. - Verifying the 'labels' index exists on senaite_catalog_sample and that search_objects_by_label round-trips an add/remove on a labeled object. --- CHANGES.rst | 1 + .../core/profiles/default/metadata.xml | 2 +- src/senaite/core/tests/doctests/API_label.rst | 114 ++++++++++++++++++ src/senaite/core/upgrade/v02_07_000.py | 39 ++++++ src/senaite/core/upgrade/v02_07_000.zcml | 13 ++ 5 files changed, 168 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a8cfa264a8..674692a40f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 2.7.0 (unreleased) ------------------ +- #2958 Add labels with colors, filtering, and bulk-manage modal for samples - #2953 Fix IClientAwareMixin interface mismatch breaking client access on DX content - #2955 Defer workflow transition lookup with a React content-menu component - #2954 Declare i18n:domain on duration widget templates diff --git a/src/senaite/core/profiles/default/metadata.xml b/src/senaite/core/profiles/default/metadata.xml index dcf92e33cf..1dc6eb3fb4 100644 --- a/src/senaite/core/profiles/default/metadata.xml +++ b/src/senaite/core/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 2745 + 2746 profile-Products.ATContentTypes:base profile-senaite.impress:default diff --git a/src/senaite/core/tests/doctests/API_label.rst b/src/senaite/core/tests/doctests/API_label.rst index 4d9edf339a..8a49ec9ccb 100644 --- a/src/senaite/core/tests/doctests/API_label.rst +++ b/src/senaite/core/tests/doctests/API_label.rst @@ -218,3 +218,117 @@ Labels can be searched via the API and return all labeled objects: >>> results = search_objects_by_label(["SENAITE"]) >>> len(results) == 3 True + + +Color helpers +............. + +`is_safe_color` whitelists `#rrggbb` values before they get inlined +into CSS `style` attributes: + + >>> is_safe_color("#0d6efd") + True + >>> is_safe_color("#abcdef") + True + >>> is_safe_color("#ABC") + False + >>> is_safe_color("blue") + False + >>> is_safe_color("") + False + >>> is_safe_color(None) + False + >>> is_safe_color('"> + diff --git a/src/senaite/core/browser/static/js/senaite.core.modal.manage_labels.js b/src/senaite/core/browser/static/js/senaite.core.modal.manage_labels.js new file mode 100644 index 0000000000..fad0aa7ab1 --- /dev/null +++ b/src/senaite/core/browser/static/js/senaite.core.modal.manage_labels.js @@ -0,0 +1,83 @@ +/* Manage Labels modal — toggle chip selection, sync hidden field, + * wire color presets and the random button. + * + * Loaded by `senaite/core/browser/modals/templates/manage_labels.pt` + * via `++plone++senaite.core.static/js/senaite.core.modal.manage_labels.js`. + * + * The script is tolerant to multiple modal opens: it scopes every + * query to the form element it finds and bails out silently when + * the modal markup is not present. + */ +(function() { + "use strict"; + + function init() { + var form = document.querySelector(".manage-labels-form"); + if (!form) return; + + var selectedField = form.querySelector( + 'input[name="selected_labels"]'); + var newInput = form.querySelector('input[name="new_label"]'); + var colorInput = form.querySelector('input[name="new_label_color"]'); + var toggles = form.querySelectorAll(".manage-labels-toggle"); + var presets = form.querySelectorAll(".manage-labels-preset"); + var randomBtn = form.querySelector(".manage-labels-random"); + + function syncSelectedField() { + var picked = []; + toggles.forEach(function(btn) { + if (btn.getAttribute("data-selected") === "1") { + picked.push(btn.getAttribute("data-label")); + } + }); + if (selectedField) { + selectedField.value = picked.join(","); + } + } + + function onToggleClick(event) { + var btn = event.currentTarget; + var on = btn.getAttribute("data-selected") === "1"; + btn.setAttribute("data-selected", on ? "0" : "1"); + btn.classList.toggle("is-selected", !on); + btn.classList.toggle("is-removed", on); + syncSelectedField(); + } + + function onPresetClick(event) { + if (!colorInput) return; + colorInput.value = event.currentTarget.getAttribute("data-color"); + } + + function onRandomClick() { + if (!colorInput) return; + var hex = "#" + Math.floor(Math.random() * 0xffffff) + .toString(16).padStart(6, "0"); + colorInput.value = hex; + } + + toggles.forEach(function(btn) { + btn.addEventListener("click", onToggleClick); + if (btn.getAttribute("data-selected") === "1") { + btn.classList.add("is-selected"); + } + }); + presets.forEach(function(sw) { + sw.addEventListener("click", onPresetClick); + }); + if (randomBtn) { + randomBtn.addEventListener("click", onRandomClick); + } + + syncSelectedField(); + if (newInput) newInput.focus(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + // Modal markup is already in the DOM (the listing controller + // injects it after the document is ready). + init(); + } +})(); From 4d5fcbc429741a02fb0986ac0346d3bca162e7d2 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 21:06:45 +0200 Subject: [PATCH 18/25] Move LABEL_COLOR_PRESETS into senaite.core.config The curated chip-color palette consumed by the Manage Labels modal lived as a module-level constant inside the modal handler. Move it to senaite.core.config alongside the other shared project-wide constants so the same tuple list can be reused if / when other consumers want the same swatch set (e.g. a future inline picker on the Label edit form, or a custom add-on override). Tuple order is stable; importers continue to receive the same 9-color sequence. --- src/senaite/core/browser/modals/manage_labels.py | 16 +--------------- src/senaite/core/config/__init__.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/senaite/core/browser/modals/manage_labels.py b/src/senaite/core/browser/modals/manage_labels.py index c4edccf4ed..f7d9370e8a 100644 --- a/src/senaite/core/browser/modals/manage_labels.py +++ b/src/senaite/core/browser/modals/manage_labels.py @@ -23,25 +23,11 @@ from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from senaite.core.api import label as label_api from senaite.core.browser.modals import Modal +from senaite.core.config import LABEL_COLOR_PRESETS SAMPLE_LABEL_REINDEX = ["labels"] -# Curated preset palette — picked for medium saturation so white text -# stays readable. Names are i18n-stable English keys; titles come from -# the template (they are tooltip-only and can be translated there). -LABEL_COLOR_PRESETS = [ - ("Red", u"#d33a3a"), - ("Orange", u"#e8852b"), - ("Yellow", u"#d4a017"), - ("Green", u"#2f9e44"), - ("Teal", u"#0d9488"), - ("Blue", u"#0d6efd"), - ("Purple", u"#7c3aed"), - ("Pink", u"#db2777"), - ("Slate", u"#475569"), -] - def _normalize_color(value): if not value: diff --git a/src/senaite/core/config/__init__.py b/src/senaite/core/config/__init__.py index 1aac097065..0ac09d828f 100644 --- a/src/senaite/core/config/__init__.py +++ b/src/senaite/core/config/__init__.py @@ -20,3 +20,18 @@ PROFILE_ID = "profile-senaite.core:default" PROJECTNAME = "senaite.core" + +# Curated preset palette for the Manage Labels modal's color picker. +# Mid-saturation values picked so white chip text stays readable. +# Names are i18n-stable English keys used as preset button tooltips. +LABEL_COLOR_PRESETS = [ + ("Red", u"#d33a3a"), + ("Orange", u"#e8852b"), + ("Yellow", u"#d4a017"), + ("Green", u"#2f9e44"), + ("Teal", u"#0d9488"), + ("Blue", u"#0d6efd"), + ("Purple", u"#7c3aed"), + ("Pink", u"#db2777"), + ("Slate", u"#475569"), +] From d4f93b9ee7de470d094e9940123e920d35614227 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 21:12:49 +0200 Subject: [PATCH 19/25] Render the Label heading as a chip via an @@title override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier LabelColorViewlet emitted a - From 4fa8d12ffe30eadf62256ebe281899e731fade71 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 21:19:24 +0200 Subject: [PATCH 20/25] Expose Label color as a getColor catalog metadata column The setup catalog now indexes Label.getColor() instead of the bare .color attribute. This matches SENAITE's existing 'get' metadata convention used across other content types (getId, getTitle, getDescription, getAnalysesNum, ...) and decouples the catalog column from the attribute storage shape. Label.getColor() returns a unicode hex string (empty when unset or non-unicode-decodable), so chip-rendering consumers can read the brain column with no further coercion. Consumers updated to the method-call column: - senaite.core.api.label.get_label_colors - senaite.core.browser.label.api.LabelsAPI._route_available The setup_sample_labels upgrade step now adds the getColor column and drops the bare 'color' column if a pre-release install of this PR seeded it. Pre-existing installs without either column get just the getColor column. --- src/senaite/core/api/label.py | 2 +- src/senaite/core/browser/label/api.py | 2 +- src/senaite/core/catalog/setup_catalog.py | 2 +- src/senaite/core/content/label.py | 11 +++++++++++ src/senaite/core/upgrade/v02_07_000.py | 9 +++++++-- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/senaite/core/api/label.py b/src/senaite/core/api/label.py index fceb92d35f..373abcceff 100644 --- a/src/senaite/core/api/label.py +++ b/src/senaite/core/api/label.py @@ -292,7 +292,7 @@ def get_label_colors(names=None): brains = search(query, catalog=SETUP_CATALOG) out = {} for brain in brains: - color = getattr(brain, "color", None) + color = getattr(brain, "getColor", None) if color: out[api.safe_unicode(brain.Title)] = api.safe_unicode(color) return out diff --git a/src/senaite/core/browser/label/api.py b/src/senaite/core/browser/label/api.py index dd0d497df8..a80233bafd 100644 --- a/src/senaite/core/browser/label/api.py +++ b/src/senaite/core/browser/label/api.py @@ -116,7 +116,7 @@ def _route_available(self): brains = label_api.query_labels() labels = [] for brain in brains: - color = getattr(brain, "color", u"") or u"" + color = getattr(brain, "getColor", u"") or u"" labels.append({ "name": api.safe_unicode(brain.Title), "color": api.safe_unicode(color), diff --git a/src/senaite/core/catalog/setup_catalog.py b/src/senaite/core/catalog/setup_catalog.py index f00b6ba170..6588ba7513 100644 --- a/src/senaite/core/catalog/setup_catalog.py +++ b/src/senaite/core/catalog/setup_catalog.py @@ -56,7 +56,7 @@ # attribute name "Description", "Type", - "color", # Label color (used by chip rendering) + "getColor", # Label color (used by chip rendering) "description", "getCategoryUID", "getClientUID", diff --git a/src/senaite/core/content/label.py b/src/senaite/core/content/label.py index 7a3b2b34e0..9e5e194dd7 100644 --- a/src/senaite/core/content/label.py +++ b/src/senaite/core/content/label.py @@ -98,3 +98,14 @@ class Label(Container): """A container for labels """ _catalogs = [SETUP_CATALOG] + + def getColor(self): + """Return the configured chip color as a unicode hex string. + + Exposed as a catalog metadata column on senaite_catalog_setup + so chip-rendering consumers can read the color from the brain + without waking the Label object. Always returns a unicode + value (empty string for unset / invalid colors). + """ + value = getattr(self, "color", u"") or u"" + return value if isinstance(value, unicode) else value.decode("utf-8") diff --git a/src/senaite/core/upgrade/v02_07_000.py b/src/senaite/core/upgrade/v02_07_000.py index 37ee979e89..d74ad7c5cf 100644 --- a/src/senaite/core/upgrade/v02_07_000.py +++ b/src/senaite/core/upgrade/v02_07_000.py @@ -2203,9 +2203,14 @@ def setup_sample_labels(tool): logger.info("Adding 'getLabels' column to sample catalog ...") add_catalog_column(catalog, "getLabels") - logger.info("Adding 'color' column to setup catalog ...") + logger.info("Adding 'getColor' column to setup catalog ...") setup_catalog = api.get_tool(SETUP_CATALOG) - add_catalog_column(setup_catalog, "color") + add_catalog_column(setup_catalog, "getColor") + # The pre-release of this PR used a bare 'color' attribute column; + # drop it if present so the catalog reflects the final method-call + # convention. + if "color" in setup_catalog.schema(): + del_column(setup_catalog, "color") label_brains = setup_catalog(portal_type="Label") logger.info("Refreshing %s Label brains for color metadata " "and seeding rename-cascade baseline ..." From db65698f75adeed46fc0933343fa726ce5561854 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 21:20:05 +0200 Subject: [PATCH 21/25] Move LABEL_COLOR_PRESETS into senaite.core.config.labels The curated 9-color palette was sitting at the senaite.core.config package root, mixed in with the cross-cutting PROFILE_ID and PROJECTNAME constants. Promote it to its own submodule so the labels feature owns the file outright and future label-related config (e.g. a registry default palette, a default chip CSS class list) has a natural home. The modal handler now imports `from senaite.core.config.labels import LABEL_COLOR_PRESETS`. No other consumers exist yet, so this is a clean move. --- .../core/browser/modals/manage_labels.py | 2 +- src/senaite/core/config/__init__.py | 15 -------- src/senaite/core/config/labels.py | 34 +++++++++++++++++++ 3 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 src/senaite/core/config/labels.py diff --git a/src/senaite/core/browser/modals/manage_labels.py b/src/senaite/core/browser/modals/manage_labels.py index f7d9370e8a..7db6cc268e 100644 --- a/src/senaite/core/browser/modals/manage_labels.py +++ b/src/senaite/core/browser/modals/manage_labels.py @@ -23,7 +23,7 @@ from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from senaite.core.api import label as label_api from senaite.core.browser.modals import Modal -from senaite.core.config import LABEL_COLOR_PRESETS +from senaite.core.config.labels import LABEL_COLOR_PRESETS SAMPLE_LABEL_REINDEX = ["labels"] diff --git a/src/senaite/core/config/__init__.py b/src/senaite/core/config/__init__.py index 0ac09d828f..1aac097065 100644 --- a/src/senaite/core/config/__init__.py +++ b/src/senaite/core/config/__init__.py @@ -20,18 +20,3 @@ PROFILE_ID = "profile-senaite.core:default" PROJECTNAME = "senaite.core" - -# Curated preset palette for the Manage Labels modal's color picker. -# Mid-saturation values picked so white chip text stays readable. -# Names are i18n-stable English keys used as preset button tooltips. -LABEL_COLOR_PRESETS = [ - ("Red", u"#d33a3a"), - ("Orange", u"#e8852b"), - ("Yellow", u"#d4a017"), - ("Green", u"#2f9e44"), - ("Teal", u"#0d9488"), - ("Blue", u"#0d6efd"), - ("Purple", u"#7c3aed"), - ("Pink", u"#db2777"), - ("Slate", u"#475569"), -] diff --git a/src/senaite/core/config/labels.py b/src/senaite/core/config/labels.py new file mode 100644 index 0000000000..84f750e92e --- /dev/null +++ b/src/senaite/core/config/labels.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.CORE. +# +# SENAITE.CORE 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 2018-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +# Curated preset palette for the Manage Labels modal's color picker. +# Mid-saturation values picked so white chip text stays readable. +# Names are i18n-stable English keys used as preset button tooltips. +LABEL_COLOR_PRESETS = [ + ("Red", u"#d33a3a"), + ("Orange", u"#e8852b"), + ("Yellow", u"#d4a017"), + ("Green", u"#2f9e44"), + ("Teal", u"#0d9488"), + ("Blue", u"#0d6efd"), + ("Purple", u"#7c3aed"), + ("Pink", u"#db2777"), + ("Slate", u"#475569"), +] From 56fd9c2528fc19e10cafd392313ac820eb55cc5e Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sun, 21 Jun 2026 21:26:07 +0200 Subject: [PATCH 22/25] Consolidate label feature constants into senaite.core.config.labels Four constants that were either duplicated or hard-coded across the labels feature now have a single home next to LABEL_COLOR_PRESETS: - SAMPLE_LABEL_REINDEX = ["labels"] Was duplicated in browser/label/api.py, browser/modals/manage_labels.py and subscribers/label.py. Every consumer that mutates labels on a sample and then reindexes now imports the shared list. - LABEL_STORAGE = "senaite.core.labels" Was a module-level constant in api/label.py. Moved to config so the storage key is a label-config concept rather than an API-module detail. - PREVIOUS_TITLE_KEY = "senaite.core.label.previous_title" Was a module-level constant in subscribers/label.py and re-imported from there by the upgrade step. Both consumers now go through config.labels. - DEFAULT_LABEL_COLOR = u"#0d6efd" Was hard-coded as `value="#0d6efd"` in the modal's Chameleon template. The modal now exposes `view.default_new_label_color` and the template binds the color input's `value` attribute through it so changing the default is a one-line edit in config.labels. --- src/senaite/core/api/label.py | 2 +- src/senaite/core/browser/label/api.py | 4 +--- .../core/browser/modals/manage_labels.py | 9 ++++++--- .../browser/modals/templates/manage_labels.pt | 2 +- src/senaite/core/config/labels.py | 18 ++++++++++++++++++ src/senaite/core/subscribers/label.py | 12 +++--------- src/senaite/core/upgrade/v02_07_000.py | 2 +- 7 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/senaite/core/api/label.py b/src/senaite/core/api/label.py index 373abcceff..1ea968f821 100644 --- a/src/senaite/core/api/label.py +++ b/src/senaite/core/api/label.py @@ -33,13 +33,13 @@ from Products.CMFPlone.utils import classImplements from senaite.core.catalog import LABEL_CATALOG from senaite.core.catalog import SETUP_CATALOG +from senaite.core.config.labels import LABEL_STORAGE from senaite.core.interfaces import ICanHaveLabels from senaite.core.interfaces import IHaveLabels from senaite.core.interfaces import ILabel from zope.interface import alsoProvides from zope.interface import noLongerProvides -LABEL_STORAGE = "senaite.core.labels" BEHAVIOR_ID = ICanHaveLabels.__identifier__ # 6-digit hex color string `#rrggbb`. Used to validate the optional diff --git a/src/senaite/core/browser/label/api.py b/src/senaite/core/browser/label/api.py index a80233bafd..999c885508 100644 --- a/src/senaite/core/browser/label/api.py +++ b/src/senaite/core/browser/label/api.py @@ -23,14 +23,12 @@ from bika.lims.decorators import returns_json from Products.Five.browser import BrowserView from senaite.core.api import label as label_api +from senaite.core.config.labels import SAMPLE_LABEL_REINDEX from senaite.core.permissions import ManageLabels from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse -SAMPLE_LABEL_REINDEX = ["labels"] - - @implementer(IPublishTraverse) class LabelsAPI(BrowserView): """JSON endpoint for label management. diff --git a/src/senaite/core/browser/modals/manage_labels.py b/src/senaite/core/browser/modals/manage_labels.py index 7db6cc268e..86d7de5703 100644 --- a/src/senaite/core/browser/modals/manage_labels.py +++ b/src/senaite/core/browser/modals/manage_labels.py @@ -23,10 +23,9 @@ from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from senaite.core.api import label as label_api from senaite.core.browser.modals import Modal +from senaite.core.config.labels import DEFAULT_LABEL_COLOR from senaite.core.config.labels import LABEL_COLOR_PRESETS - - -SAMPLE_LABEL_REINDEX = ["labels"] +from senaite.core.config.labels import SAMPLE_LABEL_REINDEX def _normalize_color(value): @@ -56,6 +55,10 @@ def __call__(self): def add_status_message(self, message, level="info"): return self.context.plone_utils.addPortalMessage(message, level) + def default_new_label_color(self): + """Seed value for the new-label color picker.""" + return DEFAULT_LABEL_COLOR + def get_color_presets(self): """Returns `[{name, color}, ...]` for the preset palette.""" return [{"name": name, "color": color} diff --git a/src/senaite/core/browser/modals/templates/manage_labels.pt b/src/senaite/core/browser/modals/templates/manage_labels.pt index 9f09d320c4..734a47177b 100644 --- a/src/senaite/core/browser/modals/templates/manage_labels.pt +++ b/src/senaite/core/browser/modals/templates/manage_labels.pt @@ -72,7 +72,7 @@ id="manage-labels-new-color" class="manage-labels-color" name="new_label_color" - value="#0d6efd" /> + tal:attributes="value view/default_new_label_color" />