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
64 changes: 50 additions & 14 deletions site/cds_rdm/inspire_harvester/load/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",

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.

could you make sure the mapper will store it with correct scheme (apprn)? Because the only test you are adding here is the validator test, not the correct mapping

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The mapping test is already covered here: https://github.com/CERNDocumentServer/cds-rdm/blob/master/site/tests/inspire_harvester/test_transformer.py#L273
I also added one test on this PR that runs the mapper and then the pidstore rule, so you can see that path here too.

)
)


@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 (
Expand Down Expand Up @@ -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":
Expand All @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions site/tests/inspire_harvester/test_validator.py
Original file line number Diff line number Diff line change
@@ -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]
Loading