From b7d5d7a505f7f7b3c0f7cdb1c7b6e77bc9f94256 Mon Sep 17 00:00:00 2001 From: Pal Kerecsenyi Date: Thu, 29 Oct 2026 11:22:45 +0100 Subject: [PATCH 1/3] feat(rdm): mixed file restriction splitting + community roles checks --- cds_migrator_kit/rdm/collection_conf.py | 1 - .../rdm/records/load/entities/parent.py | 67 ++- .../rdm/records/load/entities/record.py | 20 +- .../load/entities/restriction_split_load.py | 270 +++++++++++ .../{ep_split.py => split_entries.py} | 50 +- ..._load.py => split_migration_entry_load.py} | 164 +++---- cds_migrator_kit/rdm/records/load/load.py | 54 +-- cds_migrator_kit/rdm/records/streams.py | 4 +- .../transform/entities/community_members.py | 199 ++++++++ .../records/transform/entities/migration.py | 4 + .../rdm/records/transform/entities/parent.py | 237 ++++++---- .../rdm/records/transform/entities/record.py | 40 +- .../transform/entities/restrictions.py | 174 +++++++ .../rdm/records/transform/entities/version.py | 104 ++--- .../rdm/records/transform/mappers/base.py | 3 +- .../rdm/records/transform/mappers/record.py | 24 - .../rdm/records/transform/models/_config.py | 7 +- .../rdm/records/transform/transform.py | 12 +- .../records/transform/transform_versions.py | 2 + .../transform/xml_processing/rules/base.py | 13 +- .../transform/xml_processing/rules/hr.py | 9 +- .../xml_processing/rules/research.py | 13 - cds_migrator_kit/runner/runner.py | 4 - migration_cli/collections/_example.yaml | 14 +- migration_cli/collections/isolde.yaml | 2 +- migration_cli/collections/totem.yaml | 9 + tests/cds-rdm/conftest.py | 54 +++ .../bulletin_issue/dumps/test_records.json | 31 ++ tests/cds-rdm/data/hr/dumps/test_records.json | 74 ++- .../hr_restricted/dumps/test_records.json | 78 ++++ tests/cds-rdm/data/it/dumps/test_records.json | 179 ++++++- .../data/it/files/g79/792188/content.pdf;1 | Bin 0 -> 13264 bytes .../data/lep_exp/dumps/test_records.json | 33 ++ .../data/research/dumps/record_836244.json | 2 +- .../cds-rdm/data/sspn/dumps/test_records.json | 435 ++++++++++++++++++ .../staff_association/dumps/test_records.json | 64 +++ .../data/thesis/dump/test_records.json | 95 ++++ tests/cds-rdm/helpers.py | 3 +- tests/cds-rdm/test_access_permissions.py | 114 +---- tests/cds-rdm/test_base_rules.py | 46 +- tests/cds-rdm/test_ep_approval_entry.py | 101 ++-- tests/cds-rdm/test_hr_migration.py | 20 +- tests/cds-rdm/test_json_translation_rules.py | 2 - tests/cds-rdm/test_mixed_restrictions.py | 370 +++++++++++++++ tests/cds-rdm/test_resolve_grants.py | 113 +++-- tests/cds-rdm/test_restricted_collections.py | 202 ++++++++ tests/cds-rdm/test_scientific_community.py | 64 ++- tests/cds-rdm/test_transform_versions.py | 202 ++++---- 48 files changed, 2962 insertions(+), 820 deletions(-) create mode 100644 cds_migrator_kit/rdm/records/load/entities/restriction_split_load.py rename cds_migrator_kit/rdm/records/load/entities/{ep_split.py => split_entries.py} (91%) rename cds_migrator_kit/rdm/records/load/entities/{ep_migration_entry_load.py => split_migration_entry_load.py} (68%) create mode 100644 cds_migrator_kit/rdm/records/transform/entities/community_members.py create mode 100644 cds_migrator_kit/rdm/records/transform/entities/restrictions.py delete mode 100644 cds_migrator_kit/rdm/records/transform/mappers/record.py create mode 100644 migration_cli/collections/totem.yaml create mode 100644 tests/cds-rdm/data/it/files/g79/792188/content.pdf;1 create mode 100644 tests/cds-rdm/test_mixed_restrictions.py create mode 100644 tests/cds-rdm/test_restricted_collections.py diff --git a/cds_migrator_kit/rdm/collection_conf.py b/cds_migrator_kit/rdm/collection_conf.py index 12a7fb82..8afee8a2 100644 --- a/cds_migrator_kit/rdm/collection_conf.py +++ b/cds_migrator_kit/rdm/collection_conf.py @@ -53,7 +53,6 @@ "restricted", "create_inclusion_request", "update_new_version_publication_date", - "access_grants_view", "preferred_model", ) diff --git a/cds_migrator_kit/rdm/records/load/entities/parent.py b/cds_migrator_kit/rdm/records/load/entities/parent.py index ce42eb65..2a00672d 100644 --- a/cds_migrator_kit/rdm/records/load/entities/parent.py +++ b/cds_migrator_kit/rdm/records/load/entities/parent.py @@ -11,6 +11,7 @@ from flask import current_app from invenio_access.permissions import system_identity from invenio_accounts.models import User +from invenio_communities.communities.records.api import Community from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp from invenio_pidstore.errors import PIDAlreadyExists from invenio_pidstore.models import PersistentIdentifier, PIDStatus @@ -31,13 +32,20 @@ class ParentLoad: methods are the counterpart of ``RecordEntry``. """ - def __init__(self, entry: MigrationEntry, migration_logger, record_state): + def __init__( + self, + entry: MigrationEntry, + migration_logger, + record_state, + is_split_public_record=None, + ): """Constructor. :param record_parent: the built ``RecordParent`` for this entry (``MigrationEntry["parent"]``). :param migration_logger: kept for parity with the other load-side entity collaborators; not used directly by this class today. + :param is_split_public_record: whether this is the public component of a record split into a restricted/public pair. If None, this is not a split record. """ self.record_parent = entry["parent"] self.migration_logger = migration_logger @@ -45,6 +53,8 @@ def __init__(self, entry: MigrationEntry, migration_logger, record_state): self.migration_entry = entry self.versions = entry["versions"] self.ep_approval = entry.get("ep_approval") + self.is_split_public_record = is_split_public_record + self.has_restricted_community = False def load(self, published_record, uow): """Load access/communities, then access grants for every version. @@ -62,53 +72,66 @@ def load(self, published_record, uow): freshly-read parent instance, which ``load_access_and_communities``'s stale in-memory parent doesn't see). """ - self.load_access_and_communities(published_record, uow) - self.load_access_grants(published_record, uow) + self.has_restricted_community = self.load_access_and_communities( + published_record, uow + ) + self.load_access_grants(uow) self.set_stateless_committee_approval(published_record, uow) def load_access_and_communities(self, draft, uow): """Load access rights and communities in a single parent commit.""" parent = draft._record.parent parent.access = self.record_parent.body["access"] - for community in self.record_parent.communities["ids"]: + + has_restricted_community = False + for community_id in self.record_parent.communities["ids"]: + community = Community.get_record(community_id) + if community.access.visibility_is_restricted: + has_restricted_community = True parent.communities.add(community) + parent.communities.default = self.record_parent.communities["default"] uow.register(ParentRecordCommitOp(parent)) + return has_restricted_community - def load_access_grants(self, published_record, uow): + def load_access_grants(self, uow): """Load access grants from metadata and record grants efficiently. :param draft: the draft/published record whose parent grants are set. :param recid: this record's legacy recid, for error reporting. """ recid = self.migration_entry["record"].recid - # `record_state["versions"]` is keyed by invenio's own sequential - # `record.versions.index` (always 1, 2, 3, ...), while `self.versions` - # (`entry["versions"]`) is keyed by the *legacy* version number, which - # can start above 1 when the legacy version 1 was hard-deleted (see - # ``RecordLoad.pre_publish``). Both lists are built by iterating the - # same records in the same order (``RecordLoad._load_versions``), so - # match them up by position rather than by (possibly mismatched) key. - legacy_versions = list(self.versions.keys()) - for legacy_version, version_state in zip( - legacy_versions, self.record_state["versions"] - ): - access_dict = self.versions[legacy_version]["access"] + + for version_state in self.record_state["versions"]: published_record = current_rdm_records_service.read( system_identity, version_state["new_recid"] ) + if ( + self.has_restricted_community + and published_record._record.access.protection.record == "public" + ): + # We shouldn't get here (transform/entities/version.py should make the record fully restricted if it's in a restricted collection). + # However, we add an extra check anyway. + raise ManualImportRequired( + message="The record is public but it was added to at least one restricted community, which is not allowed.", + stage="load", + ) + parent = published_record._record.parent identity = system_identity - specific_file_restrictions = access_dict.get("meta", "") - if not specific_file_restrictions and not self.record_parent.access_grants: + file_restrictions = self.migration_entry["restrictions"]["files"] + if self.is_split_public_record or ( + not file_restrictions and not self.record_parent.access_grants + ): + # Skip loading access grants if this is the public component OR if the parent record doesn't have any + # custom grants and none of the files are restricted continue + default_permission = "view" - groups, emails, grants_with_perms = self.record_parent.resolve_grants( - specific_file_restrictions - ) + groups, emails, grants_with_perms = self.record_parent.resolve_grants() def _create_grant(subject_type, subject_id, permission): grant_data = { diff --git a/cds_migrator_kit/rdm/records/load/entities/record.py b/cds_migrator_kit/rdm/records/load/entities/record.py index e7c94359..55e27dc6 100644 --- a/cds_migrator_kit/rdm/records/load/entities/record.py +++ b/cds_migrator_kit/rdm/records/load/entities/record.py @@ -27,7 +27,7 @@ from cds_migrator_kit.rdm.records.transform.entities.parent import RecordParent from cds_migrator_kit.rdm.records.transform.entities.record import RecordEntry from cds_migrator_kit.rdm.records.transform.entities.version import ( - VersionAccess, + VersionAccessObj, VersionFileEntry, ) @@ -60,7 +60,7 @@ def __init__( record_entry: RecordEntry, record_parent: RecordParent, migration_logger, - is_final_record=True, + is_public_record=True, update_new_version_publication_date=False, record_state_logger=None, ): @@ -71,7 +71,7 @@ def __init__( :param record_parent: the built ``RecordParent`` for this entry - only used on the first version, to set up the new draft's parent access/communities (see ``pre_publish()``). - :param is_final_record: whether this is the "real"/final record for + :param is_public_record: whether this is the "real"/final record for its legacy recid - False for the restricted half of an EP approval split, which exists only as an internal source record and must skip legacy-recid-facing side effects (rep-number PID @@ -83,7 +83,7 @@ def __init__( self.record_entry = record_entry self.record_parent = record_parent self.migration_logger = migration_logger - self.is_final_record = is_final_record + self.is_public_record = is_public_record self.update_new_version_publication_date = update_new_version_publication_date self.record_state_logger = record_state_logger @@ -196,15 +196,15 @@ def _is_pdf(file_data): record.files.default_preview = preview["key"] uow.register(RecordCommitOp(record)) - def load_access(self, draft, access_dict: VersionAccess, uow): + def load_access(self, draft, access_dict: VersionAccessObj, uow): """Set this version's access on the published record.""" record = draft._record - record.access = access_dict["access_obj"] + record.access = access_dict uow.register(RecordCommitOp(record)) def assign_rep_numbers(self, draft): """Mint ``cdsrn`` PIDs for this draft's report-number identifiers.""" - if not self.is_final_record: + if not self.is_public_record: return draft_report_nums = {} for index, id in enumerate(draft.data["metadata"].get("identifiers", [])): @@ -296,7 +296,7 @@ def pre_publish(self, identity, versions, version, draft, uow): def after_publish_update_dois(self, identity, record, uow): """Update migrated DOIs post publish.""" - if not self.is_final_record: + if not self.is_public_record: return migrated_pids = self.record_entry.body["pids"] for pid_type, identifier in migrated_pids.items(): @@ -337,7 +337,7 @@ def after_publish_update_created(self, record, version_data, version, uow): def after_publish_mint_recid(self, record): """Mint legacy ids for redirections assigned to the parent.""" - if not self.is_final_record: + if not self.is_public_record: return legacy_recid = self.record_entry.recid if record._record.versions.index == 1: @@ -427,7 +427,7 @@ def log_record_state(self, record_state_context): rolled back by a failure elsewhere in the same load (e.g. a grant creation failure). """ - if self.is_final_record: + if self.is_public_record: self.record_state_logger.add_record_state(record_state_context) def _load_record_state(self, legacy_recid, records): diff --git a/cds_migrator_kit/rdm/records/load/entities/restriction_split_load.py b/cds_migrator_kit/rdm/records/load/entities/restriction_split_load.py new file mode 100644 index 00000000..350f0470 --- /dev/null +++ b/cds_migrator_kit/rdm/records/load/entities/restriction_split_load.py @@ -0,0 +1,270 @@ +# -*- 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. + +"""Splits and loads records with mixed file restrictions (public/restricted) into separate public and restricted records.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from invenio_db.uow import UnitOfWork +from invenio_rdm_records.records.api import RDMParent + +from cds_migrator_kit.errors import UnexpectedValue +from cds_migrator_kit.rdm.records.load.entities.approval_request import ApprovalRequest +from cds_migrator_kit.rdm.records.load.entities.approval_request_load import ( + ApprovalRequestLoad, +) +from cds_migrator_kit.rdm.records.load.entities.parent import ParentLoad +from cds_migrator_kit.rdm.records.load.entities.split_entries import ( + PublicEntry, + RestrictedEntry, +) +from cds_migrator_kit.rdm.records.load.load import CDSMigrationEntryLoad +from cds_migrator_kit.rdm.records.transform.entities.migration import MigrationEntry + + +class RecordSplitStrategy(ABC): + """ + A strategy for splitting records depending on why we're splitting them (e.g. if they have mixed file restrictions). + + This specifies some methods that the SplitMigrationEntryLoad hooks into during the load process, depending on which strategy + it has selected. + """ + + def __init__( + self, loader: CDSMigrationEntryLoad, entry: MigrationEntry, recid: str + ) -> None: + """Constructor.""" + self.loader = loader + self.entry = entry + self.recid = recid + + @staticmethod + @abstractmethod + def should_use(entry: MigrationEntry) -> bool: + """Whether this strategy should be used. + + This is evaluated in a list in split_migration_entry_load.py. Whichever strategy is first in that list to return True for this method is the one that gets selected. + + If no strategies match, the regular non-split load is used. + """ + raise NotImplementedError + + @abstractmethod + def build_entries(self) -> tuple[MigrationEntry, MigrationEntry]: + """Build the public/restricted entries.""" + raise NotImplementedError + + @abstractmethod + def after_restricted_parent(self, uow: UnitOfWork, restricted_record_state: dict): + """Actions to perform after the restricted record's parent has been created.""" + raise NotImplementedError + + @abstractmethod + def after_both_loaded( + self, + uow: UnitOfWork, + public_record_state: dict, + restricted_record_state: dict, + ): + """Actions to perform after both the restricted and non-restricted record have been loading.""" + raise NotImplementedError + + @abstractmethod + def public_relation_to_restricted(self) -> tuple[str, str | None]: + """ + The details for how the restricted record should be listed as a relation on the public record. + + A tuple of `(relation_id, resource_type | None)` + """ + raise NotImplementedError + + @abstractmethod + def restricted_relation_to_public(self) -> tuple[str, str | None]: + """ + The details for how the public record should be listed as a relation on the restricted record. + + A tuple of `(relation_id, resource_type | None)` + """ + raise NotImplementedError + + +class MixedRestrictionSplitStrategy(RecordSplitStrategy): + """Splits a record based on the restriction of its files.""" + + @staticmethod + def should_use(entry: MigrationEntry) -> bool: + """This strategy is used if the record has a mix of restricted and non-restricted files.""" + return ( + entry["record"].has_any_restricted_file() + and entry["record"].has_any_non_restricted_file() + ) + + def build_entries(self) -> tuple[MigrationEntry, MigrationEntry]: + """Build the public/restricted entries.""" + public_entry = PublicEntry( + self.entry, + migration_logger=self.loader.migration_logger, + ).build() + restricted_entry = RestrictedEntry( + self.entry, + migration_logger=self.loader.migration_logger, + ).build() + return (public_entry, restricted_entry) + + def after_restricted_parent(self, uow: UnitOfWork, restricted_record_state: dict): + """Not applicable.""" + pass + + def after_both_loaded( + self, + uow: UnitOfWork, + public_record_state: dict, + restricted_record_state: dict, + ): + """Not applicable.""" + pass + + def public_relation_to_restricted(self) -> tuple[str, str | None]: + """For mixed-restriction records, we use isvariantformof in both directions (see decision log).""" + return ("isvariantformof", None) + + def restricted_relation_to_public(self) -> tuple[str, str | None]: + """For mixed-restriction records, we use isvariantformof in both directions (see decision log).""" + return ("isvariantformof", None) + + +class EPRecordSplitStrategy(RecordSplitStrategy): + """EP-specific splitting strategy.""" + + @staticmethod + def should_use(entry: MigrationEntry): + """This strategy is used if the parent record has an EP approval set.""" + ep_approval = entry.get("ep_approval") + return ep_approval is not None and len(ep_approval) != 0 + + def build_entries(self) -> tuple[MigrationEntry, MigrationEntry]: + """Build the entries with the approval request attached.""" + record_body = self.entry["record"].body + assert record_body is not None + metadata = record_body.get("metadata", {}) + ep_approval = self.entry.get("ep_approval") + + self.approval_request = ApprovalRequest( + ep_approval=ep_approval, + legacy_recid=self.recid, + title=metadata.get("title"), + resource_type=metadata.get("resource_type"), + dry_run=self.loader.dry_run, + ) + self.approval_request.validate() + + # Split the metadata and files + public_entry = PublicEntry( + self.entry, + migration_logger=self.loader.migration_logger, + approval_request=self.approval_request, + ).build() + restricted_entry = RestrictedEntry( + self.entry, + migration_logger=self.loader.migration_logger, + approval_request=self.approval_request, + ).build() + return (public_entry, restricted_entry) + + def after_restricted_parent(self, uow: UnitOfWork, restricted_record_state: dict): + """Create the approval request only after the parent has been created.""" + approval_request_load = ApprovalRequestLoad(self.approval_request) + approval_request_load.create(restricted_record_state, uow=uow) + + def after_both_loaded( + self, + uow: UnitOfWork, + public_record_state: dict, + restricted_record_state: dict, + ): + """Write the EP approval object to the parent record.""" + self._link_parent_ep_approvals( + restricted_record_state, + public_record_state, + legacy_recid=self.recid, + uow=uow, + ) + + def public_relation_to_restricted(self) -> tuple[str, str | None]: + """The public record is a version of the restricted record, since usually only one of the draft versions gets published.""" + return ("isversionof", self.approval_request.resource_type) + + def restricted_relation_to_public(self) -> tuple[str, str | None]: + """The restricted record is a variant form of the public record, since it contains the original drafts and possibly more versions.""" + return ("isvariantformof", self.approval_request.resource_type) + + def _link_parent_ep_approvals( + self, restricted_record_state, public_record_state, legacy_recid, uow=None + ): + """Write parent metadata and link public/restricted records. + + If ``uow`` is provided, the writes are registered on it without + committing, so the caller can group this atomically with other + operations. + """ + if self.loader.dry_run: + return + + if not restricted_record_state or not public_record_state: + raise UnexpectedValue( + message="Both public and restricted records are required for EP approval.", + stage="load", + recid=legacy_recid, + priority="critical", + ) + + if uow is not None: + self._write_parent_ep_approvals( + restricted_record_state, public_record_state, uow + ) + return + + with UnitOfWork() as inner_uow: + self._write_parent_ep_approvals( + restricted_record_state, public_record_state, inner_uow + ) + inner_uow.commit() + + def _write_parent_ep_approvals( + self, restricted_record_state, public_record_state, uow + ): + """Register the EP approval parent writes for both records on the given uow.""" + report_number = self.approval_request.report_number + approval_iso = self.approval_request.approved_at.isoformat() + restricted_recid = restricted_record_state["latest_version"] + public_recid = public_record_state["latest_version"] + restricted_parent = RDMParent.get_record( + restricted_record_state["parent_object_uuid"] + ) + public_parent = RDMParent.get_record(public_record_state["parent_object_uuid"]) + + ParentLoad.write_committee_approval_obj( + restricted_parent, + { + "reportnumber": [report_number], + "datetime": approval_iso, + "approved_internal_version": restricted_recid, + "approved_public_version": public_recid, + "source_public_version": restricted_recid, + }, + uow, + ) + ParentLoad.write_committee_approval_obj( + public_parent, + { + "reportnumber": [report_number], + "source_internal_version": restricted_recid, + }, + uow, + ) diff --git a/cds_migrator_kit/rdm/records/load/entities/ep_split.py b/cds_migrator_kit/rdm/records/load/entities/split_entries.py similarity index 91% rename from cds_migrator_kit/rdm/records/load/entities/ep_split.py rename to cds_migrator_kit/rdm/records/load/entities/split_entries.py index 12f18658..7ffea49d 100644 --- a/cds_migrator_kit/rdm/records/load/entities/ep_split.py +++ b/cds_migrator_kit/rdm/records/load/entities/split_entries.py @@ -54,14 +54,14 @@ def _split_copy(entry: MigrationEntry) -> MigrationEntry: return split -class MetadataEntry: - """Build a load entry for the public or restricted EP approval split.""" +class SplitEntry: + """Build a load entry for the public or restricted record split.""" - def __init__(self, entry: MigrationEntry, approval_request, migration_logger): + def __init__(self, entry: MigrationEntry, migration_logger, approval_request=None): """Constructor.""" self.entry = entry - self.approval_request = approval_request self.migration_logger = migration_logger + self.approval_request = approval_request def identifiers(self, identifiers): """Return identifiers for this split.""" @@ -113,15 +113,10 @@ def _remove_doi_pid(self, split): """Remove DOI PID from record.""" pass - # Set by subclasses - written onto ``access_obj["record"]``/``["files"]`` + # Set by subclasses - written onto ``access["record"]``/``["files"]`` # for every version, and reused as the ``split_type`` label passed to # ``_log_removed_identifiers()``. _access_status = None - # Whether to drop the version's file-restriction ``meta`` string - kept - # for the restricted split (``ParentLoad.load_access_grants()`` reads it - # to resolve access grants), stripped for the public one (never - # restricted, so there's nothing to resolve grants from). - _strip_access_meta = False def _include_file(self, file_data, context): """Return whether a file belongs to this split; override in subclasses.""" @@ -161,17 +156,14 @@ def _build_versions(self, split: MigrationEntry) -> Dict[int, VersionEntry]: previous_signature = signature - version_access = deepcopy(version_data.get("access", {})) - access_obj = deepcopy(version_access.get("access_obj", {})) + access_obj = deepcopy(version_data.get("access", {})) access_obj["record"] = self._access_status access_obj["files"] = self._access_status - if self._strip_access_meta: - version_access.pop("meta", None) - version_access["access_obj"] = access_obj + version_data["access"] = access_obj new_version_data = deepcopy(version_data) new_version_data["files"] = deepcopy(versioned_files) - new_version_data["access"] = version_access + new_version_data["access"] = access_obj new_versions[len(new_versions) + 1] = new_version_data @@ -202,7 +194,7 @@ def _version_signature(versioned_files): ) -class PublicEntry(MetadataEntry): +class PublicEntry(SplitEntry): """Build the public EP approval split entry.""" _access_status = "public" @@ -216,6 +208,9 @@ def _no_versions_error_message(self): def identifiers(self, identifiers): """Return identifiers for the public split, dropping EP approval RNs.""" + if not self.approval_request: + return identifiers + kept = [] removed = [] for id_entry in identifiers: @@ -241,9 +236,17 @@ def identifiers(self, identifiers): return kept def _apply_entry_modifications(self, split): - split.pop("_request_data", None) - split["parent"].body["access"]["owned_by"] = {"user": "system"} - self._add_cern_scientific_community(split) + if self.approval_request is not None: + split.pop("_request_data", None) + split["parent"].body["access"]["owned_by"] = {"user": "system"} + + if ( + self.approval_request is not None + or self.entry["parent"].resource_type_applicable_for_scientific_community() + ): + # If it's an EP record, always add the scientific community. Otherwise, only add it if the resource + # type requires it. + self._add_cern_scientific_community(split) def _add_cern_scientific_community(self, entry): community_id = _cern_scientific_community_id() @@ -256,7 +259,7 @@ def _add_cern_scientific_community(self, entry): communities["ids"] = ids -class RestrictedEntry(MetadataEntry): +class RestrictedEntry(SplitEntry): """Build the restricted EP approval split entry.""" _access_status = "restricted" @@ -336,7 +339,10 @@ def identifiers(self, identifiers): continue # Remove CERN-(PH-)?EP-YYYY-NNN but keep *-DRAFT report numbers. if EP_APPROVAL_REPORT_NUMBER_RE.match(identifier): - if identifier != self.approval_request.report_number: + if ( + self.approval_request is not None + and identifier != self.approval_request.report_number + ): raise UnexpectedValue( message=( "EP report number is not the same as the approved entry" diff --git a/cds_migrator_kit/rdm/records/load/entities/ep_migration_entry_load.py b/cds_migrator_kit/rdm/records/load/entities/split_migration_entry_load.py similarity index 68% rename from cds_migrator_kit/rdm/records/load/entities/ep_migration_entry_load.py rename to cds_migrator_kit/rdm/records/load/entities/split_migration_entry_load.py index e633eb56..bee7d58b 100644 --- a/cds_migrator_kit/rdm/records/load/entities/ep_migration_entry_load.py +++ b/cds_migrator_kit/rdm/records/load/entities/split_migration_entry_load.py @@ -7,6 +7,8 @@ """Splits and loads an EP approval record as a public/restricted pair.""" +from __future__ import annotations + from invenio_access.permissions import system_identity from invenio_db import db from invenio_db.uow import UnitOfWork @@ -14,16 +16,21 @@ from invenio_rdm_records.records.api import RDMParent from cds_migrator_kit.errors import ManualImportRequired, UnexpectedValue +from cds_migrator_kit.rdm.records.load.entities.restriction_split_load import ( + EPRecordSplitStrategy, + MixedRestrictionSplitStrategy, + RecordSplitStrategy, +) from cds_migrator_kit.rdm.records.transform.entities.migration import MigrationEntry from ..load import CDSMigrationEntryLoad from .approval_request import ApprovalRequest from .approval_request_load import ApprovalRequestLoad -from .ep_split import PublicEntry, RestrictedEntry from .parent import ParentLoad +from .split_entries import PublicEntry, RestrictedEntry -class EPMigrationEntryLoad(CDSMigrationEntryLoad): +class SplitMigrationEntryLoad(CDSMigrationEntryLoad): """Splits and loads an EP approval record as a public/restricted pair. Adds EP-approval splitting on top of the plain per-entry loader: builds @@ -43,7 +50,7 @@ class EPMigrationEntryLoad(CDSMigrationEntryLoad): inherited plain behavior. """ - def _load(self, entry: MigrationEntry): + def _load(self, entry: MigrationEntry | None): """Route to the plain per-entry loader, or split, based on the entry. Overrides ``CDSMigrationEntryLoad._load()`` to add the ep_approval @@ -59,12 +66,23 @@ def _load(self, entry: MigrationEntry): if self._should_skip_replicated_record(entry): return - if not entry.get("ep_approval"): + split_strategies: list[type[RecordSplitStrategy]] = [ + EPRecordSplitStrategy, + MixedRestrictionSplitStrategy, + ] + # Find the first split strategy that thinks it should be used for this record. + split_strategy = next( + (strat for strat in split_strategies if strat.should_use(entry)), None + ) + + if split_strategy is None: return super()._load(entry) - return self._load_split(entry, recid) + return self._load_split(entry, recid, split_strategy(self, entry, recid)) - def _load_split(self, entry: MigrationEntry, recid): + def _load_split( + self, entry: MigrationEntry, recid: str, strategy: RecordSplitStrategy + ): """Load a record with EP approval by splitting it into two records. Configure the 2 records by separating the files, then: @@ -88,38 +106,16 @@ def _load_split(self, entry: MigrationEntry, recid): are applied to the *public* half - the discoverable/"final" record for this legacy recid. """ + assert self.migration_logger is not None + try: - ep_approval = entry.get("ep_approval") - record_body = entry["record"].body - metadata = record_body.get("metadata", {}) - - self.approval_request = ApprovalRequest( - ep_approval=ep_approval, - legacy_recid=recid, - title=metadata.get("title"), - resource_type=metadata.get("resource_type"), - dry_run=self.dry_run, - ) - self.approval_request.validate() - approval_request_load = ApprovalRequestLoad(self.approval_request) - - # Split the metadata and files - public_entry = PublicEntry( - entry, - approval_request=self.approval_request, - migration_logger=self.migration_logger, - ).build() - restricted_entry = RestrictedEntry( - entry, - approval_request=self.approval_request, - migration_logger=self.migration_logger, - ).build() + public_entry, restricted_entry = strategy.build_entries() restricted_record_load = self.record_load_cls( restricted_entry["record"], restricted_entry["parent"], self.migration_logger, - is_final_record=False, + is_public_record=False, update_new_version_publication_date=self.update_new_version_publication_date, record_state_logger=self.record_state_logger, ) @@ -127,7 +123,7 @@ def _load_split(self, entry: MigrationEntry, recid): public_entry["record"], public_entry["parent"], self.migration_logger, - is_final_record=True, + is_public_record=True, update_new_version_publication_date=self.update_new_version_publication_date, record_state_logger=self.record_state_logger, ) @@ -150,15 +146,18 @@ def _load_split(self, entry: MigrationEntry, recid): restricted_record_state = restricted_record_load.build_record_state( recid, restricted_records ) + assert restricted_record_state is not None # Parent access grants + communities before EP approval request # (needs parent.communities.default for referee group lookup). self.parent_load_cls( - restricted_entry, self.migration_logger, restricted_record_state + restricted_entry, + self.migration_logger, + restricted_record_state, + is_split_public_record=False, ).load(published_record=restricted_records[-1], uow=uow) - # 2. Create and approve EP approval request - approval_request_load.create(restricted_record_state, uow=uow) + strategy.after_restricted_parent(uow, restricted_record_state) self.request_load_cls(restricted_entry).load( restricted_records, self.create_inclusion_request, uow @@ -171,7 +170,7 @@ def _load_split(self, entry: MigrationEntry, recid): ) if not public_record_state: raise UnexpectedValue( - message="Public record is required for EP approval.", + message="Public record is required for record splitting.", stage="load", recid=recid, priority="critical", @@ -179,7 +178,10 @@ def _load_split(self, entry: MigrationEntry, recid): # Load access grants + communities for the public record self.parent_load_cls( - public_entry, self.migration_logger, public_record_state + public_entry, + self.migration_logger, + public_record_state, + is_split_public_record=True, ).load(published_record=public_records[-1], uow=uow) # Original-dump persistence for the public half. @@ -188,27 +190,30 @@ def _load_split(self, entry: MigrationEntry, recid): ) # Link the records with related_identifiers + public_to_restricted_relation_id, public_to_restricted_resource_type = ( + strategy.public_relation_to_restricted() + ) self._append_related_identifier( public_record_state["latest_version"], restricted_record_state["latest_version"], - "isversionof", - self.approval_request.resource_type, + public_to_restricted_relation_id, + public_to_restricted_resource_type, uow=uow, ) + restricted_to_public_relation_id, restricted_to_public_resource_type = ( + strategy.restricted_relation_to_public() + ) self._append_related_identifier( restricted_record_state["latest_version"], public_record_state["latest_version"], - "isvariantformof", - self.approval_request.resource_type, + restricted_to_public_relation_id, + restricted_to_public_resource_type, uow=uow, ) # 4. Write EP approval metadata on both parents - self._link_parent_ep_approvals( - restricted_record_state, - public_record_state, - legacy_recid=recid, - uow=uow, + strategy.after_both_loaded( + uow, public_record_state, restricted_record_state ) public_record_state["internal_version"] = restricted_record_state[ @@ -241,71 +246,6 @@ def _load_split(self, entry: MigrationEntry, recid): ) self.migration_logger.add_log(exc, record=entry) - def _link_parent_ep_approvals( - self, restricted_record_state, public_record_state, legacy_recid, uow=None - ): - """Write parent metadata and link public/restricted records. - - If ``uow`` is provided, the writes are registered on it without - committing, so the caller can group this atomically with other - operations. - """ - if self.dry_run: - return - - if not restricted_record_state or not public_record_state: - raise UnexpectedValue( - message="Both public and restricted records are required for EP approval.", - stage="load", - recid=legacy_recid, - priority="critical", - ) - - if uow is not None: - self._write_parent_ep_approvals( - restricted_record_state, public_record_state, uow - ) - return - - with UnitOfWork() as inner_uow: - self._write_parent_ep_approvals( - restricted_record_state, public_record_state, inner_uow - ) - inner_uow.commit() - - def _write_parent_ep_approvals( - self, restricted_record_state, public_record_state, uow - ): - """Register the EP approval parent writes for both records on the given uow.""" - report_number = self.approval_request.report_number - approval_iso = self.approval_request.approved_at.isoformat() - restricted_recid = restricted_record_state["latest_version"] - public_recid = public_record_state["latest_version"] - restricted_parent = RDMParent.get_record( - restricted_record_state["parent_object_uuid"] - ) - public_parent = RDMParent.get_record(public_record_state["parent_object_uuid"]) - - ParentLoad.write_committee_approval_obj( - restricted_parent, - { - "reportnumber": [report_number], - "datetime": approval_iso, - "approved_internal_version": restricted_recid, - "approved_public_version": public_recid, - "source_public_version": restricted_recid, - }, - uow, - ) - ParentLoad.write_committee_approval_obj( - public_parent, - { - "reportnumber": [report_number], - "source_internal_version": restricted_recid, - }, - uow, - ) - def _append_related_identifier( self, record_id, target_id, relation_id, resource_type, uow=None ): diff --git a/cds_migrator_kit/rdm/records/load/load.py b/cds_migrator_kit/rdm/records/load/load.py index 4bfcbe4e..0e49f9d9 100644 --- a/cds_migrator_kit/rdm/records/load/load.py +++ b/cds_migrator_kit/rdm/records/load/load.py @@ -102,51 +102,6 @@ def _save_original_dumped_record(self, entry: MigrationEntry, recid_state, uow): ) uow.register(ModelCommitOp(_original_dump_model)) - @staticmethod - def _verify_restricted_files(entry: MigrationEntry, records): - """Check that files of a record with legacy file restrictions are restricted. - - If any legacy file of the entry has a non-empty ``status``, every - published version of the record must have its files access set to - ``restricted``. Runs as the last step of the load, inside the unit of - work, so raising rolls the whole record back. - - EP approval entries are excused: they are split into a public and a - restricted record anyway, so the public one legitimately has public files. - - :raises ManualImportRequired: with ``critical`` priority on any mismatch. - """ - if entry.get("ep_approval"): - return - - recid = entry["record"].recid - restricted_keys = [ - file_data["key"] - for version_data in entry.get("versions", {}).values() - for file_data in version_data.get("files", {}).values() - if file_data.get("metadata", {}).get("status") - ] - if not restricted_keys: - return - - for published in records: - record = published._record - files_protection = record.access.protection.files - if files_protection != "restricted": - raise ManualImportRequired( - message=( - "Record has files with legacy restrictions but the " - f"migrated record {record.pid.pid_value} has files " - f"access '{files_protection}' instead of 'restricted'" - ), - field="access", - subfield="files", - stage="load", - recid=recid, - priority="critical", - value=restricted_keys, - ) - @staticmethod def _have_migrated_recid(recid): """Check if we have minted `lrecid` pid.""" @@ -318,7 +273,7 @@ def _load(self, entry: MigrationEntry): entry["record"], entry["parent"], self.migration_logger, - is_final_record=True, + is_public_record=True, update_new_version_publication_date=self.update_new_version_publication_date, record_state_logger=self.record_state_logger, ) @@ -338,14 +293,13 @@ def _load(self, entry: MigrationEntry): entry, recid_state_after_load, uow ) self.parent_load_cls( - entry, self.migration_logger, recid_state_after_load + entry, + self.migration_logger, + recid_state_after_load, ).load(published_record=records[-1], uow=uow) self.request_load_cls(entry).load( records, self.create_inclusion_request, uow ) - # final step of the load: nothing is committed if - # restricted files ended up with a wrong access - self._verify_restricted_files(entry, records) uow.commit() if recid_state_after_load: # only log to disk once the unit of work has actually diff --git a/cds_migrator_kit/rdm/records/streams.py b/cds_migrator_kit/rdm/records/streams.py index 8893fec7..725e662c 100644 --- a/cds_migrator_kit/rdm/records/streams.py +++ b/cds_migrator_kit/rdm/records/streams.py @@ -12,13 +12,13 @@ from cds_migrator_kit.extract.extract import LegacyExtract from cds_migrator_kit.rdm.records.transform.transform import CDSToRDMRecordTransform -from .load.entities.ep_migration_entry_load import EPMigrationEntryLoad +from .load.entities.split_migration_entry_load import SplitMigrationEntryLoad RecordStreamDefinition = StreamDefinition( name="records", extract_cls=LegacyExtract, transform_cls=CDSToRDMRecordTransform, - load_cls=EPMigrationEntryLoad, + load_cls=SplitMigrationEntryLoad, ) """ETL stream for CDS to RDM records. diff --git a/cds_migrator_kit/rdm/records/transform/entities/community_members.py b/cds_migrator_kit/rdm/records/transform/entities/community_members.py new file mode 100644 index 00000000..f9e00765 --- /dev/null +++ b/cds_migrator_kit/rdm/records/transform/entities/community_members.py @@ -0,0 +1,199 @@ +# -*- 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. + +"""Checks of the legacy roles against the members of the target RDM communities.""" + +from functools import lru_cache + +from flask import current_app +from invenio_accounts.models import Role, User +from invenio_communities.members.records.models import MemberModel +from invenio_db import db + +from cds_migrator_kit.errors import ManualImportRequired +from cds_migrator_kit.rdm.records.transform.entities.restrictions import ( + RecordRestrictions, + legacy_role_subjects, +) + + +# Members don't change during a migration run: query each community only once. +@lru_cache +def community_subjects(community_id: str) -> tuple[frozenset[str], frozenset[str]]: + """Return the ``(group names, user emails)`` of a community's active members. + + Reads the DB directly rather than the search index, so that members added + right before the run are not missed. Pending invitations are not active. + """ + members = MemberModel.query.filter_by(community_id=community_id, active=True) + + group_ids = [m.group_id for m in members if m.group_id] + user_ids = [m.user_id for m in members if m.user_id] + + groups = frozenset( + name for (name,) in db.session.query(Role.name).filter(Role.id.in_(group_ids)) + ) + emails = frozenset( + email.lower() + for (email,) in db.session.query(User.email).filter(User.id.in_(user_ids)) + ) + return groups, emails + + +class CommunityMembersCheck: + """Checks the target communities' members cover the legacy roles of a record. + + Run by ``RecordParent.build()``. Raises ``ManualImportRequired`` when + someone who could view or edit the record on legacy would lose access on + RDM. + """ + + def __init__( + self, + restrictions: RecordRestrictions, + communities_ids, + recid, + migration_logger=None, + ): + """Constructor. + + :param restrictions: the dump's ``restrictions`` of the record. + :param communities_ids: configured target community ids for this + migration run (``CDSToRDMRecordTransform.communities_ids``). + :param recid: the legacy record id, for error reporting. + :param migration_logger: where non-blocking warnings of this record + are logged (e.g. extra members in a restricted community). + """ + self.restrictions = restrictions + self.communities_ids = communities_ids + self.recid = recid + self.migration_logger = migration_logger + + def check(self): + """Run all the community members checks.""" + self._check_restricted_collections() + self._check_edit_roles() + + def _check_restricted_collections(self): + """Check the target communities give access to the restricted collection. + + A legacy collection is restricted when it has roles. Everyone covered by + these roles must be a member of each configured target community, or + they would lose access on RDM. Extra community members are allowed, but + logged. Records in several restricted collections can't be mapped to one + set of members, so they need manual curation. + """ + restricted = { + name: roles + for name, roles in self.restrictions["collections"].items() + if roles + } + if not restricted: + return + + recid = self.recid + if len(restricted) > 1: + raise ManualImportRequired( + message="Record belongs to more than one restricted collection", + field="restrictions", + subfield="collections", + stage="transform", + recid=recid, + priority="critical", + value=sorted(restricted), + ) + + name, roles = restricted.popitem() + groups, emails = self._check_missing_community_members( + roles, + f"read access to the restricted legacy collection '{name}'", + ) + + for community_id in self._member_checked_communities_ids(): + # Calculate which roles the community has extra relative to the legacy + # CDS collection (missing ones were checked above) + community_groups, community_emails = community_subjects(community_id) + + extra = (community_groups - groups) | (community_emails - emails) + if extra and self.migration_logger: + self.migration_logger.add_information( + recid, + { + "message": ( + f"Community {community_id} has members that are not in " + f"the roles of restricted collection '{name}'" + ), + "value": ", ".join(sorted(extra)), + }, + ) + + def _check_edit_roles(self): + """Check everyone who could edit the legacy record is a community member. + + Applies to every record, restricted or not: the roles allowed to edit + the record's access collections. The roles allowed to edit any + collection (``any_collection``) are left out: they are the few + instance-wide curators, who must not become community members. + """ + collections = self.restrictions["edit"]["collections"] + roles = [role for roles in collections.values() for role in roles] + self._check_missing_community_members( + roles, + "legacy edit/curation permissions", + ) + + def _member_checked_communities_ids(self): + """Target communities whose members must match the legacy roles. + + The CERN Research community is a common community for research all streams, so + its members are not expected to match any collection's roles. + """ + scientific_community = current_app.config.get( + "CDS_CERN_SCIENTIFIC_COMMUNITY_ID" + ) + return [ + community_id + for community_id in self.communities_ids + if str(community_id) != str(scientific_community) + ] + + def _check_missing_community_members(self, roles, label): + """Warn unless everyone covered by ``roles`` is in each target community. + + :param label: what the roles are, for the error message. + :return: ``(groups, emails)`` covered by ``roles``. + """ + try: + groups, emails = legacy_role_subjects(roles) + except ValueError as e: + raise ManualImportRequired( + message=f"Cannot map the {label}: {e}", + field="restrictions", + stage="transform", + recid=self.recid, + priority="critical", + value=[role["firerole_def_src"] for role in roles], + ) + + if not groups and not emails: + return groups, emails + + for community_id in self._member_checked_communities_ids(): + community_groups, community_emails = community_subjects(community_id) + missing = (groups - community_groups) | (emails - community_emails) + if missing and self.migration_logger: + self.migration_logger.add_information( + self.recid, + { + "message": ( + f"Community {community_id} is missing members with {label}" + ), + "value": missing, + }, + ) + + return groups, emails diff --git a/cds_migrator_kit/rdm/records/transform/entities/migration.py b/cds_migrator_kit/rdm/records/transform/entities/migration.py index 304931c5..c2ae88f9 100644 --- a/cds_migrator_kit/rdm/records/transform/entities/migration.py +++ b/cds_migrator_kit/rdm/records/transform/entities/migration.py @@ -14,6 +14,9 @@ from cds_migrator_kit.rdm.records.transform.entities.parent import RecordParent from cds_migrator_kit.rdm.records.transform.entities.record import RecordEntry from cds_migrator_kit.rdm.records.transform.entities.request import RecordRequest +from cds_migrator_kit.rdm.records.transform.entities.restrictions import ( + RecordRestrictions, +) from cds_migrator_kit.rdm.records.transform.entities.version import VersionEntry @@ -45,5 +48,6 @@ class MigrationEntry(TypedDict): _request_data: RecordRequest # EP approval workflow entries for this record, if any (possibly []). ep_approval: NotRequired[List[dict]] + restrictions: RecordRestrictions _original_dump: dict _clc_sync: Any diff --git a/cds_migrator_kit/rdm/records/transform/entities/parent.py b/cds_migrator_kit/rdm/records/transform/entities/parent.py index 2dff5271..efb37b94 100644 --- a/cds_migrator_kit/rdm/records/transform/entities/parent.py +++ b/cds_migrator_kit/rdm/records/transform/entities/parent.py @@ -19,8 +19,17 @@ UnexpectedValue, ) from cds_migrator_kit.rdm.records.transform.config import CERN_SCIENTIFIC_RESOURCE_TYPES +from cds_migrator_kit.rdm.records.transform.entities.community_members import ( + CommunityMembersCheck, +) +from cds_migrator_kit.rdm.records.transform.entities.restrictions import ( + RecordRestrictions, + RecordRestrictionsFile, + legacy_role_subjects, + normalize_group_name, + parse_firerole, +) from cds_migrator_kit.rdm.records.transform.mappers.base import RecordTransformContext -from cds_migrator_kit.rdm.records.transform.mappers.record import AccessGrantsMapper EMAIL_PATTERN = re.compile(r"[^@]+@[^@]+\.[^@]+") @@ -40,7 +49,12 @@ class RecordParent: """ def __init__( - self, record, raw_dump_entry, dojson_entry, communities_ids, access_grants_view + self, + record, + raw_dump_entry, + dojson_entry, + communities_ids, + migration_logger=None, ): """Constructor. @@ -55,23 +69,34 @@ def __init__( ``CDSToRDMRecordTransform._check_forgotten_keys()``. :param communities_ids: configured target community ids for this migration run (``CDSToRDMRecordTransform.communities_ids``). - :param access_grants_view: configured collection-wide view grants - (``CDSToRDMRecordTransform.access_grants_view``). + :param migration_logger: where non-blocking warnings of this record + are logged (e.g. extra members in a restricted community). """ self.record = record self.raw_dump_entry = raw_dump_entry self.dojson_entry = dojson_entry self.communities_ids = communities_ids - self.access_grants_view = access_grants_view + self.migration_logger = migration_logger self.body = None self.communities = None self.access_grants = None def build(self): """Populate ``body``/``communities``/``access_grants``; returns self.""" + # Owners and viewers are read from the dump's `restrictions`: the + # values the MARC rules produce are discarded, so that they are not + # flagged by the forgotten-keys check + self.dojson_entry.pop("submitter", None) + self.dojson_entry.pop("access_grants", None) access = self._build_access() + CommunityMembersCheck( + restrictions=self.raw_dump_entry["restrictions"], + communities_ids=self.communities_ids, + recid=self.record.recid, + migration_logger=self.migration_logger, + ).check() self.communities = self._build_communities() - self.access_grants = self._build_access_grants_from_record_marc() + self.access_grants = self._build_access_grants_from_record_dump() self.body = { # loader is responsible for creating/updating if the PID exists, # this part will be simply omitted. @@ -83,7 +108,25 @@ def build(self): def _build_access(self): """Resolve the owner and return the parent's access dict.""" - email = self.dojson_entry.pop("submitter", None) + restrictions: RecordRestrictions = self.raw_dump_entry["restrictions"] + owners: set[str] = set() + for field in restrictions["record"]["owners"]: + owners.update(restrictions["record"]["owners"][field]) + + if len(owners) > 1: + raise ManualImportRequired( + message="Cannot handle records with more than one owner", + field="owned_by", + subfield="user", + stage="transform", + recid=self.record.recid, + priority="critical", + value=owners, + ) + + email = None + if owners: + email = owners.pop() if not email: owner = "system" elif email in _submitter_id_by_email: @@ -103,14 +146,24 @@ def _build_access(self): ) return {"owned_by": {"user": owner}} - def _should_add_scientific_community(self): - if self.record.has_any_restrictions(): - return False + def resource_type_applicable_for_scientific_community(self): + """Whether the resource type is applicable for the scientific community.""" resource_type_id = ( self.record.body.get("metadata", {}).get("resource_type", {}).get("id") ) return resource_type_id in CERN_SCIENTIFIC_RESOURCE_TYPES + def should_add_scientific_community(self): + """Whether to add the scientific community during the transform step. + + If the record has mixed public/restricted files but the resource type is applicable for the scientific + community, it will get added during the load step in `split_entries.py` instead. + """ + return ( + not self.record.has_any_restrictions() + and self.resource_type_applicable_for_scientific_community() + ) + def _build_communities(self): """Combine the configured target communities with the record's own.""" communities = self.dojson_entry.pop("communities", []) @@ -123,7 +176,7 @@ def _build_communities(self): raise MissingConfiguration( "CDS_CERN_SCIENTIFIC_COMMUNITY_ID is not configured" ) - if self._should_add_scientific_community(): + if self.should_add_scientific_community(): if scientific_community not in communities: communities.append(scientific_community) @@ -131,51 +184,51 @@ def _build_communities(self): return {"ids": communities, "default": self.communities_ids[0]} return {} - def _build_access_grants_from_record_marc(self): + def _build_access_grants_from_record_dump(self) -> list[dict[str, str]]: """Compute the access grants to create on this parent after publish.""" - ctx = RecordTransformContext( - dojson_entry=self.dojson_entry, - raw_dump_entry=self.raw_dump_entry, - access_grants_view=self.access_grants_view, - ) - return AccessGrantsMapper().map_value(ctx) + restrictions: RecordRestrictions = self.raw_dump_entry["restrictions"] + access_grants = [] + + for field in restrictions["record"]["viewers"]: + access_grants.extend( + {grant: "view"} for grant in restrictions["record"]["viewers"][field] + ) - def resolve_grants(self, specific_file_restrictions=""): + # TODO: access grants for restrictions["record"]["owners"]??? + + return access_grants + + def resolve_grants(self): """Resolve which groups/emails/permissions get access grants for a version. - Combines this parent's own ``access_grants`` (record-level, from - legacy access-grant metadata) with ``specific_file_restrictions`` (a - version-specific file-restriction status string, e.g. "firerole: - allow group ..."). Pure computation - the caller (``load.py``) is - responsible for actually creating the grants against the RDM parent. + Combines this parent's own ``access_grants`` (record-level, from the + dump's ``restrictions["record"]["viewers"]``) with the file + restrictions in ``restrictions["files"]``. Pure computation - the + caller (``load/entities/parent.py``) is responsible for actually + creating the grants against the RDM parent. - :param specific_file_restrictions: the ``meta`` value from a - version's access dict (``VersionEntry["access"]["meta"]``), or - "" if that version has no individual file restriction. :return: ``(groups, emails, grants_with_perms)`` - see - ``load.py::_after_publish_load_parent_access_grants()`` for how - these are consumed to create the actual grants. + ``ParentLoad`` for how these are consumed to create the grants. """ default_permission = "view" grants_with_perms = {} # ----Parse file status metadata----# - groups, emails = self._resolve_restriction_groups(specific_file_restrictions) + groups, emails = self._resolve_file_restriction_groups_and_emails() + file_restrictions_empty = not groups and not emails + assert self.access_grants is not None # ----Parse record access grants----# for grant_info in self.access_grants: - if not isinstance(grant_info, dict) or not grant_info: - continue - subject, permission = next(iter(grant_info.items())) permission = permission or default_permission grants_with_perms[subject] = permission # attention! - # this is important - if there was no specific restrictions on the file, - # then the record grands takes over - but if file had specific status, + # this is important - if there were no restrictions on the files, + # then the record grants take over - but if a file had a specific status, # then we take the least possible access - if not specific_file_restrictions: + if file_restrictions_empty: if EMAIL_PATTERN.match(subject): emails.add(subject) else: @@ -183,73 +236,79 @@ def resolve_grants(self, specific_file_restrictions=""): return groups, emails, grants_with_perms - def _resolve_restriction_groups(self, specific_file_restrictions): - """Resolve the groups/emails implied by a file-restriction string. + def _resolve_file_restriction_groups_and_emails(self): + """Resolve the groups/emails that can view the record's restricted files. - Covers every form a legacy file ``status`` can take (see - ``docs/legacy_restriction_paths.md``): the literal ``"restricted"``, a - bare ``" [CERN]"`` e-group name, a ``firerole:`` expression, or - - as a last resort - a key in ``CDS_ACCESS_GROUP_MAPPINGS``. Raises - ``ManualImportRequired`` for anything unrecognised. + Unions ``_resolve_file_restriction()`` over every entry of the dump's + ``restrictions["files"]`` (which only lists files with a non-empty + ``status``). ``RecordVersion`` already rejects records whose restricted + files don't share a single status. - :param specific_file_restrictions: the ``meta`` value from a - version's access dict, or "" when the version has no individual - file restriction. - :return: ``(groups, emails)`` - the groups/emails this restriction - string grants view access to. + :return: ``(groups, emails)`` - empty when no file is restricted. """ groups = set() emails = set() - if not specific_file_restrictions: - return groups, emails + file_restrictions: list[RecordRestrictionsFile] = self.raw_dump_entry[ + "restrictions" + ]["files"] + for file_restriction in file_restrictions: + file_groups, file_emails = self._resolve_file_restriction(file_restriction) + groups.update(file_groups) + emails.update(file_emails) + return groups, emails + + def _resolve_file_restriction(self, file_restriction: RecordRestrictionsFile): + """Resolve the groups/emails implied by one restricted file. + Covers every form a legacy file ``status`` can take (see + ``docs/legacy_restriction_paths.md``), in order: the literal + ``"restricted"``, a bare ``" [CERN]"`` e-group name, a + ``firerole:`` expression, a key in ``CDS_ACCESS_GROUP_MAPPINGS`` + (curated override), and finally the legacy roles authorised for the + status, as dumped in the file's ``roles``. Raises + ``ManualImportRequired`` for anything unrecognised. + """ + status = file_restriction["status"].strip() + roles = file_restriction["roles"] group_mappings = current_app.config.get("CDS_ACCESS_GROUP_MAPPINGS", {}) - if specific_file_restrictions == "restricted": - # https://cds.cern.ch/admin/webaccess/webaccessadmin.py/showroledetails?id_role=69 - groups.add("cern-personnel") - elif specific_file_restrictions.strip().endswith("[CERN]") and not any( - kw in specific_file_restrictions for kw in ("firerole:", "allow ") - ): - # bare CERN e-group name, e.g. - # "cds-ph-ep-publications-referee-non-lhc [CERN]" - groups.add(self._normalize_group_name(specific_file_restrictions)) - elif any( - kw in specific_file_restrictions - for kw in ("firerole: allow group", "allow email") - ): - meta_str = specific_file_restrictions.replace("\r\n", "\n") - - # Parse groups - group_matches = re.search(r'allow group\s+((?:"[^"]+",?\s*)+)', meta_str) - if group_matches: - group_values = re.findall(r'"([^"]+)"', group_matches.group(1)) - for g in group_values: - groups.add(self._normalize_group_name(g)) - - # Parse emails - email_matches = re.search(r'allow email\s+((?:"[^"]+",?\s*)+)', meta_str) - if email_matches: - email_values = re.findall(r'"([^"]+)"', email_matches.group(1)) - emails.update(email_values) - elif specific_file_restrictions in group_mappings: - # last resort: a simple keyword mapped to CERN e-group(s) - groups.update(group_mappings[specific_file_restrictions]) - else: - raise ManualImportRequired( - message="Unexpected permission format.", + def _manual_import_required(message): + return ManualImportRequired( + message=message, field="access", subfield="subject.id", - stage="load", + stage="transform", recid=self.record.recid, priority="critical", - value=specific_file_restrictions, + value=status, ) - return groups, emails + if status == "SSO" or status == "restricted": + # https://cds.cern.ch/admin/webaccess/webaccessadmin.py/showroledetails?id_role=69 + return {"cern-personnel"}, set() + if status.endswith("[CERN]") and not any( + kw in status for kw in ("firerole:", "allow ") + ): + # bare CERN e-group name, e.g. + # "cds-ph-ep-publications-referee-non-lhc [CERN]" + return {self._normalize_group_name(status)}, set() + if status.startswith("firerole:"): + try: + return parse_firerole(status) + except ValueError as e: + raise _manual_import_required(f"Unexpected permission format: {e}") + if status in group_mappings: + # a simple keyword mapped to CERN e-group(s) + return set(group_mappings[status]), set() + if roles: + try: + return legacy_role_subjects(roles) + except ValueError as e: + raise _manual_import_required( + f"Cannot map the roles of file status '{status}': {e}" + ) + raise _manual_import_required("Unexpected permission format.") @staticmethod def _normalize_group_name(subject): - if subject.endswith(" [CERN]"): - subject = subject.replace(" [CERN]", "") - return subject.strip() + return normalize_group_name(subject) diff --git a/cds_migrator_kit/rdm/records/transform/entities/record.py b/cds_migrator_kit/rdm/records/transform/entities/record.py index 9832a8f9..9413f336 100644 --- a/cds_migrator_kit/rdm/records/transform/entities/record.py +++ b/cds_migrator_kit/rdm/records/transform/entities/record.py @@ -22,6 +22,9 @@ PIDS_SCHEMES_ALLOWED, PIDS_SCHEMES_TO_DROP, ) +from cds_migrator_kit.rdm.records.transform.entities.restrictions import ( + RecordRestrictions, +) from cds_migrator_kit.rdm.records.transform.mappers.base import RecordTransformContext from cds_migrator_kit.rdm.records.transform.mappers.registry import ( CUSTOM_FIELD_MAPPERS, @@ -84,6 +87,8 @@ def __init__( :param dojson_entry: the DOJSON-processed record data (``dump.latest_revision``'s content) - passed in separately since ``build()`` mutates it in place as it builds the record. + :param restricted: whether the stream's config is set to restricted (not whether + this individual record is restricted) """ self.dump = dump self.raw_dump_entry = dump.data @@ -95,39 +100,26 @@ def __init__( self.created = dump.first_created self.recid = self._recid(dump) - # None when RecordFlaggedCuration was raised and caught. - self.access_status = None - try: - self.access_status = self._access(dojson_entry) - except RecordFlaggedCuration as exc: - self.migration_logger.add_information( - self.raw_dump_entry["recid"], - {"message": exc.message, "value": exc.value}, - ) - - def _access(self, dojson_entry): - record_restriction = dojson_entry.pop("record_restriction", None) - if isinstance(record_restriction, list): - record_restriction = record_restriction[0] - restrictions = "restricted" if self.restricted else record_restriction - if not restrictions: - raise RecordFlaggedCuration( - message="record restriction not found make sure the record should be public", - stage="transform", - field="record_restriction", - ) - return restrictions + # Just the globally configured restriction for this record. Other sources of restriction + # are still taken into account even if this is `public` + self.access_status = "restricted" if self.restricted else "public" def has_any_restricted_file(self): """True if any of the legacy files carries a restriction status.""" - return any(file.get("status") for file in self.raw_dump_entry["files"]) + restrictions: RecordRestrictions = self.raw_dump_entry["restrictions"] + return len(restrictions["files"]) != 0 + + def has_any_non_restricted_file(self): + """True if any of the legacy files don't carry a restriction status.""" + return any(not file.get("status") for file in self.raw_dump_entry["files"]) def has_any_restrictions(self): - """True if the record, its stream or any of its files is restricted.""" + """True if the record, its stream, any of its files, or one of its legacy collections is restricted.""" return ( self.restricted or self.access_status != "public" or self.has_any_restricted_file() + or len(self.raw_dump_entry["restrictions"].get("collections", {})) != 0 ) def _recid(self, dump): diff --git a/cds_migrator_kit/rdm/records/transform/entities/restrictions.py b/cds_migrator_kit/rdm/records/transform/entities/restrictions.py new file mode 100644 index 00000000..eaf3e2aa --- /dev/null +++ b/cds_migrator_kit/rdm/records/transform/entities/restrictions.py @@ -0,0 +1,174 @@ +# -*- 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. + +"""A typed restriction dictionary for dumped records.""" + +import re +from typing import TypedDict + + +class RoleUser(TypedDict): + """A user.""" + + email: str + """The user's email address.""" + + +class LegacyRole(TypedDict): + """A role on the legacy system.""" + + id: str + """The role's ID.""" + name: str + """The role's name.""" + description: str + """A description of the role.""" + firerole_def_src: str + """The firerole definition of the role.""" + users: list[RoleUser] + """A list of users manually added to the role (usually the users are defined by an e-group in the firerole).""" + + +class RecordRestrictionsFile(TypedDict): + """Permissions of a restricted file of the record.""" + + bibdocid: int + """The ID of the file.""" + status: str + """The raw `status` string (e.g. `SSO`).""" + roles: list[LegacyRole] + """Who has ``viewrestdoc` for the given status.""" + + +class RecordRestrictionsPermissions(TypedDict): + """MARC-derived restrictions of the record itself.""" + + owners: dict[str, list[str]] + """The submitters/owners of this records (MARC tag -> list of role names).""" + viewers: dict[str, list[str]] + """The allowed viewers of this records (MARC tag -> list of role names).""" + + +class RecordRestrictionsEdit(TypedDict): + """Details of who can edit a record (`runbibedit`).""" + + collections: dict[str, list[LegacyRole]] + """A list of the record's collections and who has `runbibedit` on each of them.""" + any_collection: list[LegacyRole] + """Who has `runbibedit` globally across CDS.""" + + +class RecordRestrictionsCommentsRoles(TypedDict): + """Details of who can perform a comment-related action.""" + + roles: list[LegacyRole] + """Roles allowed to perform the action on the record's primary collection.""" + any_collection: list[LegacyRole] + """Roles allowed to perform the action globally across CDS.""" + + +class RecordRestrictionsCommentsRestrictedComment(TypedDict): + """Details about specific comment restrictions.""" + + restriction: str + """The restriction.""" + comment_ids: list[int] + """List of comments this applies to.""" + roles: list[LegacyRole] + """List of roles allowed to access these comments.""" + + +class RecordRestrictionsComments(TypedDict): + """Details about the comment restrictions of a record.""" + + primary_collection: str + """The 'guessed' primary collection of the record (via 980).""" + view: RecordRestrictionsCommentsRoles + """Roles with permission to view comments (`viewcomment` on the primary collection).""" + send: RecordRestrictionsCommentsRoles + """Roles with permission to submit comments (`sendcomment` on the primary collection).""" + record_restriction: str + """The restriction 'status' of the record (via 5061_a).""" + restricted_comments: list[RecordRestrictionsCommentsRestrictedComment] + """Roles required to view specific comment IDs (`viewrestrcomment`).""" + + +class RecordRestrictions(TypedDict): + """The restrictions object as dumped by invenio-migrator.""" + + collections: dict[str, list[LegacyRole]] + """ + The restricted collections the record is in and the corresponding roles that have `viewrestrcoll` on that collection. + + If this dictionary is empty, the record is not in any restricted collections. + """ + files: list[RecordRestrictionsFile] + """ + A list of the record's restricted files. + + Note: ONLY restricted files are included in this list. Files without a `status` are not listed. So records that are fully public will have an empty list here. + """ + record: RecordRestrictionsPermissions + """The owners and viewers granted access for this specific record via MARC tags.""" + edit: RecordRestrictionsEdit + """Roles with the `runbibedit` permission on this record's collections.""" + comments: RecordRestrictionsComments + """Various permissions related to the record's comments.""" + + +_FIREROLE_ALLOW_RE = re.compile(r'^allow\s+(group|email)\s+((?:"[^"]+"\s*,?\s*)+)$') +# Lines that add nothing on top of the default (deny) behaviour +_FIREROLE_NOOP_LINES = {"deny any", "deny all"} + + +def normalize_group_name(subject: str) -> str: + """Strip the legacy ``[CERN]`` suffix from an e-group name.""" + if subject.endswith(" [CERN]"): + subject = subject.replace(" [CERN]", "") + return subject.strip() + + +def parse_firerole(src: str) -> tuple[set[str], set[str]]: + """Return the ``(groups, emails)`` a firerole definition allows. + + Only plain ``allow group "..."`` / ``allow email "..."`` rules can be + represented on RDM, anything else (``allow any``, ``deny group``, ``allow + remote_ip``, ...) raises ``ValueError``. + """ + groups = set() + emails = set() + for line in src.replace("\r\n", "\n").split("\n"): + line = line.strip() + if line.startswith("firerole:"): + line = line[len("firerole:") :].strip() + if not line or line.startswith("#") or line in _FIREROLE_NOOP_LINES: + continue + match = _FIREROLE_ALLOW_RE.match(line) + if not match: + raise ValueError(f"Unsupported firerole rule: {line}") + values = re.findall(r'"([^"]+)"', match.group(2)) + if match.group(1) == "group": + groups.update(normalize_group_name(value) for value in values) + else: + emails.update(value.lower() for value in values) + return groups, emails + + +def legacy_role_subjects(roles: list[LegacyRole]) -> tuple[set[str], set[str]]: + """Return all the ``(groups, emails)`` covered by a list of legacy roles. + + Combines the groups/emails of each role's firerole definition with the + users directly assigned to the role. + """ + groups = set() + emails = set() + for role in roles: + role_groups, role_emails = parse_firerole(role["firerole_def_src"] or "") + groups.update(role_groups) + emails.update(role_emails) + emails.update(user["email"].lower() for user in role["users"]) + return groups, emails diff --git a/cds_migrator_kit/rdm/records/transform/entities/version.py b/cds_migrator_kit/rdm/records/transform/entities/version.py index a766c6f4..4998a42e 100644 --- a/cds_migrator_kit/rdm/records/transform/entities/version.py +++ b/cds_migrator_kit/rdm/records/transform/entities/version.py @@ -15,6 +15,9 @@ from typing_extensions import Required from cds_migrator_kit.errors import ManualImportRequired +from cds_migrator_kit.rdm.records.transform.entities.restrictions import ( + RecordRestrictions, +) LEGACY_FILES_PATH_ROOT = Path("/opt/cdsweb/var/data/files/") @@ -26,20 +29,6 @@ class VersionAccessObj(TypedDict): files: Optional[str] -class VersionAccess(TypedDict, total=False): - """A version's access - ``VersionEntry["access"]``. - - Set directly on the record post-create via - ``load.py::_load_record_access`` (``record.access = access_dict["access_obj"]``). - """ - - access_obj: Required[VersionAccessObj] - # Raw legacy file-restriction status string, present only when an - # individual file carried its own restriction - see - # RecordVersion.compute_access(). - meta: str - - class VersionFileMetadata(TypedDict): """``VersionFileEntry["metadata"]``.""" @@ -78,7 +67,7 @@ class VersionEntry(TypedDict): # RecordEntryData["body"]["metadata"]["publication_date"]) - see # RecordVersionsTransform.build(). publication_date: Union[Arrow, str] - access: VersionAccess + access: VersionAccessObj class RecordVersion: @@ -93,6 +82,7 @@ class RecordVersion: def __init__( self, record_access, + restrictions: RecordRestrictions, files_dump_dir, migration_logger, raw_file_dumps=None, @@ -116,6 +106,7 @@ def __init__( (by per-file access); a public/restricted file mix is expected. """ self.record_access = record_access + self.restrictions = restrictions self.files_dump_dir = files_dump_dir self.migration_logger = migration_logger self.raw_file_dumps = raw_file_dumps or [] @@ -139,74 +130,61 @@ def build(self): "access": self.access, } - def compute_access(self): + def compute_access(self) -> VersionAccessObj: """Return this version's access dict, from this version's files.""" file = self.representative_file record_access = self.record_access + + if len(self.restrictions.get("collections", {})) != 0: + # If the record is in a restricted collection, it is necessarily restricted. + return {"record": "restricted", "files": "restricted"} + if file is None: + # If the record has no files then it is necessarily public, unless it is in + # a restricted collection. + return { + "record": record_access, + "files": record_access, + } + + # The `restrictions.files` in the raw dump only contains entries for files that + # are restricted (i.e. have status != ''). So if this list is empty, none of the + # files are restricted. + restricted_files = self.restrictions["files"] + # Distinct (non-empty) file statuses within the record + distinct_statuses = {file["status"] for file in restricted_files} + + if len(restricted_files) == 0: + # The record's files are all non-restricted, so the record is public unless it + # is in a restricted collection (as above). return { - "access_obj": { - "record": record_access, - "files": record_access, - } + "record": record_access, + "files": record_access, } - # A version carries a single access state + a single `meta`, so every - # file in it must share the same status. Any non-uniform mix — some - # public and some restricted, and/or several distinct restriction - # statuses — can't be represented, so hard-stop for manual review. recid = str(file["recid"]) - distinct_statuses = {f["status"] for f in self.raw_file_dumps} - restrictions = distinct_statuses - {""} - if self.has_ep_approval and restrictions: - # EP approval records are split into public/restricted records at - # load (ep_split), which sorts files by their own status - so a - # public + single-restriction mix is excused. The restriction is - # kept as `meta` for the restricted split's grants. - if len(restrictions) == 1: - distinct_statuses = restrictions if len(distinct_statuses) > 1: + # Records are allowed to have the following: + # - All empty-status files + # - Some empty-status files and some files with a non-empty, identical status + # - Only files with a non-empty, identical statuses + # They are not allowed to have multiple different non-empty statuses since we don't support this type of + # restricted/public record splitting; right now, we can create up to one restricted and one public record + # per legacy record. raise ManualImportRequired( - message=( - "Mixed file restrictions in one version. " - "Cannot auto-assign access — manual review required." - ), - field="files", - subfield="status", + message=f"Record had more than one distinct non-empty file status. Manual curation is needed.", stage="transform", recid=recid, - priority="critical", - value=", ".join( - f"{f['full_name']}: {f['status']}" for f in self.raw_file_dumps - ), ) - # All files share one status: public (empty) or a single restriction. - # Note: we do NOT validate the status against CDS_ACCESS_GROUP_MAPPINGS - # here. Recognising/resolving the restriction string (mapping keyword, - # firerole, bare [CERN] e-group, ...) is RecordParent.resolve_grants()'s - # job at load time; it hard-raises on anything it can't resolve. Here we - # only decide public-vs-restricted and carry the raw status as `meta`. - status = distinct_statuses.pop() - if not status: - return { - "access_obj": { - "record": record_access, - "files": record_access, - } - } - self.migration_logger.add_information( recid, { "message": "Record has individual file restrictions", - "value": status, + "value": ", ".join(file["status"] for file in restricted_files), }, ) - return { - "access_obj": {"record": record_access, "files": "restricted"}, - "meta": status, - } + return {"record": record_access, "files": "restricted"} def compute_files(self): """Transform this version's own raw file dumps into RDM file entries.""" diff --git a/cds_migrator_kit/rdm/records/transform/mappers/base.py b/cds_migrator_kit/rdm/records/transform/mappers/base.py index 506679bb..edff470d 100644 --- a/cds_migrator_kit/rdm/records/transform/mappers/base.py +++ b/cds_migrator_kit/rdm/records/transform/mappers/base.py @@ -7,6 +7,8 @@ """Base classes shared by all CDS-RDM record field mappers.""" +from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass, field @@ -27,7 +29,6 @@ class RecordTransformContext: raw_dump_entry: dict migration_logger: object = None affiliations_mapping: object = None - access_grants_view: object = None pids: dict = field(default_factory=dict) metadata: dict = field(default_factory=dict) custom_fields: dict = field(default_factory=dict) diff --git a/cds_migrator_kit/rdm/records/transform/mappers/record.py b/cds_migrator_kit/rdm/records/transform/mappers/record.py deleted file mode 100644 index 1155691c..00000000 --- a/cds_migrator_kit/rdm/records/transform/mappers/record.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2022-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. - -"""Top-level ``record_json_output`` field mappers.""" - -from cds_migrator_kit.rdm.records.transform.mappers.base import FieldMapper - - -class AccessGrantsMapper(FieldMapper): - """Maps access_grants, appending any configured collection-wide view grants.""" - - id = "access_grants" - - def map_value(self, ctx): - """Return access_grants extended with configured view grants.""" - access_grants = ctx.dojson_entry.pop("access_grants", []) - if ctx.access_grants_view: - for grant in ctx.access_grants_view: - access_grants.append({str(grant): "view"}) - return access_grants diff --git a/cds_migrator_kit/rdm/records/transform/models/_config.py b/cds_migrator_kit/rdm/records/transform/models/_config.py index 1300d4d3..c63ab62b 100644 --- a/cds_migrator_kit/rdm/records/transform/models/_config.py +++ b/cds_migrator_kit/rdm/records/transform/models/_config.py @@ -29,7 +29,10 @@ "336__a", # redundant field "340__a", # From SIS: see decision log "500__9", # Provenance of the note - "520__9", # Provenance of the description + "506__a", # These are now imported via the `restrictions` key of the dump + "506__d", # These are now imported via the `restrictions` key of the dump + "506__m", # These are now imported via the `restrictions` key of the dump + "520__9", # Provenance of the description, "536__r", # From SIS: see decision log "540__3", # Material of the license "540__9", # Also material of the license @@ -51,6 +54,7 @@ "8564_8", # File information (done by file dump) "8564_q", # File links File information (done by file dump) "8564_z", # Websubmit "stamp" (migrated as file metadata) + "859__f", # The submitter(s) is now imported via the `restrictions` key of the dump "905__m", # Submitter email address "916__y", # year, redundant value "937__c", # last modified by @@ -60,6 +64,7 @@ "961__h", # CDS modification tag # TODO "961__l", # CDS modification tag # TODO "961__x", # CDS modification tag # TODO + "963__a", # Record-level restriction is now imported via the `restrictions` key of the dump "981__a", # duplicate record id "999C50", # From SIS: all 999* fields can be ignored "999C52", # https://cds.cern.ch/record/2640188/export/hm?ln=en diff --git a/cds_migrator_kit/rdm/records/transform/transform.py b/cds_migrator_kit/rdm/records/transform/transform.py index e441af92..f1aa5224 100644 --- a/cds_migrator_kit/rdm/records/transform/transform.py +++ b/cds_migrator_kit/rdm/records/transform/transform.py @@ -72,7 +72,6 @@ def __init__( plots=False, migration_logger=None, record_state_logger=None, - access_grants_view=None, preferred_model=None, ): """Constructor. @@ -82,13 +81,15 @@ def __init__( ``cds_migrator_kit.runner.runner.Runner`` always passes them when constructing this class from ``streams.yaml`` config - they aren't used by this class or ``RecordEntry``. + + ``restricted`` and ``plots`` come from the stream config, they are not + to do with individual records. """ self._throw = throw self._logger = None self.files_dump_dir = Path(files_dump_dir).absolute().as_posix() self.communities_ids = communities_ids self.restricted = restricted - self.access_grants_view = access_grants_view self.plots = plots self.migration_logger = migration_logger self.record_state_logger = record_state_logger @@ -146,7 +147,7 @@ def _parent(self, raw_dump_entry, record): raw_dump_entry=raw_dump_entry, dojson_entry=self.dojson_entry, communities_ids=self.communities_ids, - access_grants_view=self.access_grants_view, + migration_logger=self.migration_logger, ).build() def _request(self, raw_dump_entry): @@ -186,6 +187,10 @@ def _transform(self, raw_dump_entry) -> Optional[MigrationEntry]: record_request = self._request(raw_dump_entry) record = self._record(raw_dump_entry, dump) + restrictions = raw_dump_entry.get("restrictions") + if restrictions is None: + raise ManualImportRequired("Missing `restrictions` key in raw dump.") + if record: versions = self._versions(raw_dump_entry, record, bool(ep_approval)) # RecordParent is the last entity to touch dojson_entry - @@ -202,6 +207,7 @@ def _transform(self, raw_dump_entry) -> Optional[MigrationEntry]: _clc_sync=clc_sync, _request_data=record_request, ep_approval=ep_approval, + restrictions=restrictions, ) except ( diff --git a/cds_migrator_kit/rdm/records/transform/transform_versions.py b/cds_migrator_kit/rdm/records/transform/transform_versions.py index af4f3a31..5a62fafb 100644 --- a/cds_migrator_kit/rdm/records/transform/transform_versions.py +++ b/cds_migrator_kit/rdm/records/transform/transform_versions.py @@ -121,6 +121,7 @@ def build(self): cumulative_raw_dumps[file_dump["full_name"]] = file_dump versions[version_number] = RecordVersion( record_access=record_access, + restrictions=self.raw_dump_entry["restrictions"], files_dump_dir=self.files_dump_dir, migration_logger=self.migration_logger, publication_date=publication_dates[version_number], @@ -132,6 +133,7 @@ def build(self): # Record has no files. Add metadata-only record as single version versions[1] = RecordVersion( record_access=record_access, + restrictions=self.raw_dump_entry["restrictions"], files_dump_dir=self.files_dump_dir, migration_logger=self.migration_logger, publication_date=self.record.body["metadata"]["publication_date"], diff --git a/cds_migrator_kit/rdm/records/transform/xml_processing/rules/base.py b/cds_migrator_kit/rdm/records/transform/xml_processing/rules/base.py index 5ac3b137..57c9e536 100644 --- a/cds_migrator_kit/rdm/records/transform/xml_processing/rules/base.py +++ b/cds_migrator_kit/rdm/records/transform/xml_processing/rules/base.py @@ -18,12 +18,12 @@ from idutils.validators import is_arxiv, is_doi, is_handle, is_urn from invenio_accounts.models import User -from cds_migrator_kit.errors import UnexpectedValue +from cds_migrator_kit.errors import ManualImportRequired, UnexpectedValue from cds_migrator_kit.rdm.migration_config import ( RDM_RECORDS_IDENTIFIERS_SCHEMES, RDM_RECORDS_RELATED_IDENTIFIERS_SCHEMES, ) -from cds_migrator_kit.rdm.records.load.entities.ep_split import ( +from cds_migrator_kit.rdm.records.load.entities.split_entries import ( EP_APPROVAL_REPORT_NUMBER_RE, ) from cds_migrator_kit.rdm.records.transform.config import ( @@ -264,10 +264,13 @@ def record_restriction(self, key, value): restr = value.get("a", "") parsed = StringValue(restr).parse() if parsed.upper() == "PUBLIC": - return "public" + raise IgnoreKey("record_restriction") else: - raise UnexpectedValue( - field=key, subfield="a", message="Record restricted", priority="critical" + raise ManualImportRequired( + field=key, + subfield="a", + message="Record has 963__a:restricted, requires manual check", + priority="critical", ) diff --git a/cds_migrator_kit/rdm/records/transform/xml_processing/rules/hr.py b/cds_migrator_kit/rdm/records/transform/xml_processing/rules/hr.py index 943ba465..670ea5d4 100644 --- a/cds_migrator_kit/rdm/records/transform/xml_processing/rules/hr.py +++ b/cds_migrator_kit/rdm/records/transform/xml_processing/rules/hr.py @@ -7,7 +7,7 @@ from idutils.normalizers import normalize_isbn from isbnlib import NotValidISBNError -from cds_migrator_kit.errors import UnexpectedValue +from cds_migrator_kit.errors import ManualImportRequired, UnexpectedValue from cds_migrator_kit.transform.xml_processing.quality.decorators import ( for_each_value, require, @@ -230,7 +230,12 @@ def record_restriction(self, key, value): """Translates notes.""" access = value.get("a") if access and access.lower() in ("cern internal", "restricted"): - return "restricted" + raise ManualImportRequired( + field=key, + subfield="a", + message="Record has 591__a:restricted, requires manual check", + priority="critical", + ) elif access and access.lower() != "public": raise UnexpectedValue("Access field other than public", field=key, value=value) raise IgnoreKey("access") diff --git a/cds_migrator_kit/rdm/records/transform/xml_processing/rules/research.py b/cds_migrator_kit/rdm/records/transform/xml_processing/rules/research.py index 397633e7..0d3a2490 100644 --- a/cds_migrator_kit/rdm/records/transform/xml_processing/rules/research.py +++ b/cds_migrator_kit/rdm/records/transform/xml_processing/rules/research.py @@ -418,19 +418,6 @@ def meeting(self, key, value): # 540__ is fully handled by the base licenses rule (OA funding model included). -@model.over("access_grants", "^506[1_]_") -@for_each_value -def access_grants(self, key, value): - """Translates access permissions (by user email or group name).""" - raw_identifier = value.get("d") or value.get("m") or value.get("a") - subject_identifier = StringValue(raw_identifier).parse() - if not subject_identifier: - raise IgnoreKey("access_grants") - - permission_type = "view" - return {str(subject_identifier): permission_type} - - @model.over("internal_notes", "^562__") @for_each_value def internal_notes(self, key, value): diff --git a/cds_migrator_kit/runner/runner.py b/cds_migrator_kit/runner/runner.py index 6c80245d..613c39e2 100644 --- a/cds_migrator_kit/runner/runner.py +++ b/cds_migrator_kit/runner/runner.py @@ -70,9 +70,6 @@ def __init__( self.create_inclusion_request = stream_config[collection].get( "create_inclusion_request", False ) - self.access_grants_view = stream_config[collection].get( - "access_grants_view", False - ) self.plots = stream_config[collection].get("plots", False) self.preferred_model = stream_config[collection].get( "preferred_model", None @@ -102,7 +99,6 @@ def __init__( collection=collection, **transform_config, restricted=self.restricted, - access_grants_view=self.access_grants_view, plots=self.plots, preferred_model=self.preferred_model, migration_logger=self.migration_logger, diff --git a/migration_cli/collections/_example.yaml b/migration_cli/collections/_example.yaml index 653ebfec..f37901ab 100644 --- a/migration_cli/collections/_example.yaml +++ b/migration_cli/collections/_example.yaml @@ -6,14 +6,14 @@ # //, see migrating-a-collection.md. name: antares_restr # folder: former_exp/antares_restr # working directory under the root (default: name) -chunk_size: 1000 # records per dump file (default: 1000) +chunk_size: 1000 # records per dump file (default: 1000) # Legacy search queries WITHOUT the standard exclusions (-980:DELETED, ...): # the script appends them. Each query is dumped separately, and needs a # `dump_file_prefix` (mandatory): the name of its dump files, and of the files # that tag its records as MIGRATED. It must differ between queries. queries: - - q: '980__a:PRIVANTARES' + - q: "980__a:PRIVANTARES" dump_file_prefix: antares_restr # - q: '980__a:PRIVANTARESPLOT' # dump_file_prefix: antares_restr_plot @@ -46,10 +46,6 @@ flags: # this name instead of failing with "multiple models matched" (the record is # still flagged with a warning). Name of a model, e.g. research. preferred_model: research - # Groups that get view access to every record of the collection, on top of the - # legacy permissions. For restricted collections that a whole group (e.g. all of - # CERN) should see, when the legacy records do not list it. - access_grants_view: [] # e.g. [cern-personnel] # Number of parallel workers for the transformation of the records. The # `--workers` option of `invenio migration run` takes precedence. # workers: 4 @@ -57,8 +53,8 @@ flags: # Slugs of the communities the records are added to. The same in every # environment: they are resolved to ids on the pod, and must exist there. communities: - - antares # the DEFAULT collection community - - cern-research # common community + - antares # the DEFAULT collection community + - cern-research # common community # To set only if for some reason comments query differ from the records query. # If not set, global queries will kick in. @@ -67,4 +63,4 @@ communities: # Reviewers assigned to all the requests in this community community_reviewers: - - { group: antares-all } + - { group: antares-all } diff --git a/migration_cli/collections/isolde.yaml b/migration_cli/collections/isolde.yaml index 3edbe84f..83f19323 100644 --- a/migration_cli/collections/isolde.yaml +++ b/migration_cli/collections/isolde.yaml @@ -9,4 +9,4 @@ flags: # slugs of the communities the records are added to, the same in every environment; # they must exist in the environment communities: - - isolde # slug of the community + - isolde # slug of the community diff --git a/migration_cli/collections/totem.yaml b/migration_cli/collections/totem.yaml new file mode 100644 index 00000000..c3d5fa95 --- /dev/null +++ b/migration_cli/collections/totem.yaml @@ -0,0 +1,9 @@ +name: totem +queries: [] # TODO legacy queries of the collection (used by the legacy script only) +flags: + include_plots: true + create_inclusion_request: true +# slugs of the communities the records are added to, the same in every environment; +# they must exist in the environment +communities: + - 0927dbcc-63ca-41f2-8111-c1eeac3810d7 diff --git a/tests/cds-rdm/conftest.py b/tests/cds-rdm/conftest.py index 6652762d..4732069e 100644 --- a/tests/cds-rdm/conftest.py +++ b/tests/cds-rdm/conftest.py @@ -33,6 +33,7 @@ from invenio_cern_sync.sso import cern_remote_app_name from invenio_cern_sync.users.profile import CERNUserProfileSchema from invenio_communities.communities.records.api import Community +from invenio_communities.members.records.models import MemberModel from invenio_i18n import lazy_gettext as _ from invenio_pidstore.models import PersistentIdentifier, PIDStatus from invenio_rdm_records.cli import create_records_custom_field @@ -434,6 +435,28 @@ def archiver(UserFixture, app, db): return user +@pytest.fixture() +def normal_user(UserFixture, app, db, test_app): + """A normal user without special permissions.""" + u = UserFixture( + email="normal@inveniosoftware.org", + password="normal", + preferences={ + "visibility": "public", + "email_visibility": "restricted", + "notifications": { + "enabled": True, + }, + }, + active=True, + confirmed=True, + ) + u.create(app, db) + UserAggregate.index.refresh() + + return u + + @pytest.fixture(scope="module") def resource_type_type(app): """Resource type vocabulary type.""" @@ -1744,6 +1767,37 @@ def cern_scientific_community(running_app, db, app): return comm +@pytest.fixture() +def restricted_community_group(db): + """The group that is a member of ``restricted_community``.""" + role = Role(id="restricted-community-readers", name="restricted-community-readers") + db.session.add(role) + db.session.commit() + return role + + +@pytest.fixture() +def restricted_community(running_app, db, restricted_community_group): + """A restricted community with a single group as reader.""" + comm = Community.create({}) + comm.slug = "restricted-community" + comm.metadata = {"title": "Restricted Community"} + comm.access.visibility = "restricted" + comm.commit() + + db.session.add( + MemberModel( + community_id=comm.id, + group_id=restricted_community_group.id, + role="reader", + visible=True, + active=True, + ) + ) + db.session.commit() + return comm + + # @pytest.fixture() # def users(app, db): # """Create example user.""" diff --git a/tests/cds-rdm/data/bulletin_issue/dumps/test_records.json b/tests/cds-rdm/data/bulletin_issue/dumps/test_records.json index 2582efc1..e2c784bb 100644 --- a/tests/cds-rdm/data/bulletin_issue/dumps/test_records.json +++ b/tests/cds-rdm/data/bulletin_issue/dumps/test_records.json @@ -2,6 +2,37 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2234683, "creation_date": "2020-05-01 00:00:00", "record": [ diff --git a/tests/cds-rdm/data/hr/dumps/test_records.json b/tests/cds-rdm/data/hr/dumps/test_records.json index 48a3d5fa..d5ea407c 100644 --- a/tests/cds-rdm/data/hr/dumps/test_records.json +++ b/tests/cds-rdm/data/hr/dumps/test_records.json @@ -2,11 +2,44 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [ + "admin@inveniosoftware.org" + ] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2647384, "creation_date": "2018-11-16 14:14:00", "record": [ { - "marcxml": "\n 2647384\n 20210427161418.0\n \n CERN-STAFF-RULES-ED01\n \n \n eng\n \n\n fr\n \n \n Mobilit\u00e9 interne\n \n \n 2021\n \n \n CERN\n Geneva\n 01 May 2021\n \n \n CERN Internal\n \n \n admin@inveniosoftware.org\n \n \n Administrative Circular\n \n \n HR\n DI\n \n \n HR-DHO\n \n \n 2021-05-01\n 2222-12-31\n \n \n STAFFRULES\n \n ", + "marcxml": "\n 2647384\n 20210427161418.0\n \n CERN-STAFF-RULES-ED01\n \n \n eng\n \n\n fr\n \n \n Mobilit\u00e9 interne\n \n \n 2021\n \n \n CERN\n Geneva\n 01 May 2021\n \n \n PUBLIC\n \n \n admin@inveniosoftware.org\n \n \n Administrative Circular\n \n \n HR\n DI\n \n \n HR-DHO\n \n \n 2021-05-01\n 2222-12-31\n \n \n STAFFRULES\n \n ", "json": null, "modification_datetime": "2021-04-27T14:14:18+00:00" } @@ -45,6 +78,45 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [ + { + "bibdocid": 792187, + "status": "restricted", + "roles": [] + } + ], + "record": { + "owners": { + "859__f": [], + "270__m": [ + "admin@inveniosoftware.org" + ] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2364643, "creation_date": "2018-11-16 14:14:00", "record": [ diff --git a/tests/cds-rdm/data/hr_restricted/dumps/test_records.json b/tests/cds-rdm/data/hr_restricted/dumps/test_records.json index 1c04977c..15e1d16f 100644 --- a/tests/cds-rdm/data/hr_restricted/dumps/test_records.json +++ b/tests/cds-rdm/data/hr_restricted/dumps/test_records.json @@ -32,6 +32,45 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [ + { + "bibdocid": 7921875, + "status": "restricted", + "roles": [] + } + ], + "record": { + "owners": { + "859__f": [], + "270__m": [ + "admin@inveniosoftware.org" + ] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 555555, "creation_date": "2019-05-06 04:03:12", "record": [ @@ -75,6 +114,45 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [ + { + "bibdocid": 79218766, + "status": "EligibilityHRCirc", + "roles": [] + } + ], + "record": { + "owners": { + "859__f": [], + "270__m": [ + "admin@inveniosoftware.org" + ] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 23646466, "creation_date": "2013-09-06 09:31:03", "record": [ diff --git a/tests/cds-rdm/data/it/dumps/test_records.json b/tests/cds-rdm/data/it/dumps/test_records.json index 617a26d0..7bec6cc1 100644 --- a/tests/cds-rdm/data/it/dumps/test_records.json +++ b/tests/cds-rdm/data/it/dumps/test_records.json @@ -2,6 +2,41 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [ + "uploader@inveniosoftware.org" + ] + }, + "viewers": { + "506__m": [], + "5061_d": [ + "admin@inveniosoftware.org" + ] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2872558, "creation_date": "2010-06-30 08:07:36", "record": [ @@ -15,6 +50,39 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [ + "hr-dep [CERN]" + ] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2872551, "creation_date": "2008-02-18 03:43:54", "record": [ @@ -28,6 +96,39 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [] + }, + "viewers": { + "506__m": [ + "uploader123@inveniosoftware.org" + ], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2872550, "creation_date": "2006-12-14 21:29:57", "record": [ @@ -41,6 +142,37 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 126154, "creation_date": "2006-12-14 21:29:57", "record": [ @@ -64,7 +196,11 @@ "full_name": "Report on ALICE RPE Website.pdf", "superformat": ".pdf", "recids_doctype": [ - [2872569, "DEPIN_FILE", "Report on ALICE RPE Website.pdf"] + [ + 2872569, + "DEPIN_FILE", + "Report on ALICE RPE Website.pdf" + ] ], "path": "/opt/cdsweb/var/data/files/g79/792187/Report on ALICE RPE Website.pdf;1", "size": 1525703, @@ -86,6 +222,47 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [ + { + "bibdocid": 792187, + "status": "SSO", + "roles": [] + } + ], + "record": { + "owners": { + "859__f": [], + "270__m": [ + "uploader@inveniosoftware.org" + ] + }, + "viewers": { + "506__m": [], + "5061_d": [ + "admin@inveniosoftware.org" + ] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2872569, "creation_date": "2014-06-19 01:09:25", "record": [ diff --git a/tests/cds-rdm/data/it/files/g79/792188/content.pdf;1 b/tests/cds-rdm/data/it/files/g79/792188/content.pdf;1 new file mode 100644 index 0000000000000000000000000000000000000000..774c2ea70c55104973794121eae56bcad918da97 GIT binary patch literal 13264 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 literal 0 HcmV?d00001 diff --git a/tests/cds-rdm/data/lep_exp/dumps/test_records.json b/tests/cds-rdm/data/lep_exp/dumps/test_records.json index 9d5bcbb6..ef6a7e5e 100644 --- a/tests/cds-rdm/data/lep_exp/dumps/test_records.json +++ b/tests/cds-rdm/data/lep_exp/dumps/test_records.json @@ -32,6 +32,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter13@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2294138, "creation_date": "2017-11-23 16:29:37", "record": [ diff --git a/tests/cds-rdm/data/research/dumps/record_836244.json b/tests/cds-rdm/data/research/dumps/record_836244.json index 87140fe3..8b2110fc 100644 --- a/tests/cds-rdm/data/research/dumps/record_836244.json +++ b/tests/cds-rdm/data/research/dumps/record_836244.json @@ -1,3 +1,3 @@ [ -{"files": [{"comment": null, "status": "", "version": 1, "encoding": null, "creation_date": "2009-08-01T22:17:02+00:00", "bibdocid": 468507, "mime": "application/pdf", "full_name": "0505058.pdf", "superformat": ".pdf", "recids_doctype": [[836244, "Main", "0505058.pdf"]], "path": "/opt/cdsweb/var/data/files/g46/468507/0505058.pdf;1", "size": 2138, "license": {}, "modification_date": "2009-08-01T22:17:02+00:00", "copyright": {}, "url": "http://cds.cern.ch/record/836244/files/0505058.pdf", "checksum": "3c72aa6c388bdd94cdfd0b5c081b6488", "description": "Access to fulltext document", "format": ".pdf", "name": "0505058", "subformat": "", "etag": "\"468507.pdf1\"", "recid": 836244, "flags": [], "hidden": false, "type": "Main", "full_path": "/opt/cdsweb/var/data/files/g46/468507/0505058.pdf;1"}, {"comment": null, "status": "", "version": 1, "encoding": null, "creation_date": "2017-06-24T08:01:30+00:00", "bibdocid": 1323055, "mime": "application/pdf", "full_name": "arXiv:hep-ex_0505058.pdf", "superformat": ".pdf", "recids_doctype": [[836244, "Main", "arXiv:hep-ex_0505058.pdf"]], "path": "/opt/cdsweb/var/data/files/g132/1323055/content.pdf;1", "size": 93805, "license": {}, "modification_date": "2017-06-24T08:01:30+00:00", "copyright": {}, "url": "http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf", "checksum": "0942105885a3279bb7c854d2d91bb8a6", "description": null, "format": ".pdf", "name": "arXiv:hep-ex_0505058", "subformat": "", "etag": "\"1323055.pdf1\"", "recid": 836244, "flags": [], "hidden": false, "type": "Main", "full_path": "/opt/cdsweb/var/data/files/g132/1323055/content.pdf;1"}], "collections": null, "creation_date": "2005-05-20 03:41:48", "recid": 836244, "record": [{"marcxml": "\n 836244\n SzGeCERN\n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experimental Results\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20061003\n 1602\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cdsweb.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n", "json": null, "modification_datetime": "2009-08-01T23:22:06+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n \n SPIRES\n 6225080\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cdsweb.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n", "json": null, "modification_datetime": "2010-04-17T04:36:24+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20121219073815.0\n \n SPIRES\n 6225080\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2012-12-19T06:38:15+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20130116000647.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n \n \n SPIRES\n 6225080\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2013-01-15T23:06:47+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20150211071127.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2015-02-11T06:11:27+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20161219120402.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2016-12-19T11:04:02+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20170621125423.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2017-06-21T10:54:23+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20170621231428.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2017-06-21T21:14:28+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20170624100130.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n 468507\n 2138\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n Preprint\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2017-06-24T08:01:30+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20170628111829.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n 1260605\n 119-122\n C05-03-12\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n Preprint\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 119-122\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2017-06-28T09:18:29+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20230314212715.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n GAMMA GAMMA\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n Inspire\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n 1260605\n 119-122\n C05-03-12\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 119-122\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2023-03-14T20:27:15+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20260617172717.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive $\\rho\\rho$ production in tagged $\\gamma\\gamma$ interactions at LEP\n \n \n GAMMA GAMMA\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n Inspire\n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3 Collaboration\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n C05-03-12\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 875-882\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2026-06-17T15:27:17+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20260617173831.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n GAMMA GAMMA\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n Inspire\n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n 1260605\n 119-122\n C05-03-12\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 119-122\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2026-06-17T15:38:31+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20260629155636.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive $ \\rho \\rho $ Production in Tagged $\\gamma \\gamma$ Interactions at LEP\n \n \n $\\gamma \\gamma $\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n Inspire\n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3\n \n \n 875-882\n 3\n Acta Phys. Pol. B\n 37\n C05-08-30\n 2006\n \n \n 1260605\n 119-122\n C05-03-12\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 119-122\n lathuile20050312\n \n \n 705894\n 875-882\n warsaw20050831\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2026-06-29T13:56:36+00:00"}]} +{"files": [{"comment": null, "status": "", "version": 1, "encoding": null, "creation_date": "2009-08-01T22:17:02+00:00", "bibdocid": 468507, "mime": "application/pdf", "full_name": "0505058.pdf", "superformat": ".pdf", "recids_doctype": [[836244, "Main", "0505058.pdf"]], "path": "/opt/cdsweb/var/data/files/g46/468507/0505058.pdf;1", "size": 2138, "license": {}, "modification_date": "2009-08-01T22:17:02+00:00", "copyright": {}, "url": "http://cds.cern.ch/record/836244/files/0505058.pdf", "checksum": "3c72aa6c388bdd94cdfd0b5c081b6488", "description": "Access to fulltext document", "format": ".pdf", "name": "0505058", "subformat": "", "etag": "\"468507.pdf1\"", "recid": 836244, "flags": [], "hidden": false, "type": "Main", "full_path": "/opt/cdsweb/var/data/files/g46/468507/0505058.pdf;1"}, {"comment": null, "status": "", "version": 1, "encoding": null, "creation_date": "2017-06-24T08:01:30+00:00", "bibdocid": 1323055, "mime": "application/pdf", "full_name": "arXiv:hep-ex_0505058.pdf", "superformat": ".pdf", "recids_doctype": [[836244, "Main", "arXiv:hep-ex_0505058.pdf"]], "path": "/opt/cdsweb/var/data/files/g132/1323055/content.pdf;1", "size": 93805, "license": {}, "modification_date": "2017-06-24T08:01:30+00:00", "copyright": {}, "url": "http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf", "checksum": "0942105885a3279bb7c854d2d91bb8a6", "description": null, "format": ".pdf", "name": "arXiv:hep-ex_0505058", "subformat": "", "etag": "\"1323055.pdf1\"", "recid": 836244, "flags": [], "hidden": false, "type": "Main", "full_path": "/opt/cdsweb/var/data/files/g132/1323055/content.pdf;1"}], "collections": null, "restrictions": {"collections": {}, "files": [], "record": {"owners": {"859__f": [], "270__m": []}, "viewers": {"506__m": [], "5061_d": []}}, "edit": {"collections": {}, "any_collection": []}, "comments": {"primary_collection": "", "view": {"roles": [], "any_collection": []}, "send": {"roles": [], "any_collection": []}, "record_restriction": "", "restricted_comments": []}}, "creation_date": "2005-05-20 03:41:48", "recid": 836244, "record": [{"marcxml": "\n 836244\n SzGeCERN\n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experimental Results\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20061003\n 1602\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cdsweb.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n", "json": null, "modification_datetime": "2009-08-01T23:22:06+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n \n SPIRES\n 6225080\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cdsweb.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n", "json": null, "modification_datetime": "2010-04-17T04:36:24+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20121219073815.0\n \n SPIRES\n 6225080\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2012-12-19T06:38:15+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20130116000647.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n \n \n SPIRES\n 6225080\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2013-01-15T23:06:47+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20150211071127.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 002521938\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2015-02-11T06:11:27+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20161219120402.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2016-12-19T11:04:02+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20170621125423.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2017-06-21T10:54:23+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20170621231428.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n hep-ex/0505058\n \n \n eng\n \n \n Vorobev, I\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2017-06-21T21:14:28+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20170624100130.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n 468507\n 2138\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n Preprint\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n", "json": null, "modification_datetime": "2017-06-24T08:01:30+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20170628111829.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n 1260605\n 119-122\n C05-03-12\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n Preprint\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 119-122\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2017-06-28T09:18:29+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20230314212715.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n GAMMA GAMMA\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n Inspire\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n 1260605\n 119-122\n C05-03-12\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 119-122\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2023-03-14T20:27:15+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20260617172717.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive $\\rho\\rho$ production in tagged $\\gamma\\gamma$ interactions at LEP\n \n \n GAMMA GAMMA\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n Inspire\n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3 Collaboration\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n C05-03-12\n 2006\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 875-882\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2026-06-17T15:27:17+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20260617173831.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive Rho Rho Production in Tagged Gamma-Gamma Interactions at LEP\n \n \n GAMMA GAMMA\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n Inspire\n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3\n \n \n 875-882\n Acta Phys. Pol. B\n 37\n 2006\n \n \n 1260605\n 119-122\n C05-03-12\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n \n \n Carnegie Mellon University\n \n \n on behalf of the L\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 119-122\n lathuile20050312\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2026-06-17T15:38:31+00:00"}, {"marcxml": "\n 836244\n SzGeCERN\n 20260629155636.0\n \n oai:cds.cern.ch:836244\n cerncds:FULLTEXT\n cerncds:CERN:FULLTEXT\n cerncds:CERN\n \n \n SPIRES\n 6225080\n \n \n Inspire\n 682846\n \n \n arXiv\n hep-ex/0505058\n hep-ex\n \n \n eng\n \n \n PACS\n 13.60.Le\n \n \n Vorobiev, I.\n Carnegie Mellon U.\n \n \n Exclusive $ \\rho \\rho $ Production in Tagged $\\gamma \\gamma$ Interactions at LEP\n \n \n $\\gamma \\gamma $\n \n \n 2006\n \n \n 18 May 2005\n \n \n 4 p\n \n \n arXiv\n 4 pages, 2 figures. Talk on XXXXth Rencontres de Moriond, QCD and High Energy Hadronic Interactions, La Thuile, Italy, March 12 to 19, 2005\n \n \n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n Inspire\n Exclusive rho rho production in two-photon collisions between a quasi-real, gamma, and a virtual, gamma*, photons is studied at LEP at centre-of-mass energies 89 GeV < sqrt{s} < 209 GeV with a total integrated luminosity of 854.7 pb-1. The cross sections of the gamma gamma* -> rho rho processes are determined as a function of the photon virtuality, Q^2, and the two-photon centre-of-mass energy, Wgg, in the kinematic region: 0.2 GeV^2 < Q^2 < 30 GeV^2 and 1.1 GeV < Wgg < 3 GeV.\n \n \n LANL EDS\n \n \n SIS LANLPUBL2006\n \n \n SIS:2006 PR/LKR added\n \n \n SzGeCERN\n Particle Physics - Experiment\n \n \n ARTICLE\n \n \n CERN\n \n \n CERN LEP\n L3\n \n \n LANL EDS\n High Energy Physics - Experiment\n \n \n L3\n \n \n 875-882\n 3\n Acta Phys. Pol. B\n 37\n C05-08-30\n 2006\n \n \n 1260605\n 119-122\n C05-03-12\n \n \n http://documents.cern.ch/cgi-bin/setlink?base=preprint&categ=hep-ex&id=0505058\n Access to fulltext document\n \n \n 468507\n 2138\n http://cds.cern.ch/record/836244/files/0505058.pdf\n Access to fulltext document\n \n \n 1323055\n 93805\n http://cds.cern.ch/record/836244/files/arXiv:hep-ex_0505058.pdf\n \n \n n\n 200520\n \n \n 13\n \n \n 20100416\n 0806\n CER01\n 20050519\n \n \n 836059\n 119-122\n lathuile20050312\n \n \n 705894\n 875-882\n warsaw20050831\n \n \n PUBLIC\n \n \n 002522105CER\n \n \n ARTICLE\n \n \n ConferencePaper\n \n", "json": null, "modification_datetime": "2026-06-29T13:56:36+00:00"}]} ] \ No newline at end of file diff --git a/tests/cds-rdm/data/sspn/dumps/test_records.json b/tests/cds-rdm/data/sspn/dumps/test_records.json index 95b417f3..44fa1655 100644 --- a/tests/cds-rdm/data/sspn/dumps/test_records.json +++ b/tests/cds-rdm/data/sspn/dumps/test_records.json @@ -34,6 +34,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter15@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2788738, "creation_date": "2021-10-26 13:51:23", "record": [ @@ -89,6 +122,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter2@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2684743, "creation_date": "2019-08-02 22:21:57", "record": [ @@ -196,6 +262,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter5@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2889522, "creation_date":"2024-02-19 13:51:23", "record": [ @@ -381,6 +480,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter6@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2051872, "creation_date":"2015-09-11 13:51:23", "record": [ @@ -454,6 +586,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter7@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2207587, "creation_date": "2016-08-12 13:27:04", "record": [ @@ -482,6 +647,39 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter16@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2783103, "creation_date": "2016-08-12 13:27:04", "record": [ @@ -532,6 +730,45 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [ + { + "bibdocid": 2325606, + "status": "firerole: allow group \"it-dep\",\"hr-dep [CERN]\"\r\nallow email \"uploader@inveniosoftware.org\"", + "roles": [] + } + ], + "record": { + "owners": { + "859__f": [ + "submitter7@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2783104, "creation_date":"2021-10-04 13:51:23", "record": [ @@ -592,6 +829,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter9@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2046076, "creation_date": "2015-08-21 11:40:00", "record": [ @@ -657,6 +927,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter10@gmail.com" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 1597985, "creation_date": "2013-09-06 09:31:03", "record": [ @@ -715,6 +1018,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter11@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2041388, "creation_date": "2015-08-07 19:39:37", "record": [ @@ -774,6 +1110,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter12@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2779426, "creation_date": "2021-08-27 19:34:36", "record": [ @@ -827,6 +1196,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter13@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2294138, "creation_date": "2017-11-23 16:29:37", "record": [ @@ -850,6 +1252,39 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter16@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2783112, "creation_date": "2021-10-04 13:51:23", "record": [ diff --git a/tests/cds-rdm/data/staff_association/dumps/test_records.json b/tests/cds-rdm/data/staff_association/dumps/test_records.json index e6a4df95..683f9b89 100644 --- a/tests/cds-rdm/data/staff_association/dumps/test_records.json +++ b/tests/cds-rdm/data/staff_association/dumps/test_records.json @@ -2,6 +2,37 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 3000001, "creation_date": "2020-05-01 00:00:00", "record": [ @@ -15,6 +46,39 @@ { "files": [], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter13@cern.ch" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 3000002, "creation_date": "2021-06-01 00:00:00", "record": [ diff --git a/tests/cds-rdm/data/thesis/dump/test_records.json b/tests/cds-rdm/data/thesis/dump/test_records.json index 70903741..1cad5a88 100644 --- a/tests/cds-rdm/data/thesis/dump/test_records.json +++ b/tests/cds-rdm/data/thesis/dump/test_records.json @@ -38,6 +38,37 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2742366, "creation_date": "2025-04-29 08:25:01", "record": [ @@ -87,6 +118,37 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2741624, "creation_date": "2007-04-18 03:22:36", "record": [ @@ -237,6 +299,39 @@ } ], "collections": null, + "restrictions": { + "collections": {}, + "files": [], + "record": { + "owners": { + "859__f": [ + "submitter10@gmail.com" + ], + "270__m": [] + }, + "viewers": { + "506__m": [], + "5061_d": [] + } + }, + "edit": { + "collections": {}, + "any_collection": [] + }, + "comments": { + "primary_collection": "", + "view": { + "roles": [], + "any_collection": [] + }, + "send": { + "roles": [], + "any_collection": [] + }, + "record_restriction": "", + "restricted_comments": [] + } + }, "recid": 2316709, "creation_date": "2013-07-10 03:31:16", "record": [ diff --git a/tests/cds-rdm/helpers.py b/tests/cds-rdm/helpers.py index 263974a2..e46ae3a5 100644 --- a/tests/cds-rdm/helpers.py +++ b/tests/cds-rdm/helpers.py @@ -69,6 +69,7 @@ def config(mocker, community, orcid_name_data): "load": { "legacy_pids_to_redirect": "tests/cds-rdm/data/hr/duplicated_pids.json" }, + "restricted": "True", }, "hr_restricted": { "data_dir": "tests/cds-rdm/data/hr_restricted", @@ -83,9 +84,7 @@ def config(mocker, community, orcid_name_data): "load": { "legacy_pids_to_redirect": "tests/cds-rdm/data/hr_restricted/duplicated_pids.json" }, - # Configure access_grants_view with specific groups "restricted": "True", - "access_grants_view": ["cern-personnel"], }, "thesis": { "data_dir": "tests/cds-rdm/data/thesis", diff --git a/tests/cds-rdm/test_access_permissions.py b/tests/cds-rdm/test_access_permissions.py index a0e1c9be..698e46d9 100644 --- a/tests/cds-rdm/test_access_permissions.py +++ b/tests/cds-rdm/test_access_permissions.py @@ -20,15 +20,14 @@ def grant_access_permissions(record): - """2872558.""" + """2872558. + + Only ``5061_d`` (admin) is a legacy viewer tag: ``5061_m`` (hr-dep) grants + nothing. + """ dict_rec = record.to_dict() assert dict_rec["parent"]["access"]["grants"] == [ - { - "permission": "view", - "subject": {"id": "hr-dep", "type": "role"}, - "origin": "migrated", - }, { "permission": "view", "subject": {"id": "1", "type": "user"}, @@ -99,106 +98,3 @@ def test_access_permissions( grant_access_permissions(loaded_rec) if record["legacy_recid"] == "2872569": file_restricted(loaded_rec) - - -# Tests for access_grants_view configuration (collection-wide access grants) - - -def test_collection_with_access_grants_view_configuration( - test_app, - uploader, - client, - search, - search_clear, - superuser_identity, - orcid_name_data, - community, - mocker, - groups, -): - """Test that collections configured with access_grants_view properly assign access grants.""" - # Configure the stream with access_grants_view for HR collection - - stream_config = config(mocker, community, orcid_name_data) - - runner = Runner( - stream_definitions=[RecordStreamDefinition], - config_filepath=Path(stream_config).absolute(), - dry_run=False, - collection="hr_restricted", - keep_logs=False, - ) - runner.run() - - # Check that records were migrated with access grants from configuration - RDMRecord.index.refresh() - - # Test record (555555) to ensure access_grants_view is applied consistently - legacy_recid = "555555" - results = current_rdm_records_service.search( - system_identity, q=f"metadata.identifiers.identifier:{legacy_recid}" - ) - assert results.total == 1 - new_record = current_rdm_records_service.read( - system_identity, list(results.hits)[0]["id"] - ) - new_record = new_record.to_dict() - - # Verify this record also has parent with access grants - parent_id = new_record["parent"]["id"] - assert parent_id is not None - - # restricted file status, restricted = cern-personnel - # see https://cds.cern.ch/admin/webaccess/webaccessadmin.py/showactiondetails?id_action=39&reverse=1 - assert new_record["access"]["record"] == "restricted" - assert new_record["access"]["files"] == "restricted" - grants = new_record["parent"]["access"]["grants"] - sorted_grants = sorted(grants, key=lambda d: d["subject"]["id"]) - expected = [ - { - "permission": "view", - "subject": {"id": "cern-personnel", "type": "role"}, - "origin": "migrated", - }, - ] - - sorted_expected = sorted(expected, key=lambda d: d["subject"]["id"]) - assert sorted_grants == sorted_expected - - # assigned status - legacy_recid = "23646466" - results = current_rdm_records_service.search( - system_identity, q=f"metadata.identifiers.identifier:{legacy_recid}" - ) - assert results.total == 1 - - new_record = current_rdm_records_service.read( - system_identity, list(results.hits)[0]["id"] - ) - new_record = new_record.to_dict() - - # Verify this record also has parent with access grants - parent_id = new_record["parent"]["id"] - assert parent_id is not None - - assert new_record["access"]["record"] == "restricted" - assert new_record["access"]["files"] == "restricted" - grants = new_record["parent"]["access"]["grants"] - sorted_grants = sorted(grants, key=lambda d: d["subject"]["id"]) - expected = [ - { - "permission": "view", - "subject": {"id": "hr-web-gacepa", "type": "role"}, - "origin": "migrated", - }, - { - "permission": "view", - "subject": {"id": "eligibility-retr-actual", "type": "role"}, - "origin": "migrated", - }, - ] - - sorted_expected = sorted(expected, key=lambda d: d["subject"]["id"]) - # check if restricted file status takes over the collection settings - # see https://cds.cern.ch/admin/webaccess/webaccessadmin.py/showactiondetails?id_action=39&reverse=1 - assert sorted_grants == sorted_expected diff --git a/tests/cds-rdm/test_base_rules.py b/tests/cds-rdm/test_base_rules.py index 55f76a0b..9621df17 100644 --- a/tests/cds-rdm/test_base_rules.py +++ b/tests/cds-rdm/test_base_rules.py @@ -10,7 +10,7 @@ import pytest from dojson.errors import IgnoreKey -from cds_migrator_kit.errors import UnexpectedValue +from cds_migrator_kit.errors import ManualImportRequired, UnexpectedValue from cds_migrator_kit.rdm.records.transform.xml_processing.rules.base import ( _pids, custom_fields_693, @@ -63,41 +63,17 @@ def test_recid_string_conversion_to_int(self): class TestRecordRestriction: """Test record_restriction function from base.py.""" - def test_record_restriction_public(self): - """Test that PUBLIC returns 'public'.""" - record = {} - result = record_restriction(record, "963__", {"a": "PUBLIC"}) - assert result == "public" - - def test_record_restriction_public_lowercase(self): - """Test that public (lowercase) returns 'public'.""" - record = {} - result = record_restriction(record, "963__", {"a": "public"}) - assert result == "public" - - def test_record_restriction_public_mixed_case(self): - """Test that Public (mixed case) returns 'public'.""" - record = {} - result = record_restriction(record, "963__", {"a": "Public"}) - assert result == "public" - - def test_record_restriction_restricted_raises_error(self): - """Test that non-public values raise UnexpectedValue.""" - record = {} - with pytest.raises(UnexpectedValue): - record_restriction(record, "963__", {"a": "RESTRICTED"}) - - def test_record_restriction_empty_raises_error(self): - """Test that empty value raises UnexpectedValue.""" - record = {} - with pytest.raises(UnexpectedValue): - record_restriction(record, "963__", {"a": ""}) + @pytest.mark.parametrize("value", ["PUBLIC", "public", "Public"]) + def test_record_restriction_public_is_ignored(self, value): + """PUBLIC (any case) is ignored: access comes from the dump's restrictions.""" + with pytest.raises(IgnoreKey): + record_restriction({}, "963__", {"a": value}) - def test_record_restriction_cern_internal_raises_error(self): - """Test that CERN INTERNAL raises UnexpectedValue.""" - record = {} - with pytest.raises(UnexpectedValue): - record_restriction(record, "963__", {"a": "CERN INTERNAL"}) + @pytest.mark.parametrize("value", ["RESTRICTED", "", "CERN INTERNAL"]) + def test_record_restriction_non_public_requires_manual_import(self, value): + """Non-public values need a manual check.""" + with pytest.raises(ManualImportRequired): + record_restriction({}, "963__", {"a": value}) class TestCustomFields693: diff --git a/tests/cds-rdm/test_ep_approval_entry.py b/tests/cds-rdm/test_ep_approval_entry.py index bcac8616..b4bd41ae 100644 --- a/tests/cds-rdm/test_ep_approval_entry.py +++ b/tests/cds-rdm/test_ep_approval_entry.py @@ -15,7 +15,7 @@ from cds_migrator_kit.errors import UnexpectedValue from cds_migrator_kit.rdm.migration_config import CDS_CERN_SCIENTIFIC_COMMUNITY_ID -from cds_migrator_kit.rdm.records.load.entities.ep_split import ( +from cds_migrator_kit.rdm.records.load.entities.split_entries import ( EPPHAPP_FILE_TYPE, PublicEntry, RestrictedEntry, @@ -172,10 +172,7 @@ def _versions_with_epphapp(): PUBLIC_FILE_KEY: _public_file(checksum="main-v1"), }, "publication_date": "2020-01-10", - "access": { - "access_obj": {"record": None, "files": "restricted"}, - "meta": "EP Restricted Draft", - }, + "access": {"record": None, "files": "restricted"}, }, ), ( @@ -186,10 +183,7 @@ def _versions_with_epphapp(): PUBLIC_FILE_KEY: _public_file(checksum="main-v1"), }, "publication_date": "2020-01-12", - "access": { - "access_obj": {"record": None, "files": "restricted"}, - "meta": "EP Restricted Draft", - }, + "access": {"record": None, "files": "restricted"}, }, ), ( @@ -200,10 +194,7 @@ def _versions_with_epphapp(): PUBLIC_FILE_KEY: _public_file(checksum="main-v1"), }, "publication_date": "2020-01-14", - "access": { - "access_obj": {"record": None, "files": "restricted"}, - "meta": "EP Restricted Draft", - }, + "access": {"record": None, "files": "restricted"}, }, ), ( @@ -214,10 +205,7 @@ def _versions_with_epphapp(): PUBLIC_FILE_KEY: _public_file(checksum="main-v1"), }, "publication_date": "2020-01-15", - "access": { - "access_obj": {"record": None, "files": "restricted"}, - "meta": "EP Restricted Draft", - }, + "access": {"record": None, "files": "restricted"}, }, ), ] @@ -237,7 +225,7 @@ def _versions_public_only(): ), }, "publication_date": "2020-02-01", - "access": {"access_obj": {"record": None, "files": None}}, + "access": {"record": None, "files": None}, }, ), ] @@ -250,7 +238,7 @@ class TestPublicEntryVersions: def test_public_excludes_epphapp_files(self, app): entry = _make_entry(_versions_with_epphapp()) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() for _, vdata in result["versions"].items(): @@ -262,7 +250,7 @@ def test_public_excludes_epphapp_files(self, app): def test_public_deduplicates_identical_versions(self, app): entry = _make_entry(_versions_with_epphapp()) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert len(result["versions"]) == 1 @@ -270,12 +258,12 @@ def test_public_deduplicates_identical_versions(self, app): def test_public_access_is_public(self, app): entry = _make_entry(_versions_with_epphapp()) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() for _, vdata in result["versions"].items(): - assert vdata["access"]["access_obj"]["record"] == "public" - assert vdata["access"]["access_obj"]["files"] == "public" + assert vdata["access"]["record"] == "public" + assert vdata["access"]["files"] == "public" def test_public_raises_when_no_public_files(self, app): versions = OrderedDict( @@ -288,7 +276,8 @@ def test_public_raises_when_no_public_files(self, app): }, "publication_date": "2020-01-10", "access": { - "access_obj": {"record": None, "files": "restricted"}, + "record": None, + "files": "restricted", }, }, ), @@ -297,7 +286,9 @@ def test_public_raises_when_no_public_files(self, app): entry = _make_entry(versions) with pytest.raises(UnexpectedValue, match="No public files found"): PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, + _make_migration_logger(), + approval_request=_make_approval_request(), ).build() def test_public_excludes_restricted_non_epphapp_files(self, app): @@ -323,14 +314,14 @@ def test_public_excludes_restricted_non_epphapp_files(self, app): PUBLIC_FILE_KEY: _public_file(), }, "publication_date": "2020-01-01", - "access": {"access_obj": {"record": None, "files": None}}, + "access": {"record": None, "files": None}, }, ), ] ) entry = _make_entry(versions) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() files = result["versions"][1]["files"] @@ -349,7 +340,7 @@ def test_public_multiple_distinct_versions(self, app): ), }, "publication_date": "2020-01-01", - "access": {"access_obj": {"record": None, "files": None}}, + "access": {"record": None, "files": None}, }, ), ( @@ -364,14 +355,14 @@ def test_public_multiple_distinct_versions(self, app): ), }, "publication_date": "2020-02-01", - "access": {"access_obj": {"record": None, "files": None}}, + "access": {"record": None, "files": None}, }, ), ] ) entry = _make_entry(versions) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert len(result["versions"]) == 2 @@ -383,7 +374,7 @@ class TestRestrictedEntryVersions: def test_restricted_keeps_only_epphapp_when_present(self, app): entry = _make_entry(_versions_with_epphapp()) result = RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() for _, vdata in result["versions"].items(): @@ -395,7 +386,7 @@ def test_restricted_keeps_only_epphapp_when_present(self, app): def test_restricted_keeps_all_changing_epphapp_versions(self, app): entry = _make_entry(_versions_with_epphapp()) result = RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert len(result["versions"]) == 4 @@ -403,17 +394,19 @@ def test_restricted_keeps_all_changing_epphapp_versions(self, app): def test_restricted_access_is_restricted(self, app): entry = _make_entry(_versions_with_epphapp()) result = RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() for _, vdata in result["versions"].items(): - assert vdata["access"]["access_obj"]["record"] == "restricted" - assert vdata["access"]["access_obj"]["files"] == "restricted" + assert vdata["access"]["record"] == "restricted" + assert vdata["access"]["files"] == "restricted" def test_restricted_uses_public_files_when_no_epphapp(self, app): entry = _make_entry(_versions_public_only()) logger = _make_migration_logger() - result = RestrictedEntry(entry, _make_approval_request(), logger).build() + result = RestrictedEntry( + entry, logger, approval_request=_make_approval_request() + ).build() assert len(result["versions"]) == 1 assert "document.pdf" in result["versions"][1]["files"] @@ -427,7 +420,7 @@ def test_restricted_raises_when_no_files_at_all(self, app): { "files": {}, "publication_date": "2020-01-01", - "access": {"access_obj": {"record": None, "files": None}}, + "access": {"record": None, "files": None}, }, ), ] @@ -435,7 +428,9 @@ def test_restricted_raises_when_no_files_at_all(self, app): entry = _make_entry(versions) with pytest.raises(UnexpectedValue, match="No files found"): RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, + _make_migration_logger(), + approval_request=_make_approval_request(), ).build() @@ -452,7 +447,7 @@ def test_public_removes_cern_ep_report_numbers(self, app): ] entry = _make_entry(_versions_with_epphapp(), identifiers=identifiers) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() identifiers = result["record"].body["metadata"]["identifiers"] @@ -474,7 +469,7 @@ def test_public_keeps_non_ep_cdsrn(self, app): ] entry = _make_entry(_versions_with_epphapp(), identifiers=identifiers) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() cdsrn_ids = [ @@ -492,7 +487,7 @@ class TestRestrictedEntryIdentifiers: def test_restricted_removes_matching_cern_ep_rn(self, app): entry = _make_entry(_versions_with_epphapp()) result = RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() cdsrn_values = { @@ -511,7 +506,7 @@ def test_restricted_removes_apprn(self, app): ] entry = _make_entry(_versions_with_epphapp(), identifiers=identifiers) result = RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert not any( @@ -529,7 +524,7 @@ def test_restricted_keeps_draft_report_number(self, app): ] entry = _make_entry(_versions_with_epphapp(), identifiers=identifiers) result = RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() cdsrn_values = { @@ -549,13 +544,15 @@ def test_restricted_raises_on_mismatched_report_number(self, app): entry = _make_entry(_versions_with_epphapp(), identifiers=identifiers) with pytest.raises(UnexpectedValue, match="not the same"): RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, + _make_migration_logger(), + approval_request=_make_approval_request(), ).build() def test_restricted_removes_doi_pid(self, app): entry = _make_entry(_versions_with_epphapp(), has_doi=True) result = RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert "doi" not in result["record"].body.get("pids", {}) @@ -567,7 +564,7 @@ class TestPublicEntryModifications: def test_public_removes_request_data(self, app): entry = _make_entry(_versions_with_epphapp()) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert "_request_data" not in result @@ -575,7 +572,7 @@ def test_public_removes_request_data(self, app): def test_public_sets_owned_by_system(self, app): entry = _make_entry(_versions_with_epphapp()) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert result["parent"].body["access"]["owned_by"] == {"user": "system"} @@ -583,7 +580,7 @@ def test_public_sets_owned_by_system(self, app): def test_public_adds_cern_scientific_community(self, app): entry = _make_entry(_versions_with_epphapp()) result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert _cern_scientific_community_id() in result["parent"].communities["ids"] @@ -595,7 +592,7 @@ def test_public_does_not_duplicate_community(self, app): _cern_scientific_community_id(), ] result = PublicEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() community_ids = result["parent"].communities["ids"] @@ -608,7 +605,9 @@ class TestEntryImmutability: def test_public_build_does_not_mutate_original(self, app): entry = _make_entry(_versions_with_epphapp()) original = deepcopy(entry) - PublicEntry(entry, _make_approval_request(), _make_migration_logger()).build() + PublicEntry( + entry, _make_migration_logger(), approval_request=_make_approval_request() + ).build() assert ( entry["record"].body["metadata"]["identifiers"] @@ -619,7 +618,7 @@ def test_restricted_build_does_not_mutate_original(self, app): entry = _make_entry(_versions_with_epphapp()) original = deepcopy(entry) RestrictedEntry( - entry, _make_approval_request(), _make_migration_logger() + entry, _make_migration_logger(), approval_request=_make_approval_request() ).build() assert ( diff --git a/tests/cds-rdm/test_hr_migration.py b/tests/cds-rdm/test_hr_migration.py index c43d1e75..9a85d9db 100644 --- a/tests/cds-rdm/test_hr_migration.py +++ b/tests/cds-rdm/test_hr_migration.py @@ -20,7 +20,7 @@ from invenio_rdm_records.records.api import RDMDraft, RDMParent, RDMRecord from invenio_search.engine import dsl -from cds_migrator_kit.errors import UnexpectedValue +from cds_migrator_kit.errors import ManualImportRequired, UnexpectedValue from cds_migrator_kit.rdm.records.streams import RecordStreamDefinition from cds_migrator_kit.rdm.records.transform.xml_processing.rules.hr import ( access_grants, @@ -92,7 +92,7 @@ def test_full_hr_stream( # Title extracted from field 037__ (CERN-STAFF-RULES-ED01) when missing in 245__ assert new_record["metadata"]["title"] == "Staff Rules and Regulations ed. 01" - # Restricted access - field 591 --> CERN INTERNAL + # Restricted access - the HR stream is configured as restricted assert new_record["access"] == { "record": "restricted", "files": "restricted", @@ -280,15 +280,13 @@ def test_resource_type_invalid(): resource_type({}, "980__", {"a": "invalid_type"}) -def test_record_restriction_values(): - """Test record_restriction function with different access values.""" - # Test CERN internal - assert record_restriction({}, "591__", {"a": "CERN INTERNAL"}) == ["restricted"] - assert record_restriction({}, "591__", {"a": "cern internal"}) == ["restricted"] - - # Test restricted - assert record_restriction({}, "591__", {"a": "RESTRICTED"}) == ["restricted"] - assert record_restriction({}, "591__", {"a": "restricted"}) == ["restricted"] +@pytest.mark.parametrize( + "access", ["CERN INTERNAL", "cern internal", "RESTRICTED", "restricted"] +) +def test_record_restriction_values(access): + """Restricted access values need a manual check.""" + with pytest.raises(ManualImportRequired): + record_restriction({}, "591__", {"a": access}) def test_record_restriction_public(): diff --git a/tests/cds-rdm/test_json_translation_rules.py b/tests/cds-rdm/test_json_translation_rules.py index 6f70691a..df876616 100644 --- a/tests/cds-rdm/test_json_translation_rules.py +++ b/tests/cds-rdm/test_json_translation_rules.py @@ -105,7 +105,6 @@ def test_migrate_sspn_record(datadir, base_app): ], "submitter": "oa@cern.ch", "status_week_date": "2017-09-18", - "record_restriction": "public", } @@ -247,5 +246,4 @@ def test_migrate_record_all_fields(datadir, base_app): ], "submitter": "vj@cern.ch", "status_week_date": "2019-07-29", - "record_restriction": "public", } diff --git a/tests/cds-rdm/test_mixed_restrictions.py b/tests/cds-rdm/test_mixed_restrictions.py new file mode 100644 index 00000000..57af1b7b --- /dev/null +++ b/tests/cds-rdm/test_mixed_restrictions.py @@ -0,0 +1,370 @@ +# -*- 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. + +"""Who can see what after migrating legacy records with mixed restrictions. + +Each test: + +1. describes a legacy record with ``legacy_record()``: only the parts that + matter for access (files and their statuses, owners, viewers, ...), on top + of a fixed minimal MARC record; +2. migrates it with ``migrate()``: the real transform and load steps, as in a + migration run, into the test database; +3. compares ``summarize()`` of each resulting RDM record with the expected + access, exactly - so that any extra grant, file or community fails the test. + +A record with both public and restricted files is split into a public record +and a restricted record, which link to each other with ``isvariantformof``. +""" + +from unittest.mock import MagicMock + +import pytest +from invenio_access.permissions import system_identity +from invenio_accounts.models import User +from invenio_rdm_records.proxies import current_rdm_records_service + +from cds_migrator_kit.errors import ManualImportRequired +from cds_migrator_kit.rdm.records.load.entities.split_migration_entry_load import ( + SplitMigrationEntryLoad, +) +from cds_migrator_kit.rdm.records.transform.entities import ( + parent as transform_parent_module, +) +from cds_migrator_kit.rdm.records.transform.entities.community_members import ( + community_subjects, +) +from cds_migrator_kit.rdm.records.transform.transform import CDSToRDMRecordTransform + +RECID = 1000001 +LEGACY_FILES_ROOT = "/opt/cdsweb/var/data/files" +# The content of every file, and its checksum, which the load step verifies. +FILE_CONTENT = b"test" +FILE_CHECKSUM = "098f6bcd4621d373cade4e832627b4f6" + +# A minimal legacy summer student note, which only the summer student report +# model matches. +MARC_TEMPLATE = """ + {recid} + + CERN-STUDENTS-Note-2021-001 + + + Doe, Jane + + + A record with mixed restrictions + + + 04 Oct 2021 + + + n + 202140 + + + NOTE + +""" + + +def legacy_file(name, status="", bibdocid=None): + """A file of a legacy record; ``status`` is empty for public files.""" + return {"full_name": name, "status": status, "bibdocid": bibdocid} + + +def legacy_record(files, owners=(), viewers=(), restricted_collections=None): + """A legacy record dump, as exported by invenio-migrator. + + :param files: ``legacy_file()`` entries. + :param owners: emails of the record owners (``859__f``). + :param viewers: emails or groups allowed to view the record (``506__m``). + :param restricted_collections: restricted collections of the record, with + the legacy roles allowed to view each of them. + """ + file_dumps = [] + for index, file in enumerate(files): + bibdocid = file["bibdocid"] or index + 1 + file_dumps.append( + { + "recid": RECID, + "bibdocid": bibdocid, + "full_name": file["full_name"], + "name": file["full_name"].removesuffix(".pdf"), + "status": file["status"], + "version": 1, + "type": "Main", + "hidden": False, + "subformat": "", + "format": ".pdf", + "superformat": ".pdf", + "mime": "application/pdf", + "checksum": FILE_CHECKSUM, + "size": 4, + "creation_date": "2021-10-04T12:00:00+00:00", + "modification_date": "2021-10-04T12:00:00+00:00", + "full_path": f"{LEGACY_FILES_ROOT}/g0/{bibdocid}/content.pdf;1", + "path": f"{LEGACY_FILES_ROOT}/g0/{bibdocid}/content.pdf;1", + "url": f"http://cds.cern.ch/record/{RECID}/files/{file['full_name']}", + "recids_doctype": [[RECID, "Main", file["full_name"]]], + "comment": None, + "description": None, + "encoding": None, + "flags": [], + "license": {}, + "copyright": {}, + } + ) + + return { + "recid": RECID, + "creation_date": "2021-10-04 12:00:00", + "collections": None, + "record": [ + { + "json": None, + "marcxml": MARC_TEMPLATE.format(recid=RECID), + "modification_datetime": "2021-10-04T12:00:00+00:00", + } + ], + "files": file_dumps, + "restrictions": { + "collections": restricted_collections or {}, + "files": [ + {"bibdocid": f["bibdocid"], "status": f["status"], "roles": []} + for f in file_dumps + if f["status"] + ], + "record": { + "owners": {"859__f": list(owners), "270__m": []}, + "viewers": {"506__m": list(viewers), "5061_d": []}, + }, + "edit": {"collections": {}, "any_collection": []}, + "comments": { + "primary_collection": "", + "view": {"roles": [], "any_collection": []}, + "send": {"roles": [], "any_collection": []}, + "record_restriction": "", + "restricted_comments": [], + }, + }, + } + + +@pytest.fixture() +def migrate(test_app, community, groups, search_clear, tmp_path): + """Return a function migrating a legacy record, see the module docstring. + + It returns the migrated RDM records: ``[record]``, or ``[public, + restricted]`` when the record was split. If the migration of the record + fails, the error is raised instead of only being logged. + """ + # Module-level caches, which would otherwise leak users and members + # between tests. + transform_parent_module._submitter_id_by_email.clear() + community_subjects.cache_clear() + + def raise_error(error, record=None): + raise error + + migration_logger = MagicMock() + migration_logger.add_log.side_effect = raise_error + record_state_logger = MagicMock() + + def _migrate(legacy, target_community=community): + for file in legacy["files"]: + path = tmp_path / file["full_path"].removeprefix(LEGACY_FILES_ROOT + "/") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(FILE_CONTENT) + + transform = CDSToRDMRecordTransform( + files_dump_dir=tmp_path, + communities_ids=[str(target_community.id)], + migration_logger=migration_logger, + record_state_logger=record_state_logger, + ) + load = SplitMigrationEntryLoad( + migration_logger=migration_logger, + record_state_logger=record_state_logger, + ) + load._load(transform._transform(legacy)) + + state = record_state_logger.add_record_state.call_args.args[0] + record_ids = [state["latest_version"]] + if "internal_version" in state: + record_ids.append(state["internal_version"]) + return [ + current_rdm_records_service.read(system_identity, record_id) + for record_id in record_ids + ] + + return _migrate + + +def summarize(record): + """The access-related parts of a migrated RDM record, as plain values.""" + record = current_rdm_records_service.read(system_identity, record.id) + parent = record._record.parent + + def email(user_id): + return User.query.get(int(user_id)).email + + owner = parent.access.owned_by.owner_id + grants = [] + for grant in parent.access.grants: + subject = grant.subject_id + if grant.subject_type == "user": + subject = email(subject) + grants.append((grant.subject_type, subject, grant.permission)) + + return { + "access": { + "record": record.data["access"]["record"], + "files": record.data["access"]["files"], + }, + "files": sorted(record.data["files"]["entries"]), + "owner": owner if owner == "system" else email(owner), + "grants": set(grants), + "communities": set(parent.communities.ids), + "related": [ + (related["relation_type"]["id"], related["identifier"]) + for related in record.data["metadata"].get("related_identifiers", []) + ], + } + + +def test_public_and_sso_files(migrate, uploader, community, cern_scientific_community): + """The SSO file goes to a restricted record, viewable by CERN personnel.""" + legacy = legacy_record( + files=[ + legacy_file("public.pdf"), + legacy_file("secret.pdf", status="SSO"), + ], + owners=[uploader.email], + ) + + public, restricted = migrate(legacy) + + assert summarize(public) == { + "access": {"record": "public", "files": "public"}, + "files": ["public.pdf"], + "owner": uploader.email, + "grants": set(), + "communities": {str(community.id), str(cern_scientific_community.id)}, + "related": [("isvariantformof", restricted.id)], + } + assert summarize(restricted) == { + "access": {"record": "restricted", "files": "restricted"}, + "files": ["secret.pdf"], + "owner": uploader.email, + "grants": {("role", "cern-personnel", "view")}, + "communities": {str(community.id)}, + "related": [("isvariantformof", public.id)], + } + + +def test_only_public_files(migrate, uploader, community, cern_scientific_community): + """The record should not get split, only the public one should get created.""" + legacy = legacy_record( + files=[ + legacy_file("public.pdf"), + ], + owners=[uploader.email], + ) + + migrated_records = migrate(legacy) + assert len(migrated_records) == 1 + public = migrated_records[0] + + assert summarize(public) == { + "access": {"record": "public", "files": "public"}, + "files": ["public.pdf"], + "owner": uploader.email, + "grants": set(), + "communities": {str(community.id), str(cern_scientific_community.id)}, + "related": [], + } + + +def test_only_restricted_files(migrate, uploader, community): + """The record should not get split, only the restricted one should get created.""" + legacy = legacy_record( + files=[ + legacy_file("secret.pdf", status="SSO"), + ], + owners=[uploader.email], + ) + + migrated_records = migrate(legacy) + assert len(migrated_records) == 1 + restricted = migrated_records[0] + + assert summarize(restricted) == { + "access": {"record": "public", "files": "restricted"}, + "files": ["secret.pdf"], + "owner": uploader.email, + "grants": {("role", "cern-personnel", "view")}, + "communities": {str(community.id)}, + "related": [], + } + + +def test_fails_public_in_restricted_community(migrate, uploader, restricted_community): + legacy = legacy_record( + files=[ + legacy_file("public.pdf"), + legacy_file("secret.pdf", status="SSO"), + ], + owners=[uploader.email], + ) + + with pytest.raises(ManualImportRequired) as exc: + migrate(legacy, target_community=restricted_community) + + assert ( + exc.value.message is not None + and "added to at least one restricted community" in exc.value.message + ) + + +def test_grants_in_restricted_community( + migrate, uploader, restricted_community, normal_user +): + legacy = legacy_record( + files=[ + legacy_file("no_status.pdf"), + ], + owners=[uploader.email], + viewers=[normal_user.email], + restricted_collections={ + "Example Collection": [ + { + "id": "example-role", + "name": "Example role", + "description": "Example description", + "firerole_def_src": 'allow group "example-group"', + "users": [], + } + ] + }, + ) + + migrated_records = migrate(legacy, target_community=restricted_community) + + assert len(migrated_records) == 1 + restricted = migrated_records[0] + + assert summarize(restricted) == { + "access": {"record": "restricted", "files": "restricted"}, + "files": ["no_status.pdf"], + "owner": uploader.email, + "grants": { + ("user", normal_user.email, "view"), + }, + "communities": {str(restricted_community.id)}, + "related": [], + } diff --git a/tests/cds-rdm/test_resolve_grants.py b/tests/cds-rdm/test_resolve_grants.py index b1e6e2f2..0d26a3d7 100644 --- a/tests/cds-rdm/test_resolve_grants.py +++ b/tests/cds-rdm/test_resolve_grants.py @@ -8,10 +8,10 @@ """Unit tests for ``RecordParent.resolve_grants()``. These characterise the current behaviour of every branch that turns a -legacy file-restriction string (``VersionEntry["access"]["meta"]``) plus -the record's own access grants into ``(groups, emails, grants_with_perms)``. -They rely on the shared ``test_app`` fixture (conftest) for the application -context and its ``CDS_ACCESS_GROUP_MAPPINGS`` config (``SSO`` -> +legacy restriction dump plus the record's own access grants into +``(groups, emails, grants_with_perms)``. They rely on the shared +``test_app`` fixture (conftest) for the application context and +its ``CDS_ACCESS_GROUP_MAPPINGS`` config (``SSO`` -> ``cern-personnel``, ``HrDepRestrFile`` -> ``hr-dep`` + ``fap-dep-tpr-mi-staf``). """ @@ -23,14 +23,20 @@ from cds_migrator_kit.rdm.records.transform.entities.parent import RecordParent -def make_parent(access_grants=None): - """Build a RecordParent bypassing build(); set access_grants directly.""" +def make_parent(access_grants=None, statuses=(), roles=None): + """Build a RecordParent bypassing build(); set access_grants directly. + + :param statuses: the status of each restricted file, all given ``roles``. + """ + files = [ + {"bibdocid": i, "status": status, "roles": roles or []} + for i, status in enumerate(statuses) + ] parent = RecordParent( record=SimpleNamespace(recid="123"), - raw_dump_entry={"recid": "123"}, + raw_dump_entry={"recid": "123", "restrictions": {"files": files}}, dojson_entry={}, communities_ids=["some-community"], - access_grants_view=[], ) parent.access_grants = access_grants or [] return parent @@ -41,7 +47,7 @@ def make_parent(access_grants=None): def test_empty_restriction_no_grants(test_app): """No file restriction and no record grants -> everything empty.""" - groups, emails, grants = make_parent().resolve_grants("") + groups, emails, grants = make_parent().resolve_grants() assert groups == set() assert emails == set() assert grants == {} @@ -49,7 +55,7 @@ def test_empty_restriction_no_grants(test_app): def test_mapped_status_resolves_to_group(test_app): """A status in CDS_ACCESS_GROUP_MAPPINGS resolves to its mapped groups.""" - groups, emails, grants = make_parent().resolve_grants("SSO") + groups, emails, grants = make_parent(statuses=["SSO"]).resolve_grants() assert groups == {"cern-personnel"} assert emails == set() assert grants == {} @@ -57,49 +63,51 @@ def test_mapped_status_resolves_to_group(test_app): def test_mapped_status_multiple_groups(test_app): """A status mapping to several groups adds all of them.""" - groups, _, _ = make_parent().resolve_grants("HrDepRestrFile") + groups, _, _ = make_parent(statuses=["HrDepRestrFile"]).resolve_grants() assert groups == {"hr-dep", "fap-dep-tpr-mi-staf"} def test_literal_restricted_maps_to_cern_personnel(test_app): """The literal 'restricted' status maps to cern-personnel.""" - groups, emails, grants = make_parent().resolve_grants("restricted") + groups, emails, grants = make_parent(statuses=["restricted"]).resolve_grants() assert groups == {"cern-personnel"} assert emails == set() def test_bare_cern_egroup_name(test_app): """A bare ' [CERN]' e-group name is added, '[CERN]' stripped.""" - groups, emails, _ = make_parent().resolve_grants( - "cds-ph-ep-publications-referee-non-lhc [CERN]" - ) + groups, emails, _ = make_parent( + statuses=["cds-ph-ep-publications-referee-non-lhc [CERN]"] + ).resolve_grants() assert groups == {"cds-ph-ep-publications-referee-non-lhc"} assert emails == set() def test_firerole_allow_group(test_app): """A firerole 'allow group' string parses out the group(s).""" - groups, emails, _ = make_parent().resolve_grants( - 'firerole: allow group "atlas-active-members [CERN]"\ndeny all' - ) + groups, emails, _ = make_parent( + statuses=['firerole: allow group "atlas-active-members [CERN]"\ndeny all'] + ).resolve_grants() assert groups == {"atlas-active-members"} assert emails == set() def test_firerole_allow_multiple_groups(test_app): """A firerole 'allow group' with several quoted groups parses all.""" - groups, _, _ = make_parent().resolve_grants( - 'firerole: allow group "atlas-active-members [CERN]", "cms-members [CERN]"\n' - "deny all" - ) + groups, _, _ = make_parent( + statuses=[ + 'firerole: allow group "atlas-active-members [CERN]", "cms-members [CERN]"\n' + "deny all" + ] + ).resolve_grants() assert groups == {"atlas-active-members", "cms-members"} def test_firerole_allow_email(test_app): """A firerole 'allow email' string parses out the email(s).""" - groups, emails, _ = make_parent().resolve_grants( - 'firerole: allow email "a@cern.ch"\ndeny all' - ) + groups, emails, _ = make_parent( + statuses=['firerole: allow email "a@cern.ch"\ndeny all'] + ).resolve_grants() assert groups == set() assert emails == {"a@cern.ch"} @@ -107,7 +115,50 @@ def test_firerole_allow_email(test_app): def test_unmapped_non_firerole_status_raises(test_app): """An unknown status that is neither mapped nor firerole hard-raises.""" with pytest.raises(ManualImportRequired): - make_parent().resolve_grants("TOTALLY_UNKNOWN_STATUS") + make_parent(statuses=["TOTALLY_UNKNOWN_STATUS"]).resolve_grants() + + +def test_unsupported_firerole_rule_raises(test_app): + """A firerole with rules RDM can't represent hard-raises.""" + with pytest.raises(ManualImportRequired): + make_parent( + statuses=['firerole: allow remote_ip "137.138.0.0/16"'] + ).resolve_grants() + + +NA61_ROLE = { + "id": 397, + "name": "NA61EditorialBoard", + "description": "Editorial board for NA61", + "firerole_def_src": 'allow group "cds-edboard-na61 [CERN]"', + "users": [{"email": "a@cern.ch"}], +} + + +def test_unmapped_status_resolves_from_roles(test_app): + """An unmapped status falls back to the legacy roles authorised for it.""" + groups, emails, _ = make_parent( + statuses=["NA61-editorial-board"], roles=[NA61_ROLE] + ).resolve_grants() + assert groups == {"cds-edboard-na61"} + assert emails == {"a@cern.ch"} + + +def test_mapped_status_takes_precedence_over_roles(test_app): + """CDS_ACCESS_GROUP_MAPPINGS overrides the dumped roles.""" + groups, emails, _ = make_parent( + statuses=["SSO"], roles=[NA61_ROLE] + ).resolve_grants() + assert groups == {"cern-personnel"} + assert emails == set() + + +def test_several_restricted_files_are_unioned(test_app): + """Groups/emails of every restricted file are combined.""" + groups, _, _ = make_parent( + statuses=["SSO", "restricted", "it-dep [CERN]"] + ).resolve_grants() + assert groups == {"cern-personnel", "it-dep"} # --- Record access grants (``self.access_grants``) ------------------------- @@ -121,7 +172,7 @@ def test_record_group_grant_without_file_restriction(test_app): """ groups, emails, grants = make_parent( [{"some-group [CERN]": "manage"}] - ).resolve_grants("") + ).resolve_grants() assert groups == {"some-group"} assert emails == set() assert grants == {"some-group [CERN]": "manage"} @@ -129,7 +180,7 @@ def test_record_group_grant_without_file_restriction(test_app): def test_record_email_grant_defaults_to_view(test_app): """A record email grant with no permission defaults to 'view'.""" - groups, emails, grants = make_parent([{"user@cern.ch": None}]).resolve_grants("") + groups, emails, grants = make_parent([{"user@cern.ch": None}]).resolve_grants() assert groups == set() assert emails == {"user@cern.ch"} assert grants == {"user@cern.ch": "view"} @@ -138,9 +189,9 @@ def test_record_email_grant_defaults_to_view(test_app): def test_file_restriction_withholds_record_subjects_from_access(test_app): """When a file restriction exists, record subjects are kept out of groups/emails (least access) but still recorded in grants_with_perms.""" - groups, emails, grants = make_parent([{"extra-group": "view"}]).resolve_grants( - "SSO" - ) + groups, emails, grants = make_parent( + [{"extra-group": "view"}], statuses=["SSO"] + ).resolve_grants() assert groups == {"cern-personnel"} assert emails == set() assert grants == {"extra-group": "view"} diff --git a/tests/cds-rdm/test_restricted_collections.py b/tests/cds-rdm/test_restricted_collections.py new file mode 100644 index 00000000..1aba19ff --- /dev/null +++ b/tests/cds-rdm/test_restricted_collections.py @@ -0,0 +1,202 @@ +# -*- 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. + +"""Tests for the restricted collection roles vs. target community members check.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from invenio_accounts.models import Role +from invenio_communities.members.records.models import MemberModel + +from cds_migrator_kit.errors import ManualImportRequired +from cds_migrator_kit.rdm.records.transform.entities import ( + community_members as community_members_module, +) +from cds_migrator_kit.rdm.records.transform.entities.community_members import ( + CommunityMembersCheck, + community_subjects, +) +from cds_migrator_kit.rdm.records.transform.entities.restrictions import ( + legacy_role_subjects, + parse_firerole, +) + + +def make_role(firerole_def_src="", users=()): + return { + "id": 1, + "name": "SomeRole", + "description": "", + "firerole_def_src": firerole_def_src, + "users": [{"email": email} for email in users], + } + + +NA61_ROLE = make_role('allow group "cds-edboard-na61 [CERN]"', ["Jane.Doe@cern.ch"]) + + +def make_check( + collections, + members, + monkeypatch, + communities_ids=("comm-a",), + edit=None, +): + """Check of the given collections, against communities with ``members``.""" + monkeypatch.setattr( + community_members_module, + "community_subjects", + lambda community_id: members[community_id], + ) + monkeypatch.setattr( + community_members_module, "current_app", SimpleNamespace(config={}) + ) + return CommunityMembersCheck( + restrictions={ + "collections": collections, + "edit": edit or {"collections": {}, "any_collection": []}, + }, + communities_ids=list(communities_ids), + recid="123", + migration_logger=MagicMock(), + ) + + +class TestParseFirerole: + def test_groups_and_emails(self): + groups, emails = parse_firerole( + 'allow group "a [CERN]", "b [CERN]"\r\nallow email "X@cern.ch"\ndeny any' + ) + assert groups == {"a", "b"} + assert emails == {"x@cern.ch"} + + def test_firerole_prefix(self): + assert parse_firerole('firerole: allow group "a [CERN]"') == ({"a"}, set()) + + @pytest.mark.parametrize( + "src", ["allow any", 'deny group "a [CERN]"', 'allow remote_ip "1.2.3.4"'] + ) + def test_unsupported(self, src): + with pytest.raises(ValueError): + parse_firerole(src) + + def test_role_subjects(self): + assert legacy_role_subjects([NA61_ROLE, make_role(users=["b@cern.ch"])]) == ( + {"cds-edboard-na61"}, + {"jane.doe@cern.ch", "b@cern.ch"}, + ) + + +class TestCheckRestrictedCollections: + def test_no_restricted_collection(self, monkeypatch): + check = make_check({"NA61": [], "NA61 Papers": []}, {}, monkeypatch) + check._check_restricted_collections() + check.migration_logger.add_information.assert_not_called() + + def test_several_restricted_collections(self, monkeypatch): + check = make_check({"A": [NA61_ROLE], "B": [NA61_ROLE]}, {}, monkeypatch) + with pytest.raises(ManualImportRequired): + check._check_restricted_collections() + + def test_exact_match(self, monkeypatch): + members = {"comm-a": ({"cds-edboard-na61"}, {"jane.doe@cern.ch"})} + check = make_check({"NA61": [NA61_ROLE]}, members, monkeypatch) + check._check_restricted_collections() + check.migration_logger.add_information.assert_not_called() + + def test_missing_member(self, monkeypatch): + members = { + "comm-a": ({"cds-edboard-na61"}, {"jane.doe@cern.ch"}), + "comm-b": ({"cds-edboard-na61"}, set()), + } + check = make_check( + {"NA61": [NA61_ROLE]}, members, monkeypatch, ("comm-a", "comm-b") + ) + check._check_restricted_collections() + check.migration_logger.add_information.assert_called_once() + + def test_extra_member_is_logged(self, monkeypatch): + members = { + "comm-a": ({"cds-edboard-na61", "curators"}, {"jane.doe@cern.ch"}), + } + check = make_check({"NA61": [NA61_ROLE]}, members, monkeypatch) + check._check_restricted_collections() + check.migration_logger.add_information.assert_called_once() + _, state = check.migration_logger.add_information.call_args.args + assert state["value"] == "curators" + + def test_unsupported_firerole(self, monkeypatch): + check = make_check({"NA61": [make_role("allow any")]}, {}, monkeypatch) + with pytest.raises(ManualImportRequired): + check._check_restricted_collections() + + +def test_community_subjects(db, community, uploader): + role = Role(id="cds-edboard-na61", name="cds-edboard-na61") + db.session.add(role) + db.session.flush() + for kwargs in ({"group_id": role.id}, {"user_id": uploader.id}): + db.session.add( + MemberModel( + community_id=community.id, + role="reader", + visible=True, + active=True, + **kwargs, + ) + ) + db.session.commit() + + community_subjects.cache_clear() + assert community_subjects(str(community.id)) == ( + frozenset({"cds-edboard-na61"}), + frozenset({"uploader@inveniosoftware.org"}), + ) + + +class TestCheckEditRoles: + EDITOR_ROLE = make_role('allow group "cds-curators [CERN]"', ["Ed@cern.ch"]) + + def test_no_edit_roles(self, monkeypatch): + check = make_check({}, {}, monkeypatch) + check._check_edit_roles() + + def test_members(self, monkeypatch): + members = {"comm-a": ({"cds-curators", "other"}, {"ed@cern.ch"})} + edit = {"collections": {"NA61": [self.EDITOR_ROLE]}, "any_collection": []} + check = make_check({}, members, monkeypatch, edit=edit) + check._check_edit_roles() + + def test_any_collection_roles_not_checked(self, monkeypatch): + """Instance-wide curators must not have to be community members.""" + members = {"comm-a": (set(), set())} + edit = {"collections": {}, "any_collection": [self.EDITOR_ROLE]} + check = make_check({}, members, monkeypatch, edit=edit) + check._check_edit_roles() + + def test_missing_member(self, monkeypatch): + members = {"comm-a": ({"cds-curators"}, set())} + edit = {"collections": {"NA61": [self.EDITOR_ROLE]}, "any_collection": []} + check = make_check({}, members, monkeypatch, edit=edit) + check._check_edit_roles() + check.migration_logger.add_information.assert_called_once() + + +def test_cern_scientific_community_is_not_checked(monkeypatch): + """The CERN Research community's members are never compared to the roles.""" + members = {"comm-a": ({"cds-edboard-na61"}, {"jane.doe@cern.ch"})} + check = make_check( + {"NA61": [NA61_ROLE]}, members, monkeypatch, ("comm-a", "cern-research") + ) + monkeypatch.setattr( + community_members_module, + "current_app", + SimpleNamespace(config={"CDS_CERN_SCIENTIFIC_COMMUNITY_ID": "cern-research"}), + ) + check._check_restricted_collections() diff --git a/tests/cds-rdm/test_scientific_community.py b/tests/cds-rdm/test_scientific_community.py index 1e53af42..b0031674 100644 --- a/tests/cds-rdm/test_scientific_community.py +++ b/tests/cds-rdm/test_scientific_community.py @@ -34,10 +34,30 @@ def _file_dump(version=1, status="", full_name="draft.pdf"): } -def _dojson_entry(access="public", communities=None): +def _restrictions(files): + """Build the dump's ``restrictions``, as the legacy dumper would. + + Like a legacy BibDoc status, a file's restriction is the status of its + latest revision; only restricted files are listed. + """ + statuses = {} + for file_dump in sorted(files, key=lambda f: f["version"]): + statuses[file_dump["full_name"]] = file_dump["status"] + return { + "collections": {}, + "files": [ + {"bibdocid": i, "status": status, "roles": []} + for i, status in enumerate(statuses.values()) + if status + ], + "record": {"owners": {}, "viewers": {}}, + "edit": {"collections": {}, "any_collection": []}, + } + + +def _dojson_entry(communities=None): """Build a minimal DOJSON-processed entry (it carries no ``files`` key).""" return { - "record_restriction": access, "communities": communities or [], } @@ -56,7 +76,12 @@ def _record( """ record = RecordEntry( dump=SimpleNamespace( - data={"recid": RECID, "files": files or []}, first_created=None + data={ + "recid": RECID, + "files": files or [], + "restrictions": _restrictions(files or []), + }, + first_created=None, ), dojson_entry=dojson_entry, restricted=restricted, @@ -82,7 +107,6 @@ def _build_communities( raw_dump_entry=record.raw_dump_entry, dojson_entry=dojson_entry, communities_ids=communities_ids, - access_grants_view=None, ) return parent._build_communities() @@ -106,13 +130,17 @@ def test_restricted_file_in_latest_version(self): assert record.has_any_restricted_file() assert record.has_any_restrictions() - def test_restricted_file_in_older_version(self): - """A restricted file in an older version is detected, even if the latest is public.""" + def test_restriction_lifted_in_latest_version(self): + """A file whose latest revision is public is not restricted. + + A legacy file's status is set per BibDoc: the dump only has the + current one. + """ files = [_file_dump(version=1, status="restricted"), _file_dump(version=2)] record = _record(_dojson_entry(), files=files) - assert record.has_any_restricted_file() - assert record.has_any_restrictions() + assert not record.has_any_restricted_file() + assert not record.has_any_restrictions() @pytest.fixture @@ -163,17 +191,6 @@ def test_research_resource_types( assert str(cern_scientific_community.id) in result["ids"] assert str(cern_scientific_community.id) != result["default"] - def test_skip_restricted_record( - self, communities_ids, community, cern_scientific_community - ): - """Restricted records are not included in the CERN Research community.""" - result = _build_communities(communities_ids, _dojson_entry(access="restricted")) - - assert result == { - "ids": [str(community.id)], - "default": str(community.id), - } - def test_skip_restricted_files( self, communities_ids, community, cern_scientific_community ): @@ -189,14 +206,17 @@ def test_skip_restricted_files( "default": str(community.id), } - def test_skip_restricted_file_in_older_version( + def test_skip_restricted_file_with_several_versions( self, communities_ids, community, cern_scientific_community ): - """A restricted file in an older version excludes the whole parent.""" + """A restricted file with several revisions excludes the whole parent.""" result = _build_communities( communities_ids, _dojson_entry(), - files=[_file_dump(version=1, status="restricted"), _file_dump(version=2)], + files=[ + _file_dump(version=1, status="restricted"), + _file_dump(version=2, status="restricted"), + ], ) assert result == { diff --git a/tests/cds-rdm/test_transform_versions.py b/tests/cds-rdm/test_transform_versions.py index 0f526f7f..9d53decf 100644 --- a/tests/cds-rdm/test_transform_versions.py +++ b/tests/cds-rdm/test_transform_versions.py @@ -68,6 +68,28 @@ def _file_dump( } +def _raw_dump_entry(files): + """Build a raw dump entry, with the ``restrictions`` the legacy dumper adds. + + Like a legacy BibDoc status, a file's restriction is the status of its + latest revision; only restricted files are listed. + """ + statuses = {} + for file_dump in sorted(files, key=lambda f: f["version"]): + statuses[file_dump["bibdocid"]] = file_dump["status"] + return { + "recid": 123, + "files": files, + "restrictions": { + "files": [ + {"bibdocid": bibdocid, "status": status, "roles": []} + for bibdocid, status in statuses.items() + if status + ] + }, + } + + def _record(pids=None): """Build a minimal RecordEntry-shaped test double.""" body = {"metadata": {"publication_date": "2020-01-01"}} @@ -95,14 +117,13 @@ def transform(tmp_path): def test_versions_preserve_file_revision_per_record_version(transform): """Each record version keeps the file revision.""" - raw_dump_entry = { - "recid": 123, - "files": [ + raw_dump_entry = _raw_dump_entry( + [ _file_dump(file_version=1, checksum="checksum-v1"), _file_dump(file_version=2, checksum="checksum-v2"), _file_dump(full_name="test.pdf", file_version=1, checksum="checksum-v3"), - ], - } + ] + ) versions = transform._versions(raw_dump_entry, _record()) @@ -118,9 +139,8 @@ def test_versions_preserve_file_revision_per_record_version(transform): def test_versions_with_skipped_files(transform): """Versions with skipped files should not create extra record versions.""" - raw_dump_entry = { - "recid": 123, - "files": [ + raw_dump_entry = _raw_dump_entry( + [ _file_dump( full_name="main.pdf", file_version=1, @@ -161,8 +181,8 @@ def test_versions_with_skipped_files(transform): bibdocid=20, creation_date="2020-01-06T00:00:00+00:00", ), - ], - } + ] + ) versions = transform._versions(raw_dump_entry, _record()) @@ -177,28 +197,25 @@ def test_versions_with_skipped_files(transform): def test_versions_no_files_falls_back_to_metadata_only_version(transform): """A record with no files gets a single, empty, public version.""" - raw_dump_entry = {"recid": 123, "files": []} + raw_dump_entry = _raw_dump_entry([]) versions = transform._versions(raw_dump_entry, _record()) assert list(versions.keys()) == [1] assert versions[1]["files"] == {} assert versions[1]["publication_date"] == "2020-01-01" - assert versions[1]["access"] == { - "access_obj": {"record": "public", "files": "public"} - } + assert versions[1]["access"] == {"record": "public", "files": "public"} def test_versions_external_doi_collapses_into_single_version(transform): """An external DOI record gets one version with every file, not one per revision.""" - raw_dump_entry = { - "recid": 123, - "files": [ + raw_dump_entry = _raw_dump_entry( + [ _file_dump(file_version=1, checksum="checksum-v1"), _file_dump(file_version=2, checksum="checksum-v2"), _file_dump(full_name="test.pdf", file_version=1, checksum="checksum-v3"), - ], - } + ] + ) versions = transform._versions(raw_dump_entry, _external_doi_record()) @@ -213,7 +230,7 @@ def test_versions_external_doi_collapses_into_single_version(transform): def test_versions_external_doi_with_no_files_falls_back_to_metadata_only(transform): """An external DOI record with no files still gets the metadata-only fallback.""" - raw_dump_entry = {"recid": 123, "files": []} + raw_dump_entry = _raw_dump_entry([]) versions = transform._versions(raw_dump_entry, _external_doi_record()) @@ -221,22 +238,16 @@ def test_versions_external_doi_with_no_files_falls_back_to_metadata_only(transfo assert versions[1]["files"] == {} -def test_versions_individual_file_restriction_sets_access_meta(transform): +def test_versions_individual_file_restriction_sets_files_restricted(transform): """A file with its own restriction status flags that version as restricted.""" status = ( 'firerole: allow group "some-group [CERN]"\ndeny until "1996-02-01"\nallow all' ) - raw_dump_entry = { - "recid": 123, - "files": [_file_dump(status=status)], - } + raw_dump_entry = _raw_dump_entry([_file_dump(status=status)]) versions = transform._versions(raw_dump_entry, _record()) - assert versions[1]["access"] == { - "access_obj": {"record": "public", "files": "restricted"}, - "meta": status, - } + assert versions[1]["access"] == {"record": "public", "files": "restricted"} transform.migration_logger.add_information.assert_called_once() recid, info = transform.migration_logger.add_information.call_args[0] assert recid == "123" @@ -245,90 +256,95 @@ def test_versions_individual_file_restriction_sets_access_meta(transform): def test_versions_keyword_file_restriction_is_status_form_agnostic(transform): - """A plain keyword status flows through as restricted + meta. + """A plain keyword status flows through as restricted. - compute_access no longer validates the status against + compute_access doesn't validate the status against CDS_ACCESS_GROUP_MAPPINGS (hence no app context needed here); recognising - the keyword is resolve_grants' job at load time. + the keyword is resolve_grants' job. """ - raw_dump_entry = { - "recid": 123, - "files": [_file_dump(status="SSO")], - } + raw_dump_entry = _raw_dump_entry([_file_dump(status="SSO")]) versions = transform._versions(raw_dump_entry, _record()) - assert versions[1]["access"] == { - "access_obj": {"record": "public", "files": "restricted"}, - "meta": "SSO", - } + assert versions[1]["access"] == {"record": "public", "files": "restricted"} def test_versions_all_public_files_stay_public(transform): """A version whose files all have empty status stays fully public.""" - raw_dump_entry = { - "recid": 123, - "files": [ + raw_dump_entry = _raw_dump_entry( + [ _file_dump(full_name="a.pdf", bibdocid=1, status=""), _file_dump(full_name="b.pdf", bibdocid=2, status=""), - ], - } + ] + ) versions = transform._versions(raw_dump_entry, _record()) assert set(versions[1]["files"]) == {"a.pdf", "b.pdf"} - assert versions[1]["access"] == { - "access_obj": {"record": "public", "files": "public"} - } + assert versions[1]["access"] == {"record": "public", "files": "public"} transform.migration_logger.add_information.assert_not_called() @pytest.mark.parametrize( "files", [ - # public + restricted (dump order must not matter) + # dump order must not matter [("restricted.pdf", "SSO"), ("public.pdf", "")], [("public.pdf", ""), ("restricted.pdf", "SSO")], - # two distinct restriction statuses + # several files sharing the same restriction + [("public.pdf", ""), ("a.pdf", "SSO"), ("b.pdf", "SSO")], + ], +) +def test_versions_public_and_single_restriction_mix_is_restricted(transform, files): + """Public files mixed with files of a single restriction status are allowed. + + The record is split into a public and a restricted record at load time. + """ + raw_dump_entry = _raw_dump_entry( + [ + _file_dump(full_name=name, bibdocid=i + 1, status=status) + for i, (name, status) in enumerate(files) + ] + ) + + versions = transform._versions(raw_dump_entry, _record()) + + assert versions[1]["access"] == {"record": "public", "files": "restricted"} + + +@pytest.mark.parametrize( + "files", + [ [("a.pdf", "SSO"), ("b.pdf", "ITDepRestrFile")], - # public + two distinct restriction statuses [("public.pdf", ""), ("a.pdf", "SSO"), ("b.pdf", "ITDepRestrFile")], ], ) -def test_versions_non_uniform_file_statuses_raise_and_report_all(transform, files): - """A version whose files don't all share one status hard-stops. +def test_versions_several_distinct_restrictions_raise(transform, files): + """Files with more than one distinct restriction status hard-stop. - All files must share a single status (all public, or all the same - restriction). Any other mix is unrepresentable and raises - independent of - dump order - reporting every file with its status for manual review. + At most one restricted and one public record can be created per legacy + record, so several restrictions can't be represented. """ - raw_dump_entry = { - "recid": 123, - "files": [ + raw_dump_entry = _raw_dump_entry( + [ _file_dump(full_name=name, bibdocid=i + 1, status=status) for i, (name, status) in enumerate(files) - ], - } + ] + ) - with pytest.raises(ManualImportRequired) as exc_info: + with pytest.raises(ManualImportRequired): transform._versions(raw_dump_entry, _record()) - reported = exc_info.value.value - for name, status in files: - assert f"{name}: {status}" in reported - -def test_versions_carry_forward_restricted_into_public_version_raises(transform): - """Access is evaluated over a version's CUMULATIVE (carried-forward) files. +def test_versions_restriction_comes_from_current_file_statuses(transform): + """Access is evaluated from the files' current (dumped) restrictions. Legacy: A v1 restricted + B v1 restricted, then A v2 public (B stays v1). - v2's cumulative file set is {A public, B restricted} - a mix - so it must - hard-stop rather than silently marking the version (and restricted B) - public. + Only B is still restricted, so every version has restricted files (the + public/restricted mix is split at load time). """ - raw_dump_entry = { - "recid": 123, - "files": [ + raw_dump_entry = _raw_dump_entry( + [ _file_dump( full_name="A.pdf", file_version=1, @@ -350,25 +366,24 @@ def test_versions_carry_forward_restricted_into_public_version_raises(transform) status="", creation_date="2020-02-01T00:00:00+00:00", ), - ], - } + ] + ) - with pytest.raises(ManualImportRequired) as exc_info: - transform._versions(raw_dump_entry, _record()) + versions = transform._versions(raw_dump_entry, _record()) - assert "A.pdf: " in exc_info.value.value - assert "B.pdf: SSO" in exc_info.value.value + assert list(versions.keys()) == [1, 2] + for version in versions.values(): + assert version["access"] == {"record": "public", "files": "restricted"} -def test_versions_restriction_lifted_on_only_file_becomes_public(transform): - """A lone file whose restriction is lifted in a new revision: no false mix. +def test_versions_restriction_lifted_on_only_file_is_public(transform): + """A lone file whose restriction is lifted in a new revision is public. - v1 is restricted, v2 (the same file, now public) is public - the cumulative - set per version uses the latest revision per file, so there is no mix. + The dump only has the file's current restriction (none), so every version, + including v1, is public. """ - raw_dump_entry = { - "recid": 123, - "files": [ + raw_dump_entry = _raw_dump_entry( + [ _file_dump( full_name="A.pdf", file_version=1, @@ -383,16 +398,11 @@ def test_versions_restriction_lifted_on_only_file_becomes_public(transform): status="", creation_date="2020-02-01T00:00:00+00:00", ), - ], - } + ] + ) versions = transform._versions(raw_dump_entry, _record()) - assert versions[1]["access"] == { - "access_obj": {"record": "public", "files": "restricted"}, - "meta": "SSO", - } - assert versions[2]["access"] == { - "access_obj": {"record": "public", "files": "public"} - } + assert versions[1]["access"] == {"record": "public", "files": "public"} + assert versions[2]["access"] == {"record": "public", "files": "public"} assert versions[2]["files"]["A.pdf"]["version"] == 2 From 5f28b785f428628c8db112e1c49f208364828e68 Mon Sep 17 00:00:00 2001 From: Pal Kerecsenyi Date: Tue, 3 Nov 2026 17:03:47 +0100 Subject: [PATCH 2/3] feat(north-area): add back na58, update na6* yamls --- cds_migrator_kit/rdm/records/transform/models/_config.py | 4 ++++ migration_cli/collections/na58.yaml | 9 +++++++++ migration_cli/collections/na61.yaml | 4 ++-- migration_cli/collections/na62.yaml | 4 ++-- migration_cli/collections/na63.yaml | 4 ++-- migration_cli/collections/na64.yaml | 4 ++-- migration_cli/collections/na65.yaml | 4 ++-- migration_cli/collections/na66.yaml | 4 ++-- 8 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 migration_cli/collections/na58.yaml diff --git a/cds_migrator_kit/rdm/records/transform/models/_config.py b/cds_migrator_kit/rdm/records/transform/models/_config.py index c63ab62b..14ce6edb 100644 --- a/cds_migrator_kit/rdm/records/transform/models/_config.py +++ b/cds_migrator_kit/rdm/records/transform/models/_config.py @@ -37,6 +37,10 @@ "540__3", # Material of the license "540__9", # Also material of the license "540__g", # From SIS (see decision log) + "541__a", # See decision log + "541__c", # See decision log + "541__d", # See decision log + "541__e", # See decision log "542__3", # Also material of the license "595__i", # From SIS: see decision log "695__9", # From SIS: see decision log diff --git a/migration_cli/collections/na58.yaml b/migration_cli/collections/na58.yaml new file mode 100644 index 00000000..1d7721af --- /dev/null +++ b/migration_cli/collections/na58.yaml @@ -0,0 +1,9 @@ +name: na58 +queries: [] # TODO legacy queries of the collection (used by the legacy script only) +flags: + include_plots: true + create_inclusion_request: true +# slugs of the communities the records are added to, the same in every environment; +# they must exist in the environment +communities: + - na58 diff --git a/migration_cli/collections/na61.yaml b/migration_cli/collections/na61.yaml index a42041ee..b77197c0 100644 --- a/migration_cli/collections/na61.yaml +++ b/migration_cli/collections/na61.yaml @@ -1,9 +1,9 @@ name: na61 -queries: [] # TODO legacy queries of the collection (used by the legacy script only) +queries: [] # TODO legacy queries of the collection (used by the legacy script only) flags: include_plots: true create_inclusion_request: true # slugs of the communities the records are added to, the same in every environment; # they must exist in the environment communities: - - TODO # slug of the community + - na61 diff --git a/migration_cli/collections/na62.yaml b/migration_cli/collections/na62.yaml index de64827c..153a8f7c 100644 --- a/migration_cli/collections/na62.yaml +++ b/migration_cli/collections/na62.yaml @@ -1,9 +1,9 @@ name: na62 -queries: [] # TODO legacy queries of the collection (used by the legacy script only) +queries: [] # TODO legacy queries of the collection (used by the legacy script only) flags: include_plots: true create_inclusion_request: true # slugs of the communities the records are added to, the same in every environment; # they must exist in the environment communities: - - TODO # slug of the community + - na62 diff --git a/migration_cli/collections/na63.yaml b/migration_cli/collections/na63.yaml index 4eabedbf..96550e5e 100644 --- a/migration_cli/collections/na63.yaml +++ b/migration_cli/collections/na63.yaml @@ -1,9 +1,9 @@ name: na63 -queries: [] # TODO legacy queries of the collection (used by the legacy script only) +queries: [] # TODO legacy queries of the collection (used by the legacy script only) flags: include_plots: true create_inclusion_request: true # slugs of the communities the records are added to, the same in every environment; # they must exist in the environment communities: - - TODO # slug of the community + - na63 diff --git a/migration_cli/collections/na64.yaml b/migration_cli/collections/na64.yaml index 523e52b3..a310f5dd 100644 --- a/migration_cli/collections/na64.yaml +++ b/migration_cli/collections/na64.yaml @@ -1,9 +1,9 @@ name: na64 -queries: [] # TODO legacy queries of the collection (used by the legacy script only) +queries: [] # TODO legacy queries of the collection (used by the legacy script only) flags: include_plots: true create_inclusion_request: true # slugs of the communities the records are added to, the same in every environment; # they must exist in the environment communities: - - TODO # slug of the community + - na64 diff --git a/migration_cli/collections/na65.yaml b/migration_cli/collections/na65.yaml index ceda51db..02d57506 100644 --- a/migration_cli/collections/na65.yaml +++ b/migration_cli/collections/na65.yaml @@ -1,9 +1,9 @@ name: na65 -queries: [] # TODO legacy queries of the collection (used by the legacy script only) +queries: [] # TODO legacy queries of the collection (used by the legacy script only) flags: include_plots: true create_inclusion_request: true # slugs of the communities the records are added to, the same in every environment; # they must exist in the environment communities: - - TODO # slug of the community + - na65 diff --git a/migration_cli/collections/na66.yaml b/migration_cli/collections/na66.yaml index 997d66c6..603d2e66 100644 --- a/migration_cli/collections/na66.yaml +++ b/migration_cli/collections/na66.yaml @@ -1,9 +1,9 @@ name: na66 -queries: [] # TODO legacy queries of the collection (used by the legacy script only) +queries: [] # TODO legacy queries of the collection (used by the legacy script only) flags: include_plots: true create_inclusion_request: true # slugs of the communities the records are added to, the same in every environment; # they must exist in the environment communities: - - TODO # slug of the community + - na66 From d729cebcc30029b47d5cc3959ee09476a1da3750 Mon Sep 17 00:00:00 2001 From: Pal Kerecsenyi Date: Wed, 4 Nov 2026 09:25:47 +0100 Subject: [PATCH 3/3] feat(north-area): add committee configs --- cds_migrator_kit/rdm/migration_config.py | 47 +++++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/cds_migrator_kit/rdm/migration_config.py b/cds_migrator_kit/rdm/migration_config.py index 9b7c02a2..fd9cbd12 100644 --- a/cds_migrator_kit/rdm/migration_config.py +++ b/cds_migrator_kit/rdm/migration_config.py @@ -25,10 +25,10 @@ CDSCommunitiesPermissionPolicy, CDSRDMPreservationSyncPermissionPolicy, ) +from invenio_app_rdm.config import * from invenio_app_rdm.config import CELERY_BEAT_SCHEDULE as APP_RDM_CELERY_BEAT_SCHEDULE from invenio_app_rdm.config import STATS_AGGREGATIONS as _APP_RDM_STATS_AGGREGATIONS from invenio_app_rdm.config import STATS_EVENTS as _APP_RDM_STATS_EVENTS -from invenio_app_rdm.config import * from invenio_cern_sync.sso import cern_keycloak, cern_remote_app_name from invenio_cern_sync.users.profile import CERNUserProfileSchema from invenio_i18n import lazy_gettext as _ @@ -41,10 +41,7 @@ from invenio_rdm_records.config import ( RDM_RECORDS_IDENTIFIERS_SCHEMES as RDM_RECORDS_RELATED_IDENTIFIERS_SCHEMES, ) -from invenio_rdm_records.config import ( - RDM_RECORDS_PERSONORG_SCHEMES, - always_valid, -) +from invenio_rdm_records.config import RDM_RECORDS_PERSONORG_SCHEMES, always_valid from invenio_rdm_records.proxies import current_rdm_records_service as record_service from invenio_rdm_records.services.components import DefaultRecordsComponents from invenio_vocabularies.config import ( @@ -656,4 +653,44 @@ def resolve_record_pid(pid): "counter_digits": 3, # zero-padding width, e.g. 3 → "001" }, }, + "742fea6c-0685-4da2-8000-c1f19635ba86": { + # NA58 + "label": "EP approval", # shown in UI buttons/headings + "referee_group": "cds-ph-ep-publications-referee-non-lhc", # CERN e-group slug + "report_number": { + "prefix": "CERN-EP", # literal prefix, e.g. "CERN-EP" + "include_year": True, # append the current year after prefix + "counter_digits": 3, # zero-padding width, e.g. 3 → "001" + }, + }, + "cf52a005-cc57-4a69-a135-771bf9f28d8a": { + # NA61 + "label": "EP approval", # shown in UI buttons/headings + "referee_group": "cds-ph-ep-publications-referee-non-lhc", # CERN e-group slug + "report_number": { + "prefix": "CERN-EP", # literal prefix, e.g. "CERN-EP" + "include_year": True, # append the current year after prefix + "counter_digits": 3, # zero-padding width, e.g. 3 → "001" + }, + }, + "71045612-b90c-4dba-83a5-c5e55d5b0622": { + # NA62 + "label": "EP approval", # shown in UI buttons/headings + "referee_group": "cds-ph-ep-publications-referee-non-lhc", # CERN e-group slug + "report_number": { + "prefix": "CERN-EP", # literal prefix, e.g. "CERN-EP" + "include_year": True, # append the current year after prefix + "counter_digits": 3, # zero-padding width, e.g. 3 → "001" + }, + }, + "25a0fa11-6e27-419a-a00e-3b4b30ef0182": { + # NA64 + "label": "EP approval", # shown in UI buttons/headings + "referee_group": "cds-ph-ep-publications-referee-non-lhc", # CERN e-group slug + "report_number": { + "prefix": "CERN-EP", # literal prefix, e.g. "CERN-EP" + "include_year": True, # append the current year after prefix + "counter_digits": 3, # zero-padding width, e.g. 3 → "001" + }, + }, }