Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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)
------------------

- #2959 Refactor Remarks into a reusable React widget with edit and version history
- #2957 Harmonize form input widths via tunable CSS variables
- #2956 Restrict client discount fields to lab staff
- #2958 Add labels with colors, filtering, and bulk-manage modal for samples
Expand Down
2 changes: 1 addition & 1 deletion src/bika/lims/browser/analysisrequest/add2.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
"Products.Archetypes.Widget.StringWidget",
"Products.Archetypes.Widget.BooleanWidget",
"bika.lims.browser.widgets.priorityselectionwidget.PrioritySelectionWidget", # noqa
"bika.lims.browser.widgets.remarkswidget.RemarksWidget",
"senaite.core.browser.widgets.remarkswidget.RemarksWidget",
"bika.lims.browser.widgets.selectionwidget.SelectionWidget",
]

Expand Down
301 changes: 10 additions & 291 deletions src/bika/lims/browser/fields/remarksfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,294 +18,13 @@
# Copyright 2018-2025 by it's authors.
# Some rights reserved, see README and LICENSE.

import re

import six

from AccessControl import ClassSecurityInfo
from bika.lims import api
from bika.lims.browser.widgets import RemarksWidget
from bika.lims.events import RemarksAddedEvent
from bika.lims.interfaces import IRemarksField
from bika.lims.utils import tmpID
from DateTime import DateTime
from Products.Archetypes.event import ObjectEditedEvent
from Products.Archetypes.Field import ObjectField
from Products.Archetypes.Registry import registerField
from Products.CMFPlone.i18nl10n import ulocalized_time
from zope import event
from zope.interface import implements


class RemarksHistory(list):
"""A list containing a remarks history, but __str__ returns the legacy
format from instances prior v1.3.3
"""

def html(self):
return api.text_to_html(str(self))

def __str__(self):
"""Returns the remarks in legacy format
"""
remarks = map(lambda rec: str(rec), self)
remarks = filter(None, remarks)
return "\n".join(remarks)

def __eq__(self, y):
if isinstance(y, six.string_types):
return str(self) == y
return super(RemarksHistory, self).__eq__(y)


class RemarksHistoryRecord(dict):
"""A dict implementation that represents a record/entry of Remarks History
"""

def __init__(self, *arg, **kw):
super(RemarksHistoryRecord, self).__init__(*arg, **kw)
self["id"] = self.id or tmpID()
self["user_id"] = self.user_id
self["user_name"] = self.user_name
self["created"] = self.created or DateTime().ISO()
self["content"] = self.content

@property
def id(self):
return self.get("id", "")

@property
def user_id(self):
return self.get("user_id", "")

@property
def user_name(self):
return self.get("user_name", "")

@property
def created(self):
return self.get("created", "")

@property
def created_ulocalized(self):
return ulocalized_time(self.created,
long_format=True,
context=api.get_portal(),
request=api.get_request(),
domain="senaite.core")

@property
def content(self):
return self.get("content", "")

@property
def html_content(self):
return api.text_to_html(self.content)

def __str__(self):
"""Returns a legacy string format of the Remarks record
"""
if not self.content:
return ""
if self.created and self.user_id:
# Build the legacy format
return "=== {} ({})\n{}".format(self.created, self.user_id,
self.content)
return self.content


class RemarksField(ObjectField):
"""A field that stores remarks. The value submitted to the setter
will always be appended to the actual value of the field.
A divider will be included with extra information about the text.
"""

_properties = ObjectField._properties.copy()
_properties.update({
'type': 'remarks',
'widget': RemarksWidget,
'default': '',
})

implements(IRemarksField)
security = ClassSecurityInfo()

@property
def searchable(self):
"""Returns False, preventing this field to be searchable by AT's
SearcheableText
"""
return False

@security.private
def set(self, instance, value, **kwargs):
"""Adds the value to the existing text stored in the field,
along with a small divider showing username and date of this entry.
"""

