Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,8 @@ node_modules

# local tmp folder
tmp
scripts
scripts

# nvm, python
.nvmrc
.python-version
2 changes: 1 addition & 1 deletion .invenio
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ description = CDS RDM InvenioRDM Instance
author_name = CERN
author_email = info@cds-rdm.com
year = 2022
python_version = 3.9
python_version = 3.14

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this leftover?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if we are still using that, but I upgraded locally to 3.14 and upgraded the information there too.

database = postgresql
search = opensearch2
file_storage = local
Expand Down
15 changes: 9 additions & 6 deletions assets/js/components/record_details/CommitteeApproval.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ export class CommitteeApprovalManageSection extends Component {
</Divider>

<p className="text-muted text-align-center">
{pubRn ? i18next.t("EP-approved as ") : i18next.t("EP-approved record")}
{pubRn?.length
? i18next.t("EP-approved as ")
: i18next.t("EP-approved record")}

{pubRn && <strong>{pubRn}</strong>}
{pubRn?.length > 0 && <strong>{pubRn.join(", ")}</strong>}

{canViewReviewedVersion && draftRecordId && (
<>
Expand Down Expand Up @@ -124,7 +126,8 @@ export class CommitteeApprovalManageSection extends Component {
const isAccepted = openRequest?.status === "accepted";
// approvedReportNumber is always populated by the backend (scans the full
// parent if the current version doesn't carry the CF itself).
const canResubmit = canSubmit && !isPending && !approvedReportNumber && !isAccepted;
const canResubmit =
canSubmit && !isPending && !approvedReportNumber?.length && !isAccepted;
// canCreatePublicFlag comes from the backend and already encodes version-order
// eligibility (only versions >= the approved version may create a public record).
const canCreatePublic = canCreatePublicFlag && !publicRecordId;
Expand All @@ -139,7 +142,7 @@ export class CommitteeApprovalManageSection extends Component {
const step1Active = !step1Completed && !isPending;

// Step 2 — EP Board review
const step2Completed = !!approvedReportNumber;
const step2Completed = !!approvedReportNumber?.length;
const step2Active = isPending;
const step2Disabled = !isPending && !step2Completed;

Expand Down Expand Up @@ -231,13 +234,13 @@ export class CommitteeApprovalManageSection extends Component {
requestLink ? (
<a href={requestLink} target="_blank" rel="noreferrer">
{i18next.t("Approved as {{rn}}", {
rn: approvedReportNumber,
rn: approvedReportNumber.join(", "),
})}
<Icon name="external alternate" className="ml-5" />
</a>
) : (
i18next.t("Approved as {{rn}}", {
rn: approvedReportNumber,
rn: approvedReportNumber.join(", "),
})
)
) : (
Expand Down
6 changes: 3 additions & 3 deletions assets/js/components/record_details/RecordVersionItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const RecordVersionItemContent = ({ item, activeVersion, doi }) => {
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;
!!approvedReportNumber?.length && 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;

Expand Down Expand Up @@ -55,7 +55,7 @@ export const RecordVersionItemContent = ({ item, activeVersion, doi }) => {
{" "}
<span className="text-muted-darken">
<Icon name="check circle" size="small" />
{approvedReportNumber}
{approvedReportNumber.join(", ")}
</span>
</>
)}
Expand All @@ -71,7 +71,7 @@ export const RecordVersionItemContent = ({ item, activeVersion, doi }) => {
className="text-muted-darken"
>
<Icon name="external alternate" size="small" />
{approvedReportNumber}
{approvedReportNumber.join(", ")}
</a>
</>
)}
Expand Down
2 changes: 1 addition & 1 deletion invenio.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -692,7 +692,7 @@ RDM_RECORDS_IDENTIFIERS_SCHEMES = {
"validator": always_valid,
"datacite": "CDS"},
"apprn": {"label": _("Approval Report Number"),
"validator": schemes.is_approval_report_number,
"validator": always_valid,
"datacite": "CDS"},
"aleph": {"label": _("Aleph number"),
"validator": schemes.is_aleph,
Expand Down
97 changes: 58 additions & 39 deletions site/cds_rdm/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
"""CDS RDM service components."""

from flask import current_app
from flask_principal import ActionNeed
from invenio_access import Permission
from invenio_access.permissions import system_user_id
from invenio_communities.proxies import current_communities
from invenio_drafts_resources.services.records.components import ServiceComponent
from invenio_i18n import gettext as _
Expand Down Expand Up @@ -132,22 +131,19 @@ def publish(self, identity, draft=None, record=None, **kwargs):
class CommitteeApprovalComponent(ServiceComponent):
"""Guard and sync committee 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 committee approval report-number pattern
(e.g. CERN-EP-*).
3. Regenerates the ``apprn`` metadata identifier from parent committee_approval
on every save — only the public approved record carries it (detected by
``source_internal_version`` on the parent).
1. Blocks everyone except the system process from adding/modifying/deleting
``apprn`` scheme identifiers — including admins via the UI.
2. Regenerates the ``apprn`` metadata identifier from parent committee_approval
only when ``source_internal_version`` is set on the parent. This covers
two cases:
- Public approved copy in the two-record flow (views.py sets
``source_internal_version`` to the internal record's recid).
- Single-record migration case (migrate_cdsrn_to_apprn.py sets
``source_internal_version`` to the record's own recid).
The internal record in the normal flow never has ``source_internal_version``
on its parent, so it never carries the apprn identifier.
"""

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 _committee_approval_prefixes(self):
"""Return the set of fixed prefixes from all configured committee communities.

Expand All @@ -162,9 +158,9 @@ def _committee_approval_prefixes(self):
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):
def _validate_identifier_changes(self, identity, data, record, errors):
"""Raise ValidationError if a non-system identity modifies apprn."""
if identity.id == system_user_id:
return

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this raise instead of return to keep inline with the docstring?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but we return if the user is system. We raise for non-system users as per docstring, do I miss something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I mis-read, sorry!
But maybe we can have some kind of warning or log with the return, because the main bug might still happen if this component gets triggered by the system user either via a script or separately maybe due to a support ticket, and we don't return silently. WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wasn't a test added to test the fix of the initial bug?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed adding it before, we should add it.


incoming_identifiers = (data.get("metadata") or {}).get("identifiers", [])
Expand All @@ -179,7 +175,7 @@ def _validate_identifier_changes(self, identity, data, record):
}
if incoming_apprn != stored_apprn:
error_msg = _(
"The 'apprn' identifier is system-managed and cannot be "
"The approval report number is system-managed and cannot be "
"added, modified, or removed manually."
)

Expand Down Expand Up @@ -216,7 +212,7 @@ def _validate_identifier_changes(self, identity, data, record):
"field": f"metadata.identifiers.{index}.identifier",
"messages": [
_(
f"The value '{val}' matches an EP approval "
f"The value '{val}' matches an approval "
"report number pattern and cannot be used as "
"a CDS report number."
)
Expand All @@ -226,36 +222,59 @@ def _validate_identifier_changes(self, identity, data, record):
if errors:
raise ValidationErrorWithMessageAsList(errors)

def _should_sync_apprn(self, record, committee_approval):
"""Return True if apprn should be synced with parent committee_approval."""
reportnumber = committee_approval.get("reportnumber")
source_internal = committee_approval.get("source_internal_version")
if reportnumber and source_internal:
return True
return False

def _regenerate_apprn_identifier(self, record, data):
"""Keep apprn in metadata.identifiers in sync with parent committee_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.
committee_approval.reportnumber is the source of truth (a list of strings).
The metadata apprn entries are derived from it exactly — no other apprn
entries are preserved.
"""
ea = (
committee_approval = (
(record.parent.get("permission_flags") if record.parent else None) or {}
).get("committee_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

existing_identifiers = (data.get("metadata") or {}).get("identifiers", [])
app_rn = [i for i in existing_identifiers if i.get("scheme") == "apprn"]
not_apprn = [i for i in existing_identifiers if i.get("scheme") != "apprn"]

if self._should_sync_apprn(record, committee_approval):
reportnumbers = committee_approval.get("reportnumber") or []
apprn = [{"scheme": "apprn", "identifier": rn} for rn in reportnumbers]
new_identifiers = apprn + not_apprn
else:
# check if there are any remaining apprn identifiers and raise a validation error if they exist
if app_rn:
errors = [
{
"field": "metadata.identifiers",
"messages": [
_(
"The approval report number is system-managed and cannot be "
"added, modified, or removed manually.",
)
],
}
]
raise ValidationErrorWithMessageAsList(errors)
new_identifiers = not_apprn

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the record does not have a committee_approval field then the identifiers are getting stripped out. That means, we need in the migration to populate the custom_field. This will be taken into account when running the https://gitlab.cern.ch/cds-team/production_scripts/-/merge_requests/59 and during migration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we raise a validation error or warning when for some reason the committee_approval field is missing but apprn identifiers were passed instead of stripping them out? @kpsherva @palkerecsenyi @sakshamarora1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes


data.setdefault("metadata", {})["identifiers"] = new_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)
self._validate_identifier_changes(identity, data, record, errors)

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._validate_identifier_changes(identity, data, record, errors)
self._regenerate_apprn_identifier(record, data)

def publish(self, identity, draft=None, record=None, **kwargs):
Expand Down
2 changes: 1 addition & 1 deletion site/cds_rdm/requests/committee_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def execute(self, identity: Identity, uow: UnitOfWork) -> None:
# Write committee_approval into permission_flags — single source of truth.
pf = topic.parent.get("permission_flags") or {}
pf["committee_approval"] = {
"reportnumber": report_number,
"reportnumber": [report_number],
"datetime": datetime.now(timezone.utc).isoformat(),
"approved_internal_version": topic["id"],
}
Expand Down
2 changes: 1 addition & 1 deletion site/cds_rdm/requests/committee_approval_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def get_committee_approval_state(record_ui, record=None):
- community_enrolled: bool
- is_public_approved_record: bool
- open_request: dict or None — {id, status, links}
- approved_report_number: str or None
- approved_report_number: list<str> or None
- approval_date: str or None
- committee_approval: dict — raw parent committee_approval (for frontend version badges)
- draft_record_id: str or None
Expand Down
5 changes: 3 additions & 2 deletions site/cds_rdm/requests/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ def publish_public_record(pid_value):
)
if cern_scientific_community_id:
try:
current_record_communities_service.add(
_, errors = current_record_communities_service.add(
system_identity,
new_record.data["id"],
data={
Expand All @@ -257,7 +257,7 @@ def publish_public_record(pid_value):
"content": (
f"This inclusion request was automatically "
f"generated when publishing the EP-approved "
f"public record for {report_number}. The "
f"public record for {', '.join(report_number)}. The "
f"document has been reviewed and approved by "
f"the EP Publication Committee."
)
Expand All @@ -283,6 +283,7 @@ def publish_public_record(pid_value):
pf["committee_approval"] = {
**ea,
"approved_public_version": new_record_id,
"source_public_version": src_id,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have documentation describing these keys?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add some documentation in our internal docs

}
src_rec_obj.parent["permission_flags"] = pf
src_rec_obj.parent.commit()
Expand Down
18 changes: 9 additions & 9 deletions site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,16 @@
{%- set clc_sync_entry = get_clc_sync_entry(record_ui) %}
{%- set additional_permissions = evaluate_permissions(record, ['manage_clc_sync']) %}

{# 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 %}
{# Collect all apprn identifiers on the public EP-approved record. #}
{%- set apprn_identifier = record_ui.get("metadata", {}).get("identifiers", [])
| selectattr("scheme", "equalto", "apprn")
| map(attribute="identifier")
| default(None) %}

{%- block record_header -%}
{{ super() }}
{%- if apprn_identifier.value %}
{%- if apprn_identifier %}
{%- for identifier in apprn_identifier %}
<script>
/* Inject apprn label into the right-aligned labels column, before the resource type label. */
document.addEventListener("DOMContentLoaded", function () {
Expand All @@ -29,11 +28,12 @@
var label = document.createElement("span");
label.setAttribute("role", "note");
label.className = "ui label horizontal small blue mb-5";
label.textContent = "{{ apprn_identifier.value }}";
label.textContent = "{{ identifier }}";
rightCol.insertBefore(label, rightCol.firstChild);
}
});
</script>
{% endfor %}
{%- endif %}
{%- endblock record_header -%}

Expand Down
Loading
Loading