+
+ {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." + )} +
+| {{ _("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"] }} | +
| {{ _("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") }} | +
.
", "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") }}. | +
| {{ _("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") }} | +