Skip to content

Commit 10b1af5

Browse files
committed
Merge branch 'hotfix/25.10.0'
2 parents 83259d8 + 5da34df commit 10b1af5

File tree

16 files changed

+221
-32
lines changed

16 files changed

+221
-32
lines changed

CHANGELOG

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.
44

5+
25.10.0 (2025-06-11)
6+
====================
7+
8+
- Manual GUID and DOI assignment during Preprint and Registration Creation
9+
510
25.09.0 (2025-05-14)
611
====================
712

api/preprints/serializers.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
from api.institutions.utils import update_institutions_if_user_associated
3939
from api.preprints.fields import DOIField
4040
from api.taxonomies.serializers import TaxonomizableSerializerMixin
41+
from api.waffle.utils import flag_is_active
4142
from framework.exceptions import PermissionsError, UnpublishedPendingPreprintVersionExists
43+
from osf import features
4244
from website.project import signals as project_signals
4345
from osf.exceptions import NodeStateError, PreprintStateError
4446
from osf.models import (
@@ -499,16 +501,25 @@ class PreprintCreateSerializer(PreprintSerializer):
499501
# Overrides PreprintSerializer to make id nullable, adds `create`
500502
# TODO: add better Docstrings
501503
id = IDField(source='_id', required=False, allow_null=True)
504+
manual_guid = ser.CharField(write_only=True, required=False, allow_null=True, allow_blank=True)
505+
manual_doi = ser.CharField(write_only=True, required=False, allow_null=True, allow_blank=True)
502506

503507
def create(self, validated_data):
508+
504509
creator = self.context['request'].user
505510
provider = validated_data.pop('provider', None)
506511
if not provider:
507512
raise exceptions.ValidationError(detail='You must specify a valid provider to create a preprint.')
508-
509513
title = validated_data.pop('title')
510514
description = validated_data.pop('description', '')
511-
preprint = Preprint.create(provider=provider, title=title, creator=creator, description=description)
515+
516+
# For manual GUID and DOI assignment during creation for privileged users
517+
manual_guid = validated_data.pop('manual_guid', None)
518+
manual_doi = validated_data.pop('manual_doi', None)
519+
if manual_doi and not flag_is_active(self.context['request'], features.MANUAL_DOI_AND_GUID):
520+
raise exceptions.ValidationError(detail='Manual DOI assignment is not allowed.')
521+
522+
preprint = Preprint.create(provider=provider, title=title, creator=creator, description=description, manual_guid=manual_guid, manual_doi=manual_doi)
512523

513524
return self.update(preprint, validated_data)
514525

api/registrations/serializers.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import pytz
22
import json
3+
4+
from api.waffle.utils import flag_is_active
5+
from osf import features
36
from website.archiver.utils import normalize_unicode_filenames
47

58
from packaging.version import Version
@@ -733,6 +736,10 @@ def __init__(self, *args, **kwargs):
733736
else:
734737
self.fields['draft_registration'] = ser.CharField(write_only=True)
735738

739+
# For manual GUID and DOI assignment during creation for privileged users
740+
manual_guid = ser.CharField(write_only=True, required=False, allow_null=True, allow_blank=True)
741+
manual_doi = ser.CharField(write_only=True, required=False, allow_null=True, allow_blank=True)
742+
736743
# For newer versions
737744
embargo_end_date = VersionedDateTimeField(write_only=True, allow_null=True, default=None)
738745
included_node_ids = ser.ListField(write_only=True, required=False)
@@ -786,6 +793,8 @@ def get_children_by_version(self, validated_data):
786793
return validated_data.get('children', [])
787794

788795
def create(self, validated_data):
796+
797+
manual_guid = validated_data.pop('manual_guid', None)
789798
auth = get_user_auth(self.context['request'])
790799
draft = validated_data.pop('draft', None)
791800
registration_choice = self.get_registration_choice_by_version(validated_data)
@@ -810,7 +819,7 @@ def create(self, validated_data):
810819
)
811820

