Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b9a9445
add missing migrations
Johnetordoff Sep 4, 2025
f066051
[ENG-9193] Re-route unregistered contributor claim (#11377)
antkryt Oct 20, 2025
2fa247b
Merge branch 'hotfix/25.17.8'
adlius Oct 22, 2025
f409206
Merge tag '25.17.8' into develop
adlius Oct 22, 2025
56e065b
fix api3_and_osf ci
Ostap-Zherebetskyi Oct 22, 2025
bfad7ee
fix unit tests
Ostap-Zherebetskyi Oct 22, 2025
3a836bb
Remove skipped tests
Ostap-Zherebetskyi Oct 22, 2025
a920785
[ENG-9619] Merge pull request #11385 from Ostap-Zherebetskyi/fix/api3-ci
brianjgeiger Oct 23, 2025
4f5acbc
ENG-9127 add contributors from parent project to component
mkovalua Oct 17, 2025
06715e2
implement unit test for component project contributors from parent pr…
mkovalua Oct 20, 2025
6c49b78
Merge branch 'hotfix/25.17.9'
adlius Oct 28, 2025
1dd3954
Merge tag '25.17.9' into develop
adlius Oct 28, 2025
a4cda76
fix api3 test branch and collection submission issues
Johnetordoff Oct 31, 2025
f855072
fix collections submissions tests in `api_tests/collection_submissions/`
Johnetordoff Oct 31, 2025
ee3985f
fix crossref email notification type mismatch
Johnetordoff Oct 31, 2025
2a13d0c
fix TestCustomItemMetadataRecordDetail by using expect_errors=True,
Johnetordoff Oct 31, 2025
c4bee2c
mock action list tests
Johnetordoff Oct 31, 2025
ef76e96
mock collect object notifications
Johnetordoff Nov 1, 2025
f073c7b
Merge branch 'develop' of github.com:johnetordoff/osf.io into fix-api…
Johnetordoff Nov 1, 2025
20d6682
add more mocking to collection submissions
Johnetordoff Nov 1, 2025
5f63571
Merge branch 'develop' of https://github.com/centerforopenscience/osf…
Johnetordoff Nov 1, 2025
6f133ba
fix throttle tests for notification refactor
Johnetordoff Nov 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion admin/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ def get_claim_links(self, user):

for guid, value in user.unclaimed_records.items():
obj = Guid.load(guid)
url = f'{DOMAIN}user/{user._id}/{guid}/claim/?token={value["token"]}'
url = f'{DOMAIN}legacy/user/{user._id}/{guid}/claim/?token={value["token"]}'
links.append(f'Claim URL for {obj.content_type.model} {obj._id}: {url}')

return links or ['User currently has no active unclaimed records for any nodes.']
Expand Down
6 changes: 6 additions & 0 deletions api/nodes/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,12 @@ def update(self, instance, validated_data):
validated_data,
)

class NodeContributorsUpdateSerializer(ser.Serializer):
def update(self, instance, validated_data):
if project := instance.root:
instance.copy_contributors_from(project)
return instance


class NodeLinksSerializer(JSONAPISerializer):

