+
+ {i18next.t("Approved as {{rn}}", { rn: approved_report_number })}
+ {" — "}
+
+ {i18next.t("See approved request")}
+
+ {i18next.t("Approved as {{rn}}", { rn: approved_report_number })} +
+ ); + })()} + {!publicRecordId && canCreatePublic && ( + <> +| {{ _("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 — dump_only CF preservation (via service) +# --------------------------------------------------------------------------- + + +def test_apprn_preserved_after_uploader_update( + minimal_restricted_record, uploader, app, db +): + """CommitteeApprovalComponent silently restores committee_approval CF. + + ``cern:committee_approval`` is dump_only — user payloads are stripped by + marshmallow. The component then restores the stored value from the draft + record, so the field is always preserved regardless of what the uploader + sends. No error is raised; the field is simply kept. + """ + service = current_rdm_records.records_service + + draft = service.create(uploader.identity, minimal_restricted_record) + record = service.publish(uploader.identity, id_=draft.id) + + # System writes the approval directly (bypasses dump_only). + report_number = f"CERN-EP-{YEAR}-001" + sys_draft = service.edit(system_identity, id_=record.id) + _set_committee_approval(sys_draft, {"reportnumber": report_number}) + service.update_draft(system_identity, id_=sys_draft.id, data=dict(sys_draft.data)) + record = service.publish(system_identity, id_=sys_draft.id) + + # Verify the CF and the derived apprn identifier are present. + ca = record.data.get("custom_fields", {}).get("cern:committee_approval", {}) + assert ca.get("reportnumber") == report_number + apprn_ids = [ + i for i in record.data.get("metadata", {}).get("identifiers", []) + if i.get("scheme") == "apprn" + ] + assert len(apprn_ids) == 1 and apprn_ids[0]["identifier"] == report_number + + # Uploader opens an edit draft and tries to supply a different reportnumber. + edit_draft = service.edit(uploader.identity, id_=record.id) + tampered_data = dict(edit_draft.data) + tampered_data.setdefault("custom_fields", {})["cern:committee_approval"] = { + "reportnumber": "TAMPERED" + } + result = service.update_draft(uploader.identity, id_=edit_draft.id, data=tampered_data) + + # No validation error — the field is silently restored, not blocked. + assert not result.errors + + # The stored CF is unchanged. + refreshed_draft = service.read_draft(uploader.identity, id_=edit_draft.id) + ca_after = refreshed_draft.data.get("custom_fields", {}).get("cern:committee_approval", {}) + assert ca_after.get("reportnumber") == report_number + + # Even if the uploader omits the CF entirely, the restore brings it back. + stripped_data = dict(edit_draft.data) + stripped_data.setdefault("custom_fields", {}).pop("cern:committee_approval", None) + result = service.update_draft(uploader.identity, id_=edit_draft.id, data=stripped_data) + assert not result.errors + refreshed_draft = service.read_draft(uploader.identity, id_=edit_draft.id) + ca_after = refreshed_draft.data.get("custom_fields", {}).get("cern:committee_approval", {}) + assert ca_after.get("reportnumber") == 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_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 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") }} | +