diff --git a/CHANGES.rst b/CHANGES.rst
index 0aba5b07b5..c4117fe7eb 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,7 @@ Changelog
2.2.0 (unreleased)
------------------
+- #2004 Added behavior to allow sharing objects across clients users
- #2001 Fix Traceback when rendering UIDReferenceWidget with limited privileges
- #1999 Allow multi-choice/multiselect interim fields in calculations
- #1998 Fix analisys hidden status erases when submit through worksheet
diff --git a/src/bika/lims/profiles/default/rolemap.xml b/src/bika/lims/profiles/default/rolemap.xml
index 09d08eab77..98ac9ac600 100644
--- a/src/bika/lims/profiles/default/rolemap.xml
+++ b/src/bika/lims/profiles/default/rolemap.xml
@@ -13,6 +13,11 @@
+
+
+
@@ -33,6 +38,7 @@
+
@@ -47,6 +53,7 @@
+
@@ -61,6 +68,7 @@
+
diff --git a/src/senaite/core/adapters/configure.zcml b/src/senaite/core/adapters/configure.zcml
index 09d7d69416..c73e2ffe15 100644
--- a/src/senaite/core/adapters/configure.zcml
+++ b/src/senaite/core/adapters/configure.zcml
@@ -34,4 +34,12 @@
factory=".localroles.ClientAwareLocalRoles"
name="senaite.core.adapter.localroles.clientaware" />
+
+
+
diff --git a/src/senaite/core/adapters/localroles.py b/src/senaite/core/adapters/localroles.py
index 0422c8b0c6..97e4fafa83 100644
--- a/src/senaite/core/adapters/localroles.py
+++ b/src/senaite/core/adapters/localroles.py
@@ -24,6 +24,7 @@
from bika.lims.utils import get_client
from borg.localrole.default_adapter import DefaultLocalRoleAdapter
from plone.memoize import ram
+from senaite.core.behaviors import IClientShareableBehavior
from senaite.core.interfaces import IDynamicLocalRoles
from zope.component import getAdapters
from zope.interface import implementer
@@ -32,8 +33,12 @@
def _getRolesInContext_cachekey(method, self, context, principal_id):
"""Function that generates the key for volatile caching
"""
+ # We need the cachekey to change when global roles of a given user change
+ user = api.get_user(principal_id)
+ roles = user and ":".join(sorted(user.getRoles())) or ""
return ".".join([
principal_id,
+ roles,
api.get_path(context),
api.get_modification_date(context).ISO(),
])
@@ -53,6 +58,10 @@ def getRolesInContext(self, context, principal_id):
@param principal_id: User login id
@return List of dynamically calculated local-roles for user and context
"""
+ if not api.get_user(principal_id):
+ # principal_id can be a group name, but we consider users only
+ return []
+
roles = set()
path = api.get_path(context)
adapters = getAdapters((context,), IDynamicLocalRoles)
@@ -126,3 +135,36 @@ def getRoles(self, principal_id):
return []
return ["Owner"]
+
+
+@implementer(IDynamicLocalRoles)
+class ClientShareableLocalRoles(object):
+ """Adapter for the assignment of roles for content shared across clients
+ """
+
+ def __init__(self, context):
+ self.context = context
+
+ def getRoles(self, principal_id):
+ """Returns ["ClientGuest"] local role if the current context is
+ shareable across clients and the user for the principal_id belongs to
+ one of the clients for which the context can be shared
+ """
+ # Get the clients this context is shared with
+ behavior = IClientShareableBehavior(self.context)
+ clients = filter(api.is_uid, behavior.getRawClients())
+ if not clients:
+ return []
+
+ # Check if the user belongs to at least one of the clients
+ # this context is shared with
+ query = {
+ "portal_type": "Contact",
+ "getUsername": principal_id,
+ "getParentUID": clients,
+ }
+ brains = api.search(query, catalog="portal_catalog")
+ if len(brains) == 0:
+ return []
+
+ return ["ClientGuest"]
diff --git a/src/senaite/core/behaviors/__init__.py b/src/senaite/core/behaviors/__init__.py
new file mode 100644
index 0000000000..750e6cf097
--- /dev/null
+++ b/src/senaite/core/behaviors/__init__.py
@@ -0,0 +1,24 @@
+# -*- 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-2022 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+# Convenient imports
+from clientshareable import IClientShareable
+from clientshareable import IClientShareableBehavior
+from clientshareable import IClientShareableMarker
diff --git a/src/senaite/core/behaviors/clientshareable.py b/src/senaite/core/behaviors/clientshareable.py
new file mode 100644
index 0000000000..0e245f247d
--- /dev/null
+++ b/src/senaite/core/behaviors/clientshareable.py
@@ -0,0 +1,153 @@
+# -*- 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-2022 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+from AccessControl import ClassSecurityInfo
+from bika.lims import senaiteMessageFactory as _
+from plone.autoform import directives
+from plone.autoform.interfaces import IFormFieldProvider
+from plone.behavior.interfaces import IBehavior
+from plone.supermodel import model
+from plone.supermodel.directives import fieldset
+from Products.CMFCore import permissions
+from senaite.core.behaviors.utils import get_behavior_schema
+from senaite.core.schema import UIDReferenceField
+from senaite.core.z3cform.widgets.uidreference import UIDReferenceWidgetFactory
+from zope.interface import implementer
+from zope.interface import Interface
+from zope.interface import provider
+
+
+class IClientShareable(Interface):
+ """Marker interface to implement by types for which ClientShareableBehavior
+ can be applied
+ """
+ pass
+
+
+class IClientShareableMarker(Interface):
+ """Marker interface provided by objects with ClientShareableBehavior
+ """
+ pass
+
+
+@provider(IFormFieldProvider)
+class IClientShareableBehavior(model.Schema):
+ """Behavior with schema fields to allow to share the context with users
+ that belong to other clients
+ """
+
+ clients = UIDReferenceField(
+ title=_(u"Clients"),
+ description=_(
+ u"Clients with whom this content will be shared across. This "
+ u"content will become available on searches to users that belong "
+ u"to any of the selected clients thanks to the role 'ClientGuest'"
+ ),
+ allowed_types=("Client", ),
+ multi_valued=True,
+ required=False,
+ )
+
+ directives.widget(
+ "clients",
+ UIDReferenceWidgetFactory,
+ catalog="portal_catalog",
+ query={
+ "portal_type": "Client",
+ "is_active": True,
+ "sort_on": "title",
+ "sort_order": "ascending",
+ },
+ display_template="${title}",
+ columns=[
+ {
+ "name": "title",
+ "width": "30",
+ "align": "left",
+ "label": _(u"Title"),
+ }, {
+ "name": "description",
+ "width": "70",
+ "align": "left",
+ "label": _(u"Description"),
+ },
+ ],
+ limit=15,
+ )
+
+ fieldset(
+ "clientshareable",
+ label=u"Client share",
+ fields=["clients"],
+ )
+
+
+@implementer(IBehavior, IClientShareableBehavior)
+class ClientShareableFactory(object):
+ """Factory that provides IClientShareableBehavior"""
+
+ security = ClassSecurityInfo()
+
+ def __init__(self, context):
+ self.context = context
+ self._schema = None
+
+ @property
+ def schema(self):
+ """Return the schema provided by the underlying behavior
+ """
+ if self._schema is None:
+ behavior = IClientShareableBehavior
+ self._schema = get_behavior_schema(self.context, behavior)
+ return self._schema
+
+ def accessor(self, fieldname, raw=False):
+ """Return the field accessor for the fieldname
+ """
+ if fieldname in self.schema:
+ field = self.schema[fieldname]
+ if raw:
+ return field.get_raw
+ return field.get
+ return None
+
+ def mutator(self, fieldname):
+ """Return the field mutator for the fieldname
+ """
+ if fieldname in self.schema:
+ return self.schema[fieldname].set
+ return None
+
+ @security.protected(permissions.View)
+ def getClients(self):
+ accessor = self.accessor("clients")
+ return accessor(self.context)
+
+ @security.protected(permissions.View)
+ def getRawClients(self):
+ accessor = self.accessor("clients", raw=True)
+ return accessor(self.context)
+
+ @security.protected(permissions.ModifyPortalContent)
+ def setClients(self, value):
+ mutator = self.mutator("clients")
+ mutator(self.context, value)
+
+ clients = property(getClients, setClients)
diff --git a/src/senaite/core/behaviors/configure.zcml b/src/senaite/core/behaviors/configure.zcml
new file mode 100644
index 0000000000..597dc90a19
--- /dev/null
+++ b/src/senaite/core/behaviors/configure.zcml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/senaite/core/behaviors/utils.py b/src/senaite/core/behaviors/utils.py
new file mode 100644
index 0000000000..5fd969fbaa
--- /dev/null
+++ b/src/senaite/core/behaviors/utils.py
@@ -0,0 +1,33 @@
+# -*- 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-2022 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+
+from plone.dexterity.utils import getAdditionalSchemata
+
+
+def get_behavior_schema(context, behavior):
+ """Returns the schema of the context that is provided by the behavior
+ interface passed-in, if any
+ """
+ schemata = getAdditionalSchemata(context=context)
+ for sch in schemata:
+ if sch.isOrExtends(behavior):
+ return sch
+ raise TypeError("No behavior schema found")
diff --git a/src/senaite/core/configure.zcml b/src/senaite/core/configure.zcml
index 16768be5fd..15b5ce314d 100644
--- a/src/senaite/core/configure.zcml
+++ b/src/senaite/core/configure.zcml
@@ -12,6 +12,7 @@
+
diff --git a/src/senaite/core/schema/uidreferencefield.py b/src/senaite/core/schema/uidreferencefield.py
index cc2b1ad731..7b9eb05805 100644
--- a/src/senaite/core/schema/uidreferencefield.py
+++ b/src/senaite/core/schema/uidreferencefield.py
@@ -177,6 +177,9 @@ def set(self, object, value):
:type value: list/tuple/str
"""
+ # Object might be a behavior instead of the object itself
+ object = self._get_content_object(object)
+
# always mark the object if references are set
# NOTE: there might be multiple UID reference field set on this object!
if value:
@@ -222,8 +225,7 @@ def unlink_backref(self, source, target):
"""
# Target might be a behavior instead of the object itself
- if IBehavior.providedBy(target):
- target = target.context
+ target = self._get_content_object(target)
# This should be actually not possible
if self.is_initializing(target):
@@ -256,8 +258,7 @@ def link_backref(self, source, target):
"""
# Target might be a behavior instead of the object itself
- if IBehavior.providedBy(target):
- target = target.context
+ target = self._get_content_object(target)
# Object is initializing and don't have an UID!
# -> Postpone to set back references in event handler
@@ -304,12 +305,8 @@ def _get(self, object, as_objects=False):
:returns: list of referenced UIDs
"""
- # when creating a new object the context is the container
- # which does not have the field
- if self.interface and not self.interface.providedBy(object):
- if self.multi_valued:
- return []
- return None
+ # Object might be a behavior instead of the object itself
+ object = self._get_content_object(object)
uids = super(UIDReferenceField, self).get(object)
@@ -348,3 +345,12 @@ def _validate(self, value):
if not types.issubset(allowed_types):
raise ValueError("Only the following types are allowed: %s"
% ",".join(allowed_types))
+
+ def _get_content_object(self, thing):
+ """Returns the underlying content object
+ """
+ if IBehavior.providedBy(thing):
+ return self._get_content_object(thing.context)
+ if api.is_dexterity_content(thing):
+ return thing
+ raise ValueError("Not a valid object: %s" % repr(thing))