From a69b2e554c52ab70e2de99a0cdc3a3f1397152ae Mon Sep 17 00:00:00 2001 From: Saksham Date: Tue, 30 Jun 2026 14:59:42 +0200 Subject: [PATCH] feat(components): Auto submit public records to cern research --- invenio.cfg | 21 +++++- site/cds_rdm/components.py | 65 ++++++++++++----- site/cds_rdm/tasks.py | 49 +++++++++++++ site/tests/conftest.py | 50 +++++++++++++ site/tests/test_components.py | 132 ++++++++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 20 deletions(-) diff --git a/invenio.cfg b/invenio.cfg index fb6689cc..df36ccf1 100644 --- a/invenio.cfg +++ b/invenio.cfg @@ -69,9 +69,7 @@ from invenio_app_rdm.config import \ from invenio_app_rdm.config import \ VOCABULARIES_DATASTREAM_WRITERS as DEFAULT_VOCABULARIES_DATASTREAM_WRITERS from cds_rdm.clc_sync.services.components import ClcSyncComponent -from cds_rdm.components import CDSResourcePublication -from cds_rdm.components import SubjectsValidationComponent -from cds_rdm.components import MintAlternateIdentifierComponent +from cds_rdm.components import CDSResourcePublication, MintAlternateIdentifierComponent, PublicationInclusionComponent, SubjectsValidationComponent from cds_rdm.pids import validate_optional_doi_transitions from cds_rdm.views import frontpage_view_function, inspire_link_render from cds_rdm.reviews.policy import CDSRecordVersionReviewPolicy @@ -451,6 +449,22 @@ RDM_COMMUNITY_REQUIRED_TO_PUBLISH = True RDM_NEW_RECORD_VERSION_REVIEW_POLICY = CDSRecordVersionReviewPolicy CDS_COMMUNITIES_REQUIRING_NEW_RECORD_VERSION_REVIEW = [] +# Public research publication resource types that should be included in the CERN Scientific community. +CDS_CERN_SCIENTIFIC_RESOURCE_TYPES = { + "publication-dissertation", + "publication-book", + "publication-section", + "publication-conferencepaper", + "publication-conferenceproceeding", + "publication-journal", + "publication-article", + "publication-preprint", + "publication-report", + "publication-technicalnote", + "publication-note", + "publication", +} + RDM_SEARCH = { **deepcopy(RDM_SEARCH), "query_parser_cls": QueryParser.factory( @@ -610,6 +624,7 @@ RDM_RECORDS_SERVICE_COMPONENTS = [ CDSResourcePublication, ClcSyncComponent, MintAlternateIdentifierComponent, + PublicationInclusionComponent, VCSComponent, ] diff --git a/site/cds_rdm/components.py b/site/cds_rdm/components.py index 6a117603..fee05569 100644 --- a/site/cds_rdm/components.py +++ b/site/cds_rdm/components.py @@ -19,23 +19,7 @@ from invenio_records_resources.services.uow import TaskOp from marshmallow import ValidationError -from .tasks import sync_alternate_identifiers - -# @shared_task() -# def create_community_inclusion_request(record_id): -# """Create a community inclusion request for .""" -# # Create a community-inclusion request -# csc_community_id = current_app.config.get("CDS_CERN_SCIENTIFIC_COMMUNITY_ID") -# data = dict( -# communities=[ -# { -# "id": csc_community_id, -# "require_review": True, -# } -# ] -# ) - -# current_rdm_records.record_communities_service.add(system_identity, record_id, data) +from .tasks import submit_community_inclusion_request, sync_alternate_identifiers def is_record_public(record): @@ -218,3 +202,50 @@ def publish(self, identity, draft=None, record=None): record_id=str(record.id), ) ) + + +class PublicationInclusionComponent(ServiceComponent): + """Auto-submit a community inclusion request to the CERN Scientific Community. + + Triggers on publish for public records whose resource type is listed in + `CDS_CERN_SCIENTIFIC_RESOURCE_TYPES`. The request is created asynchronously + after the publish transaction commits. + """ + + def _is_eligible(self, draft, record): + """Return True when the record should be auto-submitted for community inclusion.""" + if not is_record_public(draft): + return False + + resource_type = draft["metadata"]["resource_type"]["id"] + research_resource_types = current_app.config.get( + "CDS_CERN_SCIENTIFIC_RESOURCE_TYPES", set() + ) + if resource_type not in research_resource_types: + return False + + csc_community_id = current_app.config.get("CDS_CERN_SCIENTIFIC_COMMUNITY_ID") + if not csc_community_id: + current_app.logger.error( + "CDS_CERN_SCIENTIFIC_COMMUNITY_ID is not configured; " + "skipping auto community inclusion for record %s.", + record.pid.pid_value, + ) + return False + + if csc_community_id in record.parent.communities.ids: + return False + + return True + + def publish(self, identity, draft=None, record=None, **kwargs): + """Schedule community inclusion request after the record is published.""" + if not self._is_eligible(draft, record): + return + + self.uow.register( + TaskOp( + submit_community_inclusion_request, + record_id=record.pid.pid_value, + ) + ) diff --git a/site/cds_rdm/tasks.py b/site/cds_rdm/tasks.py index a0b5d28a..bcb76b07 100644 --- a/site/cds_rdm/tasks.py +++ b/site/cds_rdm/tasks.py @@ -16,6 +16,7 @@ from invenio_cern_sync.users.sync import sync as users_sync from invenio_db import db from invenio_pidstore.models import PersistentIdentifier, PIDStatus +from invenio_rdm_records.proxies import current_record_communities_service from invenio_rdm_records.records.api import RDMRecord from invenio_records_resources.proxies import current_service_registry from invenio_records_resources.services.uow import UnitOfWork @@ -312,3 +313,51 @@ def sync_alternate_identifiers(parent_id, record_id): ) db.session.add(report_number_pid) db.session.commit() + + +@shared_task(ignore_result=True) +def submit_community_inclusion_request(record_id): + """Create and submit a community inclusion request to the CERN Scientific Community after a public record satisfies the criteria.""" + csc_community_id = current_app.config.get("CDS_CERN_SCIENTIFIC_COMMUNITY_ID") + + data = { + "communities": [ + { + "id": csc_community_id, + "require_review": True, + "comment": { + "payload": { + "content": "

