From 0815d89745a2046d42259302efa283bc0f18016b Mon Sep 17 00:00:00 2001 From: zzacharo Date: Wed, 13 May 2026 14:55:44 +0200 Subject: [PATCH 1/4] feat(ep-approval): EP approval workflow v3 backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement EP approval workflow storing approval state in parent.permission_flags["ep_approval"] — shared across all versions in a record family, eliminating DB scans, version propagation, and edit/publish cycles on accept. - ep_approval stored in parent permission_flags (no custom schema needed; permission_flags already allows additionalProperties in parent-v3.0.0.json) - EPApprovalAcceptAction: write directly to parent permission_flags on accept - EPApprovalSubmitAction: re-submission guard is a single parent read - ep_approval_state.py: reads from parent permission_flags, no version scanning; public record detected by source_internal_version key - views.py (publish_public_record): reads/writes ep_approval via permission_flags on both the internal draft parent and the new public record parent - CommitteeApprovalComponent: remove _restore_committee_approval and version CF; _regenerate_apprn_identifier reads from parent (apprn only on public records, detected by source_internal_version) - Remove CommitteeApprovalCF from custom fields (version-level CF no longer used) - notifications, generators, permissions, schemes: EP approval support - templates: EP approval section in record detail, manage menu, and request view - tests: replace CF-based test with parent permission_flags ep_approval test ep_approval keys (internal draft parent): reportnumber, datetime, approved_internal_version, approved_public_version, source_public_version ep_approval keys (public record parent): reportnumber, source_internal_version --- .gitignore | 3 +- invenio.cfg | 49 +- pyproject.toml | 4 +- scripts/metadata_checks.py | 2 - site/cds_rdm/components.py | 149 +++++ site/cds_rdm/ext.py | 3 + site/cds_rdm/generators.py | 67 +++ site/cds_rdm/notifications/__init__.py | 9 + site/cds_rdm/notifications/ep_approval.py | 103 ++++ site/cds_rdm/notifications/generators.py | 55 ++ site/cds_rdm/permissions.py | 18 +- site/cds_rdm/requests/__init__.py | 13 + site/cds_rdm/requests/ep_approval.py | 310 +++++++++++ site/cds_rdm/requests/ep_approval_state.py | 212 ++++++++ site/cds_rdm/requests/views.py | 325 +++++++++++ site/cds_rdm/schemes.py | 17 + .../semantic-ui/cds_rdm/records/detail.html | 28 + .../cds_rdm/records/manage_menu.html | 1 + .../invenio_requests/ep-approval/index.html | 154 ++++++ site/cds_rdm/webpack.py | 10 +- site/pyproject.toml | 5 + site/tests/conftest.py | 150 ++++- site/tests/test_ep_approval.py | 512 ++++++++++++++++++ .../records/details/side_bar/versions.html | 29 + .../ep-approval-request.submit.jinja | 104 ++++ .../invenio_requests/ep-approval/index.html | 120 ++++ uv.lock | 13 +- 27 files changed, 2432 insertions(+), 33 deletions(-) create mode 100644 site/cds_rdm/notifications/__init__.py create mode 100644 site/cds_rdm/notifications/ep_approval.py create mode 100644 site/cds_rdm/notifications/generators.py create mode 100644 site/cds_rdm/requests/__init__.py create mode 100644 site/cds_rdm/requests/ep_approval.py create mode 100644 site/cds_rdm/requests/ep_approval_state.py create mode 100644 site/cds_rdm/requests/views.py create mode 100644 site/cds_rdm/templates/semantic-ui/invenio_requests/ep-approval/index.html create mode 100644 site/tests/test_ep_approval.py create mode 100644 templates/semantic-ui/invenio_app_rdm/records/details/side_bar/versions.html create mode 100644 templates/semantic-ui/invenio_notifications/ep-approval-request.submit.jinja create mode 100644 templates/semantic-ui/invenio_requests/ep-approval/index.html diff --git a/.gitignore b/.gitignore index 1d2863ba..efa7d011 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,5 @@ celerybeat-schedule.db node_modules # local tmp folder -tmp \ No newline at end of file +tmp +scripts \ No newline at end of file diff --git a/invenio.cfg b/invenio.cfg index 31ed0417..a20f9c23 100644 --- a/invenio.cfg +++ b/invenio.cfg @@ -31,6 +31,15 @@ from invenio_app_rdm.config import \ STATS_AGGREGATIONS as _APP_RDM_STATS_AGGREGATIONS from invenio_app_rdm.config import STATS_EVENTS as _APP_RDM_STATS_EVENTS from invenio_app_rdm.config import NOTIFICATIONS_BUILDERS +from cds_rdm.notifications.ep_approval import ( + EPApprovalAcceptNotificationBuilder, + EPApprovalDeclineNotificationBuilder, + EPApprovalSubmitNotificationBuilder, +) +from invenio_cern_sync.sso import cern_keycloak, cern_remote_app_name +from invenio_cern_sync.users.profile import CERNUserProfileSchema +from invenio_oauthclient.views.client import auto_redirect_login +from invenio_preservation_sync.utils import preservation_info_render from invenio_previewer.config import \ PREVIEWER_PREFERENCE as DEFAULT_PREVIEWER_PREFERENCE from invenio_rdm_records.checks import requests as checks_requests @@ -70,6 +79,7 @@ from invenio_app_rdm.config import \ VOCABULARIES_DATASTREAM_WRITERS as DEFAULT_VOCABULARIES_DATASTREAM_WRITERS from cds_rdm.clc_sync.services.components import ClcSyncComponent from cds_rdm.components import CDSResourcePublication +from cds_rdm.components import CommitteeApprovalComponent from cds_rdm.components import SubjectsValidationComponent from cds_rdm.components import MintAlternateIdentifierComponent from cds_rdm.pids import validate_optional_doi_transitions @@ -207,9 +217,9 @@ COMMUNITIES_COLLECTIONS_ENABLED = True # See https://github.com/inveniosoftware/invenio-records-resources/blob/master/invenio_records_resources/config.py # TODO: Set with your own hostname when deploying to production -SITE_UI_URL = "https://127.0.0.1" +SITE_UI_URL = "https://127.0.0.1:5000" -SITE_API_URL = "https://127.0.0.1/api" +SITE_API_URL = "https://127.0.0.1:5000/api" APP_RDM_DEPOSIT_FORM_DEFAULTS = { "publication_date": lambda: datetime.now().strftime("%Y-%m-%d"), @@ -436,9 +446,35 @@ CDS_EOS_OFFLOAD_X509_KEY_PATH = "" # check nginx config for more details CDS_EOS_OFFLOAD_REDIRECT_BASE_PATH = "" -CDS_CERN_SCIENTIFIC_COMMUNITY_ID = "c2c46ab3-5fb4-4d86-83c6-5d9dc8392d6f" +CDS_CERN_SCIENTIFIC_COMMUNITY_ID = "7b7828aa-a3e0-4da0-b78c-7995cf20e542" """The id of the CERN Scientific community.""" +# --------------------------------------------------------------------------- +# EP / Publication Approval Workflow +# --------------------------------------------------------------------------- + +CDS_EP_APPROVAL_COMMUNITIES = { + # Map community UUID → workflow config. + # UUIDs are used (not slugs) because slugs can be renamed. + # + "9646dd3f-55b4-4132-9060-afe76cd724d0": { + "label": "EP approval", # shown in UI buttons/headings + "referee_group": "cds-ph-ep-publication", # CERN e-group slug + "report_number_pattern": "CERN-EP-{year}-{seq:03d}", + }, +} +"""Communities enrolled in the Publication Approval Workflow. + +Keyed by community UUID. Each entry must have: + - ``label``: human-readable name shown in the UI (e.g. "EP approval") + - ``referee_group``: CERN e-group whose members act as referees + - ``report_number_pattern``: Python format string with ``{year}`` and + ``{seq:03d}`` placeholders (e.g. ``"CERN-EP-{year}-{seq:03d}"``). + +All generated numbers share the ``apprn`` PID type, so uniqueness is +enforced across all enrolled communities. +""" + CHECKS_ENABLED = True """Enable metadata checks.""" @@ -545,6 +581,9 @@ RDM_RECORDS_IDENTIFIERS_SCHEMES = { "cdsrn": {"label": _("CDS Report Number"), "validator": always_valid, "datacite": "CDS"}, + "apprn": {"label": _("Approval Report Number"), + "validator": schemes.is_approval_report_number, + "datacite": "CERN"}, "aleph": {"label": _("Aleph number"), "validator": schemes.is_aleph, "datacite": "ALEPH"}, @@ -606,6 +645,7 @@ RDM_RECORDS_PERSONORG_SCHEMES = { RDM_RECORDS_SERVICE_COMPONENTS = [ SubjectsValidationComponent, + CommitteeApprovalComponent, *DefaultRecordsComponents, CDSResourcePublication, ClcSyncComponent, @@ -737,6 +777,9 @@ NOTIFICATIONS_BUILDERS = { RepositoryReleaseFailureNotificationBuilder.type: RepositoryReleaseFailureNotificationBuilder, RepositoryReleaseCommunityRequiredNotificationBuilder.type: RepositoryReleaseCommunityRequiredNotificationBuilder, RepositoryReleaseCommunitySubmittedNotificationBuilder.type: RepositoryReleaseCommunitySubmittedNotificationBuilder, + EPApprovalSubmitNotificationBuilder.type: EPApprovalSubmitNotificationBuilder, + EPApprovalAcceptNotificationBuilder.type: EPApprovalAcceptNotificationBuilder, + EPApprovalDeclineNotificationBuilder.type: EPApprovalDeclineNotificationBuilder, } NOTIFICATIONS_GROUP_EMAIL_DOMAIN = "cern.ch" diff --git a/pyproject.toml b/pyproject.toml index c98fd8bc..e40ca809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,9 @@ cds-rdm = { workspace = true } invenio-cern-sync = { git = "https://github.com/cerndocumentserver/invenio-cern-sync", rev = "v0.7.0" } [tool.uv] -override-dependencies = [] +override-dependencies = [ + "invenio-requests @ git+https://github.com/inveniosoftware/invenio-requests.git@feature/ep-approval" +] [tool.uv.workspace] members = [ diff --git a/scripts/metadata_checks.py b/scripts/metadata_checks.py index 236514c0..58c36402 100644 --- a/scripts/metadata_checks.py +++ b/scripts/metadata_checks.py @@ -14,8 +14,6 @@ invenio shell scripts/metadata_checks.py """ -from copy import deepcopy - from flask import current_app from invenio_checks.models import CheckConfig, Severity from invenio_communities.proxies import current_communities diff --git a/site/cds_rdm/components.py b/site/cds_rdm/components.py index 6a117603..f6be0b38 100644 --- a/site/cds_rdm/components.py +++ b/site/cds_rdm/components.py @@ -8,13 +8,18 @@ """CDS RDM service components.""" +import re + from flask import current_app +from flask_principal import ActionNeed +from invenio_access import Permission from invenio_communities.proxies import current_communities from invenio_drafts_resources.services.records.components import ServiceComponent from invenio_i18n import gettext as _ from invenio_i18n import lazy_gettext as _ from invenio_pidstore.errors import PIDAlreadyExists from invenio_pidstore.models import PersistentIdentifier, PIDStatus +from invenio_rdm_records.records.api import RDMRecord from invenio_rdm_records.services.errors import ValidationErrorWithMessageAsList from invenio_records_resources.services.uow import TaskOp from marshmallow import ValidationError @@ -134,6 +139,150 @@ def publish(self, identity, draft=None, record=None, **kwargs): ) +class CommitteeApprovalComponent(ServiceComponent): + """Guard and sync EP approval identifiers. + + 1. Blocks non-privileged users from adding/modifying/deleting ``apprn`` + scheme identifiers — these are system-managed only. + 2. Blocks non-privileged users from adding a ``cdsrn`` identifier whose + value matches any configured EP approval report-number pattern + (e.g. CERN-EP-*). + 3. Regenerates the ``apprn`` metadata identifier from parent ep_approval + on every save — only the public approved record carries it (detected by + ``source_internal_version`` on the parent). + """ + + def _is_privileged(self, identity): + """Return True if the identity is system or has superuser access.""" + return identity.id == "system" or Permission( + ActionNeed("superuser-access") + ).allows(identity) + + def _ep_approval_prefixes(self): + """Return the set of fixed prefixes from all configured EP patterns. + + E.g. pattern "CERN-EP-{year}-{seq:03d}" → prefix "CERN-EP-". + Used to detect cdsrn values that collide with EP report numbers. + """ + communities = current_app.config.get("CDS_EP_APPROVAL_COMMUNITIES", {}) + prefixes = set() + for cfg in communities.values(): + pattern = cfg.get("report_number_pattern", "") + # Extract the literal part before the first placeholder. + prefix = re.split(r"\{", pattern)[0] + if prefix: + prefixes.add(prefix) + return prefixes + + def _validate_identifier_changes(self, identity, data, record): + """Raise ValidationError if the user is modifying protected identifiers.""" + if self._is_privileged(identity): + return + + incoming = (data.get("metadata") or {}).get("identifiers", []) + stored = (record.get("metadata") or {}).get("identifiers", []) + + # Index stored apprn values for comparison. + stored_apprn = { + i["identifier"] for i in stored if i.get("scheme") == "apprn" + } + incoming_apprn = { + i["identifier"] for i in incoming if i.get("scheme") == "apprn" + } + if incoming_apprn != stored_apprn: + errors = [ + { + "field": f"metadata.identifiers.{index}.identifier", + "messages": [ + _( + "The 'apprn' identifier is system-managed and cannot be " + "added, modified, or removed manually." + ) + ], + } + for index, i in enumerate(incoming) + if i.get("scheme") == "apprn" + ] + if not errors: + # apprn was removed — point to the field without a specific index + errors = [ + { + "field": "metadata.identifiers", + "messages": [ + _( + "The 'apprn' identifier is system-managed and cannot be " + "added, modified, or removed manually." + ) + ], + } + ] + raise ValidationErrorWithMessageAsList(errors) + + # Block cdsrn values that look like EP report numbers. + ep_prefixes = self._ep_approval_prefixes() + if ep_prefixes: + errors = [] + for index, ident in enumerate(incoming): + if ident.get("scheme") == "cdsrn": + val = ident.get("identifier", "") + if any(val.startswith(p) for p in ep_prefixes): + errors.append( + { + "field": f"metadata.identifiers.{index}.identifier", + "messages": [ + _( + f"The value '{val}' matches an EP approval " + "report number pattern and cannot be used as " + "a CDS report number." + ) + ], + } + ) + if errors: + raise ValidationErrorWithMessageAsList(errors) + + def _regenerate_apprn_identifier(self, record, data): + """Keep apprn in metadata.identifiers in sync with parent ep_approval. + + The apprn identifier is only added when ``source_internal_version`` is present + on the parent — that key is set exclusively on the public approved record's + parent by the ``publish_public_record`` view. + """ + ea = ((record.parent.get("permission_flags") if record.parent else None) or {}).get("ep_approval") or {} + reportnumber = ea.get("reportnumber") + source_internal = ea.get("source_internal_version") + identifiers = [ + i + for i in (data.get("metadata") or {}).get("identifiers", []) + if i.get("scheme") != "apprn" + ] + if reportnumber and source_internal: + identifiers = [{"scheme": "apprn", "identifier": reportnumber}] + identifiers + data.setdefault("metadata", {})["identifiers"] = identifiers + + def create(self, identity, data=None, record=None, errors=None, **kwargs): + """Validate apprn identifier on draft creation.""" + self._validate_identifier_changes(identity, data, record) + + def update_draft(self, identity, data=None, record=None, errors=None, **kwargs): + """Validate and regenerate apprn identifier on draft update.""" + self._validate_identifier_changes(identity, data, record) + self._regenerate_apprn_identifier(record, data) + + def publish(self, identity, draft=None, record=None, **kwargs): + """Regenerate apprn identifier on publish. + + Validation is intentionally skipped here — publish does not accept + user-supplied data, and create/update_draft already guard all entry + points. The ``record`` argument at publish time is a newly created + empty object (populated by later components), so comparing against it + would produce false positives. + """ + # draft is the RDMDraft API object (extends dict); pass it directly + # so that modifications to metadata.identifiers are persisted. + self._regenerate_apprn_identifier(draft, draft) + + class MintAlternateIdentifierComponent(ServiceComponent): """Service component for minting alternative identifier `CDS Report Number`.""" diff --git a/site/cds_rdm/ext.py b/site/cds_rdm/ext.py index 17d9dcd7..fa4c2619 100644 --- a/site/cds_rdm/ext.py +++ b/site/cds_rdm/ext.py @@ -6,9 +6,11 @@ # the terms of the GPL-2.0 License; see LICENSE file for more details. """CDS-RDM module.""" + from cds_rdm.clc_sync.resources.config import CLCSyncResourceConfig from cds_rdm.clc_sync.resources.resource import CLCSyncResource from cds_rdm.clc_sync.resources.utils import get_clc_sync_entry +from cds_rdm.requests.ep_approval_state import get_ep_approval_state from cds_rdm.clc_sync.services.config import CLCSyncServiceConfig from cds_rdm.clc_sync.services.service import CLCSyncService from cds_rdm.harvester_download.resources import ( @@ -40,6 +42,7 @@ def init_app(self, app): self.init_services(app) self.init_resources(app) app.jinja_env.globals["get_clc_sync_entry"] = get_clc_sync_entry + app.jinja_env.globals["get_ep_approval_state"] = get_ep_approval_state app.jinja_env.globals["evaluate_permissions"] = evaluate_permissions # Register filter for building linked records search query app.jinja_env.filters["get_linked_records_search_query"] = ( diff --git a/site/cds_rdm/generators.py b/site/cds_rdm/generators.py index 813b521a..e39da0b7 100644 --- a/site/cds_rdm/generators.py +++ b/site/cds_rdm/generators.py @@ -143,3 +143,70 @@ class AllowMetadataOnlyForCurators(Generator): def needs(self, **kwargs): """Enabling Needs.""" return [allow_metadata_only_action] + + +class EPWorkflowCommunityManager(Generator): + """Allows community managers of EP-workflow-enrolled communities. + + A community is enrolled by having its UUID listed as a key in the + ``CDS_EP_APPROVAL_COMMUNITIES`` config dict. + """ + + def needs(self, record=None, **kwargs): + """Return needs for all enrolled communities' manager roles. + + The record's parent communities are intersected with the config to find + the relevant community, then we require the community-manager role need. + """ + from invenio_communities.generators import CommunityRoleNeed + + ep_communities = current_app.config.get("CDS_EP_APPROVAL_COMMUNITIES", {}) + if record is None: + return [] + + default_community_id = record.parent.get("communities", {}).get("default") + needs = [] + if default_community_id in ep_communities: + needs.append(CommunityRoleNeed(default_community_id, "curator")) + needs.append(CommunityRoleNeed(default_community_id, "manager")) + needs.append(CommunityRoleNeed(default_community_id, "owner")) + return needs + + def query_filter(self, **kwargs): + """Not used for search filters.""" + return [] + + +class EPCommitteeReferee(Generator): + """Allows members of the EP referee group configured for a community. + + Reads ``referee_group`` from the community's entry in + ``CDS_EP_APPROVAL_COMMUNITIES`` and returns the corresponding RoleNeed. + """ + + def needs(self, record=None, request=None, **kwargs): + """Return the RoleNeed for the EP referee group.""" + ep_communities = current_app.config.get("CDS_EP_APPROVAL_COMMUNITIES", {}) + + # Try to resolve community from the request topic or from the record. + obj = record + if obj is None and request is not None: + try: + obj = request.topic.resolve() + except Exception: + return [] + + if obj is None: + return [] + + community_ids = list(obj.parent.get("communities", {}).get("ids", [])) + needs = [] + for community_id in community_ids: + cfg = ep_communities.get(community_id) + if cfg: + needs.append(RoleNeed(cfg["referee_group"])) + return needs + + def query_filter(self, **kwargs): + """Not used for search filters.""" + return [] diff --git a/site/cds_rdm/notifications/__init__.py b/site/cds_rdm/notifications/__init__.py new file mode 100644 index 00000000..efdde473 --- /dev/null +++ b/site/cds_rdm/notifications/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the GPL-2.0 License; see LICENSE file for more details. + +"""CDS RDM notifications.""" diff --git a/site/cds_rdm/notifications/ep_approval.py b/site/cds_rdm/notifications/ep_approval.py new file mode 100644 index 00000000..f8bdf70b --- /dev/null +++ b/site/cds_rdm/notifications/ep_approval.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the GPL-2.0 License; see LICENSE file for more details. + +"""EP Approval notification builders.""" + +from __future__ import annotations + +from typing import ClassVar + +from flask_principal import Identity +from invenio_notifications.models import Notification +from invenio_notifications.registry import EntityResolverRegistry +from invenio_notifications.services.builders import NotificationBuilder +from invenio_notifications.services.filters import RecipientFilter +from invenio_notifications.services.generators import ( + ContextGenerator, + EntityResolve, + RecipientGenerator, + UserEmailBackend, +) +from invenio_requests.records.api import Request +from invenio_users_resources.notifications.filters import UserPreferencesRecipientFilter +from invenio_users_resources.notifications.generators import UserRecipient + +from .generators import GroupMembersRecipient + + +class EPApprovalNotificationBuilder(NotificationBuilder): + """Base notification builder for EP approval request actions.""" + + type: ClassVar[str] = "ep-approval-request" + + context: ClassVar[list[ContextGenerator]] = [ + EntityResolve(key="request"), + EntityResolve(key="request.topic"), + EntityResolve(key="request.receiver"), + # request.created_by and executing_user are intentionally omitted: + # when the action is performed by system_identity there is no resolvable + # user record, which causes a PermissionDeniedError in the users service. + ] + + recipients: ClassVar[list[RecipientGenerator]] = [] + + recipient_filters: ClassVar[list[RecipientFilter]] = [ + UserPreferencesRecipientFilter(), + ] + + recipient_backends: ClassVar[list[UserEmailBackend]] = [ + UserEmailBackend(), + ] + + @classmethod + def build(cls, identity: Identity, request: Request) -> Notification: + """Build notification.""" + return Notification( + type=cls.type, + context={ + "executing_user": EntityResolverRegistry.reference_identity(identity), + "request": EntityResolverRegistry.reference_entity(request), + }, + ) + + +class EPApprovalSubmitNotificationBuilder(EPApprovalNotificationBuilder): + """Notify the EP referee group when a request is submitted.""" + + type: ClassVar[str] = f"{EPApprovalNotificationBuilder.type}.submit" + # created_by omitted: submit can be triggered by system_identity which has + # no resolvable user record. + recipients: ClassVar[list[RecipientGenerator]] = [ + GroupMembersRecipient("request.receiver"), + ] + + +class EPApprovalAcceptNotificationBuilder(EPApprovalNotificationBuilder): + """Notify the submitter when their request is accepted.""" + + type: ClassVar[str] = f"{EPApprovalNotificationBuilder.type}.accept" + context: ClassVar[list[ContextGenerator]] = [ + *EPApprovalNotificationBuilder.context, + EntityResolve(key="request.created_by"), + ] + recipients: ClassVar[list[RecipientGenerator]] = [ + UserRecipient("request.created_by"), + ] + + +class EPApprovalDeclineNotificationBuilder(EPApprovalNotificationBuilder): + """Notify the submitter when their request is declined.""" + + type: ClassVar[str] = f"{EPApprovalNotificationBuilder.type}.decline" + context: ClassVar[list[ContextGenerator]] = [ + *EPApprovalNotificationBuilder.context, + EntityResolve(key="request.created_by"), + ] + recipients: ClassVar[list[RecipientGenerator]] = [ + UserRecipient("request.created_by"), + ] diff --git a/site/cds_rdm/notifications/generators.py b/site/cds_rdm/notifications/generators.py new file mode 100644 index 00000000..40a8d6b9 --- /dev/null +++ b/site/cds_rdm/notifications/generators.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the GPL-2.0 License; see LICENSE file for more details. + +"""Notification recipient generators.""" + +from __future__ import annotations + +from typing import Any + +from invenio_access.permissions import system_identity +from invenio_accounts.models import Role +from invenio_notifications.models import Notification, Recipient +from invenio_notifications.services.generators import RecipientGenerator +from invenio_records.dictutils import dict_lookup +from invenio_search.engine import dsl +from invenio_users_resources.proxies import current_users_service + + +class GroupMembersRecipient(RecipientGenerator): + """Recipient generator that resolves all members of a group/role. + + Looks up the group reference at ``key`` in the notification context, + then fetches every user belonging to that role and adds them as + recipients. + """ + + def __init__(self, key: str) -> None: + """Initialise with the context key pointing to the group.""" + self.key = key + + def __call__( + self, + notification: Notification, + recipients: dict[str, Recipient], + ) -> dict[str, Recipient]: + """Add all members of the referenced group to ``recipients``.""" + group: dict[str, Any] = dict_lookup(notification.context, self.key) + + role: Role = Role.query.filter(Role.id == group["id"]).one() + + user_ids: list[str] = [str(u.id) for u in role.users] + if not user_ids: + return recipients + + filter_: dsl.Q = dsl.Q("terms", **{"id": user_ids}) + users = current_users_service.scan(system_identity, extra_filter=filter_) + for u in users: + recipients[u["id"]] = Recipient(data=u) + + return recipients diff --git a/site/cds_rdm/permissions.py b/site/cds_rdm/permissions.py index 68c9818d..8e9a24e1 100644 --- a/site/cds_rdm/permissions.py +++ b/site/cds_rdm/permissions.py @@ -30,6 +30,7 @@ ArchiverRead, AuthenticatedRegularUser, CERNEmailsGroups, + EPCommitteeReferee, HarvesterCurator, Librarian, ) @@ -70,15 +71,23 @@ class CDSRDMRecordPermissionPolicy(RDMRecordPermissionPolicy): """Record permission policy.""" can_create = [AuthenticatedRegularUser(), SystemProcess()] - can_read = RDMRecordPermissionPolicy.can_read + [ArchiverRead()] + can_read = RDMRecordPermissionPolicy.can_read + [ + ArchiverRead(), + EPCommitteeReferee(), + ] can_search = RDMRecordPermissionPolicy.can_search + [ArchiverRead()] can_search_revisions = RDMRecordPermissionPolicy.can_manage - can_read_files = RDMRecordPermissionPolicy.can_read_files + [ArchiverRead()] + can_read_files = RDMRecordPermissionPolicy.can_read_files + [ + ArchiverRead(), + EPCommitteeReferee(), + ] can_get_content_files = RDMRecordPermissionPolicy.can_get_content_files + [ - ArchiverRead() + ArchiverRead(), + EPCommitteeReferee(), ] can_media_get_content_files = RDMRecordPermissionPolicy.can_get_content_files + [ - ArchiverRead() + ArchiverRead(), + EPCommitteeReferee(), ] can_read_deleted = [ IfRecordDeleted( @@ -118,6 +127,7 @@ class CDSJobLogsPermissionPolicy(JobLogsPermissionPolicy): can_read = JobLogsPermissionPolicy.can_search + class CDSRequestsPermissionPolicy(RDMRequestsPermissionPolicy): """Requests permission policy.""" diff --git a/site/cds_rdm/requests/__init__.py b/site/cds_rdm/requests/__init__.py new file mode 100644 index 00000000..0239a4cf --- /dev/null +++ b/site/cds_rdm/requests/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the GPL-2.0 License; see LICENSE file for more details. + +"""CDS RDM request types.""" + +from .ep_approval import EPApprovalRequest + +__all__ = ["EPApprovalRequest"] diff --git a/site/cds_rdm/requests/ep_approval.py b/site/cds_rdm/requests/ep_approval.py new file mode 100644 index 00000000..bd33d206 --- /dev/null +++ b/site/cds_rdm/requests/ep_approval.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the GPL-2.0 License; see LICENSE file for more details. + +"""EP Approval request type.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Final + +from flask import current_app +from flask_principal import Identity +from invenio_access.permissions import system_identity +from invenio_db import db +from invenio_i18n import lazy_gettext as _ +from invenio_notifications.services.uow import NotificationOp +from invenio_pidstore.models import PersistentIdentifier, PIDStatus +from invenio_rdm_records.records.api import RDMRecord +from invenio_rdm_records.requests.base import BaseRequest as RDMBaseRequest +from invenio_records_resources.services.errors import PermissionDeniedError +from invenio_records_resources.services.uow import UnitOfWork +from invenio_requests.customizations import actions +from marshmallow import ValidationError, fields + +# PID type stored in pidstore_pid.pid_type (VARCHAR(6) — keep ≤ 6 chars). +# The identifier scheme name in record metadata is "apprn" (no length limit). +APPRN_PID_TYPE = "apprn" + + +from ..notifications.ep_approval import ( + EPApprovalAcceptNotificationBuilder, + EPApprovalDeclineNotificationBuilder, + EPApprovalSubmitNotificationBuilder, +) + +# --------------------------------------------------------------------------- +# Actions +# --------------------------------------------------------------------------- + + +def _resolve_community_config(request): + """Resolve the EP approval config for the request's topic record. + + Validates that: + - the topic record belongs to at least one community, and + - that community is enrolled in CDS_EP_APPROVAL_COMMUNITIES. + + Raises ``ValueError`` with a descriptive message if either condition fails. + """ + topic = request.topic.resolve() + default_community_id = topic.parent.get("communities", {}).get("default", "") + if not default_community_id: + raise ValidationError( + "The record is not part of any community. " + "It must belong to an EP-approval-enabled community before submitting." + ) + ep_communities = current_app.config.get("CDS_EP_APPROVAL_COMMUNITIES", {}) + if default_community_id in ep_communities: + return ep_communities[default_community_id] + raise ValidationError( + "The record's community is not enrolled in the EP approval workflow. " + "Only records in EP-approval-enabled communities can be submitted." + ) + + +class EPApprovalSubmitAction(actions.CreateAndSubmitAction): + """Submit action — validate community enrollment and notify referees.""" + + def execute(self, identity: Identity, uow: UnitOfWork) -> None: + """Execute submit: validate community, store version ID, notify referees.""" + # Enforce that only community managers of enrolled communities (or system) + # can submit. The base service only checks generic can_create. + from cds_rdm.generators import EPWorkflowCommunityManager + + topic = self.request.topic.resolve() + is_system = identity.id == "system" + allowed = is_system or any( + need in identity.provides + for need in EPWorkflowCommunityManager().needs(record=topic) + ) + if not allowed: + raise PermissionDeniedError() + + # Fail fast: ensure the record belongs to an enrolled community before + # the request is persisted as submitted. + _resolve_community_config(self.request) + + # Reject if the parent already carries an approval report number. + if ((topic.parent.get("permission_flags") or {}).get("ep_approval") or {}).get("reportnumber"): + raise ValidationError( + "A version of this record already has an approval report number assigned. " + "A new EP approval request cannot be submitted." + ) + + # Reject if there is already a submitted (pending) request for ANY version + # in the family — prevents parallel submissions from older/newer versions. + from invenio_requests.proxies import current_requests_service + + parent_recids = [] + for family_rec in RDMRecord.get_records_by_parent(topic.parent): + pid = PersistentIdentifier.query.filter_by( + pid_type="recid", + object_uuid=str(family_rec.id), + object_type="rec", + ).first() + if pid: + parent_recids.append(pid.pid_value) + parent_recids = parent_recids or [topic["id"]] + + topic_query = " OR ".join(f'topic.record:"{r}"' for r in parent_recids) + existing = list( + current_requests_service.search( + system_identity, + params={ + "q": ( + f"({topic_query}) AND type:\"ep-approval\"" + ' AND status:"submitted"' + ), + "size": 1, + }, + ).hits + ) + if existing: + raise ValidationError( + "An EP approval request is already pending for this record." + ) + + # Store the recid (pid_value) of the submitted version so that + # _propagate_to_newer_versions can match it against search hit["id"]. + # The UUID (topic.id) is intentionally NOT stored here; it is resolved + # at accept time directly from the request topic. + self.request["payload"]["submitted_version_id"] = topic["id"] + + uow.register( + NotificationOp( + EPApprovalSubmitNotificationBuilder.build( + identity=identity, + request=self.request, + ) + ) + ) + super().execute(identity, uow) + + +class EPApprovalAcceptAction(actions.AcceptAction): + """Accept action — auto-generate the approval report number and assign it.""" + + def _community_config(self): + """Resolve the community config for this request (guaranteed enrolled at submit).""" + return _resolve_community_config(self.request) + + def _generate_report_number(self, pattern: str) -> str: + """Auto-generate the next sequential report number for the given pattern. + + Derives the next sequence number from the MAX existing apprn PID value + for this pattern prefix and year, not a COUNT. This is robust to gaps + (deleted PIDs, failed accepts) — the sequence only ever goes forward. + + The prefix is extracted by splitting on ``{seq`` so that zero-padded + formats like ``{seq:03d}`` do not produce a truncated prefix that misses + PIDs >= 010 (e.g. "CERN-EP-2026-00" would miss "CERN-EP-2026-010"). + + Pattern example: "CERN-EP-{year}-{seq:03d}" + """ + year = date.today().year + # Split on "{seq" to get everything before the sequence placeholder, + # then format only the year part → "CERN-EP-2026-" + prefix = pattern.split("{seq")[0].format(year=year) + existing = PersistentIdentifier.query.filter( + PersistentIdentifier.pid_type == APPRN_PID_TYPE, + PersistentIdentifier.pid_value.like(f"{prefix}%"), + ).all() + max_seq = max( + (int(p.pid_value[len(prefix):]) for p in existing if p.pid_value[len(prefix):].isdigit()), + default=0, + ) + return pattern.format(year=year, seq=max_seq + 1) + + def _mint_apprn_pid(self, report_number: str, record_uuid: str) -> None: + """Mint the apprn PID pointing at the given record UUID.""" + PersistentIdentifier.create( + pid_type=APPRN_PID_TYPE, + pid_value=report_number, + object_type="rec", + object_uuid=record_uuid, + status=PIDStatus.REGISTERED, + ) + + def execute(self, identity: Identity, uow: UnitOfWork) -> None: + """Execute accept: mint report number and write ep_approval to the parent. + + All versions in the family share the same parent, so a single write is + visible to every version — no propagation or edit/publish cycle needed. + The apprn metadata identifier is NOT added here; it is only added by + CommitteeApprovalComponent when the public approved record is created + (detected by the presence of source_internal_version on the public parent). + """ + config = self._community_config() + pattern = config["report_number_pattern"] + report_number = self._generate_report_number(pattern) + + topic = self.request.topic.resolve() + submitted_version_recid = ( + self.request["payload"].get("submitted_version_id") or topic["id"] + ) + + self._mint_apprn_pid(report_number, str(topic.id)) + + # Write ep_approval into permission_flags — single source of truth. + pf = topic.parent.get("permission_flags") or {} + pf["ep_approval"] = { + "reportnumber": report_number, + "datetime": datetime.now(timezone.utc).isoformat(), + "approved_internal_version": submitted_version_recid, + } + topic.parent["permission_flags"] = pf + topic.parent.commit() + db.session.commit() + + # Store on the request payload so the UI can display it. + self.request["payload"]["approved_report_number"] = report_number + + uow.register( + NotificationOp( + EPApprovalAcceptNotificationBuilder.build( + identity=identity, + request=self.request, + ) + ) + ) + super().execute(identity, uow) + + +class EPApprovalDeclineAction(actions.DeclineAction): + """Decline action — notify the submitter.""" + + def execute(self, identity: Identity, uow: UnitOfWork) -> None: + """Execute decline.""" + uow.register( + NotificationOp( + EPApprovalDeclineNotificationBuilder.build( + identity=identity, + request=self.request, + ) + ) + ) + super().execute(identity, uow) + + +# --------------------------------------------------------------------------- +# Request Type +# --------------------------------------------------------------------------- + + +class EPApprovalRequest(RDMBaseRequest): + """EP Approval request type. + + Allows community managers of enrolled communities to request EP committee + approval for a specific record version. On acceptance CDS auto-generates + a report number (e.g. CERN-EP-2026-001) and assigns it to the record. + """ + + type_id: Final[str] = "ep-approval" + name: Final[str] = _("EP Approval") + + available_actions: Final[dict] = { + **RDMBaseRequest.available_actions, + "create": EPApprovalSubmitAction, + "accept": EPApprovalAcceptAction, + "decline": EPApprovalDeclineAction, + "cancel": actions.CancelAction, + } + + available_statuses: Final[dict] = { + **RDMBaseRequest.available_statuses, + } + + creator_can_be_none: Final[bool] = False + topic_can_be_none: Final[bool] = False + receiver_can_be_none: Final[bool] = False + + allowed_creator_ref_types: Final[list] = ["user"] + allowed_receiver_ref_types: Final[list] = ["group"] + allowed_topic_ref_types: Final[list] = ["record"] + + # Payload fields collected from the submission form. + payload_schema: Final[dict] = { + # Populated automatically on submit, not from the form. + "submitted_version_id": fields.Str(load_default=None), + # Populated on accept by the system. + "approved_report_number": fields.Str(load_default=None), + # Form fields. + "experiment": fields.Str(required=True), + "submitted_by": fields.Str(required=True), + "role": fields.Str(required=True), + "publication_title": fields.Str(required=True), + "latest_version_url": fields.Str(load_default=None), + "rapid_approval": fields.Bool(load_default=False), + "cb_review_completed": fields.Bool(load_default=False), + "cb_process_type": fields.Str(load_default=None), # "standard" | "accelerated" + "paper_signed": fields.Bool(load_default=True), + "num_non_signers": fields.Int(load_default=0), + "controversy": fields.Bool(load_default=False), + "additional_communication": fields.Str(load_default=None), + } diff --git a/site/cds_rdm/requests/ep_approval_state.py b/site/cds_rdm/requests/ep_approval_state.py new file mode 100644 index 00000000..167c9e34 --- /dev/null +++ b/site/cds_rdm/requests/ep_approval_state.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the GPL-2.0 License; see LICENSE file for more details. + +"""EP Approval state helpers for the record landing page.""" + +from flask import current_app, g +from invenio_access.permissions import system_identity +from invenio_communities.generators import CommunityRoleNeed +from invenio_requests.proxies import current_requests_service + + +def _get_enrolled_community(record_ui): + """Return (community_id, community_config) for enrolled communities, else (None, None).""" + ep_communities = current_app.config.get("CDS_EP_APPROVAL_COMMUNITIES", {}) + parent = record_ui.get("parent", {}) if record_ui else {} + default_community_id = parent.get("communities", {}).get("default") + config = ep_communities.get(default_community_id) if default_community_id else None + return (default_community_id, config) if config else (None, None) + + +def _get_parent_ep_approval(record): + """Read ep_approval dict directly from the parent record object. + + Returns the dict or {} if not set / record not available. + """ + try: + return (record._record.parent.get("permission_flags") or {}).get("ep_approval") or {} + except Exception: + return {} + + +def _get_open_request(record_id, parent_record=None): + """Return the most-recent EP approval request for the record family. + + Uses parent.get_records_by_parent to build the topic query so we cover + all versions without a separate DB scan. + """ + try: + from invenio_pidstore.models import PersistentIdentifier + from invenio_rdm_records.records.api import RDMRecord + + if parent_record is not None: + recids = [] + for rec in RDMRecord.get_records_by_parent(parent_record): + pid = PersistentIdentifier.query.filter_by( + pid_type="recid", + object_uuid=str(rec.id), + object_type="rec", + ).first() + if pid: + recids.append(pid.pid_value) + else: + recids = [record_id] + + topic_query = " OR ".join(f'topic.record:"{r}"' for r in (recids or [record_id])) + results = current_requests_service.search( + system_identity, + params={ + "q": ( + f'({topic_query}) AND type:"ep-approval"' + ' AND (status:"submitted" OR status:"declined" OR status:"accepted")' + ), + "size": 1, + "sort": "newest", + }, + ) + hits = list(results.hits) + if not hits: + return None + req = hits[0] + return { + "id": req["id"], + "status": req.get("status"), + "links": req.get("links", {}), + } + except Exception: + return None + + +def _check_can_submit(community_id): + """Return True if the current user is a curator, manager, or owner of the community.""" + try: + identity = g.identity + return any( + CommunityRoleNeed(community_id, role) in identity.provides + for role in ("curator", "manager", "owner") + ) + except Exception: + return False + + +def _check_can_create_public(can_submit, ea, record_id): + """Return True if this version may be used as the source for a public record. + + Requires: + - can_submit is True (user is a manager/owner) + - an approval number exists on the parent + - this version's index >= the approved version's index + - no public record has been created yet + """ + if not can_submit: + return False + if not ea.get("reportnumber"): + return False + if ea.get("approved_public_version"): + return False + approved_version_recid = ea.get("approved_internal_version") + if not approved_version_recid or approved_version_recid == record_id: + return True + try: + from invenio_pidstore.models import PersistentIdentifier + from invenio_rdm_records.records.api import RDMRecord + + appr_pid = PersistentIdentifier.get("recid", approved_version_recid) + appr_rec = RDMRecord.get_record(appr_pid.object_uuid) + cur_pid = PersistentIdentifier.get("recid", record_id) + cur_rec = RDMRecord.get_record(cur_pid.object_uuid) + return cur_rec.versions.index >= appr_rec.versions.index + except Exception: + return True # fail open — backend will re-validate + + +def get_ep_approval_state(record_ui, record=None): + """Return EP approval state for the record landing page. + + Reads ep_approval from the parent record object (single source of truth). + No DB scans across versions needed. + + Returns a dict with: + - can_submit: bool + - can_create_public: bool + - community_enrolled: bool + - is_public_approved_record: bool + - open_request: dict or None — {id, status, links} + - approved_report_number: str or None + - approval_date: str or None + - ep_approval: dict — raw parent ep_approval (for frontend version badges) + - draft_record_id: str or None + - receiver_group: str or None + """ + # Read ep_approval from the parent. + ea = _get_parent_ep_approval(record) + + # Early exit: this IS the public EP-approved copy. + # The public record's parent has source_internal_version set. + if ea.get("source_internal_version"): + return { + "can_submit": False, + "can_create_public": False, + "community_enrolled": False, + "is_public_approved_record": True, + "open_request": None, + "approved_report_number": ea.get("reportnumber"), + "approval_date": None, + "ep_approval": ea, + "draft_record_id": ea["source_internal_version"], + "receiver_group": None, + "cern_scientific_community_id": None, + } + + # Check community enrollment. + community_id, community_config = _get_enrolled_community(record_ui) + if community_id is None: + return { + "can_submit": False, + "can_create_public": False, + "community_enrolled": False, + "is_public_approved_record": False, + "open_request": None, + "approved_report_number": None, + "approval_date": None, + "ep_approval": {}, + "draft_record_id": None, + "receiver_group": None, + } + + record_id = record_ui.get("id") if record_ui else None + + # Get the parent object for the request search (avoids re-scanning recids). + parent_record = None + try: + parent_record = record._record.parent + except Exception: + pass + + open_request = _get_open_request(record_id, parent_record) + can_submit = _check_can_submit(community_id) + approved_report_number = ea.get("reportnumber") + can_create_public = _check_can_create_public(can_submit, ea, record_id) + + cern_scientific_community_id = current_app.config.get( + "CDS_CERN_SCIENTIFIC_COMMUNITY_ID" + ) + + return { + "can_submit": can_submit, + "can_create_public": can_create_public, + "community_enrolled": True, + "is_public_approved_record": False, + "open_request": open_request, + "approved_report_number": approved_report_number, + "approval_date": ea.get("datetime"), + "ep_approval": ea, + "draft_record_id": None, + "receiver_group": community_config.get("referee_group"), + "cern_scientific_community_id": cern_scientific_community_id, + } diff --git a/site/cds_rdm/requests/views.py b/site/cds_rdm/requests/views.py new file mode 100644 index 00000000..ab849ac4 --- /dev/null +++ b/site/cds_rdm/requests/views.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the GPL-2.0 License; see LICENSE file for more details. + +"""CDS EP Approval API views.""" + +import copy + +from flask import Blueprint, current_app, g, jsonify, request +from flask_login import login_required +from invenio_access.permissions import system_identity +from invenio_communities.communities.records.api import Community +from invenio_communities.generators import CommunityRoleNeed +from invenio_db import db +from invenio_pidstore.errors import PIDDoesNotExistError +from invenio_pidstore.models import PersistentIdentifier +from invenio_rdm_records.proxies import ( + current_rdm_records_service, + current_record_communities_service, +) +from invenio_rdm_records.records.api import RDMRecord +from invenio_records_resources.services.errors import PermissionDeniedError +from invenio_requests.proxies import current_requests_service +from invenio_requests.resolvers.registry import ResolverRegistry + +from .ep_approval import EPApprovalRequest + + +def create_ep_approval_bp(app): + """Create EP approval API blueprint.""" + bp = Blueprint("cds_ep_approval", __name__) + + @bp.route("/records//ep-approval", methods=["POST"]) + @login_required + def submit_ep_approval(pid_value): + """Submit an EP approval request for a published record.""" + try: + record = current_rdm_records_service.read( + g.identity, pid_value, expand=False + ) + except (PIDDoesNotExistError, PermissionDeniedError) as e: + return jsonify({"message": str(e)}), 409 + + body = request.get_json(force=True) or {} + receiver_group = body.pop("receiver_group", None) + payload = body.get("payload", {}) + + if not receiver_group: + return jsonify({"message": "receiver_group is required"}), 400 + + try: + # Resolve receiver group entity. + receiver = ResolverRegistry.resolve_entity( + {"group": receiver_group}, raise_=True + ) + + title = record.data.get("metadata", {}).get("title", "") + req = current_requests_service.create( + identity=g.identity, + data={ + "title": f'EP approval for "{title}"', + "payload": payload, + }, + request_type=EPApprovalRequest, + receiver=receiver, + topic=record._record, + ) + except PermissionDeniedError: + return jsonify({"message": "Permission denied"}), 403 + except Exception as e: + return jsonify({"message": str(e)}), 400 + + return jsonify(req.to_dict()), 201 + + @bp.route("/records//ep-approval/publish-public", methods=["POST"]) + @login_required + def publish_public_record(pid_value): + """Create a public approved record from an approved draft. + + Requires the calling user to be a community manager/owner of the + record's enrolled community. + + Steps: + 1. Read the approved draft — must have ep_approval.reportnumber set on parent. + 2. Build a new public record: copy metadata + files, set access=public. + 3. Create draft, import files, write ep_approval to both parents, publish. + 4. Return the new public record id and links. + """ + # --- read + authorise --- + try: + draft_record = current_rdm_records_service.read( + g.identity, pid_value, expand=False + ) + except (PIDDoesNotExistError, PermissionDeniedError) as e: + return jsonify({"message": str(e)}), 403 + + # Read ep_approval from the internal draft's parent. + src_pid_obj = PersistentIdentifier.get("recid", pid_value) + src_rec_obj = RDMRecord.get_record(src_pid_obj.object_uuid) + ea = (src_rec_obj.parent.get("permission_flags") or {}).get("ep_approval") or {} + report_number = ea.get("reportnumber") + + if not report_number: + return jsonify({"message": "Record has no approved report number."}), 400 + + # Check that the calling user is a community manager/owner of the enrolled community. + default_community_id = ( + draft_record.data.get("parent", {}).get("communities", {}).get("default") + ) + if not default_community_id: + return jsonify({"message": "Record has no default community."}), 400 + + identity = g.identity + allowed_roles = ("curator", "manager", "owner") + if not any( + CommunityRoleNeed(default_community_id, role) in identity.provides + for role in allowed_roles + ): + return jsonify({"message": "Permission denied"}), 403 + + # Reject if the calling version predates the approved version. + approved_version_recid = ea.get("approved_internal_version") + if approved_version_recid and approved_version_recid != pid_value: + try: + appr_pid = PersistentIdentifier.get("recid", approved_version_recid) + appr_rec = RDMRecord.get_record(appr_pid.object_uuid) + cur_pid = PersistentIdentifier.get("recid", pid_value) + cur_rec = RDMRecord.get_record(cur_pid.object_uuid) + if cur_rec.versions.index < appr_rec.versions.index: + return ( + jsonify( + { + "message": ( + "Cannot create a public record from a version that " + "predates the approved version." + ) + } + ), + 400, + ) + except Exception: + pass # fail open — report_number check above already validated + + # Guard: refuse if a public record was already created. + if ea.get("approved_public_version"): + return ( + jsonify( + { + "message": "A public record for this approval already exists.", + "id": ea["approved_public_version"], + } + ), + 409, + ) + + # --- build new public record data --- + src = draft_record.data + src_id = src["id"] + + # Strip apprn from identifiers — CommitteeApprovalComponent regenerates it + # from ep_approval.reportnumber on every update_draft / publish. + new_identifiers = [ + i + for i in src.get("metadata", {}).get("identifiers", []) + if i.get("scheme") != "apprn" + ] + + new_related = list(src.get("metadata", {}).get("related_identifiers", [])) + isversionof_entry = { + "identifier": src_id, + "scheme": "cds", + "relation_type": {"id": "isversionof"}, + } + src_resource_type = src.get("metadata", {}).get("resource_type") + if src_resource_type: + isversionof_entry["resource_type"] = src_resource_type + new_related.append(isversionof_entry) + + new_metadata = { + **copy.deepcopy(src.get("metadata", {})), + "identifiers": new_identifiers, + "related_identifiers": new_related, + } + + # Strip committee_approval CF from public record — no longer used on versions. + new_custom_fields = { + k: v + for k, v in src.get("custom_fields", {}).items() + if k != "cern:committee_approval" + } + + new_record_data = { + "metadata": new_metadata, + "custom_fields": new_custom_fields, + "access": {"record": "public", "files": "public"}, + "files": {"enabled": src.get("files", {}).get("enabled", False)}, + } + + try: + # Create draft as the calling user so they own the public record. + new_draft = current_rdm_records_service.create(g.identity, new_record_data) + community_obj = Community.get_record(default_community_id) + new_draft._record.parent.communities.add(community_obj, default=True) + new_draft._record.parent.commit() + + if src.get("files", {}).get("enabled", False): + new_draft._record.files.copy(src_rec_obj.files) + new_draft._record.commit() + + # Write ep_approval into the public record's permission_flags. + # source_internal_version marks this as the public copy and links back. + pf = new_draft._record.parent.get("permission_flags") or {} + pf["ep_approval"] = { + "reportnumber": report_number, + "source_internal_version": src_id, + } + new_draft._record.parent["permission_flags"] = pf + new_draft._record.parent.commit() + db.session.commit() + + # update_draft triggers CommitteeApprovalComponent which regenerates + # the apprn identifier (now reads from parent ep_approval). + new_record = current_rdm_records_service.publish( + system_identity, new_draft.id + ) + except Exception as e: + return jsonify({"message": str(e)}), 400 + + # Submit an inclusion request to the CERN Research community. + cern_scientific_community_id = current_app.config.get( + "CDS_CERN_SCIENTIFIC_COMMUNITY_ID" + ) + if cern_scientific_community_id: + try: + current_record_communities_service.add( + system_identity, + new_record.data["id"], + data={ + "communities": [ + { + "id": cern_scientific_community_id, + "require_review": True, + "comment": { + "payload": { + "content": ( + f"This inclusion request was automatically " + f"generated when publishing the EP-approved " + f"public record for {report_number}. The " + f"document has been reviewed and approved by " + f"the EP Publication Committee." + ) + } + }, + } + ] + }, + ) + except Exception as e: + current_app.logger.warning( + f"Could not submit CERN Research inclusion request for " + f"{new_record.data['id']}: {e}" + ) + + new_record_id = new_record.data["id"] + + # Write approved_public_version + source_public_version to the internal + # draft's parent so future page loads know a public record was created. + back_link_warning = None + try: + pf = src_rec_obj.parent.get("permission_flags") or {} + pf["ep_approval"] = { + **ea, + "approved_public_version": new_record_id, + "source_public_version": src_id, + } + src_rec_obj.parent["permission_flags"] = pf + src_rec_obj.parent.commit() + db.session.commit() + + # Also add the isvariantformof back-link to the source version. + back_draft = current_rdm_records_service.edit( + system_identity, id_=src_id + ) + back_data = back_draft.data + back_related = list( + back_data.get("metadata", {}).get("related_identifiers", []) + ) + already_linked = any( + r.get("scheme") == "cds" + and (r.get("relation_type") or {}).get("id") == "isvariantformof" + and r.get("identifier") == new_record_id + for r in back_related + ) + if not already_linked: + isvariantformof_entry = { + "identifier": new_record_id, + "scheme": "cds", + "relation_type": {"id": "isvariantformof"}, + } + pub_resource_type = new_record.data.get("metadata", {}).get( + "resource_type" + ) + if pub_resource_type: + isvariantformof_entry["resource_type"] = pub_resource_type + back_related.append(isvariantformof_entry) + back_data["metadata"]["related_identifiers"] = back_related + current_rdm_records_service.update_draft( + system_identity, id_=back_draft.id, data=back_data + ) + current_rdm_records_service.publish( + system_identity, id_=back_draft.id + ) + except Exception: + back_link_warning = "Public record created but back-link on source version failed." + + response_body = {"id": new_record_id, "links": new_record.data.get("links", {})} + if back_link_warning: + response_body["warning"] = back_link_warning + return jsonify(response_body), 201 + + return bp diff --git a/site/cds_rdm/schemes.py b/site/cds_rdm/schemes.py index 93f5c847..68c4069b 100644 --- a/site/cds_rdm/schemes.py +++ b/site/cds_rdm/schemes.py @@ -152,6 +152,23 @@ def cds_report_number(): return {"validator": lambda value: True, "normalizer": lambda value: value} +# Matches patterns like CERN-EP-2026-001 or CERN-TH-2026-042 +approval_rn_regexp = re.compile(r"^[A-Z]+-[A-Z]+-\d{4}-\d+$") + + +def is_approval_report_number(val): + """Test if argument is a valid approval report number.""" + return bool(approval_rn_regexp.match(val)) + + +def approval_report_number(): + """Define validator for auto-generated approval report numbers (apprn).""" + return { + "validator": is_approval_report_number, + "normalizer": lambda value: value, + } + + def cds(): """Define scheme for CDS.""" return { diff --git a/site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html b/site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html index 66999fc3..1d1e56a5 100644 --- a/site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html +++ b/site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html @@ -9,6 +9,34 @@ {%- set clc_sync_entry = get_clc_sync_entry(record_ui) %} {%- set additional_permissions = evaluate_permissions(record, ['manage_clc_sync']) %} +{%- set ep_approval_state = get_ep_approval_state(record_ui, record) %} + +{# Find the apprn identifier on the public EP-approved record. #} +{%- set apprn_identifier = namespace(value=None) %} +{%- for ident in record_ui.get("metadata", {}).get("identifiers", []) %} + {%- if ident.get("scheme") == "apprn" %} + {%- set apprn_identifier.value = ident.get("identifier") %} + {%- endif %} +{%- endfor %} + +{%- block record_header -%} +{{ super() }} +{%- if apprn_identifier.value %} + +{%- endif %} +{%- endblock record_header -%} {%- block record_content -%} diff --git a/site/cds_rdm/templates/semantic-ui/cds_rdm/records/manage_menu.html b/site/cds_rdm/templates/semantic-ui/cds_rdm/records/manage_menu.html index b67cdd3b..71ca61cb 100644 --- a/site/cds_rdm/templates/semantic-ui/cds_rdm/records/manage_menu.html +++ b/site/cds_rdm/templates/semantic-ui/cds_rdm/records/manage_menu.html @@ -8,3 +8,4 @@ data-allowed-resource-types='{{ config.CLC_SYNC_ALLOWED_RESOURCE_TYPES | tojson }}' data-clc-sync-entry='{{ clc_sync_entry | tojson | safe }}' data-additional-permissions='{{ additional_permissions | tojson }}' +data-ep-approval='{{ ep_approval_state | tojson | safe }}' diff --git a/site/cds_rdm/templates/semantic-ui/invenio_requests/ep-approval/index.html b/site/cds_rdm/templates/semantic-ui/invenio_requests/ep-approval/index.html new file mode 100644 index 00000000..b52ec4c2 --- /dev/null +++ b/site/cds_rdm/templates/semantic-ui/invenio_requests/ep-approval/index.html @@ -0,0 +1,154 @@ +{# + Copyright (C) 2025 CERN. + + CDS-RDM is free software; you can redistribute it and/or modify it + under the terms of the GPL-2.0 License; see LICENSE file for more details. +#} + +{# Request landing page for the EP Approval request type. #} + +{% extends "invenio_requests/details/index.html" %} + +{%- block request_header %} + {% if is_user_dashboard %} + {% set back_button_url = url_for("invenio_app_rdm_users.requests") %} + {% else %} + {% set back_button_url = "" %} + {% endif %} + +
+ {% if back_button_url %} + + {% endif %} +
+
+ +
+