812821
try:
813-
registration = draft.register(auth, save=True, child_ids=children)
822+
registration = draft.register(auth, save=True, child_ids=children, manual_guid=manual_guid)
814823
except NodeStateError as err:
815824
raise exceptions.ValidationError(err)
816825

@@ -823,6 +832,11 @@ def create(self, validated_data):
823832
except ValidationError as err:
824833
raise exceptions.ValidationError(err.message)
825834
else:
835+
manual_doi = validated_data.pop('manual_doi', None)
836+
if manual_doi:
837+
if not flag_is_active(self.context['request'], features.MANUAL_DOI_AND_GUID):
838+
raise exceptions.ValidationError(detail='Manual DOI assignment is not allowed.')
839+
registration.set_identifier_value('doi', manual_doi)
826840
try:
827841
registration.require_approval(auth.user)
828842
except NodeStateError as err:

api_tests/preprints/views/test_preprint_list.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55
from django.utils import timezone
6-
from waffle.testutils import override_switch
6+
from waffle.testutils import override_switch, override_flag
77

88
from addons.github.models import GithubFile
99
from api.base.settings.defaults import API_BASE
@@ -351,6 +351,8 @@ def setUp(self):
351351

352352
self.user_two = AuthUserFactory()
353353
self.url = f'/{API_BASE}preprints/'
354+
self.manual_guid = 'abcde'
355+
self.manual_doi = '10.70102/FK2osf.io/abcde'
354356

355357
def publish_preprint(self, preprint, user, expect_errors=False):
356358
preprint_file = test_utils.create_test_preprint_file(preprint, user, 'coffee_manuscript.pdf')
@@ -362,6 +364,30 @@ def publish_preprint(self, preprint, user, expect_errors=False):
362364
)
363365
return res
364366

367+
@property
368+
def manual_guid_payload(self):
369+
return {
370+
'manual_doi': self.manual_doi,
371+
'manual_guid': self.manual_guid,
372+
}
373+
374+
def test_fail_create_prerprint_with_manual_guid(self):
375+
public_project_payload = build_preprint_create_payload(self.public_project._id, self.provider._id, attrs=self.manual_guid_payload)
376+
res = self.app.post_json_api(self.url, public_project_payload, auth=self.user.auth, expect_errors=True)
377+
assert res.status_code == 400
378+
print(res.status_code)
379+
380+
def test_create_preprint_with_manual_guid(self):
381+
public_project_payload = build_preprint_create_payload(self.public_project._id, self.provider._id, attrs=self.manual_guid_payload)
382+
with override_flag(features.MANUAL_DOI_AND_GUID, True):
383+
res = self.app.post_json_api(self.url, public_project_payload, auth=self.user.auth, )
384+
data = res.json['data']
385+
assert res.status_code == 201
386+
assert data['id'] == f'{self.manual_guid}_v1', 'manual guid was not assigned'
387+
identifiers_response = self.app.get(data['relationships']['identifiers']['links']['related']['href'], auth=self.user.auth)
388+
assert identifiers_response.status_code == 200
389+
assert identifiers_response.json['data'][0]['attributes']['value'] == self.manual_doi
390+
365391
def test_create_preprint_with_supplemental_public_project(self):
366392
public_project_payload = build_preprint_create_payload(self.public_project._id, self.provider._id)
367393

api_tests/registrations/views/test_registration_detail.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ def test_registration_fields_are_read_only(self):
593593
'custom_citation',
594594
'category',
595595
'provider_specific_metadata',
596+
'manual_guid',
596597
]
597598
for field in RegistrationSerializer._declared_fields:
598599
reg_field = RegistrationSerializer._declared_fields[field]
@@ -619,6 +620,7 @@ def test_registration_detail_fields_are_read_only(self):
619620
'custom_citation',
620621
'category',
621622
'provider_specific_metadata',
623+
'manual_guid',
622624
]
623625
for field in RegistrationDetailSerializer._declared_fields:
624626
reg_field = RegistrationSerializer._declared_fields[field]

