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))