diff --git a/site/cds_rdm/inspire_harvester/load/validator.py b/site/cds_rdm/inspire_harvester/load/validator.py index 476333c4..7e7f8984 100644 --- a/site/cds_rdm/inspire_harvester/load/validator.py +++ b/site/cds_rdm/inspire_harvester/load/validator.py @@ -11,8 +11,10 @@ from dataclasses import dataclass from flask import current_app +from invenio_pidstore.models import PersistentIdentifier from cds_rdm.inspire_harvester.utils import retrieve_identifiers +from cds_rdm.requests.committee_approval import APPRN_PID_TYPE @dataclass(frozen=True) @@ -25,18 +27,57 @@ def check(self, stream_entry, *, record=None, record_pid=None, matcher=None): raise NotImplementedError +def _incoming_apprns(stream_entry): + """Return EP/approval report numbers from the incoming entry.""" + return list( + retrieve_identifiers( + stream_entry.entry.get("metadata", {}).get("identifiers", []), + "apprn", + ) + ) + + +@dataclass(frozen=True) +class EpApprovalPidstoreRule(ValidationRule): + """Block write when an incoming EP number was never minted in CDS. + + CDS is the only place that should mint EP approval numbers. If INSPIRE + sends one that is not in pidstore, the writer skips create/update for + that entry and leaves the error on the stream entry for curators. + """ + + def check(self, stream_entry, *, record=None, record_pid=None, matcher=None): + """Return an error if any incoming ``apprn`` is missing from pidstore.""" + # Collect every apprn from INSPIRE that has no matching PID in CDS. + # one_or_none() → None means that number was never minted here. + missing = [ + number + for number in _incoming_apprns(stream_entry) + if PersistentIdentifier.query.filter_by( + pid_type=APPRN_PID_TYPE, + pid_value=number, + ).one_or_none() + is None + ] + if not missing: + return None + # Non-None return → writer treats this as a failed write and does not + # create/update the record; the message shows up in the harvest report. + return ( + "EP approval number is not minted in CDS. " + "EP approval numbers can only be assigned through the CDS " + "publishing workflow. " + f"| details: apprn={', '.join(missing)}" + ) + + @dataclass(frozen=True) class EpApprovalCreateRule(ValidationRule): """Block create when the entry carries an EP approval number.""" def check(self, stream_entry, *, record=None, record_pid=None, matcher=None): """Return an error if ``apprn`` is present on create.""" - apprns = list( - retrieve_identifiers( - stream_entry.entry.get("metadata", {}).get("identifiers", []), - "apprn", - ) - ) + apprns = _incoming_apprns(stream_entry) if not apprns: return None return ( @@ -68,12 +109,7 @@ class EpApprovalUpdateRule(ValidationRule): def check(self, stream_entry, *, record=None, record_pid=None, matcher=None): """Return an error if ``apprn`` matches a restricted CDS record.""" - apprns = list( - retrieve_identifiers( - stream_entry.entry.get("metadata", {}).get("identifiers", []), - "apprn", - ) - ) + apprns = _incoming_apprns(stream_entry) if not apprns: return None if record.get("access", {}).get("record") != "restricted": @@ -85,8 +121,8 @@ def check(self, stream_entry, *, record=None, record_pid=None, matcher=None): ) -CREATE_RULES = (EpApprovalCreateRule(), CdsDoiCreateRule()) -UPDATE_RULES = (EpApprovalUpdateRule(),) +CREATE_RULES = (EpApprovalPidstoreRule(), EpApprovalCreateRule(), CdsDoiCreateRule()) +UPDATE_RULES = (EpApprovalPidstoreRule(), EpApprovalUpdateRule()) class RecordValidator: diff --git a/site/tests/inspire_harvester/test_validator.py b/site/tests/inspire_harvester/test_validator.py new file mode 100644 index 00000000..11fcc793 --- /dev/null +++ b/site/tests/inspire_harvester/test_validator.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2026 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. + +"""INSPIRE harvester write-time validator tests.""" + +from unittest.mock import Mock + +from flask import current_app +from invenio_pidstore.models import PersistentIdentifier, PIDStatus + +from cds_rdm.inspire_harvester.load.validator import ( + EpApprovalPidstoreRule, + RecordValidator, +) +from cds_rdm.inspire_harvester.logger import Logger +from cds_rdm.inspire_harvester.transform.context import MetadataSerializationContext +from cds_rdm.inspire_harvester.transform.mappers.identifiers import IdentifiersMapper +from cds_rdm.inspire_harvester.transform.resource_types import ResourceType +from cds_rdm.requests.committee_approval import APPRN_PID_TYPE + + +def _entry_with_apprn(number): + return Mock( + entry={ + "metadata": { + "identifiers": [{"scheme": "apprn", "identifier": number}], + } + } + ) + + +def test_mapper_stores_ep_report_number_as_apprn(running_app): + """EP report numbers from INSPIRE are mapped with scheme apprn for the rule.""" + current_app.config["CDS_COMMITTEE_APPROVAL_COMMUNITIES"] = { + "ep-community": { + "report_number": {"prefix": "CERN-EP"}, + } + } + number = "CERN-EP-2099-001" + identifiers = IdentifiersMapper().map_value( + { + "metadata": {"report_numbers": [{"value": number}]}, + "created": "2023-01-01", + }, + MetadataSerializationContext( + resource_type=ResourceType.OTHER, inspire_id="12345" + ), + Logger(inspire_id="12345"), + ) + entry = Mock(entry={"metadata": {"identifiers": identifiers}}) + + assert {"identifier": number, "scheme": "apprn"} in identifiers + msg = EpApprovalPidstoreRule().check(entry) + assert msg is not None + assert "not minted in CDS" in msg + assert number in msg + + +def test_ep_pidstore_rule_passes_when_apprn_is_minted(running_app, db): + """EP numbers already minted through CDS are allowed through this rule.""" + number = "CERN-EP-2099-002" + PersistentIdentifier.create( + pid_type=APPRN_PID_TYPE, + pid_value=number, + object_type="rec", + object_uuid="00000000-0000-0000-0000-000000000099", + status=PIDStatus.REGISTERED, + ) + db.session.commit() + + assert EpApprovalPidstoreRule().check(_entry_with_apprn(number)) is None + + +def test_validator_blocks_update_for_unminted_apprn(running_app, db): + """Update path also runs the pidstore check.""" + validator = RecordValidator(matcher=Mock()) + errors = validator.validate( + "update", + _entry_with_apprn("CERN-EP-2099-003"), + record={"access": {"record": "public"}}, + record_pid="abcde-12345", + ) + + assert len(errors) == 1 + assert "not minted in CDS" in errors[0]