This record was automatically submitted to the CERN Research Community by the system because it satisfies the criteria for inclusion (resource type identified as research related).

", + "format": "html", + } + }, + } + ] + } + + try: + with UnitOfWork() as uow: + processed, errors = current_record_communities_service.add( + system_identity, record_id, data, uow=uow + ) + uow.commit() + + if errors: + for error in errors: + current_app.logger.info( + "Community inclusion not created for record %s to community %s: %s", + record_id, + error.get("community_id"), + error.get("message"), + ) + else: + current_app.logger.info( + "Community inclusion request %s submitted for record %s to community %s.", + processed[0].get("request_id"), + record_id, + processed[0].get("community_id"), + ) + except Exception as e: + current_app.logger.exception( + f"Failed to submit community inclusion request for record {record_id}: {e}" + ) diff --git a/site/tests/conftest.py b/site/tests/conftest.py index 902d7ec3..fcaba0ac 100644 --- a/site/tests/conftest.py +++ b/site/tests/conftest.py @@ -37,6 +37,8 @@ RDM_RECORDS_RELATED_IDENTIFIERS_SCHEMES, always_valid, ) +from invenio_rdm_records.proxies import current_rdm_records_service +from invenio_rdm_records.records.api import RDMRecord from invenio_rdm_records.resources.serializers import DataCite43JSONSerializer from invenio_rdm_records.services.pids import providers from invenio_records_resources.proxies import current_service_registry @@ -95,6 +97,7 @@ def mock_datacite_client(): """Mock DataCite client.""" return FakeDataCiteClient + @pytest.fixture(scope="module") def mock_crossref_client(): """Mock DataCite client.""" @@ -1606,3 +1609,50 @@ def name_full_data(): ], "affiliations": [{"name": "CustomORG"}], } + + +@pytest.fixture(scope="function") +def community(community_service, minimal_community): + """Scientific community where Thesis should be submitted.""" + minimal_community["slug"] = "TEST-COMMUNITY" + minimal_community["metadata"] = {"title": "TEST-COMMUNITY"} + c = community_service.create(system_identity, minimal_community) + Community.index.refresh() + return c._record + + +@pytest.fixture() +def record_community(db, uploader, minimal_restricted_record, community): + """Creates a record that belongs to a community.""" + + class Record: + """Test record class.""" + + def create_record( + self, + uploader=uploader, + record_dict=minimal_restricted_record, + community=community, + ): + """Creates new record that belongs to the same community.""" + # create draft + draft = current_rdm_records_service.create(uploader.identity, record_dict) + record = draft._record + if community: + # add the record to the community + record.parent.communities.add(community, default=False) + record.parent.commit() + db.session.commit() + + # publish and get record + result_item = current_rdm_records_service.publish( + uploader.identity, draft.id + ) + record = result_item._record + current_rdm_records_service.indexer.index( + record, arguments={"refresh": True} + ) + + return record + + return Record() diff --git a/site/tests/test_components.py b/site/tests/test_components.py index e7d76308..a001cf9c 100644 --- a/site/tests/test_components.py +++ b/site/tests/test_components.py @@ -10,20 +10,27 @@ import pytest from flask import current_app +from invenio_access.permissions import system_identity from invenio_pidstore.models import PersistentIdentifier, PIDStatus from invenio_rdm_records.proxies import current_rdm_records +from invenio_rdm_records.requests.community_inclusion import CommunityInclusion from invenio_rdm_records.services.components import DefaultRecordsComponents from invenio_rdm_records.services.errors import ValidationErrorWithMessageAsList from invenio_rdm_records.services.pids.providers import ( DataCiteClient, DataCitePIDProvider, ) +from invenio_requests.proxies import current_requests_service +from invenio_requests.records.api import Request +from invenio_search.engine import dsl from marshmallow import ValidationError from cds_rdm.components import ( MintAlternateIdentifierComponent, + PublicationInclusionComponent, SubjectsValidationComponent, ) +from cds_rdm.tasks import submit_community_inclusion_request def test_subjects_validation_component_update_draft( @@ -430,3 +437,128 @@ def test_mint_alternate_identifier_component( draft14 = service.create(uploader.identity, new_data) with pytest.raises(ValidationErrorWithMessageAsList): service.publish(uploader.identity, id_=draft14.id) + + +def test_publication_inclusion_component( + minimal_restricted_record, + uploader, + client, + monkeypatch, + scientific_community, + community, + record_community, + db, +): + """Test for the publication inclusion component. + + Tests all scenarios: + 1. Eligible public research record creates a CSC inclusion request on publish + 2. Restricted record does not create a request + 3. Non-research resource type does not create a request + 4. Record already in the scientific community does not create a request + 5. Duplicate submission does not create duplicate requests + 6. Missing scientific community config throws an error + """ + client = uploader.login(client) + service = current_rdm_records.records_service + + monkeypatch.setitem( + current_app.config, + "RDM_RECORDS_SERVICE_COMPONENTS", + [*DefaultRecordsComponents, PublicationInclusionComponent], + ) + + monkeypatch.setitem( + current_app.config, + "CDS_CERN_SCIENTIFIC_RESOURCE_TYPES", + {"publication-article", "publication-dissertation"}, + ) + + def _count_open_inclusion_requests(record_pid): + Request.index.refresh() + results = current_requests_service.search( + system_identity, + extra_filter=dsl.query.Bool( + "must", + must=[ + dsl.Q("term", **{"type": CommunityInclusion.type_id}), + dsl.Q("term", **{"topic.record": record_pid}), + dsl.Q( + "term", + **{"receiver.community": str(scientific_community.id)}, + ), + dsl.Q("term", **{"is_open": True}), + ], + ), + ) + return results.total + + # 1. Eligible public research record creates a CSC inclusion request + eligible_record = deepcopy(minimal_restricted_record) + eligible_record["access"]["record"] = "public" + eligible_record["metadata"]["resource_type"] = {"id": "publication-article"} + + published_record = record_community.create_record( + uploader=uploader, record_dict=eligible_record, community=community + ) + record_pid = published_record.pid.pid_value + + assert str(scientific_community.id) not in published_record.parent.communities.ids + assert _count_open_inclusion_requests(record_pid) == 1 + + # 2. Restricted record does not create a request + restricted_record = deepcopy(minimal_restricted_record) + restricted_record["access"]["record"] = "restricted" + restricted_record["metadata"]["resource_type"] = {"id": "publication-article"} + + published_restricted_record = record_community.create_record( + uploader=uploader, record_dict=restricted_record, community=community + ) + assert ( + _count_open_inclusion_requests(published_restricted_record.pid.pid_value) == 0 + ) + + # 3. Non-research resource type does not create a request + non_research_record = deepcopy(minimal_restricted_record) + non_research_record["access"]["record"] = "public" + non_research_record["metadata"]["resource_type"] = {"id": "image-photo"} + + published_non_research_record = record_community.create_record( + uploader=uploader, record_dict=non_research_record, community=community + ) + assert ( + _count_open_inclusion_requests(published_non_research_record.pid.pid_value) + == 0 + ) + + # 4. Record already in the scientific community does not create a request + already_in_csc_record = deepcopy(minimal_restricted_record) + already_in_csc_record["access"]["record"] = "public" + already_in_csc_record["metadata"]["resource_type"] = {"id": "publication-article"} + + published_in_csc_record = record_community.create_record( + uploader=uploader, record_dict=already_in_csc_record, community=scientific_community + ) + assert ( + str(scientific_community.id) in published_in_csc_record.parent.communities.ids + ) + assert _count_open_inclusion_requests(published_in_csc_record.pid.pid_value) == 0 + + # 5. Duplicate submission does not create duplicate requests + submit_community_inclusion_request(record_pid) + submit_community_inclusion_request(record_pid) + assert _count_open_inclusion_requests(record_pid) == 1 + + # 6. Missing scientific community config throws an error + monkeypatch.setitem(current_app.config, "CDS_CERN_SCIENTIFIC_COMMUNITY_ID", None) + + no_config_record = deepcopy(minimal_restricted_record) + no_config_record["access"]["record"] = "public" + no_config_record["metadata"]["resource_type"] = {"id": "publication-article"} + + published_no_config_record = record_community.create_record( + uploader=uploader, record_dict=no_config_record, community=community + ) + assert ( + _count_open_inclusion_requests(published_no_config_record.pid.pid_value) == 0 + )