if not value:
return

if isinstance(value, RemarksHistory):
# Override the whole history here
history = value

elif isinstance(value, (list, tuple)):
# This is a list, convert to RemarksHistory
remarks = map(lambda item: RemarksHistoryRecord(item), value)
history = RemarksHistory(remarks)

elif isinstance(value, RemarksHistoryRecord):
# This is a record, append to the history
history = self.get_history(instance)
history.insert(0, value)

elif isinstance(value, six.string_types):
# Create a new history record
record = self.to_history_record(value)

# Append the new record to the history
history = self.get_history(instance)
history.insert(0, record)

else:
raise ValueError("Type not supported: {}".format(type(value)))

# filter nasty html in the complete history
for record in history:
content = record.get("content")
record["content"] = self.to_safe_html(content)

# Store the data
ObjectField.set(self, instance, history)

if not api.is_temporary(instance):

# N.B. ensure updated catalog metadata for the snapshot
instance.reindexObject()

# notify object edited event
event.notify(ObjectEditedEvent(instance))

# notify new remarks for e.g. later email notification etc.
event.notify(RemarksAddedEvent(instance, history))

def to_safe_html(self, html):
# see: Products.PortalTransforms.tests.test_xss
pt = api.get_tool("portal_transforms")
stream = pt.convertTo("text/x-html-safe", html)
return stream.getData()

def get(self, instance, **kwargs):
"""Returns a RemarksHistory object
"""
return self.get_history(instance)

def getRaw(self, instance, **kwargs):
"""Returns raw field value (possible wrapped in BaseUnit)
"""
value = ObjectField.get(self, instance, **kwargs)
# getattr(instance, "Remarks") returns a BaseUnit
if callable(value):
value = value()
return value

def to_history_record(self, value):
"""Transforms the value to an history record
"""
user = api.get_current_user()
contact = api.get_user_contact(user)
fullname = contact and contact.getFullname() or ""
if not contact:
# get the fullname from the user properties
props = api.get_user_properties(user)
fullname = props.get("fullname", "")
return RemarksHistoryRecord(user_id=user.id,
user_name=fullname,
content=value.strip())

def get_history(self, instance):
"""Returns a RemarksHistory object with the remarks entries
"""
remarks = instance.getRawRemarks()
if not remarks:
return RemarksHistory()

# Backwards compatibility with legacy from < v1.3.3
if isinstance(remarks, six.string_types):
parsed_remarks = self._parse_legacy_remarks(remarks)
if parsed_remarks is None:
remark = RemarksHistoryRecord(content=remarks.strip())
remarks = RemarksHistory([remark, ])
else:
remarks = RemarksHistory(
map(lambda r: RemarksHistoryRecord(r), parsed_remarks))

return remarks

def _parse_legacy_remarks(self, text):
"""Parse legacy remarks from the text
"""

# split legacy remarks on the complete delimiter, e.g.:
# === Tue, 28 Jan 2020 06:53:58 +0100 (admin)\nThis is a Test
lines = re.split(r"(===) ([A-Za-z]{3}, \d{1,2} [A-Za-z]{3} \d{2,4} \d{2}:\d{2}:\d{2} [+-]{1}\d{4}) \((.*?)\)", text)

record = None
records = []

# group into remark records of date, user-id and content
for line in lines:
# start a new remarks record when the marker was found
if line == "===":
record = []
# immediately append the new entry to the records
records.append(record)
# skip the marker entry
continue

# append the line to the entry until the next marker is found
# -> this also skips the empty first line
if record is not None:
record.append(line)

remarks = []

for record in records:
# each record must contain the date, user-id and text
# -> we invalidate the whole parsing if this is not given
if len(record) != 3:
return None

created, userid, content = record

# try to get the full name of the user id
fullname = self._get_fullname_from_user_id(userid)

# strip off leading and trailing escape sequences from the content
content = content.strip("\n\r\t")

