From a5fdddc1d2723f1e182edf845f2e8052c750e686 Mon Sep 17 00:00:00 2001 From: Panna Liptak Date: Thu, 8 Oct 2026 15:41:14 +0200 Subject: [PATCH] feat(ep-approval): allow managers/owners to view requests * Allow community managers and owners to view EP approval requests and receive email notifications on accept and decline. --- site/cds_rdm/generators.py | 19 ++ .../notifications/committee_approval.py | 11 +- site/cds_rdm/notifications/generators.py | 46 +++++ site/cds_rdm/permissions.py | 13 ++ site/cds_rdm/requests/committee_approval.py | 6 + site/tests/conftest.py | 2 + site/tests/test_committee_approval.py | 186 +++++++++++++++++- .../committee-approval-request.accept.jinja | 107 ++++++++++ .../committee-approval-request.decline.jinja | 99 ++++++++++ 9 files changed, 478 insertions(+), 11 deletions(-) create mode 100644 templates/semantic-ui/invenio_notifications/committee-approval-request.accept.jinja create mode 100644 templates/semantic-ui/invenio_notifications/committee-approval-request.decline.jinja diff --git a/site/cds_rdm/generators.py b/site/cds_rdm/generators.py index 171bafcb..2f022ca5 100644 --- a/site/cds_rdm/generators.py +++ b/site/cds_rdm/generators.py @@ -213,6 +213,25 @@ def query_filter(self, **kwargs): return [] +class EPWorkflowRequestCommunityManager(Generator): + """Allows managers and owners of the community a committee approval request is for. + + Request-level counterpart of ``EPWorkflowCommunityManager``: use it in request + policies. It resolves the request's topic record and applies the same community + rules. + """ + + def needs(self, request=None, **kwargs): + """Manager and owner needs of the topic record's enrolled community.""" + if request is None or request.type.type_id != "committee-approval": + return [] + return EPWorkflowCommunityManager().needs(record=request.topic.resolve()) + + def query_filter(self, **kwargs): + """Not used for search filters.""" + return [] + + COMMITTEE_APPROVAL_GRANT_ORIGIN_PREFIX = "committee-approval:" COMMITTEE_APPROVAL_GRANT_PERMISSION = "committee-review" COMMITTEE_APPROVAL_ACCESS_GRANT = AccessGrant(COMMITTEE_APPROVAL_GRANT_PERMISSION) diff --git a/site/cds_rdm/notifications/committee_approval.py b/site/cds_rdm/notifications/committee_approval.py index efffe1df..a861c40c 100644 --- a/site/cds_rdm/notifications/committee_approval.py +++ b/site/cds_rdm/notifications/committee_approval.py @@ -27,7 +27,10 @@ from invenio_users_resources.notifications.filters import UserPreferencesRecipientFilter from invenio_users_resources.notifications.generators import UserRecipient -from .generators import GroupEmailRecipientGenerator +from .generators import ( + EPWorkflowCommunityMembersRecipient, + GroupEmailRecipientGenerator, +) class CommitteeApprovalNotificationBuilder(NotificationBuilder): @@ -78,7 +81,7 @@ class CommitteeApprovalSubmitNotificationBuilder(CommitteeApprovalNotificationBu class CommitteeApprovalAcceptNotificationBuilder(CommitteeApprovalNotificationBuilder): - """Notify the submitter when their request is accepted.""" + """Notify the submitter and the community managers/owners when accepted.""" type: ClassVar[str] = f"{CommitteeApprovalNotificationBuilder.type}.accept" context: ClassVar[list[ContextGenerator]] = [ @@ -87,11 +90,12 @@ class CommitteeApprovalAcceptNotificationBuilder(CommitteeApprovalNotificationBu ] recipients: ClassVar[list[RecipientGenerator]] = [ UserRecipient("request.created_by"), + EPWorkflowCommunityMembersRecipient("request.topic"), ] class CommitteeApprovalDeclineNotificationBuilder(CommitteeApprovalNotificationBuilder): - """Notify the submitter when their request is declined.""" + """Notify the submitter and the community managers/owners when declined.""" type: ClassVar[str] = f"{CommitteeApprovalNotificationBuilder.type}.decline" context: ClassVar[list[ContextGenerator]] = [ @@ -100,4 +104,5 @@ class CommitteeApprovalDeclineNotificationBuilder(CommitteeApprovalNotificationB ] recipients: ClassVar[list[RecipientGenerator]] = [ UserRecipient("request.created_by"), + EPWorkflowCommunityMembersRecipient("request.topic"), ] diff --git a/site/cds_rdm/notifications/generators.py b/site/cds_rdm/notifications/generators.py index e05899d5..8e0ad2fa 100644 --- a/site/cds_rdm/notifications/generators.py +++ b/site/cds_rdm/notifications/generators.py @@ -10,8 +10,12 @@ from __future__ import annotations +from flask import current_app +from invenio_communities.notifications.generators import CommunityMembersRecipient from invenio_notifications.models import Notification, Recipient from invenio_notifications.services.generators import RecipientGenerator +from invenio_pidstore.models import PersistentIdentifier +from invenio_rdm_records.records.api import RDMRecord from invenio_records.dictutils import dict_lookup @@ -45,3 +49,45 @@ def __call__( return recipients recipients[name] = Recipient(data={"email": f"{name}@cern.ch"}) return recipients + + +class EPWorkflowCommunityMembersRecipient(RecipientGenerator): + """Recipient generator for the managers and owners of the record's EP community. + + Looks up the (resolved) record at ``key`` in the notification context and, if + its default community is enrolled in the committee approval workflow, adds the + members of that community with one of ``roles`` as recipients. + """ + + def __init__(self, key: str, roles: tuple = ("manager", "owner")) -> None: + """Initialise with the context key pointing to the record.""" + self.key = key + self.roles = list(roles) + + def _default_community_id(self, record): + """Return the default community id of the record, if any.""" + parent = getattr(record, "parent", None) + if parent is None: + pid = PersistentIdentifier.get("recid", record["id"]) + parent = RDMRecord.get_record(pid.object_uuid).parent + return (parent.get("communities") or {}).get("default") + + def __call__( + self, + notification: Notification, + recipients: dict[str, Recipient], + ) -> dict[str, Recipient]: + """Add the community managers and owners to ``recipients``.""" + record = dict_lookup(notification.context, self.key) + community_id = self._default_community_id(record) + enrolled = current_app.config.get("CDS_COMMITTEE_APPROVAL_COMMUNITIES", {}) + if not community_id or community_id not in enrolled: + return recipients + + notification.context["_ep_community"] = {"id": community_id} + try: + return CommunityMembersRecipient("_ep_community", roles=self.roles)( + notification, recipients + ) + finally: + notification.context.pop("_ep_community", None) diff --git a/site/cds_rdm/permissions.py b/site/cds_rdm/permissions.py index 3b4432b5..135e6247 100644 --- a/site/cds_rdm/permissions.py +++ b/site/cds_rdm/permissions.py @@ -36,6 +36,8 @@ AuthenticatedRegularUser, CERNEmailsGroups, CommitteeRefereeVersionGrant, + EPWorkflowCommunityManager, + EPWorkflowRequestCommunityManager, HarvesterCurator, InspireHarvester, Librarian, @@ -120,6 +122,13 @@ class CDSRDMRecordPermissionPolicy(RDMRecordPermissionPolicy): "can_preview", exclude_generators=(RecordCommunitiesAction,) ) + # Who gets read access to a committee approval request through its topic record: + # managers and owners of the record's enrolled community. + can_view_committee_approval_request = [ + EPWorkflowCommunityManager(), + SystemProcess(), + ] + can_manage_clc_sync = [Librarian(), Administration(), SystemProcess()] can_modify_locked_files = [ @@ -160,6 +169,10 @@ class CDSRequestsPermissionPolicy(RDMRequestsPermissionPolicy): can_reply_comment = RDMRequestsPermissionPolicy.can_read + can_action_cancel = RDMRequestsPermissionPolicy.can_action_cancel + [ + EPWorkflowRequestCommunityManager() + ] + class FileModificationHarvesterPolicy(BasePolicy): """File modification policy which allows the INSPIRE harvester to modify files of all records.""" diff --git a/site/cds_rdm/requests/committee_approval.py b/site/cds_rdm/requests/committee_approval.py index ee2154c2..825f4b77 100644 --- a/site/cds_rdm/requests/committee_approval.py +++ b/site/cds_rdm/requests/committee_approval.py @@ -352,6 +352,12 @@ class CommitteeApprovalRequest(RDMBaseRequest): allowed_receiver_ref_types: Final[list] = ["group"] allowed_topic_ref_types: Final[list] = ["record"] + # Enabling read permission via ``can_view_committee_approval_request``. + needs_context: Final[dict] = { + "record_permission": "view_committee_approval_request" + } + resolve_topic_needs: Final[bool] = True + # Payload fields collected from the submission form. payload_schema: Final[dict] = { # Captured at submission for request-page and email display. diff --git a/site/tests/conftest.py b/site/tests/conftest.py index 19fc5ee2..15498a81 100644 --- a/site/tests/conftest.py +++ b/site/tests/conftest.py @@ -79,6 +79,7 @@ from cds_rdm.permissions import ( CDSCommunitiesPermissionPolicy, CDSRDMRecordPermissionPolicy, + CDSRequestsPermissionPolicy, lock_edit_record_published_files, ) from cds_rdm.schemes import is_cds, is_inspire_author @@ -201,6 +202,7 @@ def app_config(app_config, mock_datacite_client, mock_crossref_client): app_config["ACCOUNTS_USER_PROFILE_SCHEMA"] = CERNUserProfileSchema() app_config["COMMUNITIES_PERMISSION_POLICY"] = CDSCommunitiesPermissionPolicy app_config["RDM_PERMISSION_POLICY"] = CDSRDMRecordPermissionPolicy + app_config["REQUESTS_PERMISSION_POLICY"] = CDSRequestsPermissionPolicy app_config["COMMUNITIES_ALLOW_RESTRICTED"] = True app_config["CDS_GROUPS_ALLOW_CREATE_COMMUNITIES"] = [ "group-allowed-create-communities" diff --git a/site/tests/test_committee_approval.py b/site/tests/test_committee_approval.py index 422459c4..60dd0560 100644 --- a/site/tests/test_committee_approval.py +++ b/site/tests/test_committee_approval.py @@ -8,12 +8,14 @@ """Integration tests for the Committee Approval workflow.""" from datetime import date +from types import SimpleNamespace import pytest from flask_principal import RoleNeed from invenio_access.permissions import system_identity from invenio_communities.generators import CommunityRoleNeed from invenio_db import db +from invenio_notifications.models import Notification, Recipient from invenio_pidstore.models import PersistentIdentifier, PIDStatus from invenio_rdm_records.proxies import current_rdm_records from invenio_rdm_records.records.api import RDMRecord @@ -33,6 +35,8 @@ COMMITTEE_APPROVAL_GRANT_PERMISSION, committee_approval_grant_origin, ) +from cds_rdm.notifications import generators +from cds_rdm.notifications.generators import GroupEmailRecipientGenerator from cds_rdm.requests.committee_approval import ( APPRN_PID_TYPE, CommitteeApprovalAcceptAction, @@ -145,10 +149,6 @@ def test_group_email_recipient_generator_adds_cern_email(): After EntityResolve the context holds the serialized group dict returned by GroupsService.read().to_dict() — simulate that with a plain dict. """ - from invenio_notifications.models import Notification, Recipient - - from cds_rdm.notifications.generators import GroupEmailRecipientGenerator - notification = Notification( type="committee-approval-request.submit", context={ @@ -167,10 +167,6 @@ def test_group_email_recipient_generator_adds_cern_email(): def test_group_email_recipient_generator_no_name_returns_empty(): """Generator is a no-op when the resolved group dict has no name.""" - from invenio_notifications.models import Notification - - from cds_rdm.notifications.generators import GroupEmailRecipientGenerator - notification = Notification( type="committee-approval-request.submit", context={"request": {"receiver": {}}}, @@ -181,6 +177,62 @@ def test_group_email_recipient_generator_no_name_returns_empty(): assert result == {} +# --------------------------------------------------------------------------- +# EPWorkflowCommunityMembersRecipient +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("enrolled", "expected_recipients"), + [ + (True, {"manager-id", "owner-id"}), + (False, set()), + ], +) +def test_ep_community_members_recipient_generator( + monkeypatch, enrolled, expected_recipients +): + """Managers and owners of an enrolled community are recipients, others are not.""" + members = { + "manager-id": "manager", + "owner-id": "owner", + "curator-id": "curator", + "reader-id": "reader", + } + + class FakeCommunityMembersRecipient: + """Adds the members whose role is one of the requested ``roles``.""" + + def __init__(self, key, roles=None): + self.key = key + self.roles = roles + + def __call__(self, notification, recipients): + for user_id, role in members.items(): + if role in self.roles: + recipients[user_id] = Recipient(data={"id": user_id}) + return recipients + + monkeypatch.setattr( + generators, "CommunityMembersRecipient", FakeCommunityMembersRecipient + ) + config = { + "CDS_COMMITTEE_APPROVAL_COMMUNITIES": {"community-1": {}} if enrolled else {} + } + monkeypatch.setattr(generators, "current_app", SimpleNamespace(config=config)) + + record = SimpleNamespace(parent={"communities": {"default": "community-1"}}) + notification = Notification( + type="committee-approval-request.accept", + context={"request": {"topic": record}}, + ) + result = generators.EPWorkflowCommunityMembersRecipient("request.topic")( + notification, {} + ) + + assert set(result) == expected_recipients + + # --------------------------------------------------------------------------- # Scheme validator tests (pure unit) # --------------------------------------------------------------------------- @@ -642,6 +694,124 @@ def test_committee_approval_submit_permissions( assert request.data["status"] == "submitted" +# --------------------------------------------------------------------------- +# Permissions: who can read an EP approval request +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def community_user(UserFixture, committee_enrolled_community, app, db): + """Factory for users with a given role in the EP-enrolled community.""" + + def _create(role, email): + u = UserFixture( + email=email, + password="password", + preferences={ + "visibility": "public", + "email_visibility": "restricted", + "notifications": {"enabled": True}, + }, + active=True, + confirmed=True, + ) + u.create(app, db) + UserAggregate.index.refresh() + u.identity.provides.add( + CommunityRoleNeed(str(committee_enrolled_community.id), role) + ) + return u + + return _create + + +@pytest.fixture() +def submitted_request( + record_in_enrolled_community, + community_manager, + ep_referee_group, + ep_request_payload, +): + """An EP approval request submitted by ``community_manager``.""" + return current_requests_service.create( + identity=community_manager.identity, + data=ep_request_payload, + request_type=current_request_type_registry.lookup("committee-approval"), + receiver={"group": EP_GROUP_NAME}, + topic={"record": record_in_enrolled_community.id}, + ) + + +def test_committee_approval_request_readable_by_submitter( + submitted_request, community_manager, app, db +): + """The manager who submitted the request can read it.""" + read = current_requests_service.read( + community_manager.identity, submitted_request.id + ) + assert read.id == submitted_request.id + + +@pytest.mark.parametrize( + ("role", "can_read"), + [ + ("owner", True), + ("manager", True), + ("curator", False), + ("reader", False), + ], +) +def test_committee_approval_request_readable_by_community_role( + submitted_request, community_user, role, can_read, app, db +): + """Community owners and manager can read the request, other members cannot.""" + other = community_user(role, f"community-{role}@inveniosoftware.org") + + if can_read: + read = current_requests_service.read(other.identity, submitted_request.id) + assert read.id == submitted_request.id + else: + with pytest.raises(PermissionDeniedError): + current_requests_service.read(other.identity, submitted_request.id) + + +@pytest.mark.parametrize( + ("role", "can_cancel"), + [ + ("owner", True), + ("manager", True), + ("curator", False), + ("reader", False), + ], +) +def test_committee_approval_request_cancel_by_community_role( + submitted_request, community_user, role, can_cancel, app, db +): + """Community managers and owners can cancel the request, other members cannot.""" + other = community_user(role, f"community-{role}@inveniosoftware.org") + + if can_cancel: + cancelled = current_requests_service.execute_action( + other.identity, submitted_request.id, "cancel" + ) + assert cancelled.data["status"] == "cancelled" + else: + with pytest.raises(PermissionDeniedError): + current_requests_service.execute_action( + other.identity, submitted_request.id, "cancel" + ) + + +def test_committee_approval_request_cancel_by_submitter( + submitted_request, community_manager, app, db +): + """The manager who submitted the request can still cancel it.""" + cancelled = current_requests_service.execute_action( + community_manager.identity, submitted_request.id, "cancel" + ) + assert cancelled.data["status"] == "cancelled" + + # --------------------------------------------------------------------------- # Referee access grants — version scoping # --------------------------------------------------------------------------- diff --git a/templates/semantic-ui/invenio_notifications/committee-approval-request.accept.jinja b/templates/semantic-ui/invenio_notifications/committee-approval-request.accept.jinja new file mode 100644 index 00000000..fb5de068 --- /dev/null +++ b/templates/semantic-ui/invenio_notifications/committee-approval-request.accept.jinja @@ -0,0 +1,107 @@ +{% set committee_request = notification.context.request %} +{% set record = committee_request.topic %} +{% set request_id = committee_request.id %} +{% set record_title = record.metadata.title %} +{% set approval_label = committee_request.payload.get("approval_label") or "EP Committee approval" %} +{% set help_url = config.CDS_SERVICE_ELEMENT_URL %} +{# TODO: use request.links.self_html when https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 is resolved #} +{% set request_link = "{ui}/me/requests/{id}".format( + ui=config.SITE_UI_URL, id=request_id + ) +%} +{% set record_link = "{ui}/records/{id}".format( + ui=config.SITE_UI_URL, id=record.id + ) +%} +{% set account_settings_link = "{ui}/account/settings/notifications".format( + ui=config.SITE_UI_URL + ) +%} +{% set report_number = committee_request.payload.get("approved_report_number") %} + +{%- block subject -%} + [CDS] {{ _("{approval_label} request accepted for '{record_title}'").format(approval_label=approval_label, record_title=record_title) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + {% if report_number %} + + + + {% endif %} + + + + + + + + + + + + +
{{ _("Dear community managers and owners") }},
+ {{ _("The {approval_label} request for the following document has been accepted:").format(approval_label=approval_label) }} +
+ {{ _("Title:") }} {{ record_title }}
+ {{ _("Record:") }} {{ record_link }} +
{{ _("Approval report number:") }} {{ report_number }}
{{ _("The approval can now be found on the record. You can review the request at the link below.") }}
{{ _("View the {approval_label} request").format(approval_label=approval_label) }}
+ {{ _("Best regards") }},
+ --
+ CERN Document Server {{ config.SITE_UI_URL }}
+ {{ _("Need help?") }} {{ help_url }} +
{{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("Dear community managers and owners") }}, + +{{ _("The {approval_label} request for the following document has been accepted:").format(approval_label=approval_label) }} + +{{ _("Title:") }} {{ record_title }} +{{ _("Record:") }} {{ record_link }} +{% if report_number %}{{ _("Approval report number:") }} {{ report_number }} +{% endif %} +{{ _("The approval can now be found on the record. You can review the request at the link below.") }} + +{{ _("View the {approval_label} request:").format(approval_label=approval_label) }} {{ request_link }} + +{{ _("Best regards") }}, +-- +CERN Document Server {{ config.SITE_UI_URL }} +{{ _("Need help? {help_url}").format(help_url=help_url) }} + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings {account_settings_link}.").format(account_settings_link=account_settings_link) }} +{%- endblock plain_body %} + +{%- block md_body -%} +{{ _("Dear community managers and owners") }}, + +{{ _("The {approval_label} request for the following document has been accepted:").format(approval_label=approval_label) }} + +**{{ _("Title:") }}** {{ record_title }} +**{{ _("Record:") }}** {{ record_link }} +{% if report_number %}**{{ _("Approval report number:") }}** {{ report_number }} +{% endif %} +{{ _("The approval can now be found on the record. You can review the request at the link below.") }} + +{{ _("View the {approval_label} request:").format(approval_label=approval_label) }} {{ request_link }} + +{{ _("Best regards") }}, +-- +CERN Document Server {{ config.SITE_UI_URL }} +{{ _("Need help? {help_url}").format(help_url=help_url) }} + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings {account_settings_link}.").format(account_settings_link=account_settings_link) }} +{%- endblock md_body -%} diff --git a/templates/semantic-ui/invenio_notifications/committee-approval-request.decline.jinja b/templates/semantic-ui/invenio_notifications/committee-approval-request.decline.jinja new file mode 100644 index 00000000..7a1f2188 --- /dev/null +++ b/templates/semantic-ui/invenio_notifications/committee-approval-request.decline.jinja @@ -0,0 +1,99 @@ +{% set committee_request = notification.context.request %} +{% set record = committee_request.topic %} +{% set request_id = committee_request.id %} +{% set record_title = record.metadata.title %} +{% set approval_label = committee_request.payload.get("approval_label") or "EP Committee approval" %} +{% set help_url = config.CDS_SERVICE_ELEMENT_URL %} +{# TODO: use request.links.self_html when https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 is resolved #} +{% set request_link = "{ui}/me/requests/{id}".format( + ui=config.SITE_UI_URL, id=request_id + ) +%} +{% set record_link = "{ui}/records/{id}".format( + ui=config.SITE_UI_URL, id=record.id + ) +%} +{% set account_settings_link = "{ui}/account/settings/notifications".format( + ui=config.SITE_UI_URL + ) +%} + +{%- block subject -%} + [CDS] {{ _("{approval_label} request declined for '{record_title}'").format(approval_label=approval_label, record_title=record_title) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + + + + + + + + + + +
{{ _("Dear community managers and owners") }},
+ {{ _("The {approval_label} request for the following document has been declined:").format(approval_label=approval_label) }} +
+ {{ _("Title:") }} {{ record_title }}
+ {{ _("Record:") }} {{ record_link }} +
{{ _("You can read the committee's comments and follow up at the link below.") }}
{{ _("View the {approval_label} request").format(approval_label=approval_label) }}
+ {{ _("Best regards") }},
+ --
+ CERN Document Server {{ config.SITE_UI_URL }}
+ {{ _("Need help?") }} {{ help_url }} +
{{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("Dear community managers and owners") }}, + +{{ _("The {approval_label} request for the following document has been declined:").format(approval_label=approval_label) }} + +{{ _("Title:") }} {{ record_title }} +{{ _("Record:") }} {{ record_link }} + +{{ _("You can read the committee's comments and follow up at the link below.") }} + +{{ _("View the {approval_label} request:").format(approval_label=approval_label) }} {{ request_link }} + +{{ _("Best regards") }}, +-- +CERN Document Server {{ config.SITE_UI_URL }} +{{ _("Need help? {help_url}").format(help_url=help_url) }} + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings {account_settings_link}.").format(account_settings_link=account_settings_link) }} +{%- endblock plain_body %} + +{%- block md_body -%} +{{ _("Dear community managers and owners") }}, + +{{ _("The {approval_label} request for the following document has been declined:").format(approval_label=approval_label) }} + +**{{ _("Title:") }}** {{ record_title }} +**{{ _("Record:") }}** {{ record_link }} + +{{ _("You can read the committee's comments and follow up at the link below.") }} + +{{ _("View the {approval_label} request:").format(approval_label=approval_label) }} {{ request_link }} + +{{ _("Best regards") }}, +-- +CERN Document Server {{ config.SITE_UI_URL }} +{{ _("Need help? {help_url}").format(help_url=help_url) }} + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings {account_settings_link}.").format(account_settings_link=account_settings_link) }} +{%- endblock md_body -%}