Expand Down
18 changes: 18 additions & 0 deletions api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
DraftRegistrationDetailLegacySerializer,
NodeContributorsSerializer,
NodeContributorDetailSerializer,
NodeContributorsUpdateSerializer,
NodeInstitutionsRelationshipSerializer,
NodeContributorsCreateSerializer,
NodeViewOnlyLinkSerializer,
Expand Down Expand Up @@ -436,11 +437,16 @@ class NodeContributorsList(BaseContributorList, bulk_views.BulkUpdateJSONAPIView
def get_resource(self):
return self.get_node()

def get_object(self):
return self.get_node()

# overrides ListBulkCreateJSONAPIView, BulkUpdateJSONAPIView, BulkDeleteJSONAPIView
def get_serializer_class(self):
"""
Use NodeContributorDetailSerializer which requires 'id'
"""
if self.request.method == 'PATCH' and self.request.query_params.get('copy_contributors_from_parent_project'):
return NodeContributorsUpdateSerializer
if self.request.method == 'PUT' or self.request.method == 'PATCH' or self.request.method == 'DELETE':
return NodeContributorDetailSerializer
elif self.request.method == 'POST':
Expand Down Expand Up @@ -501,6 +507,18 @@ def get_serializer_context(self):
context['default_email'] = 'default'
return context

def patch(self, request, *args, **kwargs):
"""
Override the default patch behavior to handle the special case
of updating contributors by copying contributors from the parent project.
"""
if request.query_params.get('copy_contributors_from_parent_project'):
instance = self.get_object()
serializer = self.get_serializer_class()(instance, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=200)
return super().patch(request, *args, **kwargs)

class NodeContributorDetail(BaseContributorDetail, generics.RetrieveUpdateDestroyAPIView, NodeMixin, UserMixin):
"""The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/nodes_contributors_read).
Expand Down
13 changes: 11 additions & 2 deletions api_tests/actions/views/test_action_list.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import pytest
import pytest_socket

from api.base.settings.defaults import API_BASE
from osf.models import NotificationType
from osf_tests.factories import (
PreprintFactory,
AuthUserFactory,
Expand Down Expand Up @@ -189,7 +191,11 @@ def test_accept_permissions_accept(self, app, url, preprint, node_admin, moderat
assert not preprint.is_published

# Moderator can accept
res = app.post_json_api(url, accept_payload, auth=moderator.auth)
with capture_notifications() as notifications:
res = app.post_json_api(url, accept_payload, auth=moderator.auth)
assert len(notifications['emits']) == 2
assert notifications['emits'][0]['type'] == NotificationType.Type.REVIEWS_SUBMISSION_STATUS
assert notifications['emits'][1]['type'] == NotificationType.Type.REVIEWS_SUBMISSION_STATUS
assert res.status_code == 201
preprint.refresh_from_db()
assert preprint.machine_state == 'accepted'
Expand Down Expand Up @@ -319,8 +325,11 @@ def test_valid_transitions(
preprint.date_last_transitioned = None
preprint.save()
payload = self.create_payload(preprint._id, trigger=trigger)
with capture_notifications():
try:
res = app.post_json_api(url, payload, auth=moderator.auth)
except pytest_socket.SocketConnectBlockedError:
with capture_notifications():
res = app.post_json_api(url, payload, auth=moderator.auth)
assert res.status_code == 201

action = preprint.actions.order_by('-created').first()
Expand Down
15 changes: 11 additions & 4 deletions api_tests/base/test_throttling.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from unittest import mock

from api.base.settings.defaults import API_BASE
from osf.models import NotificationType

from tests.base import ApiTestCase
from osf_tests.factories import AuthUserFactory, ProjectFactory
from tests.utils import capture_notifications


class TestDefaultThrottleClasses(ApiTestCase):

Expand Down Expand Up @@ -121,10 +124,14 @@ def test_add_contrib_throttle_rate_allow_request_not_called(

@mock.patch('api.base.throttling.AddContributorThrottle.allow_request')
def test_add_contrib_throttle_rate_allow_request_called(self, mock_allow):
res = self.app.post_json_api(
self.public_url,
self.data_user_two,
auth=self.user.auth)
with capture_notifications() as notifications:
res = self.app.post_json_api(
self.public_url,
self.data_user_two,
auth=self.user.auth
)
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT
assert res.status_code == 201
assert mock_allow.call_count == 1

Expand Down
42 changes: 0 additions & 42 deletions api_tests/base/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from django.conf import settings as django_conf_settings

from rest_framework import fields
from rest_framework.exceptions import ValidationError

from api.base import utils as api_utils
from osf.models.base import coerce_guid, Guid, GuidMixin, OptionalGuidMixin, VersionedGuidMixin, InvalidGuid
Expand Down Expand Up @@ -63,47 +62,6 @@ def test_push_status_message_no_response(self):
except BaseException:
assert False, f'Exception from push_status_message via API v2 with type "{status}".'

def test_push_status_message_expected_error(self):
status_message = 'This is a message'
try:
push_status_message(status_message, kind='error')
assert False, 'push_status_message() should have generated a ValidationError exception.'

except ValidationError as e:
assert (
e.detail[0] == status_message
), 'push_status_message() should have passed along the message with the Exception.'

except RuntimeError:
assert False, 'push_status_message() should have caught the runtime error and replaced it.'

except BaseException:
assert False, 'Exception from push_status_message when called from the v2 API with type "error"'

@mock.patch('framework.status.get_session')
def test_push_status_message_unexpected_error(self, mock_get_session):
status_message = 'This is a message'
exception_message = 'this is some very unexpected problem'
mock_session = mock.Mock()
mock_session.attach_mock(mock.Mock(side_effect=RuntimeError(exception_message)), 'get')
mock_get_session.return_value = mock_session
try:
push_status_message(status_message, kind='error')
assert False, 'push_status_message() should have generated a RuntimeError exception.'
except ValidationError:
assert False, 'push_status_message() should have re-raised the RuntimeError not gotten ValidationError.'
except RuntimeError as e:
assert str(e) == exception_message, (
'push_status_message() should have re-raised the '
'original RuntimeError with the original message.'
)

except BaseException:
assert False, (
'Unexpected Exception from push_status_message when called '
'from the v2 API with type "error"'
)


@pytest.mark.django_db
class TestCoerceGuid:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ def node(collection_provider):
def collection(collection_provider):
collection = CollectionFactory(is_public=True)
collection.provider = collection_provider
with capture_notifications():
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This would fail because it didn't send notifications.

collection.save()
collection.save()
return collection


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.utils import timezone
from osf_tests.factories import NodeFactory, CollectionFactory, CollectionProviderFactory

from osf.models import CollectionSubmission
from osf.models import CollectionSubmission, NotificationType
from osf.utils.workflows import CollectionSubmissionsTriggers, CollectionSubmissionStates
from tests.utils import capture_notifications

Expand Down Expand Up @@ -33,8 +33,7 @@ def node(collection_provider):
def collection(collection_provider):
collection = CollectionFactory()
collection.provider = collection_provider
with capture_notifications():
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This would fail because it didn't send notifications.

collection.save()
collection.save()
return collection


Expand Down Expand Up @@ -122,10 +121,21 @@ def test_status_code__collection_moderator_accept_reject_moderated(self, app, no
collection_submission.state_machine.set_state(CollectionSubmissionStates.PENDING)
collection_submission.save()
test_auth = configure_test_auth(node, UserRoles.MODERATOR)
resp = app.post_json_api(POST_URL, make_payload(
collection_submission=collection_submission,
trigger=moderator_trigger.db_name
), auth=test_auth, expect_errors=True)
with capture_notifications() as notifications:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This adds needed email mocking.

resp = app.post_json_api(
POST_URL,
make_payload(
collection_submission=collection_submission,
trigger=moderator_trigger.db_name
),
auth=test_auth,
expect_errors=True
)
assert len(notifications['emits']) == 1
if moderator_trigger is CollectionSubmissionsTriggers.ACCEPT:
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_ACCEPTED
if moderator_trigger is CollectionSubmissionsTriggers.REJECT:
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REJECTED
assert resp.status_code == 201

@pytest.mark.parametrize('moderator_trigger', [CollectionSubmissionsTriggers.ACCEPT, CollectionSubmissionsTriggers.REJECT])
Expand Down Expand Up @@ -158,11 +168,27 @@ def test_status_code__remove(self, app, node, collection_submission, user_role):
collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED)
collection_submission.save()
test_auth = configure_test_auth(node, user_role)
resp = app.post_json_api(POST_URL, make_payload(
collection_submission=collection_submission,
trigger=CollectionSubmissionsTriggers.REMOVE.db_name
), auth=test_auth, expect_errors=True)
with capture_notifications() as notifications:
resp = app.post_json_api(
POST_URL,
make_payload(
collection_submission=collection_submission,
trigger=CollectionSubmissionsTriggers.REMOVE.db_name
),
auth=test_auth,
expect_errors=True
)
assert resp.status_code == 201
if user_role == UserRoles.MODERATOR:
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_MODERATOR
assert notifications['emits'][0]['kwargs']['user'] == collection_submission.creator
else:
assert len(notifications['emits']) == 2
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN
assert notifications['emits'][0]['kwargs']['user'] == collection_submission.creator
assert notifications['emits'][1]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN
assert notifications['emits'][1]['kwargs']['user'] == node.contributors.last()


@pytest.mark.django_db
Expand All @@ -182,11 +208,14 @@ def test_POST_accept__writes_action_and_advances_state(self, app, collection_sub
collection_submission.save()
test_auth = configure_test_auth(node, UserRoles.MODERATOR)
payload = make_payload(collection_submission, trigger=CollectionSubmissionsTriggers.ACCEPT.db_name)
app.post_json_api(POST_URL, payload, auth=test_auth)
with capture_notifications() as notifications:
app.post_json_api(POST_URL, payload, auth=test_auth)
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_ACCEPTED

user = collection_submission.collection.provider.get_group('moderator').user_set.first()
collection_submission.refresh_from_db()
action = collection_submission.actions.last()

assert action.trigger == CollectionSubmissionsTriggers.ACCEPT
assert action.creator == user
assert action.from_state == CollectionSubmissionStates.PENDING
Expand All @@ -198,11 +227,14 @@ def test_POST_reject__writes_action_and_advances_state(self, app, collection_sub
collection_submission.save()
test_auth = configure_test_auth(node, UserRoles.MODERATOR)
payload = make_payload(collection_submission, trigger=CollectionSubmissionsTriggers.REJECT.db_name)
app.post_json_api(POST_URL, payload, auth=test_auth)
with capture_notifications() as notifications:
app.post_json_api(POST_URL, payload, auth=test_auth)
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REJECTED

user = collection_submission.collection.provider.get_group('moderator').user_set.first()
collection_submission.refresh_from_db()
action = collection_submission.actions.last()

assert action.trigger == CollectionSubmissionsTriggers.REJECT
assert action.creator == user
assert action.from_state == CollectionSubmissionStates.PENDING
Expand All @@ -214,7 +246,14 @@ def test_POST_cancel__writes_action_and_advances_state(self, app, collection_sub
collection_submission.save()
test_auth = configure_test_auth(node, UserRoles.ADMIN_USER)
payload = make_payload(collection_submission, trigger=CollectionSubmissionsTriggers.CANCEL.db_name)
app.post_json_api(POST_URL, payload, auth=test_auth)
with capture_notifications() as notifications:
app.post_json_api(POST_URL, payload, auth=test_auth)
assert len(notifications['emits']) == 2
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_CANCEL
assert notifications['emits'][0]['kwargs']['user'] == collection_submission.creator
assert notifications['emits'][1]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_CANCEL
assert notifications['emits'][0]['kwargs']['user'] == node.creator

collection_submission.refresh_from_db()
action = collection_submission.actions.last()

Expand All @@ -229,7 +268,10 @@ def test_POST_remove__writes_action_and_advances_state(self, app, collection_sub
collection_submission.save()
test_auth = configure_test_auth(node, UserRoles.MODERATOR)
payload = make_payload(collection_submission, trigger=CollectionSubmissionsTriggers.REMOVE.db_name)
app.post_json_api(POST_URL, payload, auth=test_auth)
with capture_notifications() as notifications:
app.post_json_api(POST_URL, payload, auth=test_auth)
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_MODERATOR
user = collection_submission.collection.provider.get_group('moderator').user_set.first()
collection_submission.refresh_from_db()
action = collection_submission.actions.last()
Expand Down Expand Up @@ -269,15 +311,18 @@ def test_status_code__private_collection_moderator(self, app, node, collection,
collection.is_public = False
collection.save()
test_auth = configure_test_auth(node, UserRoles.MODERATOR)
resp = app.post_json_api(
POST_URL,
make_payload(
collection_submission,
trigger=CollectionSubmissionsTriggers.ACCEPT.db_name
),
auth=test_auth,
expect_errors=True
)
with capture_notifications() as notifications:
resp = app.post_json_api(
POST_URL,
make_payload(
collection_submission,
trigger=CollectionSubmissionsTriggers.ACCEPT.db_name
),
auth=test_auth,
expect_errors=True
)
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_ACCEPTED
assert resp.status_code == 201


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from osf.migrations import update_provider_auth_groups
from osf.models import CollectionSubmission
from osf.utils.workflows import CollectionSubmissionsTriggers, CollectionSubmissionStates
from tests.utils import capture_notifications

GET_URL = '/v2/collection_submissions/{}/actions/'

Expand Down Expand Up @@ -39,7 +40,8 @@ def collection_submission(node, collection):
collection=collection,
creator=node.creator,
)
collection_submission.save()
with capture_notifications():
collection_submission.save()
return collection_submission


Expand Down
2 changes: 1 addition & 1 deletion api_tests/crossref/views/test_crossref_email_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def test_error_response_sends_message_does_not_set_doi(self, app, url, preprint,
with capture_notifications() as notifications:
app.post(url, context_data)
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.DESK_OSF_SUPPORT_EMAIL
assert notifications['emits'][0]['type'] == NotificationType.Type.DESK_CROSSREF_ERROR
assert not preprint.get_identifier_value('doi')

def test_success_response_sets_doi(self, app, url, preprint, success_xml):
Expand Down
Loading
Loading