api_tests/registrations/views/test_registration_list.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55

66
from urllib.parse import urljoin, urlparse
77

8+
from waffle import testutils
9+
810
from api.base.settings.defaults import API_BASE
911
from api.base.versioning import CREATE_REGISTRATION_FIELD_CHANGE_VERSION
1012
from api_tests.nodes.views.test_node_draft_registration_list import AbstractDraftRegistrationTestCase
1113
from api_tests.subjects.mixins import SubjectsFilterMixin
1214
from api_tests.registrations.filters.test_filters import RegistrationListFilteringMixin
1315
from api_tests.utils import create_test_file
1416
from framework.auth.core import Auth
17+
from osf import features
1518
from osf.models import RegistrationSchema, Registration
1619
from osf_tests.factories import (
1720
EmbargoFactory,
@@ -1559,6 +1562,41 @@ def test_registration_draft_must_be_draft_of_current_node(
15591562
self, mock_enqueue, app, user, schema, url_registrations_ver):
15601563
# Overrides TestNodeRegistrationCreate - node is not in URL in this workflow
15611564
return
1565+
@pytest.fixture
1566+
def manual_guid(self):
1567+
return 'abcde'
1568+
1569+
@pytest.fixture
1570+
def manual_doi(self):
1571+
return '10.70102/FK2osf.io/abcde'
1572+
1573+
@pytest.fixture
1574+
def enable_flag(self):
1575+
with testutils.override_flag(features.MANUAL_DOI_AND_GUID, True):
1576+
yield
1577+
1578+
@pytest.fixture
1579+
def manual_guid_payload(self, payload, manual_guid, manual_doi):
1580+
payload['data']['attributes'] |= {
1581+
'manual_doi': manual_doi,
1582+
'manual_guid': manual_guid,
1583+
}
1584+
1585+
return payload
1586+
1587+
def test_fail_create_registration_with_manual_guid(self, app, user, schema, url_registrations, manual_guid_payload, manual_guid, manual_doi):
1588+
res = app.post_json_api(url_registrations, manual_guid_payload, auth=user.auth, expect_errors=True)
1589+
assert res.status_code == 400
1590+
print(res.status_code)
1591+
1592+
def test_create_registration_with_manual_guid(self, app, user, schema, url_registrations, manual_guid_payload, manual_guid, manual_doi, enable_flag):
1593+
res = app.post_json_api(url_registrations, manual_guid_payload, auth=user.auth)
1594+
data = res.json['data']
1595+
assert res.status_code == 201
1596+
assert data['id'] == manual_guid, 'manual guid was not assigned'
1597+
identifiers_response = app.get(data['relationships']['identifiers']['links']['related']['href'], auth=user.auth)
1598+
assert identifiers_response.status_code == 200
1599+
assert identifiers_response.json['data'][0]['attributes']['value'] == manual_doi
15621600

15631601
@mock.patch('framework.celery_tasks.handlers.enqueue_task')
15641602
def test_need_admin_perms_on_draft(

osf/external/gravy_valet/request_helpers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import requests
88
from requests.exceptions import RequestException
99

10+
from framework import sentry
1011
from website import settings
1112
from . import auth_helpers
1213

@@ -275,7 +276,7 @@ def _make_gv_request(
275276
return None
276277
if not response.ok:
277278
# log error to Sentry
278-
logger.error(f"GV request failed with status code {response.status_code}: {response.content}")
279+
sentry.log_message(f"GV request failed with status code {response.status_code}")
279280
pass
280281
return response
281282

osf/features.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
# 5. When a flag name is no longer referenced anywhere in this repo or in the Ember app remove it from this list.
1313
flags:
1414

15+
- flag_name: MANUAL_DOI_AND_GUID
16+
name: manual_doi_and_guid
17+
note: This is used to allow certain product staff members to manually assign doi and guid during Registration or
18+
Preprint creation. DO NOT CHANGE UNLESS ABSOLUTELY NECESSARY.
19+
everyone: false
20+
1521
- flag_name: ENABLE_GV
1622
name: gravy_waffle
1723
note: This is used to enable GravyValet, the system responible for addons, this will remove the files widget on the
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 4.2.15 on 2025-06-04 13:26
2+
3+
from django.db import migrations
4+
import osf.utils.fields
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('osf', '0029_remove_abstractnode_keenio_read_key'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='abstractnode',
16+
name='_manual_guid',
17+
field=osf.utils.fields.LowercaseCharField(blank=True, default=None, max_length=255, null=True),
18+
),
19+
]

osf/models/base.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@
2828
def _check_blacklist(guid):
2929
return BlackListGuid.objects.filter(guid=guid).exists()
3030

31+
def check_manually_assigned_guid(guid_id, length=5):
32+
if not guid_id or not isinstance(guid_id, str) or len(guid_id) != length:
33+
logger.error(f'Invalid GUID: guid_id={guid_id}')
34+
return False
35+
if _check_blacklist(guid_id):
36+
logger.error(f'Blacklisted GUID: guid_id={guid_id}')
37+
return False
38+
if Guid.objects.filter(_id=guid_id).exists():
39+
logger.error(f'Duplicate GUID: guid_id={guid_id}')
40+
return False
41+
return True
42+
3143

3244
def generate_guid(length=5):
3345
while True:
@@ -602,6 +614,13 @@ def get_semantic_iri(self):
602614
raise ValueError(f'no osfid for {self} (cannot build semantic iri)')
603615
return osfid_iri(_osfid)
604616

617+
618+
def _clear_cached_guid(instance):
619+
has_cached_guids = hasattr(instance, '_prefetched_objects_cache') and 'guids' in instance._prefetched_objects_cache
620+
if has_cached_guids:
621+
del instance._prefetched_objects_cache['guids']
622+
623+
605624
@receiver(post_save)
606625
def ensure_guid(sender, instance, **kwargs):
607626
"""Generate guid if it doesn't exist for subclasses of GuidMixin except for subclasses of VersionedGuidMixin
@@ -615,17 +634,26 @@ def ensure_guid(sender, instance, **kwargs):
615634
# Only the initial or the latest version is referred to by the base guid in the Guid table. All versions have
616635
# their "versioned" guid in the GuidVersionsThrough table.
617636
return False
637+
638+
from osf.models import Registration
639+
if issubclass(sender, Registration) and instance._manual_guid:
640+
# Note: Only skip default GUID generation if the registration has `_manual_guid` set
641+
# Note: Must clear guid cached because registration is cloned and cast from a draft registration
642+
_clear_cached_guid(instance)
643+
return False
644+
618645
existing_guids = Guid.objects.filter(
619646
object_id=instance.pk,
620647
content_type=ContentType.objects.get_for_model(instance)
621648
)
622-
has_cached_guids = hasattr(instance, '_prefetched_objects_cache') and 'guids' in instance._prefetched_objects_cache
623-
if not existing_guids.exists():
624-
# Clear query cache of instance.guids
625-
if has_cached_guids:
626-
del instance._prefetched_objects_cache['guids']
627-
Guid.objects.create(
628-
object_id=instance.pk,
629-
content_type=ContentType.objects.get_for_model(instance),
630-
_id=generate_guid(instance.__guid_min_length__)
631-
)
649+
if existing_guids.exists():
650+
return False
651+
652+
# Note: must clear cached guid because the instance could be cloned and cast from existing instance.
653+
_clear_cached_guid(instance)
654+
Guid.objects.create(
655+
object_id=instance.pk,
656+
content_type=ContentType.objects.get_for_model(instance),
657+
_id=generate_guid(instance.__guid_min_length__)
658+
)
659+
return True

0 commit comments

Comments
 (0)