# append a remarks record
remarks.append({
"created": created,
"user_id": userid,
"user_name": fullname,
"content": content,
})

return remarks

def _get_fullname_from_user_id(self, userid, default=""):
"""Try the fullname of the user
"""
fullname = default
user = api.get_user(userid)
if user:
props = api.get_user_properties(user)
fullname = props.get("fullname", fullname)
contact = api.get_user_contact(user)
fullname = contact and contact.getFullname() or fullname
return fullname


registerField(RemarksField, title="Remarks", description="")
# BBB: this module was moved to senaite.core.browser.fields.remarksfield
from senaite.core.browser.fields.remarksfield import RemarksField
from senaite.core.browser.fields.remarksfield import RemarksHistory
from senaite.core.browser.fields.remarksfield import RemarksHistoryRecord

__all__ = [
"RemarksField",
"RemarksHistory",
"RemarksHistoryRecord",
]
15 changes: 5 additions & 10 deletions src/bika/lims/browser/widgets/remarkswidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,9 @@
# Copyright 2018-2025 by it's authors.
# Some rights reserved, see README and LICENSE.

from AccessControl import ClassSecurityInfo
from Products.Archetypes.Widget import TypesWidget
# BBB: this module was moved to senaite.core.browser.widgets.remarkswidget
from senaite.core.browser.widgets.remarkswidget import RemarksWidget


class RemarksWidget(TypesWidget):
_properties = TypesWidget._properties.copy()
_properties.update({
"macro": "senaite_widgets/remarkswidget",
})

security = ClassSecurityInfo()
__all__ = [
"RemarksWidget",
]
2 changes: 1 addition & 1 deletion src/bika/lims/content/analysisrequest.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
from bika.lims.browser.fields import EmailsField
from bika.lims.browser.fields import ResultsRangesField
from bika.lims.browser.fields import UIDReferenceField
from bika.lims.browser.fields.remarksfield import RemarksField
from bika.lims.browser.fields.uidreferencefield import get_backreferences
from bika.lims.browser.widgets import DateTimeWidget
from bika.lims.browser.widgets import DecimalWidget
Expand Down Expand Up @@ -88,6 +87,7 @@
from Products.CMFPlone.utils import safe_unicode
from senaite.core.browser.fields.datetime import DateTimeField
from senaite.core.browser.fields.records import RecordsField
from senaite.core.browser.fields.remarksfield import RemarksField
from senaite.core.browser.widgets.referencewidget import ReferenceWidget
from senaite.core.catalog import ANALYSIS_CATALOG
from senaite.core.catalog import CLIENT_CATALOG
Expand Down
2 changes: 1 addition & 1 deletion src/bika/lims/content/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
from bika.lims import bikaMessageFactory as _
from bika.lims import deprecated
from bika.lims.browser.fields import UIDReferenceField
from bika.lims.browser.fields.remarksfield import RemarksField
from bika.lims.browser.widgets import DateTimeWidget
from bika.lims.browser.widgets import RemarksWidget
from senaite.core.browser.fields.remarksfield import RemarksField
from bika.lims.config import PROJECTNAME
from bika.lims.content.bikaschema import BikaFolderSchema
from bika.lims.content.clientawaremixin import ClientAwareMixin
Expand Down
2 changes: 1 addition & 1 deletion src/bika/lims/content/worksheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
from bika.lims import bikaMessageFactory as _
from bika.lims import logger
from bika.lims.browser.fields import UIDReferenceField
from bika.lims.browser.fields.remarksfield import RemarksField
from bika.lims.browser.widgets import RemarksWidget
from senaite.core.browser.fields.remarksfield import RemarksField
from bika.lims.browser.worksheet.tools import getWorksheetLayouts
from bika.lims.config import DEFAULT_WORKSHEET_LAYOUT
from bika.lims.config import PROJECTNAME
Expand Down
Loading
Loading