Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2b06196
Add ManageLabels and ViewLabels permissions
ramonski Jun 21, 2026
508bdc9
Add z3c.form ColorWidget for hex color fields
ramonski Jun 21, 2026
f86b099
Add color to the Label content type and restyle its view as a chip
ramonski Jun 21, 2026
efa7621
Extend label API and catalogs for chip color and click-to-filter
ramonski Jun 21, 2026
ac5fc81
Add label chip rendering and filter support to ListingView
ramonski Jun 21, 2026
964192e
Add Manage Labels modal and wire the Labels transition into SamplesView
ramonski Jun 21, 2026
3121ce0
Add upgrade step (2745 -> 2746), extend label doctest, and update CHA…
ramonski Jun 21, 2026
7d423e7
Consolidate label REST endpoints into a single @@labels traversal view
ramonski Jun 21, 2026
2ef590c
Namespace the labels JSON view as @@senaite_labels
ramonski Jun 21, 2026
c7e19b1
Cascade Label title renames across all labeled contents
ramonski Jun 21, 2026
5805e6d
Persist the rename-cascade baseline on the Label itself
ramonski Jun 21, 2026
64fc2ec
Color the chips in the Labeled Objects listing
ramonski Jun 21, 2026
db43777
Suppress TAL auto-call when binding chip_style helper
ramonski Jun 21, 2026
121dabe
Gate the inline Labels field with the same ViewLabels/ManageLabels pe…
ramonski Jun 21, 2026
f6a1a17
Hide sample-header fields when neither read nor write permission is g…
ramonski Jun 21, 2026
3d3d372
Filter sample-header fields by read permission before render
ramonski Jun 21, 2026
f368cde
Extract the Manage Labels modal JS into a separate static resource
ramonski Jun 21, 2026
4d5fcbc
Move LABEL_COLOR_PRESETS into senaite.core.config
ramonski Jun 21, 2026
d4f93b9
Render the Label heading as a chip via an @@title override
ramonski Jun 21, 2026
4fa8d12
Expose Label color as a getColor catalog metadata column
ramonski Jun 21, 2026
db65698
Move LABEL_COLOR_PRESETS into senaite.core.config.labels
ramonski Jun 21, 2026
56fd9c2
Consolidate label feature constants into senaite.core.config.labels
ramonski Jun 21, 2026
e35769b
Use single backticks in LabelsAPI docstrings
ramonski Jun 21, 2026
8726719
Fall back to a live read when getColor brain metadata is missing
ramonski Jun 21, 2026
d451571
Fall back to a live read in @@senaite_labels/available too
ramonski Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 90 additions & 2 deletions src/senaite/core/api/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
14 changes: 11 additions & 3 deletions src/senaite/core/behaviors/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 21 additions & 11 deletions src/senaite/core/browser/controlpanel/labels/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'<a class="sample-label" href="{url}"{style}>'
u'<span class="sample-label-text">{title}</span></a>'
).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):
Expand Down
Loading
Loading