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/api/label.py b/src/senaite/core/api/label.py index 17d4c32e4e..7fffd991e0 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 @@ -31,15 +33,47 @@ 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 +# `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,34 @@ 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: + # Prefer the `getColor` metadata column — populated by the + # 2.7 upgrade step `setup_sample_labels`. Falls back to + # waking the Label and reading the attribute directly for + # instances where the catalog rebuild has not yet run + # (e.g. between code deploy and `portal_setup` re-import). + color = getattr(brain, "getColor", None) + if not color: + obj = api.get_object(brain, default=None) + if obj is not None: + color = getattr(obj, "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/behaviors/label.py b/src/senaite/core/behaviors/label.py index 359309e2d8..52e2a8d4d2 100644 --- a/src/senaite/core/behaviors/label.py +++ b/src/senaite/core/behaviors/label.py @@ -28,10 +28,11 @@ from plone.dexterity.schema import SCHEMA_CACHE from plone.supermodel import model from plone.supermodel.directives import fieldset -from Products.CMFCore import permissions from senaite.core.api import label as label_api from senaite.core.catalog import SETUP_CATALOG from senaite.core.interfaces import ICanHaveLabels +from senaite.core.permissions import ManageLabels +from senaite.core.permissions import ViewLabels from senaite.core.z3cform.widgets.queryselect import QuerySelectWidgetFactory from zope import schema from zope.component import adapter @@ -86,6 +87,13 @@ class ILabelSchema(model.Schema): label=u"Labels", fields=["Labels"]) + # Gate the inline Labels chooser on the edit form by the same + # permissions used for chip visibility (ViewLabels) and chip + # add/remove (ManageLabels). Client users without ViewLabels + # neither see the field nor receive the chooser in form data. + directives.read_permission(Labels=ViewLabels) + directives.write_permission(Labels=ManageLabels) + directives.widget( "Labels", QuerySelectWidgetFactory, @@ -131,11 +139,11 @@ class LabelSchema(object): def __init__(self, context): self.context = context - @security.protected(permissions.View) + @security.protected(ViewLabels) def getLabels(self): return label_api.get_obj_labels(self.context) - @security.protected(permissions.ModifyPortalContent) + @security.protected(ManageLabels) def setLabels(self, value): labels = label_api.to_labels(value) return label_api.set_obj_labels(self.context, labels) 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/label/api.py b/src/senaite/core/browser/label/api.py new file mode 100644 index 0000000000..994abf1b91 --- /dev/null +++ b/src/senaite/core/browser/label/api.py @@ -0,0 +1,176 @@ +# -*- 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.api.security import check_permission +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 + + +@implementer(IPublishTraverse) +class LabelsAPI(BrowserView): + """JSON endpoint for label management. + + Routes are dispatched via subpath traversal: + + - `/@@labels/add` — POST: add one or more labels to + the context object. Auto-creates the corresponding `Label` + in `setup.labels` when the name is new. Requires + `senaite.core: Manage Labels`. + + - `/@@labels/remove` — POST: remove one or more + labels from the context object. Requires + `senaite.core: Manage Labels`. + + - `@@labels/available` — GET: returns `{name, color, + description}` for every active label so consumers (e.g. the + active-filter chips above the listing search box) can paint + chips in the matching color. Requires `senaite.core: View + Labels`. + + The browser page is registered at the lowest of the two + permissions (`ViewLabels`); the write routes re-check + `ManageLabels` and return 403 when missing. This keeps the + JSON contract symmetric (every route returns a JSON body) and + avoids exposing two ZCML page registrations for what is + logically one endpoint. + + Labels are accepted in either of the two request shapes that + HTTP can deliver: `label=foo&label=bar` (repeated key) or + `labels=foo,bar` (comma-separated). Both are parsed through + `senaite.core.api.label.parse_label_csv`. + """ + + def __init__(self, context, request): + super(LabelsAPI, self).__init__(context, request) + self.traverse_subpath = [] + + def publishTraverse(self, request, name): + self.traverse_subpath.append(name) + return self + + def __call__(self): + if len(self.traverse_subpath) != 1: + return self._not_found() + route = self.traverse_subpath[0] + handler = getattr(self, "_route_{}".format(route), None) + if handler is None: + return self._not_found() + return handler() + + # ------------------------------------------------------------------ + # Routes + # ------------------------------------------------------------------ + + @returns_json + def _route_add(self): + if not check_permission(ManageLabels, self.context): + return self._forbidden() + labels = self._read_submitted_labels() + if not labels: + return self._empty_input() + 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 {"success": True, "labels": list(new_labels)} + + @returns_json + def _route_remove(self): + if not check_permission(ManageLabels, self.context): + return self._forbidden() + labels = self._read_submitted_labels() + if not labels: + return self._empty_input() + new_labels = label_api.del_obj_labels(self.context, labels) + self.context.reindexObject(idxs=SAMPLE_LABEL_REINDEX) + return {"success": True, "labels": list(new_labels)} + + @returns_json + def _route_available(self): + brains = label_api.query_labels() + labels = [] + for brain in brains: + # Prefer the `getColor` metadata column. Falls back to + # waking the Label and reading the live attribute when + # the catalog rebuild has not yet propagated the new + # column to existing brains (matches the fallback in + # `senaite.core.api.label.get_label_colors`). + color = getattr(brain, "getColor", u"") or u"" + if not color: + obj = api.get_object(brain, default=None) + if obj is not None: + color = getattr(obj, "color", u"") or u"" + labels.append({ + "name": api.safe_unicode(brain.Title), + "color": api.safe_unicode(color), + "description": api.safe_unicode(brain.Description or u""), + }) + return {"labels": labels} + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _read_submitted_labels(self): + """Parse labels from the request form into a sorted unique list. + + Accepts both `label=foo&label=bar` (repeated keys) and + `labels=foo,bar` (comma-separated) shapes; both go through + the same parser to return unicode names. + """ + values = [] + single = self.request.form.get("label") + if isinstance(single, (list, tuple)): + values.extend(single) + elif single: + values.append(single) + multi = self.request.form.get("labels") + if multi: + values.append(multi) + return label_api.parse_label_csv(values) + + @returns_json + def _empty_input(self): + self.request.response.setStatus(400) + return { + "success": False, + "error": "No labels submitted", + "labels": list(label_api.get_obj_labels(self.context)), + } + + @returns_json + def _forbidden(self): + self.request.response.setStatus(403) + return {"success": False, "error": "Forbidden"} + + @returns_json + def _not_found(self): + self.request.response.setStatus(404) + return { + "success": False, + "error": "Unknown route. Use one of: add, remove, available", + } diff --git a/src/senaite/core/browser/label/configure.zcml b/src/senaite/core/browser/label/configure.zcml index d17efd387c..407f1b01f8 100644 --- a/src/senaite/core/browser/label/configure.zcml +++ b/src/senaite/core/browser/label/configure.zcml @@ -10,4 +10,39 @@ permission="senaite.core.permissions.ManageBika" layer="senaite.core.interfaces.ISenaiteCore"/> + + + + + + diff --git a/src/senaite/core/browser/label/labeled_objects.py b/src/senaite/core/browser/label/labeled_objects.py index d2b7e915d2..4463df2175 100644 --- a/src/senaite/core/browser/label/labeled_objects.py +++ b/src/senaite/core/browser/label/labeled_objects.py @@ -23,6 +23,7 @@ from bika.lims import api from bika.lims import bikaMessageFactory as _ from bika.lims.utils import get_link_for +from plone.memoize import view from senaite.core.i18n import translate as t from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from senaite.core.browser.listing.base import ListingView @@ -77,13 +78,6 @@ 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 - """ obj = api.get_object(obj) labels = label_api.get_obj_labels(obj) portal_type = api.get_portal_type(obj) @@ -98,9 +92,15 @@ def folderitem(self, obj, item, index): return item + @view.memoize + def _label_colors(self): + return label_api.get_label_colors() + def render_labels(self, labels): + colors = self._label_colors() template = ViewPageTemplateFile("templates/object_labels.pt") - return template(self, labels=labels) + return template(self, labels=labels, colors=colors, + chip_style=label_api.chip_style) def folderitems(self): items = super(LabeledObjectsView, self).folderitems() diff --git a/src/senaite/core/browser/label/templates/object_labels.pt b/src/senaite/core/browser/label/templates/object_labels.pt index 40ef5e64c9..f724dc910e 100644 --- a/src/senaite/core/browser/label/templates/object_labels.pt +++ b/src/senaite/core/browser/label/templates/object_labels.pt @@ -1,13 +1,15 @@ -
- -
    -
  • -
    - -
    -
  • -
