Skip to content

Commit

Permalink
Pm 4168 restricted copy groups (#274)
Browse files Browse the repository at this point in the history
* Begin implementation

* Added field `MeetingItem.restrictedCopyGroups`, a secondary `copyGroups` field where we can define other groups having access to item in other (later) states.
See #PM-4168

* Added restrictedCopyGroups to DEFAULT_COPIED_FIELDS

* Remove space before ":" in msgid

* Remove space after user icon in advice popup
  • Loading branch information
gbastien committed Apr 22, 2024
1 parent a78fd14 commit 9a7a5b4
Show file tree
Hide file tree
Showing 15 changed files with 282 additions and 41 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Expand Up @@ -18,6 +18,9 @@ Changelog
Adapted parameter `single_vote_value` so we can define a single value or
a different value for each `vote_values`.
[gbastien]
- Added field `MeetingItem.restrictedCopyGroups`, a secondary `copyGroups` field
where we can define other groups having access to item in other (later) states.
[gbastien]

4.2.9rc6 (2024-04-10)
---------------------
Expand Down
49 changes: 44 additions & 5 deletions src/Products/PloneMeeting/MeetingConfig.py
Expand Up @@ -2241,14 +2241,15 @@
schemata="advices",
multiValued=1,
vocabulary='listSelectableCopyGroups',
default=defValues.selectableCopyGroups,
enforceVocabulary=True,
write_permission="PloneMeeting: Write risky config",
),
LinesField(
name='itemCopyGroupsStates',
widget=MultiSelectionWidget(
description="ItemCopyGroupsStates",
description_msgid="item_copygroups_states_descr",
description_msgid="item_copy_groups_states_descr",
format="checkbox",
label='Itemcopygroupsstates',
label_msgid='PloneMeeting_label_itemCopyGroupsStates',
Expand All @@ -2261,6 +2262,41 @@
enforceVocabulary=True,
write_permission="PloneMeeting: Write risky config",
),
LinesField(
name='selectableRestrictedCopyGroups',
widget=MultiSelectionWidget(
size=20,
description="SelectableRestrictedCopyGroups",
description_msgid="selectable_restricted_copy_groups_descr",
format="checkbox",
label='Selectablerestrictedcopygroups',
label_msgid='PloneMeeting_label_selectableRestrictedCopyGroups',
i18n_domain='PloneMeeting',
),
schemata="advices",
multiValued=1,
vocabulary='listSelectableCopyGroups',
default=defValues.selectableRestrictedCopyGroups,
enforceVocabulary=True,
write_permission="PloneMeeting: Write risky config",
),
LinesField(
name='itemRestrictedCopyGroupsStates',
widget=MultiSelectionWidget(
description="ItemRestrictedCopyGroupsStates",
description_msgid="item_restricted_copy_groups_states_descr",
format="checkbox",
label='Itemrestrictedcopygroupsstates',
label_msgid='PloneMeeting_label_itemRestrictedCopyGroupsStates',
i18n_domain='PloneMeeting',
),
schemata="advices",
multiValued=1,
vocabulary='listItemStates',
default=defValues.itemRestrictedCopyGroupsStates,
enforceVocabulary=True,
write_permission="PloneMeeting: Write risky config",
),
LinesField(
name='hideHistoryTo',
default=defValues.hideHistoryTo,
Expand Down Expand Up @@ -7969,21 +8005,24 @@ def show_copy_groups_search(self):
set(get_plone_groups_for_user()).intersection(
self.getSelectableCopyGroups()))

def get_orgs_with_as_copy_group_on_expression_cachekey(method, self):
def get_orgs_with_as_copy_group_on_expression_cachekey(method, self, restricted=False):
'''cachekey method for self.get_orgs_with_as_copy_group_on_expression.
MeetingConfig.modified is updated when an organization added/removed/edited.'''
# the volatile is invalidated when an organization changed
return repr(self), self.modified(), get_cachekey_volatile('_users_groups_value')
return repr(self), self.modified(), get_cachekey_volatile('_users_groups_value'), restricted

@ram.cache(get_orgs_with_as_copy_group_on_expression_cachekey)
def get_orgs_with_as_copy_group_on_expression(self):
def get_orgs_with_as_copy_group_on_expression(self, restricted=False):
"""Returns a dict with organizations having a as_copy_group_on TAL expression."""
orgs = self.getUsingGroups(theObjects=True)
# keep order as new and old item local_roles are compared
# to check if other updates must be done
data = OrderedDict()
for org in orgs:
expr = org.as_copy_group_on
if restricted:
expr = org.as_restricted_copy_group_on
else:
expr = org.as_copy_group_on
if not expr or not expr.strip():
continue
data[org.UID()] = expr
Expand Down
127 changes: 113 additions & 14 deletions src/Products/PloneMeeting/MeetingItem.py
Expand Up @@ -1795,6 +1795,23 @@ def doPre_accept(self, stateChange):
multiValued=1,
vocabulary_factory='Products.PloneMeeting.vocabularies.itemcopygroupsvocabulary',
),
LinesField(
name='restrictedCopyGroups',
widget=MultiSelectionWidget(
size=10,
condition="python: here.attribute_is_used('restrictedCopyGroups')",
description="RestrictedCopyGroupsItems",
description_msgid="restricted_groups_item_descr",
format="checkbox",
label='Restrictedcopygroups',
label_msgid='PloneMeeting_label_restrictedCopyGroups',
i18n_domain='PloneMeeting',
),
optional=True,
enforceVocabulary=True,
multiValued=1,
vocabulary_factory='Products.PloneMeeting.vocabularies.itemrestrictedcopygroupsvocabulary',
),
StringField(
name='pollType',
widget=SelectionWidget(
Expand Down Expand Up @@ -3687,11 +3704,34 @@ def getAllCopyGroups(self, auto_real_plone_group_ids=False):
allGroups += tuple(self.autoCopyGroups)
return allGroups

def check_copy_groups_have_access(self):
security.declarePublic('getAllRestrictedCopyGroups')

def getAllRestrictedCopyGroups(self, auto_real_plone_group_ids=False):
"""Return manually selected restrictedCopyGroups and automatically added ones.
If p_auto_real_plone_group_ids is True, the real Plone group id is returned for
automatically added groups instead of the AUTO_COPY_GROUP_PREFIX prefixed name."""
allGroups = self.getRestrictedCopyGroups()
autoRestrictedCopyGroups = getattr(self, 'autoRestrictedCopyGroups', [])
if auto_real_plone_group_ids:
allGroups += tuple([self._realCopyGroupId(plone_group_id)
for plone_group_id in autoRestrictedCopyGroups])
else:
allGroups += tuple(autoRestrictedCopyGroups)
return allGroups

security.declarePublic('getAllBothCopyGroups')

def getAllBothCopyGroups(self, auto_real_plone_group_ids=True):
"""Get all both common and restricted copy groups."""
return self.getAllCopyGroups(auto_real_plone_group_ids=auto_real_plone_group_ids) + \
self.getAllRestrictedCopyGroups(auto_real_plone_group_ids=auto_real_plone_group_ids)

def check_copy_groups_have_access(self, restricted=False):
"""Return True if copyGroups have access in current review_state."""
tool = api.portal.get_tool('portal_plonemeeting')
cfg = tool.getMeetingConfig(self)
return self.query_state() in cfg.getItemCopyGroupsStates()
return self.query_state() in cfg.getItemRestrictedCopyGroupsStates() \
if restricted else self.query_state() in cfg.getItemCopyGroupsStates()

security.declarePublic('checkPrivacyViewable')

Expand Down Expand Up @@ -5085,6 +5125,7 @@ def sendStateDependingMailIfRelevant(self, old_review_state, transition_id, new_
"""Send notifications that depends on old/new review_state."""
self._sendAdviceToGiveMailIfRelevant(old_review_state, new_review_state)
self._sendCopyGroupsMailIfRelevant(old_review_state, new_review_state)
self._sendRestrictedCopyGroupsMailIfRelevant(old_review_state, new_review_state)
# send e-mail to group suffix
# both notitifications may be enabled in configuration to manage when item
# back to itemcreated from presented (when using WFA
Expand Down Expand Up @@ -5162,6 +5203,10 @@ def _sendAdviceToGiveToGroup(self, org_uid):
"""See docstring in interfaces.py"""
return True

def _sendCopyGroupsToGroup(self, groupId):
"""See docstring in interfaces.py"""
return True

def _sendCopyGroupsMailIfRelevant(self, old_review_state, new_review_state):
'''A transition was fired on self, check if, in the new item state,
copy groups have now access to the item.'''
Expand All @@ -5186,10 +5231,35 @@ def _sendCopyGroupsMailIfRelevant(self, old_review_state, new_review_state):
if plone_group_ids:
return sendMailIfRelevant(self, 'copyGroups', plone_group_ids, isGroupIds=True)

def _sendCopyGroupsToGroup(self, groupId):
def _sendRestrictedCopyGroupsToGroup(self, groupId):
"""See docstring in interfaces.py"""
return True

def _sendRestrictedCopyGroupsMailIfRelevant(self, old_review_state, new_review_state):
'''A transition was fired on self, check if, in the new item state,
restricted copy groups have now access to the item.'''
tool = api.portal.get_tool('portal_plonemeeting')
cfg = tool.getMeetingConfig(self)
if 'restrictedCopyGroups' not in cfg.getMailItemEvents():
return

restrictedCopyGroupsStates = cfg.getItemRestrictedCopyGroupsStates()
# Ignore if current state not in restrictedCopyGroupsStates
# Ignore if restrictedCopyGroups had already access in previous state
if new_review_state not in restrictedCopyGroupsStates or \
old_review_state in restrictedCopyGroupsStates:
return
# Send a mail to every person from getAllRestrictedCopyGroups
plone_group_ids = []
for plone_group_id in self.getAllRestrictedCopyGroups(auto_real_plone_group_ids=True):
# call hook '_sendRestrictedCopyGroupsToGroup' to be able to bypass
# send of this notification to some defined groups
if not self.adapted()._sendRestrictedCopyGroupsToGroup(plone_group_id):
continue
plone_group_ids.append(plone_group_id)
if plone_group_ids:
return sendMailIfRelevant(self, 'restrictedCopyGroups', plone_group_ids, isGroupIds=True)

def _get_proposing_group_suffix_notified_user_ids_for_review_state(
self,
review_state,
Expand Down Expand Up @@ -5544,17 +5614,20 @@ def getAutomaticAdvisersData(self):

security.declarePrivate('addAutoCopyGroups')

def addAutoCopyGroups(self, isCreated):
def addAutoCopyGroups(self, isCreated, restricted=False):
'''What group should be automatically set as copyGroups for this item?
We get it by evaluating the TAL expression on every active
organization.as_copy_group_on. The expression returns a list of suffixes
or an empty list. The method update existing copyGroups and add groups
prefixed with AUTO_COPY_GROUP_PREFIX.'''
# empty stored autoCopyGroups
self.autoCopyGroups = PersistentList()
attr_name = 'autoRestrictedCopyGroups' if restricted else 'autoCopyGroups'
setattr(self, attr_name, PersistentList())
attr = getattr(self, attr_name)
extra_expr_ctx = _base_extra_expr_ctx(self)
cfg = extra_expr_ctx['cfg']
for org_uid, expr in cfg.get_orgs_with_as_copy_group_on_expression().items():
for org_uid, expr in cfg.get_orgs_with_as_copy_group_on_expression(
restricted=restricted).items():
extra_expr_ctx.update({'item': self,
'isCreated': isCreated,
'org_uid': org_uid})
Expand All @@ -5577,7 +5650,7 @@ def addAutoCopyGroups(self, isCreated):
continue
plone_group_id = get_plone_group_id(org_uid, suffix)
auto_plone_group_id = '{0}{1}'.format(AUTO_COPY_GROUP_PREFIX, plone_group_id)
self.autoCopyGroups.append(auto_plone_group_id)
attr.append(auto_plone_group_id)

def _evalAdviceAvailableOn(self, available_on_expr, mayEdit=True):
""" """
Expand Down Expand Up @@ -5958,15 +6031,16 @@ def _realCopyGroupId(self, groupId):

security.declarePublic('displayCopyGroups')

def displayCopyGroups(self):
def displayCopyGroups(self, restricted=False):
'''Display copy groups on the item view, especially the link showing users of a group.'''
portal_url = api.portal.get().absolute_url()
field_name = 'restrictedCopyGroups' if restricted else 'copyGroups'
copyGroupsVocab = get_vocab(
self,
self.getField('copyGroups').vocabulary_factory,
self.getField(field_name).vocabulary_factory,
**{'include_auto': True, })
res = []
allCopyGroups = self.getAllCopyGroups()
allCopyGroups = self.getAllRestrictedCopyGroups() if restricted else self.getAllCopyGroups()
for term in copyGroupsVocab._terms:
if term.value not in allCopyGroups:
continue
Expand Down Expand Up @@ -6837,10 +6911,15 @@ def getDelayInfosForAdvice(self, advice_id):

security.declarePublic('getCopyGroupsHelpMsg')

def getCopyGroupsHelpMsg(self, cfg):
def getCopyGroupsHelpMsg(self, cfg, restricted=False):
'''Help message regarding copy groups configuration.'''
translated_states = translate_list(cfg.getItemCopyGroupsStates())
msg = translate(msgid="copy_groups_help_msg",
if restricted:
translated_states = translate_list(cfg.getItemRestrictedCopyGroupsStates())
msgid = "restricted_copy_groups_help_msg"
else:
translated_states = translate_list(cfg.getItemCopyGroupsStates())
msgid = "copy_groups_help_msg"
msg = translate(msgid=msgid,
domain="PloneMeeting",
mapping={"states": translated_states},
context=self.REQUEST)
Expand Down Expand Up @@ -6889,7 +6968,7 @@ def getAdviceHelpMessageFor(self, **adviceInfos):
item_advice_states = cfg.getItemAdviceStatesForOrg(adviceInfos['id'])
translated_item_advice_states = translate_list(item_advice_states)
advice_states_msg = translate(
'This advice is addable in following states : ${item_advice_states}.',
'This advice is addable in following states: ${item_advice_states}.',
mapping={'item_advice_states': translated_item_advice_states},
domain="PloneMeeting",
context=self.REQUEST)
Expand Down Expand Up @@ -7102,6 +7181,7 @@ def update_local_roles(self, reindex=True, avoid_reindex=False, **kwargs):
# update local roles regarding copyGroups
isCreated = kwargs.get('isCreated', None)
self._updateCopyGroupsLocalRoles(isCreated, cfg, item_state)
self._updateRestrictedCopyGroupsLocalRoles(isCreated, cfg, item_state)
# Update advices after update_local_roles because it
# reinitialize existing local roles
triggered_by_transition = kwargs.get('triggered_by_transition', None)
Expand Down Expand Up @@ -7193,6 +7273,25 @@ def _updateCopyGroupsLocalRoles(self, isCreated, cfg, item_state):
for copyGroupId in copyGroupIds:
self.manage_addLocalRoles(copyGroupId, (READER_USECASES['copy_groups'],))

def _updateRestrictedCopyGroupsLocalRoles(self, isCreated, cfg, item_state):
'''Give the 'Reader' local role to the restricted copy groups
depending on what is defined in the corresponding meetingConfig.'''
if not self.attribute_is_used('restrictedCopyGroups'):
return
# Check if some copyGroups must be automatically added
self.addAutoCopyGroups(isCreated=isCreated, restricted=True)

# check if copyGroups should have access to this item for current review state
if item_state not in cfg.getItemRestrictedCopyGroupsStates():
return
# Add the local roles corresponding to the selected restrictedCopyGroups.
# We give the 'Reader' role to the selected groups.
# This will give them a read-only access to the item.
restrictedCopyGroupIds = self.getAllRestrictedCopyGroups(auto_real_plone_group_ids=True)
for restrictedCopyGroupId in restrictedCopyGroupIds:
self.manage_addLocalRoles(
restrictedCopyGroupId, (READER_USECASES['restricted_copy_groups'],))

def _updatePowerObserversLocalRoles(self, cfg, item_state):
'''Give local roles to the groups defined in MeetingConfig.powerObservers.'''
extra_expr_ctx = _base_extra_expr_ctx(self)
Expand Down
2 changes: 1 addition & 1 deletion src/Products/PloneMeeting/columns.py
Expand Up @@ -81,7 +81,7 @@ class ItemGroupsInChargeAcronymColumn(AbbrColumn):

class ItemCopyGroupsColumn(VocabularyColumn):
"""A column that display the copyGroups."""
sort_index = 'getCopyGroups'
attrName = 'getAllBothCopyGroups'
vocabulary = u'Products.PloneMeeting.Groups'
the_object = True

Expand Down
4 changes: 3 additions & 1 deletion src/Products/PloneMeeting/config.py
Expand Up @@ -193,6 +193,7 @@
# If a special usecase needs to use another role, it can be specified in a sub-plugin
READER_USECASES = {
'copy_groups': 'Reader',
'restricted_copy_groups': 'Reader',
'advices': 'Reader',
'powerobservers': 'Reader',
'itemtemplatesmanagers': 'Reader',
Expand Down Expand Up @@ -228,7 +229,8 @@
DEFAULT_COPIED_FIELDS = ['title', 'description', 'detailedDescription', 'motivation',
'decision', 'decisionSuite', 'decisionEnd',
'budgetInfos', 'budgetRelated', 'sendToAuthority',
'groupsInCharge', 'proposingGroupWithGroupInCharge', 'copyGroups']
'groupsInCharge', 'proposingGroupWithGroupInCharge',
'copyGroups', 'restrictedCopyGroups']
# extra fields kept when an item is cloned in the same meeting config,
# so not the case when sent to another meeting config
EXTRA_COPIED_FIELDS_SAME_MC = ['associatedGroups', 'category', 'classifier', 'committees',
Expand Down
11 changes: 10 additions & 1 deletion src/Products/PloneMeeting/content/organization.py
Expand Up @@ -138,6 +138,14 @@ class IPMOrganization(IOrganization):
required=False,
)

form.read_permission(as_restricted_copy_group_on='PloneMeeting.manage_internal_organization_fields')
form.write_permission(as_copy_group_on='PloneMeeting.manage_internal_organization_fields')
as_restricted_copy_group_on = schema.TextLine(
title=_("PloneMeeting_label_asRestrictedCopyGroupOn"),
description=_("as_restricted_copy_group_on_descr"),
required=False,
)

form.read_permission(certified_signatures='PloneMeeting.manage_internal_organization_fields')
form.write_permission(certified_signatures='PloneMeeting.manage_internal_organization_fields')
form.widget('certified_signatures', DataGridFieldFactory, allow_reorder=True)
Expand Down Expand Up @@ -169,7 +177,8 @@ class IPMOrganization(IOrganization):
label=_(u"Application parameters"),
fields=['acronym', 'item_advice_states',
'item_advice_edit_states', 'item_advice_view_states',
'keep_access_to_item_when_advice', 'as_copy_group_on',
'keep_access_to_item_when_advice',
'as_copy_group_on', 'as_restricted_copy_group_on',
'certified_signatures', 'groups_in_charge'])


Expand Down
7 changes: 5 additions & 2 deletions src/Products/PloneMeeting/indexes.py
Expand Up @@ -127,9 +127,12 @@ def Description(obj):
@indexer(IMeetingItem)
def getCopyGroups(obj):
"""
Compute getCopyGroups to take auto copyGroups into account.
Compute copyGroups and restrictedCopyGroups to take auto copyGroups into account.
restrictedCopyGroups are prefixed by restricted__ so it can be displayed differently
in dashboard filter and column.
"""
return obj.getAllCopyGroups(auto_real_plone_group_ids=True) or EMPTY_STRING
return obj.getAllCopyGroups(auto_real_plone_group_ids=True) + \
obj.getAllRestrictedCopyGroups(auto_real_plone_group_ids=True) or EMPTY_STRING


@indexer(IMeetingItem)
Expand Down

0 comments on commit 9a7a5b4

Please sign in to comment.