{{ invenio_request.title }}

+
+ +
+{%- endblock request_header %} + +{%- block request_timeline %} + + {# EP Approval payload — two-column split card above the standard conversation. + Left group: submission info (record, experiment, submitter, role, URL). + Right group: approval checklist (rapid, CB review, paper signed, controversy). + The standard React #request-detail (timeline + metadata sidebar) renders below. + #} + {% set payload = invenio_request["payload"] or {} %} + {% set record_self_html = record_ui.links.self_html if record_ui and record_ui.links else None %} + +
+

{{ _("EP approval request") }}

+ + {% if payload.get("approved_report_number") %} +
+ {{ _("Approved report number:") }} + {{ payload["approved_report_number"] }} +
+ {% endif %} + +
+ + {# Left column: submission details #} +
+ + + {% if record_self_html %} + + + + + {% endif %} + {% if payload.get("experiment") %} + + + + + {% endif %} + {% if payload.get("submitted_by") %} + + + + + {% endif %} + {% if payload.get("role") %} + + + + + {% endif %} + {% if payload.get("latest_version_url") %} + + + + + {% endif %} + {% if payload.get("additional_communication") %} + + + + + {% endif %} + +
{{ _("Record") }} + + {{ record_ui.metadata.title }} + +
{{ _("Experiment") }}{{ payload["experiment"] }}
{{ _("Submitted by") }}{{ payload["submitted_by"] }}
{{ _("Role") }}{{ payload["role"] }}
{{ _("Latest version at") }} + + {{ payload["latest_version_url"] }} + +
{{ _("Additional communication") }}{{ payload["additional_communication"] }}
+
+ + {# Right column: approval checklist #} +
+ + + + + + + + + + + + + + + {% if not payload.get("paper_signed") %} + + + + + {% endif %} + +
{{ _("Rapid approval") }}{{ _("Yes") if payload.get("rapid_approval") else _("No") }}
{{ _("CB review completed") }} + {{ _("Yes") if payload.get("cb_review_completed") else _("No") }} + {% if payload.get("cb_review_completed") and payload.get("cb_process_type") %} + ({{ payload["cb_process_type"] }}) + {% endif %} +
{{ _("Paper signed by whole collaboration") }} + {{ _("Yes") if payload.get("paper_signed") else _("No") }} + {% if not payload.get("paper_signed") and payload.get("num_non_signers") %} +
{{ payload["num_non_signers"] }} {{ _("non-signer(s)") }} + {% endif %} +
{{ _("Controversy") }}{{ _("Yes") if payload.get("controversy") else _("No") }}
+
+ +
{# end two column grid #} +
+ + {# Standard React conversation: timeline (13 wide) + metadata sidebar (3 wide). #} + {{ super() }} + +{%- endblock request_timeline %} diff --git a/site/cds_rdm/webpack.py b/site/cds_rdm/webpack.py index 3f5ce18b..3f0af266 100644 --- a/site/cds_rdm/webpack.py +++ b/site/cds_rdm/webpack.py @@ -22,7 +22,15 @@ "cds-rdm-linked-records": "./js/cds_rdm/linked-records/index.js", "cds-rdm-harvester-reports": "./js/cds_rdm/administration/harvesterReports/index.js", }, - dependencies={"three": "^0.182.0", "three-addons": "^1.2.0"}, + dependencies={ + "three": "^0.182.0", + "three-addons": "^1.2.0", + "@visx/group": "^3.12.0", + "@visx/responsive": "^3.12.0", + "@visx/scale": "^3.12.0", + "@visx/shape": "^3.12.0", + "@visx/text": "^3.12.0", + }, ), }, ) diff --git a/site/pyproject.toml b/site/pyproject.toml index b4ce4111..1f521ab6 100644 --- a/site/pyproject.toml +++ b/site/pyproject.toml @@ -17,6 +17,7 @@ harvester_download = "cds_rdm.views:create_harvester_download_bp" [project.entry-points."invenio_base.api_blueprints"] clc_sync = "cds_rdm.views:create_cds_clc_sync_bp" +ep_approval = "cds_rdm.requests.views:create_ep_approval_bp" [project.entry-points."invenio_celery.tasks"] cds_rdm_tasks = "cds_rdm.tasks" @@ -31,8 +32,12 @@ process_inspire = "cds_rdm.inspire_harvester.jobs:ProcessInspireHarvesterJob" [project.entry-points."invenio_pidstore.minters"] legacy = "cds_rdm.minters:legacy_recid_minter" +[project.entry-points."invenio_requests.types"] +ep_approval = "cds_rdm.requests:EPApprovalRequest" + [project.entry-points."idutils.custom_schemes"] cdsrn = "cds_rdm.schemes:cds_report_number" +apprn = "cds_rdm.schemes:approval_report_number" aleph = "cds_rdm.schemes:aleph" inspire = "cds_rdm.schemes:inspire" inspire_author = "cds_rdm.schemes:inspire_author" diff --git a/site/tests/conftest.py b/site/tests/conftest.py index 902d7ec3..df3bb5b9 100644 --- a/site/tests/conftest.py +++ b/site/tests/conftest.py @@ -10,7 +10,6 @@ from collections import namedtuple import pytest -from celery import current_app as current_celery_app from flask import current_app from flask_webpackext.manifest import ( JinjaManifest, @@ -27,6 +26,7 @@ from invenio_communities.communities.records.api import Community from invenio_communities.proxies import current_communities from invenio_i18n import lazy_gettext as _ +from invenio_notifications.services.builders import NotificationBuilder from invenio_pidstore.models import PersistentIdentifier, PIDStatus from invenio_rdm_records.cli import create_records_custom_field from invenio_rdm_records.config import ( @@ -37,7 +37,10 @@ RDM_RECORDS_RELATED_IDENTIFIERS_SCHEMES, always_valid, ) +from invenio_rdm_records.proxies import current_rdm_records +from invenio_rdm_records.records.api import RDMRecord from invenio_rdm_records.resources.serializers import DataCite43JSONSerializer +from invenio_rdm_records.services.components import DefaultRecordsComponents from invenio_rdm_records.services.pids import providers from invenio_records_resources.proxies import current_service_registry from invenio_users_resources.records.api import UserAggregate @@ -55,10 +58,16 @@ from invenio_vocabularies.records.api import Vocabulary from cds_rdm import schemes +from cds_rdm.components import CommitteeApprovalComponent from cds_rdm.custom_fields import CUSTOM_FIELDS, CUSTOM_FIELDS_UI, NAMESPACES from cds_rdm.inspire_harvester.reader import InspireHTTPReader from cds_rdm.inspire_harvester.transformer import InspireJsonTransformer from cds_rdm.inspire_harvester.writer import InspireWriter +from cds_rdm.notifications.ep_approval import ( + EPApprovalAcceptNotificationBuilder, + EPApprovalDeclineNotificationBuilder, + EPApprovalSubmitNotificationBuilder, +) from cds_rdm.permissions import ( CDSCommunitiesPermissionPolicy, CDSRDMRecordPermissionPolicy, @@ -199,18 +208,21 @@ def app_config(app_config, mock_datacite_client, mock_crossref_client): app_config["CELERY_RESULT_BACKEND"] = "cache" app_config["REST_CSRF_ENABLED"] = False # Disable CSRF globally for tests app_config["RDM_RECORDS_IDENTIFIERS_SCHEMES"] = { - **{ - "cdsrn": { - "label": _("CDS Report Number"), - "validator": always_valid, - "datacite": "CDS", - }, - "aleph": { - "label": _("Aleph number"), - "validator": schemes.is_aleph, - "datacite": "ALEPH", - }, - "cds": {"label": _("CDS"), "validator": schemes.is_cds, "datacite": "CDS"}, + "cdsrn": { + "label": _("CDS Report Number"), + "validator": always_valid, + "datacite": "CDS", + }, + "aleph": { + "label": _("Aleph number"), + "validator": schemes.is_aleph, + "datacite": "ALEPH", + }, + "cds": {"label": _("CDS"), "validator": schemes.is_cds, "datacite": "CDS"}, + "apprn": { + "label": _("Approval Report Number"), + "validator": schemes.is_approval_report_number, + "datacite": "CERN", }, } app_config["RDM_RECORDS_RELATED_IDENTIFIERS_SCHEMES"] = { @@ -296,6 +308,29 @@ def app_config(app_config, mock_datacite_client, mock_crossref_client): app_config["RDM_CUSTOM_FIELDS"] = CUSTOM_FIELDS app_config["RDM_CUSTOM_FIELDS_UI"] = CUSTOM_FIELDS_UI + from invenio_requests.notifications.builders import ( + CommentRequestEventCreateNotificationBuilder, + CommentRequestEventReplyNotificationBuilder, + ) + + app_config["NOTIFICATIONS_BUILDERS"] = { + EPApprovalSubmitNotificationBuilder.type: DummyNotificationBuilder, + EPApprovalAcceptNotificationBuilder.type: DummyNotificationBuilder, + EPApprovalDeclineNotificationBuilder.type: DummyNotificationBuilder, + CommentRequestEventCreateNotificationBuilder.type: DummyNotificationBuilder, + CommentRequestEventReplyNotificationBuilder.type: DummyNotificationBuilder, + } + + # EP Approval communities — static dummy UUIDs for config-lookup tests. + # Tests that run the full accept action add their real community UUID at + # fixture time by mutating this dict directly (see ep_enrolled_community fixture). + app_config["CDS_EP_APPROVAL_COMMUNITIES"] = {} + + app_config["RDM_RECORDS_SERVICE_COMPONENTS"] = [ + CommitteeApprovalComponent, + *DefaultRecordsComponents, + ] + return app_config @@ -1606,3 +1641,92 @@ def name_full_data(): ], "affiliations": [{"name": "CustomORG"}], } + + +def _publish_record_in_community(identity, record_data, community, service): + """Helper: publish a record belonging to a community. + + Follows the pattern from invenio-rdm-records tests/conftest.py: + add the community to the draft's parent before publishing. + """ + draft = service.create(identity, record_data) + # Add community membership on the parent before publishing. + draft._record.parent.communities.add(community, default=True) + draft._record.parent.commit() + record = service.publish(identity, id_=draft.id) + RDMRecord.index.refresh() + return record + + +@pytest.fixture() +def ep_enrolled_community(community_service, running_app): + """Community enrolled in CDS_EP_APPROVAL_COMMUNITIES.""" + community_data = { + "access": { + "visibility": "public", + "members_visibility": "public", + "record_submission_policy": "open", + }, + "slug": "ep-enrolled", + "metadata": {"title": "EP Enrolled Community"}, + } + community = community_service.create(system_identity, community_data) + Community.index.refresh() + current_app.config["CDS_EP_APPROVAL_COMMUNITIES"][str(community.id)] = { + "label": "EP approval", + "referee_group": "cds-ph-ep-publication", + "report_number_pattern": "CERN-EP-{year}-{seq:03d}", + } + return community._record + + +@pytest.fixture() +def ep_non_enrolled_community(community_service, running_app): + """Community NOT enrolled in CDS_EP_APPROVAL_COMMUNITIES.""" + community_data = { + "access": { + "visibility": "public", + "members_visibility": "public", + "record_submission_policy": "open", + }, + "slug": "ep-non-enrolled", + "metadata": {"title": "Non-EP Community"}, + } + community = community_service.create(system_identity, community_data) + Community.index.refresh() + # Deliberately not added to CDS_EP_APPROVAL_COMMUNITIES. + return community._record + + +@pytest.fixture() +def record_in_enrolled_community( + minimal_restricted_record, uploader, ep_enrolled_community, running_app +): + """Published record that belongs to an EP-enrolled community.""" + service = current_rdm_records.records_service + return _publish_record_in_community( + uploader.identity, minimal_restricted_record, ep_enrolled_community, service + ) + + +@pytest.fixture() +def record_in_non_enrolled_community( + minimal_restricted_record, uploader, ep_non_enrolled_community, running_app +): + """Published record that belongs to a community NOT enrolled in EP approval.""" + service = current_rdm_records.records_service + return _publish_record_in_community( + uploader.identity, minimal_restricted_record, ep_non_enrolled_community, service + ) + + +class DummyNotificationBuilder(NotificationBuilder): + """Dummy builder class to do nothing. + + Specific test cases should override their respective builder to test functionality. + """ + + @classmethod + def build(cls, **kwargs): + """Build notification based on type and additional context.""" + return {} diff --git a/site/tests/test_ep_approval.py b/site/tests/test_ep_approval.py new file mode 100644 index 00000000..54700f4d --- /dev/null +++ b/site/tests/test_ep_approval.py @@ -0,0 +1,512 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# CDS-RDM is free software; you can redistribute it and/or modify it under +# the terms of the MIT License; see LICENSE file for more details. + +"""Integration tests for the EP Approval workflow.""" + +from datetime import date + +import pytest +from invenio_access.permissions import system_identity +from invenio_db import db +from invenio_pidstore.models import PersistentIdentifier, PIDStatus +from invenio_rdm_records.proxies import current_rdm_records +from invenio_records_resources.services.errors import PermissionDeniedError +from invenio_requests.proxies import ( + current_request_type_registry, + current_requests_service, +) +from marshmallow import ValidationError + +from cds_rdm.requests.ep_approval import APPRN_PID_TYPE, EPApprovalAcceptAction +from cds_rdm.schemes import is_approval_report_number + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +YEAR = date.today().year +EP_PATTERN = "CERN-EP-{year}-{seq:03d}" +EP_GROUP_NAME = "cds-ph-ep-publication" + + +# --------------------------------------------------------------------------- +# Local fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def ep_referee_group(app, db): + """Create the EP referee role/group in the DB.""" + ds = app.extensions["invenio-accounts"].datastore + role = ds.create_role( + id=EP_GROUP_NAME, + name=EP_GROUP_NAME, + description="EP publication committee", + ) + db.session.commit() + return role + + +@pytest.fixture() +def ep_request_payload(): + """Minimal valid EP approval request payload.""" + return { + "payload": { + "experiment": "ATLAS", + "submitted_by": "Jane Doe", + "role": "Author", + "publication_title": "Search for new physics at the LHC", + } + } + + +@pytest.fixture() +def ep_referee(UserFixture, ep_referee_group, app, db): + """A user that is a member of the EP referee group. + + The RoleNeed is injected directly — going through the full group membership + flow is out of scope for these tests. + """ + from invenio_users_resources.records.api import UserAggregate + + u = UserFixture( + email="ep-referee@inveniosoftware.org", + password="ep-referee", + preferences={ + "visibility": "public", + "email_visibility": "restricted", + "notifications": {"enabled": True}, + }, + active=True, + confirmed=True, + ) + u.create(app, db) + UserAggregate.index.refresh() + + from flask_principal import RoleNeed + + u.identity.provides.add(RoleNeed(EP_GROUP_NAME)) + return u + + +@pytest.fixture() +def community_manager(UserFixture, ep_enrolled_community, app, db): + """A user with community-manager role in the EP-enrolled community. + + The CommunityRoleNeed is injected directly into the identity because + user membership goes through invite→accept (out of scope here). + """ + from invenio_communities.generators import CommunityRoleNeed + from invenio_users_resources.records.api import UserAggregate + + u = UserFixture( + email="ep-manager@inveniosoftware.org", + password="ep-manager", + 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(ep_enrolled_community.id), "manager") + ) + return u + + +# --------------------------------------------------------------------------- +# Scheme validator tests (pure unit) +# --------------------------------------------------------------------------- + + +def test_approval_rn_valid_ep_format(): + """Standard CERN-EP format validates correctly.""" + assert is_approval_report_number(f"CERN-EP-{YEAR}-001") + assert is_approval_report_number(f"CERN-EP-{YEAR}-42") + assert is_approval_report_number(f"CERN-EP-{YEAR}-999") + + +def test_approval_rn_valid_th_format(): + """Theory department format validates correctly.""" + assert is_approval_report_number(f"CERN-TH-{YEAR}-001") + assert is_approval_report_number("CERN-TH-2099-100") + + +def test_approval_rn_invalid_formats(): + """Various invalid formats are rejected.""" + assert not is_approval_report_number(f"cern-ep-{YEAR}-001") # lowercase + assert not is_approval_report_number("CERN-EP-26-001") # 2-digit year + assert not is_approval_report_number(f"CERN-EP-{YEAR}") # missing seq + assert not is_approval_report_number(f"{YEAR}-001") # missing prefix + assert not is_approval_report_number("") # empty + assert not is_approval_report_number(f"CERN-EP-{YEAR}-abc") # non-numeric seq + + +# --------------------------------------------------------------------------- +# Request type registration +# --------------------------------------------------------------------------- + + +def test_ep_approval_request_type_is_registered(app): + """EPApprovalRequest must be discoverable via the request type registry.""" + request_type = current_request_type_registry.lookup("ep-approval", quiet=True) + assert request_type is not None + assert request_type.type_id == "ep-approval" + + +# --------------------------------------------------------------------------- +# Report number generation +# --------------------------------------------------------------------------- + + +def test_generate_report_number_first_of_year(app, db): + """First number minted in a year produces seq=1.""" + action = EPApprovalAcceptAction.__new__(EPApprovalAcceptAction) + assert action._generate_report_number(EP_PATTERN) == f"CERN-EP-{YEAR}-001" + + +def test_generate_report_number_sequential_increment(app, db): + """Each call increments the sequence based on existing PIDs.""" + action = EPApprovalAcceptAction.__new__(EPApprovalAcceptAction) + prefix = f"CERN-EP-{YEAR}-" + + for seq in ("001", "002"): + PersistentIdentifier.create( + pid_type=APPRN_PID_TYPE, + pid_value=f"{prefix}{seq}", + object_type="rec", + object_uuid="00000000-0000-0000-0000-000000000001", + status=PIDStatus.REGISTERED, + ) + db.session.commit() + + assert action._generate_report_number(EP_PATTERN) == f"CERN-EP-{YEAR}-003" + + +def test_generate_report_number_independent_prefix_counters(app, db): + """EP and TH patterns use independent counters — no cross-contamination.""" + action = EPApprovalAcceptAction.__new__(EPApprovalAcceptAction) + + PersistentIdentifier.create( + pid_type=APPRN_PID_TYPE, + pid_value=f"CERN-EP-{YEAR}-001", + object_type="rec", + object_uuid="00000000-0000-0000-0000-000000000002", + status=PIDStatus.REGISTERED, + ) + db.session.commit() + + assert action._generate_report_number(EP_PATTERN) == f"CERN-EP-{YEAR}-002" + assert ( + action._generate_report_number("CERN-TH-{year}-{seq:03d}") + == f"CERN-TH-{YEAR}-001" + ) + + +# --------------------------------------------------------------------------- +# Full workflow: submit → accept (enrolled community) +# --------------------------------------------------------------------------- + + +def test_ep_approval_submit_accept_assigns_report_number( + record_in_enrolled_community, + community_manager, + ep_referee, + ep_request_payload, + app, + db, +): + """Submit → accept: report number is auto-generated and stored on the request.""" + request_type = current_request_type_registry.lookup("ep-approval") + + request = current_requests_service.create( + identity=community_manager.identity, + data=ep_request_payload, + request_type=request_type, + receiver={"group": EP_GROUP_NAME}, + topic={"record": record_in_enrolled_community.id}, + ) + + assert request.data["status"] == "submitted" + + accepted = current_requests_service.execute_action( + identity=ep_referee.identity, + id_=request.id, + action="accept", + data={"payload": {"content": "

.

", "format": "html"}}, + ) + + assert accepted.data["status"] == "accepted" + report_number = accepted.data["payload"]["approved_report_number"] + assert report_number == f"CERN-EP-{YEAR}-001" + + pid = PersistentIdentifier.query.filter_by( + pid_type=APPRN_PID_TYPE, pid_value=report_number + ).one() + assert str(pid.object_uuid) == str(record_in_enrolled_community._record.id) + + +def test_ep_approval_second_request_increments_sequence( + record_in_enrolled_community, + community_manager, + ep_referee, + ep_request_payload, + ep_enrolled_community, + minimal_restricted_record, + app, + db, +): + """A second accepted request gets the next sequential report number.""" + request_type = current_request_type_registry.lookup("ep-approval") + + r1 = current_requests_service.create( + identity=community_manager.identity, + data=ep_request_payload, + request_type=request_type, + receiver={"group": EP_GROUP_NAME}, + topic={"record": record_in_enrolled_community.id}, + ) + current_requests_service.execute_action( + identity=ep_referee.identity, + id_=r1.id, + action="accept", + data={"payload": {"content": "

.

", "format": "html"}}, + ) + + # Second record in the same enrolled community. + from .conftest import _publish_record_in_community + + service = current_rdm_records.records_service + record2 = _publish_record_in_community( + community_manager.identity, minimal_restricted_record, ep_enrolled_community, service + ) + + r2 = current_requests_service.create( + identity=community_manager.identity, + data=ep_request_payload, + request_type=request_type, + receiver={"group": EP_GROUP_NAME}, + topic={"record": record2.id}, + ) + accepted2 = current_requests_service.execute_action( + identity=ep_referee.identity, + id_=r2.id, + action="accept", + data={"payload": {"content": "

.

", "format": "html"}}, + ) + + assert accepted2.data["payload"]["approved_report_number"] == f"CERN-EP-{YEAR}-002" + + +# --------------------------------------------------------------------------- +# Full workflow: submit → decline (enrolled community) +# --------------------------------------------------------------------------- + + +def test_ep_approval_submit_decline( + record_in_enrolled_community, + community_manager, + ep_referee, + ep_request_payload, + app, + db, +): + """Submit → decline: status is declined and no report number is issued.""" + request_type = current_request_type_registry.lookup("ep-approval") + + request = current_requests_service.create( + identity=community_manager.identity, + data=ep_request_payload, + request_type=request_type, + receiver={"group": EP_GROUP_NAME}, + topic={"record": record_in_enrolled_community.id}, + ) + assert request.data["status"] == "submitted" + + declined = current_requests_service.execute_action( + identity=ep_referee.identity, + id_=request.id, + action="decline", + data={"payload": {"content": "

.

", "format": "html"}}, + ) + + assert declined.data["status"] == "declined" + assert declined.data["payload"].get("approved_report_number") is None + assert PersistentIdentifier.query.filter_by(pid_type=APPRN_PID_TYPE).count() == 0 + + +# --------------------------------------------------------------------------- +# Community enrollment validation +# --------------------------------------------------------------------------- + + +def test_ep_approval_submit_raises_for_record_without_community( + minimal_restricted_record, uploader, ep_referee_group, app, db +): + """Submit raises ValidationError when the record belongs to no community.""" + service = current_rdm_records.records_service + draft = service.create(uploader.identity, minimal_restricted_record) + record = service.publish(uploader.identity, id_=draft.id) + + request_type = current_request_type_registry.lookup("ep-approval") + with pytest.raises(ValidationError, match="not part of any community"): + current_requests_service.create( + identity=system_identity, + data={ + "payload": { + "experiment": "OTHER", + "submitted_by": "Someone", + "role": "Author", + "publication_title": "A paper", + } + }, + request_type=request_type, + receiver={"group": EP_GROUP_NAME}, + topic={"record": record.id}, + ) + + +def test_ep_approval_submit_raises_for_non_enrolled_community( + record_in_non_enrolled_community, uploader, ep_referee_group, app, db +): + """Submit raises ValidationError when the record's community is not enrolled.""" + request_type = current_request_type_registry.lookup("ep-approval") + with pytest.raises( + ValidationError, match="not enrolled in the EP approval workflow" + ): + current_requests_service.create( + identity=system_identity, + data={ + "payload": { + "experiment": "OTHER", + "submitted_by": "Someone", + "role": "Author", + "publication_title": "A paper", + } + }, + request_type=request_type, + receiver={"group": EP_GROUP_NAME}, + topic={"record": record_in_non_enrolled_community.id}, + ) + + +# --------------------------------------------------------------------------- +# CommitteeApprovalComponent — apprn identifier derived from parent ep_approval +# --------------------------------------------------------------------------- + + +def test_apprn_identifier_derived_from_parent( + minimal_restricted_record, uploader, app, db +): + """CommitteeApprovalComponent derives apprn from parent ep_approval. + + EP approval state lives on the parent record (not the version CF). + The apprn identifier is only added to records where the parent carries + ``source_internal_version`` — that marks the public approved copy. + The internal draft and all its versions do NOT carry the apprn identifier. + """ + from invenio_pidstore.models import PersistentIdentifier + from invenio_rdm_records.records.api import RDMRecord + + service = current_rdm_records.records_service + + draft = service.create(uploader.identity, minimal_restricted_record) + record = service.publish(uploader.identity, id_=draft.id) + + report_number = f"CERN-EP-{YEAR}-001" + + # Simulate accept: write ep_approval into permission_flags (no source_internal_version). + pid_obj = PersistentIdentifier.get("recid", record.id) + rec_obj = RDMRecord.get_record(pid_obj.object_uuid) + pf = rec_obj.parent.get("permission_flags") or {} + pf["ep_approval"] = { + "reportnumber": report_number, + "approved_internal_version": record.id, + } + rec_obj.parent["permission_flags"] = pf + rec_obj.parent.commit() + db.session.commit() + + # Update and re-publish: apprn should NOT be added (no source_internal_version). + sys_draft = service.edit(system_identity, id_=record.id) + record = service.publish(system_identity, id_=sys_draft.id) + + apprn_ids = [ + i for i in record.data.get("metadata", {}).get("identifiers", []) + if i.get("scheme") == "apprn" + ] + assert len(apprn_ids) == 0, "Internal draft must NOT carry the apprn identifier" + + # Simulate public record: set source_internal_version in permission_flags. + pf = rec_obj.parent.get("permission_flags") or {} + pf["ep_approval"] = { + "reportnumber": report_number, + "source_internal_version": record.id, + } + rec_obj.parent["permission_flags"] = pf + rec_obj.parent.commit() + db.session.commit() + + # Update draft again: apprn SHOULD now appear (source_internal_version present). + sys_draft2 = service.edit(system_identity, id_=record.id) + record2 = service.publish(system_identity, id_=sys_draft2.id) + + apprn_ids = [ + i for i in record2.data.get("metadata", {}).get("identifiers", []) + if i.get("scheme") == "apprn" + ] + assert len(apprn_ids) == 1 and apprn_ids[0]["identifier"] == report_number + + +# --------------------------------------------------------------------------- +# Permissions: who can submit an EP approval request +# --------------------------------------------------------------------------- + + +def test_ep_approval_submit_permissions( + record_in_enrolled_community, + uploader, + ep_referee_group, + ep_request_payload, + ep_enrolled_community, + app, + db, +): + """Only community managers/owners of enrolled communities can submit.""" + request_type = current_request_type_registry.lookup("ep-approval") + + # Plain uploader (reader, not manager) — must be denied. + with pytest.raises(PermissionDeniedError): + current_requests_service.create( + identity=uploader.identity, + data=ep_request_payload, + request_type=request_type, + receiver={"group": EP_GROUP_NAME}, + topic={"record": record_in_enrolled_community.id}, + ) + + # Simulate the uploader being a community manager by injecting the need + # directly into the identity. members.add is groups-only; user membership + # goes through invite→accept which is out of scope for this permission test. + from invenio_communities.generators import CommunityRoleNeed + + community_id = str(ep_enrolled_community.id) + uploader.identity.provides.add(CommunityRoleNeed(community_id, "manager")) + + # Now as manager the uploader must be allowed. + request = current_requests_service.create( + identity=uploader.identity, + data=ep_request_payload, + request_type=request_type, + receiver={"group": EP_GROUP_NAME}, + topic={"record": record_in_enrolled_community.id}, + ) + assert request.data["status"] == "submitted" diff --git a/templates/semantic-ui/invenio_app_rdm/records/details/side_bar/versions.html b/templates/semantic-ui/invenio_app_rdm/records/details/side_bar/versions.html new file mode 100644 index 00000000..d5bab760 --- /dev/null +++ b/templates/semantic-ui/invenio_app_rdm/records/details/side_bar/versions.html @@ -0,0 +1,29 @@ +{# + Copyright (C) 2020 CERN. + Copyright (C) 2020 Northwestern University. + Copyright (C) 2021 TU Wien. + Copyright (C) 2022 New York University. + Copyright (C) 2025 CERN. + + Invenio RDM Records is free software; you can redistribute it and/or modify + it under the terms of the MIT License; see LICENSE file for more details. +-#} + + diff --git a/templates/semantic-ui/invenio_notifications/ep-approval-request.submit.jinja b/templates/semantic-ui/invenio_notifications/ep-approval-request.submit.jinja new file mode 100644 index 00000000..d018f756 --- /dev/null +++ b/templates/semantic-ui/invenio_notifications/ep-approval-request.submit.jinja @@ -0,0 +1,104 @@ +{% set ep_request = notification.context.request %} +{% set record = ep_request.topic %} +{% set request_id = ep_request.id %} +{% set record_title = record.metadata.title %} +{% set report_number = ep_request.title | replace('EP approval for "', '') | replace('"', '') %} +{% if recipient.data.profile is defined and recipient.data.profile.full_name %} + {% set recipient_full_name = recipient.data.profile.full_name %} +{% else %} + {% set recipient_full_name = recipient.data.name %} +{% endif %} +{% set help_url = config.CDS_SERVICE_ELEMENT_URL %} +{% 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] {{ _("Request for EP approval of '{record_title}'").format(record_title=record_title) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + + + + + + + + + + +
{{ _("Dear {recipient}").format(recipient=recipient_full_name) }}, +
+ {{ _("A new EP approval request has been submitted for the following document:") }} +
+ {{ _("Title:") }} {{ record_title }}
+ {{ _("Record:") }} {{ record_link }} +
{{ _("Please review the document and register your decision at the link below.") }}
{{ _("Review the approval request") }}
+ {{ _("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 {recipient}").format(recipient=recipient_full_name) }}, + +{{ _("A new EP approval request has been submitted for the following document:") }} + +{{ _("Title:") }} {{ record_title }} +{{ _("Record:") }} {{ record_link }} + +{{ _("Please review the document and register your decision at the link below.") }} + +{{ _("Review the approval request:") }} {{ 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 {recipient}").format(recipient=recipient_full_name) }}, + +{{ _("A new EP approval request has been submitted for the following document:") }} + +**{{ _("Title:") }}** {{ record_title }} +**{{ _("Record:") }}** {{ record_link }} + +{{ _("Please review the document and register your decision at the link below.") }} + +{{ _("Review the approval request:") }} {{ 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_requests/ep-approval/index.html b/templates/semantic-ui/invenio_requests/ep-approval/index.html new file mode 100644 index 00000000..67f568a2 --- /dev/null +++ b/templates/semantic-ui/invenio_requests/ep-approval/index.html @@ -0,0 +1,120 @@ +{# -*- coding: utf-8 -*- + + This file is part of Invenio. + Copyright (C) 2025 CERN. + + Invenio is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +#} + +{# + Renders the EP Approval request detail page. + Identical layout to community-submission, with the EP payload fields + displayed below the standard request header. +#} + +{% extends "invenio_requests/community-submission/index.html" %} + +{% set active_dashboard_menu_item = 'requests' %} + +{%- block request_header %} + {{ super() }} + + {# ── EP Approval payload summary ── #} + {% set p = invenio_request.get("payload", {}) %} + {% if p %} +
+

{{ _("Submission details") }}

+ + {% if p.get("approved_report_number") %} +
+ {{ _("Approved report number:") }} {{ p.approved_report_number }} +
+ {% endif %} + + + + + {# ── Submission info ── #} + + + + {% if p.get("experiment") %} + + + + + {% endif %} + {% if p.get("submitted_by") %} + + + + + {% endif %} + {% if p.get("role") %} + + + + + {% endif %} + {% if p.get("publication_title") %} + + + + + {% endif %} + {% if p.get("latest_version_url") %} + + + + + {% endif %} + {% if p.get("additional_communication") %} + + + + + {% endif %} + + {# ── Approval checklist ── #} + + + + + + + + + + + + + + + + {% if not p.get("paper_signed") %} + + + + + {% endif %} + + +
{{ _("Submission info") }}
{{ _("Experiment") }}{{ p.experiment }}
{{ _("Submitted by") }}{{ p.submitted_by }}
{{ _("Role") }}{{ p.role }}
{{ _("Publication title") }}{{ p.publication_title }}
{{ _("Latest version at") }} + + {{ p.latest_version_url }} + +
{{ _("Additional communication") }}{{ p.additional_communication }}
{{ _("Approval checklist") }}
{{ _("Rapid approval") }}{{ _("Yes") if p.get("rapid_approval") else _("No") }}
{{ _("CB review completed") }} + {{ _("Yes") if p.get("cb_review_completed") else _("No") }} + {% if p.get("cb_review_completed") and p.get("cb_process_type") %} + ({{ p.cb_process_type }}) + {% endif %} +
{{ _("Paper signed by whole collaboration") }} + {{ _("Yes") if p.get("paper_signed") else _("No") }} + {% if not p.get("paper_signed") and p.get("num_non_signers") %} +
{{ p.num_non_signers }} {{ _("non-signer(s)") }} + {% endif %} +
{{ _("Controversy") }}{{ _("Yes") if p.get("controversy") else _("No") }}
+
+ {% endif %} +{%- endblock request_header %} diff --git a/uv.lock b/uv.lock index e58edce8..49cf7902 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "cds-rdm", "cds-rdm-app", ] +overrides = [{ name = "invenio-requests", git = "https://github.com/inveniosoftware/invenio-requests.git?rev=feature%2Fep-approval" }] [[package]] name = "aiobotocore" @@ -3102,7 +3103,7 @@ wheels = [ [[package]] name = "invenio-app-rdm" -version = "14.0.0rc1" +version = "15.0.0b0.dev0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cairosvg", version = "2.8.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -3144,9 +3145,9 @@ dependencies = [ { name = "invenio-theme" }, { name = "invenio-userprofiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/74/01e5cc7ff97d730d946631477d90e680e00fa10944e7fa809625fce8170f/invenio_app_rdm-14.0.0rc1.tar.gz", hash = "sha256:34956743e4f1189dfd391ce9d83ffa62a5a9e29d3e6bfa02f1a9c6704a94f90c", size = 766388, upload-time = "2026-06-08T06:33:30.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/6f/dd253a1d88a72e2396192bd4afc726e04d8158b5432fc0a66490586e17ee/invenio_app_rdm-15.0.0b0.dev0.tar.gz", hash = "sha256:0040196154d5c2bd6d50b5a18cee2d495f3c1983bb24f3d8c54e49be4e3bc6fe", size = 766253, upload-time = "2026-06-08T09:16:34.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/48/fe5fdec69ae6a69f224be8e20beaf3b2e6bcea174f2192d6b8d9043f008c/invenio_app_rdm-14.0.0rc1-py2.py3-none-any.whl", hash = "sha256:8c955625ad522051f353fa44444fa7c1dad5100de2c291b91e20f881510ece6f", size = 1208884, upload-time = "2026-06-08T06:33:28.563Z" }, + { url = "https://files.pythonhosted.org/packages/61/fc/e2257ade3751e467a667c9a2f0f1fe2ef6a248be2f3a3fdd47d9362ec8bd/invenio_app_rdm-15.0.0b0.dev0-py2.py3-none-any.whl", hash = "sha256:edd783ea35694d501f4b23c5dc45ba6642a74598563a223a7360d6500cc4889b", size = 1208941, upload-time = "2026-06-08T09:16:31.874Z" }, ] [package.optional-dependencies] @@ -3816,16 +3817,12 @@ wheels = [ [[package]] name = "invenio-requests" version = "14.0.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/inveniosoftware/invenio-requests.git?rev=feature%2Fep-approval#0279a1ea2129e831b6a57d904833414eef692062" } dependencies = [ { name = "invenio-records-resources" }, { name = "invenio-theme" }, { name = "invenio-users-resources" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/10/e50636b67f49c253cfc472ec6cbcf373d3ea429cf022a0ac5f142a36defb/invenio_requests-14.0.0.tar.gz", hash = "sha256:e74ac7fdf4463ce79aa5d1d9f99f42b433b5ac20a8e2527125b7a5259284f19c", size = 179998, upload-time = "2026-06-05T13:14:59.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/24/e8c6ff77622081db1ed7a1b630674b8204472d914db378daffe163f95a3a/invenio_requests-14.0.0-py2.py3-none-any.whl", hash = "sha256:401d7effe56bcf200d0368225174fcb611a4516afd79c63dee22216bbd62a382", size = 390547, upload-time = "2026-06-05T13:14:58.519Z" }, -] [[package]] name = "invenio-rest" From ea9847bc18677279b302e4b8f22a2f0cde0eb288 Mon Sep 17 00:00:00 2001 From: zzacharo Date: Mon, 18 May 2026 11:42:49 +0200 Subject: [PATCH 2/4] feat(ep-approval): EP approval workflow v3 frontend Add React components and overrides for the EP approval workflow UI. Version badges read from parent.permission_flags["ep_approval"] (single source of truth shared across all versions) rather than a per-version CF map. - EPApproval: manage section with submit/re-submit, pending/declined state, create-public-record button; new-version interceptor while request pending - EPApprovalSubmitModal: submission form with experiment/role/paper fields - CreatePublicRecordModal: confirmation modal for creating the public record - EPApprovalRequestDetails: request details panel for referees - RecordVersionItem: badges from ep_approval dict (approved_internal_version, source_public_version, approved_public_version keys) - overridableRegistry: wire up RecordVersionItem and EPApproval overrides --- .../record_details/CreatePublicRecordModal.js | 227 +++++++++++++ .../components/record_details/EPApproval.js | 302 ++++++++++++++++++ .../record_details/EPApprovalSubmitModal.js | 233 ++++++++++++++ .../record_details/RecordVersionItem.js | 86 ++++- .../requests/EPApprovalRequestDetails.js | 156 +++++++++ .../overridableRegistry/mapping.js | 12 +- site/cds_rdm/requests/ep_approval_state.py | 23 +- 7 files changed, 1025 insertions(+), 14 deletions(-) create mode 100644 assets/js/components/record_details/CreatePublicRecordModal.js create mode 100644 assets/js/components/record_details/EPApproval.js create mode 100644 assets/js/components/record_details/EPApprovalSubmitModal.js create mode 100644 assets/js/components/requests/EPApprovalRequestDetails.js diff --git a/assets/js/components/record_details/CreatePublicRecordModal.js b/assets/js/components/record_details/CreatePublicRecordModal.js new file mode 100644 index 00000000..8eeadccd --- /dev/null +++ b/assets/js/components/record_details/CreatePublicRecordModal.js @@ -0,0 +1,227 @@ +// This file is part of CDS RDM +// Copyright (C) 2025 CERN. +// +// CDS RDM is free software; you can redistribute it and/or modify it +// under the terms of the GPL-2.0 License; see LICENSE file for more details. + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Button, Checkbox, Header, Icon, Message, Modal } from "semantic-ui-react"; +import { http } from "react-invenio-forms"; +import { i18next } from "@translations/invenio_rdm_records/i18next"; + +export class CreatePublicRecordModal extends Component { + constructor(props) { + super(props); + this.state = { + submitting: false, + error: null, + publicRecord: null, + alreadyExists: false, + agreedToTerms: false, + agreedToCommunity: false, + }; + } + + handleCreate = async () => { + const { record, onSuccess } = this.props; + this.setState({ submitting: true, error: null, alreadyExists: false }); + try { + const response = await http.post( + `/api/records/${record.id}/ep-approval/publish-public`, + {}, + { headers: { "Content-Type": "application/json" } } + ); + this.setState({ publicRecord: response.data }); + onSuccess(response.data); + } catch (err) { + if (err?.response?.status === 409 && err?.response?.data?.id) { + // A public record already exists — show a warning and surface the link. + const existingRecord = err.response.data; + this.setState({ publicRecord: existingRecord, alreadyExists: true }); + onSuccess(existingRecord); + } else { + const msg = + err?.response?.data?.message || + i18next.t("An error occurred. Please try again."); + this.setState({ error: msg }); + } + } finally { + this.setState({ submitting: false }); + } + }; + + handleClose = () => { + const { onClose } = this.props; + this.setState({ + error: null, + publicRecord: null, + agreedToTerms: false, + agreedToCommunity: false, + }); + onClose(); + }; + + render() { + const { open, record } = this.props; + const { submitting, error, publicRecord, alreadyExists, agreedToTerms, agreedToCommunity } = + this.state; + + const versionIndex = record?.versions?.index; + const canPublish = agreedToTerms && agreedToCommunity; + + const epApprovalEl = document.getElementById("recordManagement"); + const epApproval = epApprovalEl + ? JSON.parse(epApprovalEl.dataset.epApproval || "null") + : null; + const communityId = epApproval?.cern_scientific_community_id; + + return ( + +
+ + {error && } + + {publicRecord && alreadyExists ? ( + + + {i18next.t("A public record already exists")} + + + {i18next.t( + "A public record for this approval has already been created." + )}{" "} + + {i18next.t("View public record")} + + + + + ) : publicRecord ? ( + + {i18next.t("Public record created")} + + {i18next.t("The public record has been created successfully.")}{" "} + + {i18next.t("View public record")} + + + + + ) : ( + <> + +

+ {" "} + {i18next.t( + "The metadata and files of Version v{{v}} will be copied to the new public record. Once published, the files can no longer be changed.", + { v: versionIndex ?? "?" } + )} +

+
+ + + this.setState({ agreedToTerms: checked }) + } + label={ + + } + /> +
+ + this.setState({ agreedToCommunity: checked }) + } + label={ + + } + /> +
+ + )} +
+ + + {!publicRecord && ( + + this.setState({ submitModalOpen: false })} + onSuccess={this.handleSubmitSuccess} + /> + + )} + {/* New-version warning modal — shown when user clicks "New version" while a request is pending */} + this.setState({ newVersionModalOpen: false })} + size="small" + > + + + {i18next.t("EP approval request pending")} + + +

+ {i18next.t( + "An EP approval request is currently pending for this record. " + + "Creating a new version is not recommended while the request is open. " + + "If you need to create a new version, please cancel the approval request first." + )} +

+
+ + + +
+ + ); + } +} diff --git a/assets/js/components/record_details/EPApprovalSubmitModal.js b/assets/js/components/record_details/EPApprovalSubmitModal.js new file mode 100644 index 00000000..14d969b2 --- /dev/null +++ b/assets/js/components/record_details/EPApprovalSubmitModal.js @@ -0,0 +1,233 @@ +// This file is part of CDS RDM +// Copyright (C) 2025 CERN. +// +// CDS RDM is free software; you can redistribute it and/or modify it +// under the terms of the GPL-2.0 License; see LICENSE file for more details. + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Button, Checkbox, Form, Header, Message, Modal } from "semantic-ui-react"; +import { http } from "react-invenio-forms"; +import { i18next } from "@translations/invenio_app_rdm/i18next"; + +const buildInitialForm = (record) => ({ + experiment: "", + submitted_by: "", + role: "", + publication_title: record?.metadata?.title || "", + latest_version_url: record?.links?.self_html || "", + rapid_approval: false, + cb_review_completed: false, + cb_process_type: "", + paper_signed: true, + num_non_signers: 0, + controversy: false, + additional_communication: "", +}); + +export class EPApprovalSubmitModal extends Component { + constructor(props) { + super(props); + this.state = { + form: buildInitialForm(props.record), + submitting: false, + error: null, + }; + } + + handleChange = (e, { name, value, checked, type }) => { + this.setState((prev) => ({ + form: { + ...prev.form, + [name]: type === "checkbox" ? checked : value, + }, + })); + }; + + handleSubmit = async () => { + const { record, receiverGroup, onSuccess } = this.props; + const { form } = this.state; + + this.setState({ submitting: true, error: null }); + try { + const payload = { + title: `Request approval for ${record["title"]}`, + receiver_group: receiverGroup, + payload: { ...form }, + }; + const response = await http.post( + `/api/records/${record.id}/ep-approval`, + payload, + { headers: { "Content-Type": "application/json" } } + ); + onSuccess(response.data); + } catch (err) { + const msg = + err?.response?.data?.message || + i18next.t("An error occurred. Please try again."); + this.setState({ error: msg }); + } finally { + this.setState({ submitting: false }); + } + }; + + handleClose = () => { + const { onClose, record } = this.props; + this.setState({ form: buildInitialForm(record), error: null }); + onClose(); + }; + + render() { + const { open } = this.props; + const { form, submitting, error } = this.state; + + return ( + +
+ + {error && } +
+ + + + + + + + + + + + {form.cb_review_completed && ( + + + + + + )} + + + + {!form.paper_signed && ( + + )} + {!form.paper_signed && ( + + + + )} + + +
+ + + + )} + + {canResubmit && ( + this.setState({ submitModalOpen: false })} + onSuccess={this.handleSubmitSuccess} + /> )} - - )} + - {/* Pending — link to the open request */} - {isPending && ( - - - {i18next.t("Document requested for approval")} - {open_request.links?.self_html && ( - <> - {" "} - - {i18next.t("View request")} - - - + {/* Step 2 — EP Board review */} + + +
+ + + {i18next.t("EP Board review")} + + + {step2Completed ? ( + requestLink ? ( + + {i18next.t("Approved as {{rn}}", { + rn: approved_report_number, + })} + + + ) : ( + i18next.t("Approved as {{rn}}", { rn: approved_report_number }) + ) + ) : ( + i18next.t("The EP secretariat will review the submission.") + )} + +
+ {step2Active && requestLink && ( + )} -
-
- )} + + - {/* Declined — warning message + link to request + allow re-submission */} - {isDeclined && ( - - - {i18next.t("The approval request was declined.")} - {open_request.links?.self_html && ( - <> - {" "} - - {i18next.t("View request")} - - - + {/* Step 3 — Create final public version */} + + +
+ + + {i18next.t("Create final public version")} + + + {step3Completed ? ( + resolvedPublicRecordUrl ? ( + + {i18next.t("View public record")} + + + ) : ( + i18next.t("Public record created.") + ) + ) : ( + i18next.t("Publish the EP-approved record publicly on CDS.") + )} + +
+ {step3Active && ( + )} -
-
- )} + + {step3Active && ( + this.setState({ createPublicModalOpen: false })} + onSuccess={this.handlePublicRecordCreated} + /> + )} + + - {/* Submit / re-submit button */} - {canResubmit && ( - <> - - this.setState({ submitModalOpen: false })} - onSuccess={this.handleSubmitSuccess} - /> - - )} {/* New-version warning modal — shown when user clicks "New version" while a request is pending */} { const ea = epApproval?.ep_approval || {}; const approvedReportNumber = ea.reportnumber; // approved_internal_version: recid of the version that was submitted and approved. - const isApprovedVersion = !!approvedReportNumber && ea.approved_internal_version === item.id; + const isApprovedVersion = + !!approvedReportNumber && ea.approved_internal_version === item.id; // source_public_version: recid of the internal version used to create the public record. const publicRecordId = ea.approved_public_version; - const isPublicSourceVersion = !!publicRecordId && ea.source_public_version === item.id; + + const isPublicSourceVersion = + !!publicRecordId && ea.source_public_version === item.id; // If approved and public-source are the same version, show only the public record link. const sameVersion = isApprovedVersion && isPublicSourceVersion; - // --- Public record side --- - // is_public_approved_record is set when viewing the public copy. - // draft_record_id links back to the internal draft it was created from. - const isPublicRecord = epApproval?.is_public_approved_record; - const draftRecordId = epApproval?.draft_record_id; - return ( @@ -79,18 +76,6 @@ export const RecordVersionItemContent = ({ item, activeVersion, doi }) => { )} - {/* Public record: show "Reviewed version" link only to users who can - edit the record (record owner, community managers/owners). */} - {isPublicRecord && epApproval?.can_view_reviewed_version && item.version === "v1" && draftRecordId && ( - <> - {" "} - - - {i18next.t("Reviewed version")} - - - )} - {doi && ( Date: Fri, 12 Jun 2026 15:22:42 +0200 Subject: [PATCH 4/4] WIP: ep workflow changes --- .../record_details/CreatePublicRecordModal.js | 35 ++- .../components/record_details/EPApproval.js | 267 +++++++++--------- .../record_details/EPApprovalSubmitModal.js | 13 +- .../record_details/RecordVersionItem.js | 11 +- .../less/cds-rdm/ep_approval/ep-workflow.less | 23 ++ assets/less/cds-rdm/globals/site.overrides | 6 +- site/cds_rdm/components.py | 42 +-- site/cds_rdm/notifications/__init__.py | 2 +- site/cds_rdm/notifications/ep_approval.py | 6 +- site/cds_rdm/notifications/generators.py | 2 +- site/cds_rdm/requests/__init__.py | 2 +- site/cds_rdm/requests/ep_approval.py | 41 +-- site/cds_rdm/requests/ep_approval_state.py | 2 +- site/cds_rdm/requests/views.py | 14 +- 14 files changed, 264 insertions(+), 202 deletions(-) create mode 100644 assets/less/cds-rdm/ep_approval/ep-workflow.less diff --git a/assets/js/components/record_details/CreatePublicRecordModal.js b/assets/js/components/record_details/CreatePublicRecordModal.js index 8eeadccd..d12293c3 100644 --- a/assets/js/components/record_details/CreatePublicRecordModal.js +++ b/assets/js/components/record_details/CreatePublicRecordModal.js @@ -6,7 +6,14 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; -import { Button, Checkbox, Header, Icon, Message, Modal } from "semantic-ui-react"; +import { + Button, + Checkbox, + Header, + Icon, + Message, + Modal, +} from "semantic-ui-react"; import { http } from "react-invenio-forms"; import { i18next } from "@translations/invenio_rdm_records/i18next"; @@ -64,8 +71,14 @@ export class CreatePublicRecordModal extends Component { render() { const { open, record } = this.props; - const { submitting, error, publicRecord, alreadyExists, agreedToTerms, agreedToCommunity } = - this.state; + const { + submitting, + error, + publicRecord, + alreadyExists, + agreedToTerms, + agreedToCommunity, + } = this.state; const versionIndex = record?.versions?.index; const canPublish = agreedToTerms && agreedToCommunity; @@ -80,7 +93,7 @@ export class CreatePublicRecordModal extends Component {
@@ -107,7 +120,9 @@ export class CreatePublicRecordModal extends Component { ) : publicRecord ? ( - {i18next.t("Public record created")} + + {i18next.t("Public record created")} + {i18next.t("The public record has been created successfully.")}{" "} - {i18next.t("By publishing, you agree that the record complies with")}{" "} + {i18next.t( + "By publishing, you agree that the record complies with" + )}{" "} - {!publicRecord && ( diff --git a/assets/js/components/record_details/EPApproval.js b/assets/js/components/record_details/EPApproval.js index c82230be..4bca4e95 100644 --- a/assets/js/components/record_details/EPApproval.js +++ b/assets/js/components/record_details/EPApproval.js @@ -18,6 +18,7 @@ import { import { i18next } from "@translations/invenio_app_rdm/i18next"; import { EPApprovalSubmitModal } from "./EPApprovalSubmitModal"; import { CreatePublicRecordModal } from "./CreatePublicRecordModal"; +import PropTypes from "prop-types"; export class EPApprovalManageSection extends Component { constructor(props) { @@ -48,7 +49,9 @@ export class EPApprovalManageSection extends Component { componentDidUpdate(_prevProps, prevState) { const { epApproval } = this.state; const prevEpApproval = prevState.epApproval; - if (epApproval?.open_request?.status !== prevEpApproval?.open_request?.status) { + if ( + epApproval?.open_request?.status !== prevEpApproval?.open_request?.status + ) { this._detachNewVersionInterceptor(); this._attachNewVersionInterceptor(); } @@ -218,59 +221,61 @@ export class EPApprovalManageSection extends Component {
{i18next.t("Approval request workflow")}
- + {/* Step 1 — Request for approval */} - -
- - - {i18next.t("Request for approval")} - - - {isDeclined ? ( - <> - {i18next.t("Request was declined.")} - {requestLink && ( - <> - {" "} - - {i18next.t("View")} - - - - )} - - ) : step1Completed ? ( - i18next.t("Request submitted.") - ) : ( - i18next.t("Submit the document for EP committee review.") - )} - + +
+
+ {i18next.t("Request for approval")} + + {isDeclined ? ( + <> + {i18next.t("Request was declined.")} + {requestLink && ( + <> + {" "} + + {i18next.t("View")} + + + + )} + + ) : step1Completed ? ( + i18next.t("Request submitted.") + ) : ( + i18next.t("Submit the document for EP committee review.") + )} + +
+ {canResubmit && ( + + )}
- {canResubmit && ( - - )}
+ {canResubmit && ( - -
- - - {i18next.t("EP Board review")} - - - {step2Completed ? ( - requestLink ? ( - - {i18next.t("Approved as {{rn}}", { + +
+
+ {i18next.t("EP Board review")} + + {step2Completed ? ( + requestLink ? ( + + {i18next.t("Approved as {{rn}}", { + rn: approved_report_number, + })} + + + ) : ( + i18next.t("Approved as {{rn}}", { rn: approved_report_number, - })} - - + }) + ) ) : ( - i18next.t("Approved as {{rn}}", { rn: approved_report_number }) - ) - ) : ( - i18next.t("The EP secretariat will review the submission.") - )} - + i18next.t( + "The EP secretariat will review the submission." + ) + )} + +
+ {step2Active && requestLink && ( + + )}
- {step2Active && requestLink && ( - - )} @@ -343,51 +340,45 @@ export class EPApprovalManageSection extends Component { active={step3Active} disabled={step3Disabled} > - -
- - - {i18next.t("Create final public version")} - - - {step3Completed ? ( - resolvedPublicRecordUrl ? ( - - {i18next.t("View public record")} - - + +
+
+ + {i18next.t("Create final public version")} + + + {step3Completed ? ( + resolvedPublicRecordUrl ? ( + + {i18next.t("View public record")} + + + ) : ( + i18next.t("Public record created.") + ) ) : ( - i18next.t("Public record created.") - ) - ) : ( - i18next.t("Publish the EP-approved record publicly on CDS.") - )} - + i18next.t( + "Publish the EP-approved record publicly on CDS." + ) + )} + +
+ {step3Active && ( + + )}
- {step3Active && ( - - )}
{step3Active && ( { const sameVersion = isApprovedVersion && isPublicSourceVersion; return ( - + {activeVersion ? ( @@ -79,7 +82,9 @@ export const RecordVersionItemContent = ({ item, activeVersion, doi }) => { {doi && ( {doi} diff --git a/assets/less/cds-rdm/ep_approval/ep-workflow.less b/assets/less/cds-rdm/ep_approval/ep-workflow.less new file mode 100644 index 00000000..5caeb911 --- /dev/null +++ b/assets/less/cds-rdm/ep_approval/ep-workflow.less @@ -0,0 +1,23 @@ +.ep-step-group .step .content { + flex: 1; +} + +.ep-step-group .step::after { + display: none !important; +} + +.ep-step-group .step::before { + width: 30px; +} + +.ep-action-step { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + column-gap: 5px; +} + +.ep-action-step .description { + font-size: 1em !important; +} diff --git a/assets/less/cds-rdm/globals/site.overrides b/assets/less/cds-rdm/globals/site.overrides index ab529674..9a05372b 100644 --- a/assets/less/cds-rdm/globals/site.overrides +++ b/assets/less/cds-rdm/globals/site.overrides @@ -1,6 +1,7 @@ @import "../mixins.less"; @import "./hero.less"; @import "../administration/harvester-reports.less"; +@import "../ep_approval/ep-workflow.less"; .rdm-logo { padding: 1em 1em 1em 0; @@ -11,7 +12,7 @@ z-index: 1; background-color: transparent !important; - & > * { + &>* { z-index: 3; position: relative; } @@ -111,6 +112,7 @@ transform: rotate(-180deg); font-size: 1em; padding: 4px; + .env-icon { transform: rotate(90deg) !important; writing-mode: horizontal-tb; @@ -128,4 +130,4 @@ &.sandbox { background-color: goldenrod; } -} \ No newline at end of file +} diff --git a/site/cds_rdm/components.py b/site/cds_rdm/components.py index f6be0b38..32302920 100644 --- a/site/cds_rdm/components.py +++ b/site/cds_rdm/components.py @@ -19,7 +19,6 @@ from invenio_i18n import lazy_gettext as _ from invenio_pidstore.errors import PIDAlreadyExists from invenio_pidstore.models import PersistentIdentifier, PIDStatus -from invenio_rdm_records.records.api import RDMRecord from invenio_rdm_records.services.errors import ValidationErrorWithMessageAsList from invenio_records_resources.services.uow import TaskOp from marshmallow import ValidationError @@ -179,50 +178,47 @@ def _validate_identifier_changes(self, identity, data, record): if self._is_privileged(identity): return - incoming = (data.get("metadata") or {}).get("identifiers", []) - stored = (record.get("metadata") or {}).get("identifiers", []) + incoming_identifiers = (data.get("metadata") or {}).get("identifiers", []) + stored_identifiers = (record.get("metadata") or {}).get("identifiers", []) # Index stored apprn values for comparison. stored_apprn = { - i["identifier"] for i in stored if i.get("scheme") == "apprn" + i["identifier"] for i in stored_identifiers if i.get("scheme") == "apprn" } incoming_apprn = { - i["identifier"] for i in incoming if i.get("scheme") == "apprn" + i["identifier"] for i in incoming_identifiers if i.get("scheme") == "apprn" } if incoming_apprn != stored_apprn: + error_msg = _( + "The 'apprn' identifier is system-managed and cannot be " + "added, modified, or removed manually." + ) + errors = [ { "field": f"metadata.identifiers.{index}.identifier", - "messages": [ - _( - "The 'apprn' identifier is system-managed and cannot be " - "added, modified, or removed manually." - ) - ], + "messages": [error_msg], } - for index, i in enumerate(incoming) + for index, i in enumerate(incoming_identifiers) if i.get("scheme") == "apprn" ] + if not errors: # apprn was removed — point to the field without a specific index errors = [ { "field": "metadata.identifiers", - "messages": [ - _( - "The 'apprn' identifier is system-managed and cannot be " - "added, modified, or removed manually." - ) - ], + "messages": [error_msg], } ] + raise ValidationErrorWithMessageAsList(errors) # Block cdsrn values that look like EP report numbers. ep_prefixes = self._ep_approval_prefixes() if ep_prefixes: errors = [] - for index, ident in enumerate(incoming): + for index, ident in enumerate(incoming_identifiers): if ident.get("scheme") == "cdsrn": val = ident.get("identifier", "") if any(val.startswith(p) for p in ep_prefixes): @@ -248,7 +244,9 @@ def _regenerate_apprn_identifier(self, record, data): on the parent — that key is set exclusively on the public approved record's parent by the ``publish_public_record`` view. """ - ea = ((record.parent.get("permission_flags") if record.parent else None) or {}).get("ep_approval") or {} + ea = ( + (record.parent.get("permission_flags") if record.parent else None) or {} + ).get("ep_approval") or {} reportnumber = ea.get("reportnumber") source_internal = ea.get("source_internal_version") identifiers = [ @@ -257,7 +255,9 @@ def _regenerate_apprn_identifier(self, record, data): if i.get("scheme") != "apprn" ] if reportnumber and source_internal: - identifiers = [{"scheme": "apprn", "identifier": reportnumber}] + identifiers + identifiers = [ + {"scheme": "apprn", "identifier": reportnumber} + ] + identifiers data.setdefault("metadata", {})["identifiers"] = identifiers def create(self, identity, data=None, record=None, errors=None, **kwargs): diff --git a/site/cds_rdm/notifications/__init__.py b/site/cds_rdm/notifications/__init__.py index efdde473..37ddd498 100644 --- a/site/cds_rdm/notifications/__init__.py +++ b/site/cds_rdm/notifications/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Invenio. -# Copyright (C) 2025 CERN. +# Copyright (C) 2026 CERN. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the GPL-2.0 License; see LICENSE file for more details. diff --git a/site/cds_rdm/notifications/ep_approval.py b/site/cds_rdm/notifications/ep_approval.py index f8bdf70b..28511f04 100644 --- a/site/cds_rdm/notifications/ep_approval.py +++ b/site/cds_rdm/notifications/ep_approval.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Invenio. -# Copyright (C) 2025 CERN. +# Copyright (C) 2026 CERN. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the GPL-2.0 License; see LICENSE file for more details. @@ -27,7 +27,7 @@ from invenio_users_resources.notifications.filters import UserPreferencesRecipientFilter from invenio_users_resources.notifications.generators import UserRecipient -from .generators import GroupMembersRecipient +from .generators import GroupMembersRecipientGenerator class EPApprovalNotificationBuilder(NotificationBuilder): @@ -73,7 +73,7 @@ class EPApprovalSubmitNotificationBuilder(EPApprovalNotificationBuilder): # created_by omitted: submit can be triggered by system_identity which has # no resolvable user record. recipients: ClassVar[list[RecipientGenerator]] = [ - GroupMembersRecipient("request.receiver"), + GroupMembersRecipientGenerator("request.receiver"), ] diff --git a/site/cds_rdm/notifications/generators.py b/site/cds_rdm/notifications/generators.py index 40a8d6b9..c8f63d54 100644 --- a/site/cds_rdm/notifications/generators.py +++ b/site/cds_rdm/notifications/generators.py @@ -21,7 +21,7 @@ from invenio_users_resources.proxies import current_users_service -class GroupMembersRecipient(RecipientGenerator): +class GroupMembersRecipientGenerator(RecipientGenerator): """Recipient generator that resolves all members of a group/role. Looks up the group reference at ``key`` in the notification context, diff --git a/site/cds_rdm/requests/__init__.py b/site/cds_rdm/requests/__init__.py index 0239a4cf..34dc3420 100644 --- a/site/cds_rdm/requests/__init__.py +++ b/site/cds_rdm/requests/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Invenio. -# Copyright (C) 2025 CERN. +# Copyright (C) 2026 CERN. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the GPL-2.0 License; see LICENSE file for more details. diff --git a/site/cds_rdm/requests/ep_approval.py b/site/cds_rdm/requests/ep_approval.py index bd33d206..de4bf31e 100644 --- a/site/cds_rdm/requests/ep_approval.py +++ b/site/cds_rdm/requests/ep_approval.py @@ -25,19 +25,22 @@ from invenio_records_resources.services.errors import PermissionDeniedError from invenio_records_resources.services.uow import UnitOfWork from invenio_requests.customizations import actions +from invenio_requests.proxies import current_requests_service from marshmallow import ValidationError, fields -# PID type stored in pidstore_pid.pid_type (VARCHAR(6) — keep ≤ 6 chars). -# The identifier scheme name in record metadata is "apprn" (no length limit). -APPRN_PID_TYPE = "apprn" - - +from ..generators import EPWorkflowCommunityManager from ..notifications.ep_approval import ( EPApprovalAcceptNotificationBuilder, EPApprovalDeclineNotificationBuilder, EPApprovalSubmitNotificationBuilder, ) +# PID type stored in pidstore_pid.pid_type (VARCHAR(6) — keep ≤ 6 chars). +# The identifier scheme name in record metadata is "apprn" (no length limit). +# TODO: what is apprn? can we name this better? +APPRN_PID_TYPE = "apprn" + + # --------------------------------------------------------------------------- # Actions # --------------------------------------------------------------------------- @@ -63,6 +66,7 @@ def _resolve_community_config(request): if default_community_id in ep_communities: return ep_communities[default_community_id] raise ValidationError( + # TODO: do we need i18n for these errors? "The record's community is not enrolled in the EP approval workflow. " "Only records in EP-approval-enabled communities can be submitted." ) @@ -75,8 +79,6 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None: """Execute submit: validate community, store version ID, notify referees.""" # Enforce that only community managers of enrolled communities (or system) # can submit. The base service only checks generic can_create. - from cds_rdm.generators import EPWorkflowCommunityManager - topic = self.request.topic.resolve() is_system = identity.id == "system" allowed = is_system or any( @@ -91,7 +93,9 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None: _resolve_community_config(self.request) # Reject if the parent already carries an approval report number. - if ((topic.parent.get("permission_flags") or {}).get("ep_approval") or {}).get("reportnumber"): + if ((topic.parent.get("permission_flags") or {}).get("ep_approval") or {}).get( + "reportnumber" + ): raise ValidationError( "A version of this record already has an approval report number assigned. " "A new EP approval request cannot be submitted." @@ -99,8 +103,6 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None: # Reject if there is already a submitted (pending) request for ANY version # in the family — prevents parallel submissions from older/newer versions. - from invenio_requests.proxies import current_requests_service - parent_recids = [] for family_rec in RDMRecord.get_records_by_parent(topic.parent): pid = PersistentIdentifier.query.filter_by( @@ -118,7 +120,7 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None: system_identity, params={ "q": ( - f"({topic_query}) AND type:\"ep-approval\"" + f'({topic_query}) AND type:"ep-approval"' ' AND status:"submitted"' ), "size": 1, @@ -134,6 +136,7 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None: # _propagate_to_newer_versions can match it against search hit["id"]. # The UUID (topic.id) is intentionally NOT stored here; it is resolved # at accept time directly from the request topic. + # TODO: why do we need this self.request["payload"]["submitted_version_id"] = topic["id"] uow.register( @@ -170,13 +173,18 @@ def _generate_report_number(self, pattern: str) -> str: year = date.today().year # Split on "{seq" to get everything before the sequence placeholder, # then format only the year part → "CERN-EP-2026-" + # TODO: I don't understand what this is doing prefix = pattern.split("{seq")[0].format(year=year) existing = PersistentIdentifier.query.filter( PersistentIdentifier.pid_type == APPRN_PID_TYPE, PersistentIdentifier.pid_value.like(f"{prefix}%"), ).all() max_seq = max( - (int(p.pid_value[len(prefix):]) for p in existing if p.pid_value[len(prefix):].isdigit()), + ( + int(p.pid_value[len(prefix) :]) + for p in existing + if p.pid_value[len(prefix) :].isdigit() + ), default=0, ) return pattern.format(year=year, seq=max_seq + 1) @@ -209,6 +217,8 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None: self.request["payload"].get("submitted_version_id") or topic["id"] ) + # TODO: do we not need to mint/reserve the report number when the request is created + # rather than accepted? E.g. maybe the curators need the number before publication self._mint_apprn_pid(report_number, str(topic.id)) # Write ep_approval into permission_flags — single source of truth. @@ -218,6 +228,8 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None: "datetime": datetime.now(timezone.utc).isoformat(), "approved_internal_version": submitted_version_recid, } + # TODO: why is this in `permission_flags`? It is instance-specific metadata so makes sense to go in a dict field, + # but why not create a generic `metadata` field or something. topic.parent["permission_flags"] = pf topic.parent.commit() db.session.commit() @@ -252,11 +264,6 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None: super().execute(identity, uow) -# --------------------------------------------------------------------------- -# Request Type -# --------------------------------------------------------------------------- - - class EPApprovalRequest(RDMBaseRequest): """EP Approval request type. diff --git a/site/cds_rdm/requests/ep_approval_state.py b/site/cds_rdm/requests/ep_approval_state.py index d3d09fbf..3d2ffa78 100644 --- a/site/cds_rdm/requests/ep_approval_state.py +++ b/site/cds_rdm/requests/ep_approval_state.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of Invenio. -# Copyright (C) 2025 CERN. +# Copyright (C) 2026 CERN. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the GPL-2.0 License; see LICENSE file for more details. diff --git a/site/cds_rdm/requests/views.py b/site/cds_rdm/requests/views.py index ab849ac4..c5531b05 100644 --- a/site/cds_rdm/requests/views.py +++ b/site/cds_rdm/requests/views.py @@ -58,6 +58,8 @@ def submit_ep_approval(pid_value): {"group": receiver_group}, raise_=True ) + print("receiver", receiver, type(receiver)) + title = record.data.get("metadata", {}).get("title", "") req = current_requests_service.create( identity=g.identity, @@ -282,9 +284,7 @@ def publish_public_record(pid_value): db.session.commit() # Also add the isvariantformof back-link to the source version. - back_draft = current_rdm_records_service.edit( - system_identity, id_=src_id - ) + back_draft = current_rdm_records_service.edit(system_identity, id_=src_id) back_data = back_draft.data back_related = list( back_data.get("metadata", {}).get("related_identifiers", []) @@ -311,11 +311,11 @@ def publish_public_record(pid_value): current_rdm_records_service.update_draft( system_identity, id_=back_draft.id, data=back_data ) - current_rdm_records_service.publish( - system_identity, id_=back_draft.id - ) + current_rdm_records_service.publish(system_identity, id_=back_draft.id) except Exception: - back_link_warning = "Public record created but back-link on source version failed." + back_link_warning = ( + "Public record created but back-link on source version failed." + ) response_body = {"id": new_record_id, "links": new_record.data.get("links", {})} if back_link_warning: