diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index bef17a795ca..182773f010a 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -249,6 +249,7 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer): 'template_node', 'title', 'type', + 'verified_resource_links', 'view_only_links', 'wiki_enabled', 'wikis', @@ -311,6 +312,8 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer): links = LinksField({'html': 'get_absolute_html_url'}) # TODO: When we have osf_permissions.ADMIN permissions, make this writable for admins + verified_resource_links = ser.DictField(required=False, allow_null=True) + license = NodeLicenseRelationshipField( related_view='licenses:license-detail', related_view_kwargs={'license_id': ''}, diff --git a/api/registrations/serializers.py b/api/registrations/serializers.py index 0e3542131fe..65deb1737c1 100644 --- a/api/registrations/serializers.py +++ b/api/registrations/serializers.py @@ -160,6 +160,7 @@ class RegistrationSerializer(NodeSerializer): ser.SerializerMethodField(help_text='When the embargo on this registration will be lifted.'), ) custom_citation = HideIfWithdrawal(ser.CharField(allow_blank=True, required=False)) + verified_resource_links = ser.DictField(required=False, allow_null=True, read_only=True) withdrawal_justification = ser.CharField(read_only=True) template_from = HideIfWithdrawal( diff --git a/api_tests/base/test_serializers.py b/api_tests/base/test_serializers.py index 57547018414..ae3873ce4dc 100644 --- a/api_tests/base/test_serializers.py +++ b/api_tests/base/test_serializers.py @@ -196,7 +196,7 @@ def test_registration_serializer(self): 'cedar_metadata_records', ] # fields that do not appear on registrations - non_registration_fields = ['registrations', 'draft_registrations', 'templated_by_count', 'settings', 'storage', 'children', 'groups', 'subjects_acceptable'] + non_registration_fields = ['registrations', 'draft_registrations', 'templated_by_count', 'settings', 'storage', 'children', 'groups', 'subjects_acceptable', 'verified_resource_links'] for field in NodeSerializer._declared_fields: assert field in RegistrationSerializer._declared_fields diff --git a/api_tests/nodes/views/test_node_detail.py b/api_tests/nodes/views/test_node_detail.py index ab2e90db5ab..6cdd011537a 100644 --- a/api_tests/nodes/views/test_node_detail.py +++ b/api_tests/nodes/views/test_node_detail.py @@ -303,6 +303,7 @@ def test_node_properties(self, app, url_public): assert res.json['data']['attributes']['registration'] is False assert res.json['data']['attributes']['collection'] is False assert res.json['data']['attributes']['tags'] == [] + assert res.json['data']['attributes']['verified_resource_links'] is None def test_requesting_folder_returns_error(self, app, user): folder = CollectionFactory(creator=user) @@ -1421,6 +1422,31 @@ def test_public_project_with_publicly_editable_wiki_turns_private( ) assert res.status_code == 200 + def test_update_verified_resource_links(self, app, user, project_public, url_public): + payload = { + 'data': { + 'id': project_public._id, + 'type': 'nodes', + 'attributes': { + 'verified_resource_links': { + 'https://doi.org/10.1234/5678': 'doi', + 'https://arxiv.org/abs/1234.5678': 'arxiv' + } + } + } + } + res = app.patch_json_api(url_public, payload, auth=user.auth) + assert res.status_code == 200 + assert res.json['data']['attributes']['verified_resource_links'] == { + 'https://doi.org/10.1234/5678': 'doi', + 'https://arxiv.org/abs/1234.5678': 'arxiv' + } + + payload['data']['attributes']['verified_resource_links'] = {} + res = app.patch_json_api(url_public, payload, auth=user.auth) + assert res.status_code == 200 + assert res.json['data']['attributes']['verified_resource_links'] == {} + @mock.patch('osf.models.node.update_doi_metadata_on_change') def test_set_node_private_updates_doi( self, mock_update_doi_metadata, app, user, project_public, diff --git a/osf/metadata/serializers/datacite/datacite_tree_walker.py b/osf/metadata/serializers/datacite/datacite_tree_walker.py index 640e00e76f0..2d1575d7e9c 100644 --- a/osf/metadata/serializers/datacite/datacite_tree_walker.py +++ b/osf/metadata/serializers/datacite/datacite_tree_walker.py @@ -113,7 +113,7 @@ def walk(self, doi_override=None): self._visit_rights(self.root) self._visit_descriptions(self.root, self.basket.focus.iri) self._visit_funding_references(self.root) - self._visit_related(self.root) + self._visit_related_and_verified_links(self.root) def _visit_identifier(self, parent_el, *, doi_override=None): if doi_override is None: @@ -373,13 +373,17 @@ def _visit_related_identifier_and_item(self, identifier_parent_el, item_parent_e self._visit_publication_year(related_item_el, related_iri) self._visit_publisher(related_item_el, related_iri) - def _visit_related(self, parent_el): + def _visit_related_and_verified_links(self, parent_el): + # Create related identifiers element and gather relation pairs relation_pairs = set() for relation_iri, datacite_relation in RELATED_IDENTIFIER_TYPE_MAP.items(): for related_iri in self.basket[relation_iri]: relation_pairs.add((datacite_relation, related_iri)) + related_identifiers_el = self.visit(parent_el, 'relatedIdentifiers', is_list=True) related_items_el = self.visit(parent_el, 'relatedItems', is_list=True) + + # First add regular related identifiers for datacite_relation, related_iri in sorted(relation_pairs): self._visit_related_identifier_and_item( related_identifiers_el, @@ -388,6 +392,20 @@ def _visit_related(self, parent_el): datacite_relation, ) + # Then add verified links to same relatedIdentifiers element + osf_item = self.basket.focus.dbmodel + verified_links = getattr(osf_item, 'verified_resource_links', None) + if verified_links: + for link, resource_type in verified_links.items(): + if link and isinstance(link, str) and smells_like_iri(link): + self.visit(related_identifiers_el, 'relatedIdentifier', text=link, attrib={ + 'relatedIdentifierType': 'URL', + 'relationType': 'References', + 'resourceTypeGeneral': resource_type + }) + else: + logger.warning('skipping non-URL verified link "%s"', link) + def _visit_name_identifiers(self, parent_el, agent_iri): for identifier in sorted(self.basket[agent_iri:DCTERMS.identifier]): identifier_type, identifier_value = self._identifier_type_and_value(identifier) diff --git a/osf/migrations/0029_abstractnode_verified_resource_links.py b/osf/migrations/0029_abstractnode_verified_resource_links.py new file mode 100644 index 00000000000..ff614f5b2de --- /dev/null +++ b/osf/migrations/0029_abstractnode_verified_resource_links.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.15 on 2025-04-10 12:49 + +from django.db import migrations +import osf.utils.datetime_aware_jsonfield + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0028_collection_grade_levels_choices_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='abstractnode', + name='verified_resource_links', + field=osf.utils.datetime_aware_jsonfield.DateTimeAwareJSONField(blank=True, encoder=osf.utils.datetime_aware_jsonfield.DateTimeAwareJSONEncoder, null=True), + ), + ] diff --git a/osf/models/node.py b/osf/models/node.py index d06af182e47..9756046ad08 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -294,6 +294,7 @@ class AbstractNode(DirtyFieldsMixin, TypedModel, AddonModelMixin, IdentifierMixi 'category', 'is_public', 'node_license', + 'verified_resource_links', ] # Named constants @@ -387,6 +388,8 @@ class AbstractNode(DirtyFieldsMixin, TypedModel, AddonModelMixin, IdentifierMixi schema_responses = GenericRelation('osf.SchemaResponse', related_query_name='nodes') + verified_resource_links = DateTimeAwareJSONField(null=True, blank=True) + class Meta: base_manager_name = 'objects' index_together = (('is_public', 'is_deleted', 'type')) diff --git a/website/identifiers/clients/datacite.py b/website/identifiers/clients/datacite.py index 64c9b0a075b..66bb2f924b6 100644 --- a/website/identifiers/clients/datacite.py +++ b/website/identifiers/clients/datacite.py @@ -57,6 +57,9 @@ def create_identifier(self, node, category, doi_value=None): doi_value = doi_value or self._get_doi_value(node) metadata_record_xml = self.build_metadata(node, doi_value, as_xml=True) if settings.DATACITE_ENABLED: + if isinstance(metadata_record_xml, bytes): + metadata_record_xml = metadata_record_xml.decode('utf-8') + resp = self._client.metadata_post(metadata_record_xml) # Typical response: 'OK (10.70102/FK2osf.io/cq695)' to doi 10.70102/FK2osf.io/cq695 doi = re.match(r'OK \((?P[a-zA-Z0-9 .\/]{0,})\)', resp).groupdict()['doi'] diff --git a/website/identifiers/tasks.py b/website/identifiers/tasks.py index f940956d54b..277df0c5b53 100644 --- a/website/identifiers/tasks.py +++ b/website/identifiers/tasks.py @@ -17,3 +17,29 @@ def task__update_doi_metadata_on_change(self, target_guid): @celery_app.task(ignore_results=True) def update_doi_metadata_on_change(target_guid): task__update_doi_metadata_on_change(target_guid) + +@celery_app.task(bind=True, max_retries=5, acks_late=True) +def task__update_doi_metadata_with_verified_links(self, target_guid): + sentry.log_message('Updating DOI with verified links for guid', + extra_data={'guid': target_guid}, + level=logging.INFO) + + Guid = apps.get_model('osf.Guid') + target_object = Guid.load(target_guid).referent + try: + + target_object.request_identifier_update(category='doi') + + sentry.log_message('DOI metadata with verified links updated for guid', + extra_data={'guid': target_guid}, + level=logging.INFO) + except Exception as exc: + sentry.log_message('Failed to update DOI metadata with verified links', + extra_data={'guid': target_guid, 'error': str(exc)}, + level=logging.ERROR) + raise self.retry(exc=exc) + +@queued_task +@celery_app.task(ignore_results=True) +def update_doi_metadata_with_verified_links(target_guid): + task__update_doi_metadata_with_verified_links(target_guid)