- + tal:define="labels options/labels; + colors options/colors; + chip_style nocall:options/chip_style;"> + + + + +
diff --git a/src/senaite/core/browser/label/title.py b/src/senaite/core/browser/label/title.py new file mode 100644 index 0000000000..581f7c1cfb --- /dev/null +++ b/src/senaite/core/browser/label/title.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. + +from cgi import escape as html_escape + +from Products.Five.browser import BrowserView +from senaite.core.api import label as label_api + + +class LabelTitleView(BrowserView): + """Override of `@@title` for Label content. + + Plone's `main_template.pt` renders the page heading via + `

`. Overriding the + view for `ILabel` lets us emit the heading already styled as a + colored chip — same look as the row chips and the chip grid in + the manage-labels modal — without resorting to a CSS-injection + viewlet or a body-class selector. + """ + + def __call__(self): + title = self.context.title or u"" + color = getattr(self.context, "color", u"") or u"" + style = label_api.chip_style(color) + style_attr = u' style="{}"'.format(style) if style else u"" + return ( + u'

' + u'' + u'{title}' + u'' + u'

' + ).format(style=style_attr, title=html_escape(title)) diff --git a/src/senaite/core/browser/listing/base.py b/src/senaite/core/browser/listing/base.py index fd9f3c3930..b38c878a5c 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", "Name", "name", "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 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..86d7de5703 --- /dev/null +++ b/src/senaite/core/browser/modals/manage_labels.py @@ -0,0 +1,203 @@ +# -*- 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 +from senaite.core.config.labels import DEFAULT_LABEL_COLOR +from senaite.core.config.labels import LABEL_COLOR_PRESETS +from senaite.core.config.labels import SAMPLE_LABEL_REINDEX + + +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 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} + 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..734a47177b --- /dev/null +++ b/src/senaite/core/browser/modals/templates/manage_labels.pt @@ -0,0 +1,117 @@ + 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) 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(); + } +})(); diff --git a/src/senaite/core/browser/viewlets/configure.zcml b/src/senaite/core/browser/viewlets/configure.zcml index c51213e1cd..bc4a02e0f0 100644 --- a/src/senaite/core/browser/viewlets/configure.zcml +++ b/src/senaite/core/browser/viewlets/configure.zcml @@ -14,6 +14,7 @@ layer="senaite.core.interfaces.ISenaiteCore" /> + : hidden — user has neither write nor read + permission, so the field is not rendered at all """ - mode = "view" if field.checkPermission("edit", self.context): mode = "edit" self.show_save = True elif field.checkPermission("view", self.context): mode = "view" + else: + # No permission to write *or* read — hide the field + # entirely. Without this branch the initial mode='view' + # leaked through and the field rendered in view mode + # regardless of the field's read_permission, defeating + # any per-field gating (e.g. the Labels field's + # ViewLabels / ManageLabels guards). + return default widget = self.get_widget(field) mode_vis = widget.isVisible(self.context, mode=mode, field=field) 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..6588ba7513 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", + "getColor", # Label color (used by chip rendering) "description", "getCategoryUID", "getClientUID", diff --git a/src/senaite/core/config/labels.py b/src/senaite/core/config/labels.py new file mode 100644 index 0000000000..e668c0200d --- /dev/null +++ b/src/senaite/core/config/labels.py @@ -0,0 +1,52 @@ +# -*- 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. + +# Annotation key holding the tuple of assigned label names on a +# labeled object. Set / read via senaite.core.api.label. +LABEL_STORAGE = "senaite.core.labels" + +# Annotation key on a Label content item carrying the last-known +# title. Read by the rename-cascade subscriber to detect title +# changes and rewrite stored names across all labeled objects. +PREVIOUS_TITLE_KEY = "senaite.core.label.previous_title" + +# Catalog index name re-fetched on every label add / remove so +# listings, the click-to-filter URL and the saved-filter presets +# see the change in the same transaction. +SAMPLE_LABEL_REINDEX = ["labels"] + +# Default color seeded into the Manage Labels modal's color picker +# when the user types a new free-text label. SENAITE blue accent. +DEFAULT_LABEL_COLOR = u"#0d6efd" + +# 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/content/label.py b/src/senaite/core/content/label.py index 56cc535d9d..9e5e194dd7 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 @@ -39,6 +42,13 @@ class ILabelSchema(model.Schema): u"title_label_title", default=u"Name" ), + description=_( + u"description_label_title", + default=u"Renaming this label rewrites the stored name " + u"on every currently labeled content. The cascade " + u"runs in the same transaction as the save and may " + u"take a moment on large datasets." + ), required=True, ) @@ -50,6 +60,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 @@ -70,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/extender/label.py b/src/senaite/core/extender/label.py index 25ea20b47b..543ddd9a1b 100644 --- a/src/senaite/core/extender/label.py +++ b/src/senaite/core/extender/label.py @@ -23,13 +23,14 @@ from archetypes.schemaextender.interfaces import ISchemaExtender from archetypes.schemaextender.interfaces import ISchemaModifier from bika.lims import senaiteMessageFactory as _ -from Products.CMFCore import permissions from senaite.core.browser.widgets.queryselect import QuerySelectWidget from senaite.core.catalog import SETUP_CATALOG from senaite.core.config.fields import AT_LABEL_FIELD from senaite.core.extender import ExtLabelField from senaite.core.interfaces import ICanHaveLabels from senaite.core.interfaces import ISenaiteCore +from senaite.core.permissions import ManageLabels +from senaite.core.permissions import ViewLabels from zope.component import adapts from zope.interface import implements @@ -51,8 +52,11 @@ class LabelSchemaExtender(object): required=False, mode="rw", schemata="Labels", - read_permission=permissions.View, - write_permission=permissions.ModifyPortalContent, + # Same gating as the listing chips and the modal: + # client users (no ViewLabels) see neither the field nor + # the chooser; only ManageLabels can write. + read_permission=ViewLabels, + write_permission=ManageLabels, widget=QuerySelectWidget( label=_("Labels"), description=_("Attached labels"), 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/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/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 @@ + + + + + + + + + + + + + + + + + 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/subscribers/configure.zcml b/src/senaite/core/subscribers/configure.zcml index a0a7ef9df2..b0e11beb1a 100644 --- a/src/senaite/core/subscribers/configure.zcml +++ b/src/senaite/core/subscribers/configure.zcml @@ -68,4 +68,19 @@ zope.lifecycleevent.interfaces.IObjectAddedEvent" handler=".intid.drop_intid_for_temporary_object"/> + + + + + diff --git a/src/senaite/core/subscribers/label.py b/src/senaite/core/subscribers/label.py new file mode 100644 index 0000000000..f5573562f0 --- /dev/null +++ b/src/senaite/core/subscribers/label.py @@ -0,0 +1,108 @@ +# -*- 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 senaite.core import logger +from senaite.core.api import label as label_api +from senaite.core.catalog import LABEL_CATALOG +from senaite.core.config.labels import PREVIOUS_TITLE_KEY +from senaite.core.config.labels import SAMPLE_LABEL_REINDEX +from zope.annotation.interfaces import IAnnotations + + +def on_label_added(label, event): + """Record the initial title as the rename baseline. + + Fires when a `Label` is added to `setup.labels`. We seed the + persistent annotation here so the first edit can compare against + a known baseline rather than treating every modification as a + rename. + """ + title = api.safe_unicode(label.title or u"") + IAnnotations(label)[PREVIOUS_TITLE_KEY] = title + + +def on_label_modified(label, event): + """Cascade a Label title rename across every labeled content. + + Reads the previous title from the persistent annotation on the + Label itself. If the title actually changed, walks the label + catalog (`senaite_catalog_label` only indexes objects providing + `IHaveLabels`) and rewrites the stored name on each, then + reindexes the `labels` index so listings, filters and the color + map see the rename immediately. + + Pre-existing Labels created before the subscriber was installed + have no annotation yet — the first modification seeds the + baseline rather than firing a false-positive cascade. + """ + annotations = IAnnotations(label) + new_title = api.safe_unicode(label.title or u"") + old_title = annotations.get(PREVIOUS_TITLE_KEY) + + if old_title is None: + # First modification after install / first save after upgrade. + # Seed the baseline; no rename to cascade. + annotations[PREVIOUS_TITLE_KEY] = new_title + return + + if old_title == new_title: + return + + affected = _rename_label_in_storage(old_title, new_title) + annotations[PREVIOUS_TITLE_KEY] = new_title + if affected: + logger.info( + "Label rename '{}' -> '{}': updated {} content(s)".format( + old_title.encode("utf-8"), + new_title.encode("utf-8"), + affected, + ) + ) + + +def _rename_label_in_storage(old_title, new_title): + """Walk every labeled object and rewrite `old_title` -> `new_title`. + + Returns the number of objects updated. When `new_title` is + already present on an object (merge case), the old entry is + dropped rather than duplicated. + """ + catalog = api.get_tool(LABEL_CATALOG) + brains = catalog(labels=old_title) + affected = 0 + for brain in brains: + obj = api.get_object(brain, default=None) + if obj is None: + continue + labels = list(label_api.get_obj_labels(obj)) + try: + index = labels.index(old_title) + except ValueError: + # Catalog brain stale; nothing to do here. + continue + if new_title in labels: + labels.pop(index) + else: + labels[index] = new_title + label_api.set_obj_labels(obj, labels) + obj.reindexObject(idxs=SAMPLE_LABEL_REINDEX) + affected += 1 + return affected diff --git a/src/senaite/core/tests/doctests/API_label.rst b/src/senaite/core/tests/doctests/API_label.rst index 4d9edf339a..3e0b94f0df 100644 --- a/src/senaite/core/tests/doctests/API_label.rst +++ b/src/senaite/core/tests/doctests/API_label.rst @@ -218,3 +218,173 @@ 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('">