From 34872238facbf0dd69ce09f981181848c9afcec1 Mon Sep 17 00:00:00 2001 From: antkryt Date: Fri, 15 Aug 2025 21:46:41 +0300 Subject: [PATCH 01/15] [ENG-8514] Remove CSRF protection from reset password api v2 POST (#11246) * remove csrf protection from reset password endpoint * update test --- api/users/views.py | 1 - api_tests/users/views/test_user_settings.py | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/api/users/views.py b/api/users/views.py index 6387bcbcea9..a0ea1e171ee 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -897,7 +897,6 @@ def get(self, request, *args, **kwargs): ) return Response(status=status.HTTP_200_OK, data={'message': status_message, 'kind': kind, 'institutional': institutional}) - @method_decorator(csrf_protect) def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/api_tests/users/views/test_user_settings.py b/api_tests/users/views/test_user_settings.py index cd4e25ff654..80f75303cdf 100644 --- a/api_tests/users/views/test_user_settings.py +++ b/api_tests/users/views/test_user_settings.py @@ -211,8 +211,7 @@ def test_get_invalid_email(self, app, url): assert res.status_code == 200 assert not mock_send_mail.called - def test_post(self, app, url, user_one, csrf_token): - app.set_cookie(CSRF_COOKIE_NAME, csrf_token) + def test_post(self, app, url, user_one): encoded_email = urllib.parse.quote(user_one.email) url = f'{url}?email={encoded_email}' res = app.get(url) @@ -227,7 +226,7 @@ def test_post(self, app, url, user_one, csrf_token): } } - res = app.post_json_api(url, payload, headers={'X-CSRFToken': csrf_token}) + res = app.post_json_api(url, payload) user_one.reload() assert res.status_code == 200 assert user_one.check_password('password2') From 7a03e98f6c3b42dbac10565b7347423a98b796b7 Mon Sep 17 00:00:00 2001 From: antkryt Date: Fri, 15 Aug 2025 21:48:20 +0300 Subject: [PATCH 02/15] add background color prop to Brand (#11254) --- admin/brands/forms.py | 1 + admin/brands/views.py | 8 ++++++++ api/brands/serializers.py | 1 + osf/migrations/0032_brand_background_color.py | 18 ++++++++++++++++++ osf/models/brand.py | 1 + osf_tests/factories.py | 1 + 6 files changed, 30 insertions(+) create mode 100644 osf/migrations/0032_brand_background_color.py diff --git a/admin/brands/forms.py b/admin/brands/forms.py index 942d4929338..05693c66a05 100644 --- a/admin/brands/forms.py +++ b/admin/brands/forms.py @@ -11,6 +11,7 @@ class Meta: widgets = { 'primary_color': TextInput(attrs={'class': 'colorpicker'}), 'secondary_color': TextInput(attrs={'class': 'colorpicker'}), + 'background_color': TextInput(attrs={'class': 'colorpicker'}), 'topnav_logo_image': TextInput(attrs={'placeholder': 'Logo should be max height of 40px', 'size': 200}), 'hero_logo_image': TextInput( attrs={'placeholder': 'Logo image should be max height of 100px', 'size': 200} diff --git a/admin/brands/views.py b/admin/brands/views.py index 01b449d3fe8..11473f86939 100644 --- a/admin/brands/views.py +++ b/admin/brands/views.py @@ -81,6 +81,7 @@ def post(self, request, *args, **kwargs): view = BrandChangeForm.as_view() primary_color = request.POST.get('primary_color') secondary_color = request.POST.get('secondary_color') + background_color = request.POST.get('background_color') if not is_a11y(primary_color): messages.warning(request, """The selected primary color is not a11y compliant. @@ -88,6 +89,9 @@ def post(self, request, *args, **kwargs): if not is_a11y(secondary_color): messages.warning(request, """The selected secondary color is not a11y compliant. For more information, visit https://color.a11y.com/""") + if background_color and not is_a11y(background_color): + messages.warning(request, """The selected background color is not a11y compliant. + For more information, visit https://color.a11y.com/""") return view(request, *args, **kwargs) @@ -109,6 +113,7 @@ def get_context_data(self, *args, **kwargs): def post(self, request, *args, **kwargs): primary_color = request.POST.get('primary_color') secondary_color = request.POST.get('secondary_color') + background_color = request.POST.get('background_color') if not is_a11y(primary_color): messages.warning(request, """The selected primary color is not a11y compliant. @@ -116,4 +121,7 @@ def post(self, request, *args, **kwargs): if not is_a11y(secondary_color): messages.warning(request, """The selected secondary color is not a11y compliant. For more information, visit https://color.a11y.com/""") + if background_color and not is_a11y(background_color): + messages.warning(request, """The selected background color is not a11y compliant. + For more information, visit https://color.a11y.com/""") return super().post(request, *args, **kwargs) diff --git a/api/brands/serializers.py b/api/brands/serializers.py index 8d040e7a93d..485d515b098 100644 --- a/api/brands/serializers.py +++ b/api/brands/serializers.py @@ -15,6 +15,7 @@ class BrandSerializer(JSONAPISerializer): primary_color = ser.CharField(read_only=True, max_length=7) secondary_color = ser.CharField(read_only=True, max_length=7) + background_color = ser.CharField(read_only=True, allow_null=True, max_length=7) links = LinksField({ 'self': 'get_absolute_url', diff --git a/osf/migrations/0032_brand_background_color.py b/osf/migrations/0032_brand_background_color.py new file mode 100644 index 00000000000..9b465e81e4a --- /dev/null +++ b/osf/migrations/0032_brand_background_color.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2025-08-12 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0031_abstractprovider_registration_word'), + ] + + operations = [ + migrations.AddField( + model_name='brand', + name='background_color', + field=models.CharField(blank=True, max_length=7, null=True), + ), + ] diff --git a/osf/models/brand.py b/osf/models/brand.py index ae4650407aa..5a857f5f3b1 100644 --- a/osf/models/brand.py +++ b/osf/models/brand.py @@ -23,6 +23,7 @@ class Meta: primary_color = models.CharField(max_length=7) secondary_color = models.CharField(max_length=7) + background_color = models.CharField(max_length=7, blank=True, null=True) def __str__(self): return f'{self.name} ({self.id})' diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 7ad8885e1ad..ea1ab95d437 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -1299,6 +1299,7 @@ class Meta: primary_color = factory.Faker('hex_color') secondary_color = factory.Faker('hex_color') + background_color = factory.Faker('hex_color') class SchemaResponseFactory(DjangoModelFactory): From 8cf2f9c4168fbed69a2155d2f1a90f76ef9a01ab Mon Sep 17 00:00:00 2001 From: Anton Krytskyi Date: Mon, 18 Aug 2025 17:35:31 +0300 Subject: [PATCH 03/15] fix flaky test_serialized_metadata --- osf/models/mixins.py | 2 +- osf_tests/metadata/test_serialized_metadata.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/osf/models/mixins.py b/osf/models/mixins.py index 0a6375d96ae..110b92b0fcc 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -138,7 +138,7 @@ def _complete_add_log(self, log, action, user=None, save=True): last_logged = log_date.replace(tzinfo=pytz.utc) else: recent_log = self.logs.latest('created') - log_date = recent_log.date if hasattr(log, 'date') else recent_log.created + log_date = recent_log.date if hasattr(recent_log, 'date') else recent_log.created last_logged = log_date if not log.should_hide: diff --git a/osf_tests/metadata/test_serialized_metadata.py b/osf_tests/metadata/test_serialized_metadata.py index bcbb18a52ae..9589e4d2b45 100644 --- a/osf_tests/metadata/test_serialized_metadata.py +++ b/osf_tests/metadata/test_serialized_metadata.py @@ -176,6 +176,7 @@ def setUp(self): mock.patch('osf.models.base.generate_guid', new=osfguid_sequence), mock.patch('osf.models.base.Guid.objects.get_or_create', new=osfguid_sequence.get_or_create), mock.patch('django.utils.timezone.now', new=forever_now), + mock.patch('osf.models.mixins.timezone.now', new=forever_now), mock.patch('osf.models.metaschema.RegistrationSchema.absolute_api_v2_url', new='http://fake.example/schema/for/test'), mock.patch('osf.models.node.Node.get_verified_links', return_value=[ {'target_url': 'https://foo.bar', 'resource_type': 'Other'} From 8e04d5cbf628c337d1cf1bb1c0733d536f306ce5 Mon Sep 17 00:00:00 2001 From: Anton Krytskyi Date: Thu, 21 Aug 2025 15:54:20 +0300 Subject: [PATCH 04/15] fix throttle test --- api_tests/users/views/test_user_settings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api_tests/users/views/test_user_settings.py b/api_tests/users/views/test_user_settings.py index 80f75303cdf..5adcd554b10 100644 --- a/api_tests/users/views/test_user_settings.py +++ b/api_tests/users/views/test_user_settings.py @@ -275,7 +275,7 @@ def test_post_invalid_password(self, app, url, user_one, csrf_token): res = app.post_json_api(url, payload, expect_errors=True, headers={'X-THROTTLE-TOKEN': 'test-token', 'X-CSRFToken': csrf_token}) assert res.status_code == 400 - def test_throrrle(self, app, url, user_one): + def test_throttle(self, app, url, user_one): encoded_email = urllib.parse.quote(user_one.email) url = f'{url}?email={encoded_email}' res = app.get(url) @@ -290,10 +290,11 @@ def test_throrrle(self, app, url, user_one): } } - res = app.post_json_api(url, payload, expect_errors=True) - assert res.status_code == 429 + res = app.post_json_api(url, payload, expect_errors=False) + assert res.status_code == 200 res = app.get(url, expect_errors=True) + assert res.status_code == 429 assert res.json['message'] == 'You have recently requested to change your password. Please wait a few minutes before trying again.' From 7a53306ceaa1b8e7b8041fef58968d4c0cfc7d61 Mon Sep 17 00:00:00 2001 From: Anton Krytskyi Date: Thu, 21 Aug 2025 16:57:45 +0300 Subject: [PATCH 05/15] reset throttle cache --- api_tests/users/views/test_user_settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api_tests/users/views/test_user_settings.py b/api_tests/users/views/test_user_settings.py index 5adcd554b10..a3750f959ba 100644 --- a/api_tests/users/views/test_user_settings.py +++ b/api_tests/users/views/test_user_settings.py @@ -10,6 +10,7 @@ UserFactory, ) from django.middleware import csrf +from django.core.cache import cache from osf.models import Email, NotableDomain from framework.auth.views import auth_email_logout from website import mails, settings @@ -189,6 +190,10 @@ def url(self): def csrf_token(self): return csrf._mask_cipher_secret(csrf._get_new_csrf_string()) + @pytest.fixture(autouse=True) + def clear_throttle_cache(self): + cache.clear() + def test_get(self, app, url, user_one): encoded_email = urllib.parse.quote(user_one.email) url = f'{url}?email={encoded_email}' From f6ede63a9c97d96b1f4a3076d6e738e577daca1d Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Wed, 27 Aug 2025 17:29:23 +0300 Subject: [PATCH 06/15] don't ignore components when create a view-only link --- api/nodes/serializers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index f9e0aeed7a9..0960ed92bb2 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -1697,12 +1697,15 @@ def create(self, validated_data): user = get_user_auth(self.context['request']).user anonymous = validated_data.pop('anonymous') node = self.context['view'].get_node() + children = self.context['request'].data.pop('target_type', []) + if children: + children = [AbstractNode.load(id) for id in children if id != 'nodes'] try: view_only_link = new_private_link( name=name, user=user, - nodes=[node], + nodes=[node, *children], anonymous=anonymous, ) except ValidationError: From cae988914d7f86e0aee2ea833a82139642879374 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Thu, 28 Aug 2025 11:35:31 +0300 Subject: [PATCH 07/15] fixed updating subscribe_osf_general_email subscription --- api/users/serializers.py | 2 +- api_tests/users/views/test_user_settings_detail.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/users/serializers.py b/api/users/serializers.py index 522fa556aec..5b36b4af148 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -581,6 +581,7 @@ def update_email_preferences(self, instance, attr, value): instance._id, username=instance.username, ) + instance.reload() else: raise exceptions.ValidationError(detail='Invalid email preference.') @@ -624,7 +625,6 @@ def to_representation(self, instance): return UserSettingsSerializer(instance=instance, context=context).data def update(self, instance, validated_data): - for attr, value in validated_data.items(): if 'two_factor_enabled' == attr: two_factor_addon = instance.get_addon('twofactor') diff --git a/api_tests/users/views/test_user_settings_detail.py b/api_tests/users/views/test_user_settings_detail.py index 50da5e6cbfa..7ebc454d290 100644 --- a/api_tests/users/views/test_user_settings_detail.py +++ b/api_tests/users/views/test_user_settings_detail.py @@ -4,7 +4,7 @@ from osf_tests.factories import ( AuthUserFactory, ) -from website.settings import OSF_HELP_LIST +from website.settings import OSF_HELP_LIST, MAILCHIMP_GENERAL_LIST @pytest.fixture() @@ -208,6 +208,9 @@ def test_authorized_patch_200(self, mock_mailchimp_client, app, user_one, payloa user_one.refresh_from_db() assert res.json['data']['attributes']['subscribe_osf_help_email'] is False assert user_one.osf_mailing_lists[OSF_HELP_LIST] is False + + assert res.json['data']['attributes']['subscribe_osf_general_email'] is True + assert user_one.mailchimp_mailing_lists[MAILCHIMP_GENERAL_LIST] is True mock_mailchimp_client.assert_called_with() def test_bad_payload_patch_400(self, app, user_one, bad_payload, url): From fb62bc9f4537dcc66910428307f813b0e92db2ad Mon Sep 17 00:00:00 2001 From: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:43:40 +0300 Subject: [PATCH 08/15] [ENG-7277] Update doc to include version as relationship (#11262) --- api/preprints/serializers.py | 47 +++++++++++++++++++++++++++++++----- api/preprints/views.py | 10 ++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/api/preprints/serializers.py b/api/preprints/serializers.py index 73e28d217d2..42b122e8091 100644 --- a/api/preprints/serializers.py +++ b/api/preprints/serializers.py @@ -154,6 +154,10 @@ class PreprintSerializer(TaxonomizableSerializerMixin, MetricsSerializerMixin, J related_view='preprints:preprint-versions', related_view_kwargs={'preprint_id': '<_id>'}, read_only=True, + help_text=( + 'Relationship to all versions of this preprint. ' + 'Related URL: /v2/preprints/{preprint_id}/versions/ (GET to list, POST to create a new version).' + ), ) citation = NoneIfWithdrawal( @@ -190,6 +194,7 @@ class PreprintSerializer(TaxonomizableSerializerMixin, MetricsSerializerMixin, J related_view='providers:preprint-providers:preprint-provider-detail', related_view_kwargs={'provider_id': ''}, read_only=False, + help_text='Relationship to the preprint provider. Required on creation.', ) files = NoneIfWithdrawal( @@ -500,11 +505,30 @@ class Meta: class PreprintCreateSerializer(PreprintSerializer): + """Serializer for creating a new preprint. + + Notes + - Overrides `PreprintSerializer` to allow nullable `id` and implements `create`. + - Requires `provider` and `title`. + - Optional `description`. + - Optional privileged fields: `manual_guid`, `manual_doi` (gated by MANUAL_DOI_AND_GUID flag). + """ # Overrides PreprintSerializer to make id nullable, adds `create` - # TODO: add better Docstrings id = IDField(source='_id', required=False, allow_null=True) - manual_guid = ser.CharField(write_only=True, required=False, allow_null=True, allow_blank=True) - manual_doi = ser.CharField(write_only=True, required=False, allow_null=True, allow_blank=True) + manual_guid = ser.CharField( + write_only=True, + required=False, + allow_null=True, + allow_blank=True, + help_text='Privileged: manually assign a GUID on creation (feature-flag gated).', + ) + manual_doi = ser.CharField( + write_only=True, + required=False, + allow_null=True, + allow_blank=True, + help_text='Privileged: manually assign an article DOI on creation (feature-flag gated).', + ) def create(self, validated_data): @@ -527,11 +551,22 @@ def create(self, validated_data): class PreprintCreateVersionSerializer(PreprintSerializer): - # Overrides PreprintSerializer to make title nullable and customize version creation - # TODO: add better Docstrings + """Serializer for creating a new version of an existing preprint. + + Notes + - Overrides `PreprintSerializer` to make `title` optional during version creation. + - Requires `create_from_guid` referencing the source preprint GUID (base or versioned). + - Only users with ADMIN on the source preprint may create a new version. + """ id = IDField(source='_id', required=False, allow_null=True) title = ser.CharField(required=False) - create_from_guid = ser.CharField(required=True, write_only=True) + create_from_guid = ser.CharField( + required=True, + write_only=True, + help_text=( + 'GUID of the source preprint to version (accepts base GUID or versioned GUID, e.g., abc12 or abc12_v3).' + ), + ) def create(self, validated_data): create_from_guid = validated_data.pop('create_from_guid', None) diff --git a/api/preprints/views.py b/api/preprints/views.py index 9e6ffdb883b..85703732979 100644 --- a/api/preprints/views.py +++ b/api/preprints/views.py @@ -228,6 +228,13 @@ def get_annotated_queryset_with_metrics(self, queryset, metric_class, metric_nam class PreprintVersionsList(PreprintMetricsViewMixin, JSONAPIBaseView, generics.ListCreateAPIView, PreprintFilterMixin): + """List existing versions of a preprint or create a new version. + + GET: Returns a collection of preprint resources representing all versions of the given preprint. + POST: Creates a new version from the current preprint. Requires ADMIN on the source preprint. + + Related to the `versions` relationship on the Preprint resource. + """ # These permissions are not checked for the list of preprints, permissions handled by the query permission_classes = ( drf_permissions.IsAuthenticatedOrReadOnly, @@ -282,6 +289,9 @@ def create(self, request, *args, **kwargs): class PreprintDetail(PreprintOldVersionsImmutableMixin, PreprintMetricsViewMixin, JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView, PreprintMixin, WaterButlerMixin): """The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/preprints_read). + + Note: The resource now exposes a `versions` relationship pointing to + `/v2/preprints/{preprint_id}/versions/` for listing or creating versions. """ permission_classes = ( drf_permissions.IsAuthenticatedOrReadOnly, From ae7afd4e4be4806080562a9c7989a4ff63d64915 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Fri, 29 Aug 2025 13:13:23 +0300 Subject: [PATCH 09/15] added tests --- .../views/test_node_view_only_links_list.py | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/api_tests/nodes/views/test_node_view_only_links_list.py b/api_tests/nodes/views/test_node_view_only_links_list.py index 1bf37bddc61..68d259262fe 100644 --- a/api_tests/nodes/views/test_node_view_only_links_list.py +++ b/api_tests/nodes/views/test_node_view_only_links_list.py @@ -5,7 +5,8 @@ from osf_tests.factories import ( ProjectFactory, AuthUserFactory, - PrivateLinkFactory + PrivateLinkFactory, + NodeRelationFactory ) @@ -227,3 +228,77 @@ def test_cannot_create_vol( } res = app.post_json_api(url, {'data': payload}, expect_errors=True) assert res.status_code == 401 + + def test_create_vol_with_components( + self, app, user, url, public_project, view_only_link): + component1 = ProjectFactory(creator=user) + component2 = ProjectFactory(creator=user) + component3 = ProjectFactory(creator=user) + NodeRelationFactory(parent=public_project, child=component1) + NodeRelationFactory(parent=public_project, child=component2) + NodeRelationFactory(parent=public_project, child=component3) + + url = f'{url}?embed=creator&embed=nodes' + payload = { + 'attributes': { + 'name': 'testmultiplenodes', + 'anonymous': False, + }, + 'relationships': { + 'nodes': { + 'data': [ + { + 'id': component1._id, + 'type': 'nodes' + }, + { + 'id': component2._id, + 'type': 'nodes' + }, + { + 'id': component3._id, + 'type': 'nodes' + } + ] + } + } + } + res = app.post_json_api(url, {'data': payload}, auth=user.auth) + assert res.status_code == 201 + data = res.json['data'] + assert len(data['embeds']['nodes']['data']) == 4 + + def test_create_vol_no_duplicated_components( + self, app, user, url, public_project, view_only_link): + component1 = ProjectFactory(creator=user) + NodeRelationFactory(parent=public_project, child=component1) + + url = f'{url}?embed=creator&embed=nodes' + payload = { + 'attributes': { + 'name': 'testmultiplenodes', + 'anonymous': False, + }, + 'relationships': { + 'nodes': { + 'data': [ + { + 'id': component1._id, + 'type': 'nodes' + }, + { + 'id': component1._id, + 'type': 'nodes' + }, + { + 'id': component1._id, + 'type': 'nodes' + }, + ] + } + } + } + res = app.post_json_api(url, {'data': payload}, auth=user.auth) + assert res.status_code == 201 + data = res.json['data'] + assert len(data['embeds']['nodes']['data']) == 2 From 3a25592bfaa69ec41b3cb983e73507d480e194df Mon Sep 17 00:00:00 2001 From: antkryt Date: Tue, 2 Sep 2025 17:36:53 +0300 Subject: [PATCH 10/15] [ENG-8691] Wrong server on reset password email (#11271) * use same logic for admin and web password reset * fix tests --- admin/users/views.py | 16 ++------ api/users/services.py | 27 ++++++++++++++ api/users/views.py | 41 +++++++++------------ api_tests/users/views/test_user_settings.py | 2 +- 4 files changed, 49 insertions(+), 37 deletions(-) create mode 100644 api/users/services.py diff --git a/admin/users/views.py b/admin/users/views.py index 38787a84a23..48c77a2bd26 100644 --- a/admin/users/views.py +++ b/admin/users/views.py @@ -12,7 +12,6 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.urls import reverse from django.core.exceptions import PermissionDenied -from django.core.mail import send_mail from django.shortcuts import redirect from django.core.paginator import Paginator from django.core.exceptions import ValidationError @@ -47,7 +46,8 @@ AddSystemTagForm ) from admin.base.views import GuidView -from website.settings import DOMAIN, OSF_SUPPORT_EMAIL +from api.users.services import send_password_reset_email +from website.settings import DOMAIN from django.urls import reverse_lazy @@ -523,17 +523,9 @@ class ResetPasswordView(UserMixin, View): def post(self, request, *args, **kwargs): email = self.request.POST['emails'] user = get_user(email) - url = furl(DOMAIN) - user.verification_key_v2 = generate_verification_key(verification_type='password_admin') - user.save() - url.add(path=f'resetpassword/{user._id}/{user.verification_key_v2["token"]}') - send_mail( - subject='Reset OSF Password', - message=f'Follow this link to reset your password: {url.url}\n Note: this link will expire in 12 hours', - from_email=OSF_SUPPORT_EMAIL, - recipient_list=[email] - ) + send_password_reset_email(user, email, institutional=False, verification_type='password_admin') + update_admin_log( user_id=self.request.user.id, object_id=user.pk, diff --git a/api/users/services.py b/api/users/services.py new file mode 100644 index 00000000000..3af86c0807c --- /dev/null +++ b/api/users/services.py @@ -0,0 +1,27 @@ +from furl import furl +from django.utils import timezone + +from framework.auth.core import generate_verification_key +from website import settings, mails + + +def send_password_reset_email(user, email, verification_type='password', institutional=False, **mail_kwargs): + """Generate a password reset token, save it to the user and send the password reset email. + """ + # new verification key (v2) + user.verification_key_v2 = generate_verification_key(verification_type=verification_type) + user.email_last_sent = timezone.now() + user.save() + + reset_link = furl(settings.DOMAIN).add(path=f'resetpassword/{user._id}/{user.verification_key_v2["token"]}').url + mail_template = mails.FORGOT_PASSWORD if not institutional else mails.FORGOT_PASSWORD_INSTITUTION + + mails.send_mail( + to_addr=email, + mail=mail_template, + reset_link=reset_link, + can_change_preferences=False, + **mail_kwargs, + ) + + return reset_link diff --git a/api/users/views.py b/api/users/views.py index a0ea1e171ee..5497feb6936 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -47,6 +47,7 @@ from api.registrations.serializers import RegistrationSerializer from api.resources import annotations as resource_annotations +from api.users.services import send_password_reset_email from api.users.permissions import ( CurrentUser, ReadOnlyOrCurrentUser, ReadOnlyOrCurrentUserRelationship, @@ -864,38 +865,30 @@ class ResetPassword(JSONAPIBaseView, generics.ListCreateAPIView): throttle_classes = (NonCookieAuthThrottle, BurstRateThrottle, RootAnonThrottle, SendEmailThrottle) def get(self, request, *args, **kwargs): + institutional = bool(request.query_params.get('institutional', None)) email = request.query_params.get('email', None) if not email: raise ValidationError('Request must include email in query params.') - institutional = bool(request.query_params.get('institutional', None)) - mail_template = mails.FORGOT_PASSWORD if not institutional else mails.FORGOT_PASSWORD_INSTITUTION - - status_message = language.RESET_PASSWORD_SUCCESS_STATUS_MESSAGE.format(email=email) - kind = 'success' # check if the user exists user_obj = get_user(email=email) - - if user_obj: + if user_obj and user_obj.is_active: # rate limit forgot_password_post if not throttle_period_expired(user_obj.email_last_sent, settings.SEND_EMAIL_THROTTLE): - status_message = 'You have recently requested to change your password. Please wait a few minutes ' \ - 'before trying again.' - kind = 'error' - return Response({'message': status_message, 'kind': kind}, status=status.HTTP_429_TOO_MANY_REQUESTS) - elif user_obj.is_active: - # new random verification key (v2) - user_obj.verification_key_v2 = generate_verification_key(verification_type='password') - user_obj.email_last_sent = timezone.now() - user_obj.save() - reset_link = f'{settings.RESET_PASSWORD_URL}{user_obj._id}/{user_obj.verification_key_v2['token']}/' - mails.send_mail( - to_addr=email, - mail=mail_template, - reset_link=reset_link, - can_change_preferences=False, - ) - return Response(status=status.HTTP_200_OK, data={'message': status_message, 'kind': kind, 'institutional': institutional}) + status_message = 'You have recently requested to change your password. ' \ + 'Please wait a few minutes before trying again.' + return Response({'message': status_message, 'kind': 'error'}, status=status.HTTP_429_TOO_MANY_REQUESTS) + + send_password_reset_email(user_obj, email, institutional=institutional) + + return Response( + status=status.HTTP_200_OK, + data={ + 'message': language.RESET_PASSWORD_SUCCESS_STATUS_MESSAGE.format(email=email), + 'kind': 'success', + 'institutional': institutional, + }, + ) def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) diff --git a/api_tests/users/views/test_user_settings.py b/api_tests/users/views/test_user_settings.py index a3750f959ba..b4fa2494174 100644 --- a/api_tests/users/views/test_user_settings.py +++ b/api_tests/users/views/test_user_settings.py @@ -205,7 +205,7 @@ def test_get(self, app, url, user_one): mock_send_mail.assert_called_with( to_addr=user_one.username, mail=mails.FORGOT_PASSWORD, - reset_link=f'{settings.RESET_PASSWORD_URL}{user_one._id}/{user_one.verification_key_v2['token']}/', + reset_link=f'{settings.DOMAIN}resetpassword/{user_one._id}/{user_one.verification_key_v2['token']}', can_change_preferences=False, ) From 00c74cdb2e59fe0dbcae929cfabdb975312e21d5 Mon Sep 17 00:00:00 2001 From: antkryt Date: Tue, 2 Sep 2025 17:38:40 +0300 Subject: [PATCH 11/15] enable filtering for linked-nodes endpoint (#11273) --- api/collections/views.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/collections/views.py b/api/collections/views.py index 41c6654e7ea..3680b0cb04a 100644 --- a/api/collections/views.py +++ b/api/collections/views.py @@ -36,6 +36,7 @@ CollectedRegistrationsRelationshipSerializer, ) from api.nodes.serializers import NodeSerializer +from api.nodes.filters import NodesFilterMixin from api.preprints.serializers import PreprintSerializer from api.subjects.views import SubjectRelationshipBaseView, BaseResourceSubjectsList from api.registrations.serializers import RegistrationSerializer @@ -506,7 +507,7 @@ def get_resource(self, check_object_permissions=True): return self.get_collection_submission(check_object_permissions) -class LinkedNodesList(JSONAPIBaseView, generics.ListAPIView, CollectionMixin, NodeOptimizationMixin): +class LinkedNodesList(JSONAPIBaseView, generics.ListAPIView, CollectionMixin, NodeOptimizationMixin, NodesFilterMixin): """List of nodes linked to this node. *Read-only*. Linked nodes are the project/component nodes pointed to by node links. This view will probably replace node_links in the near future. @@ -569,12 +570,15 @@ class LinkedNodesList(JSONAPIBaseView, generics.ListAPIView, CollectionMixin, No ordering = ('-modified',) - def get_queryset(self): + def get_default_queryset(self): auth = get_user_auth(self.request) node_ids = self.get_collection().active_guids.filter(content_type_id=ContentType.objects.get_for_model(Node).id).values_list('object_id', flat=True) nodes = Node.objects.filter(id__in=node_ids, is_deleted=False).can_view(user=auth.user, private_link=auth.private_link).order_by('-modified') return self.optimize_node_queryset(nodes) + def get_queryset(self): + return self.get_queryset_from_request() + # overrides APIView def get_parser_context(self, http_request): """ From d88bc1499168e093ca1e6e62ef10ec04b4614581 Mon Sep 17 00:00:00 2001 From: ihorsokhanexoft Date: Tue, 2 Sep 2025 18:40:11 +0300 Subject: [PATCH 12/15] remove deleted users from institutional dashboard (#11261) --- api/institutions/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/institutions/views.py b/api/institutions/views.py index b7b43d0e718..9424a69fa10 100644 --- a/api/institutions/views.py +++ b/api/institutions/views.py @@ -592,6 +592,7 @@ def get_default_search(self): InstitutionalUserReport.search() .filter('term', report_yearmonth=str(_yearmonth)) .filter('term', institution_id=self.get_institution()._id) + .exclude('term', user_name='Deleted user') ) From b498d1b523a8363ec288a015cdc4b1c32c5329f1 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Tue, 2 Sep 2025 19:43:48 +0300 Subject: [PATCH 13/15] fixed tests --- .../views/test_node_view_only_links_list.py | 17 +++++++++++------ .../test_registration_view_only_links_list.py | 5 +++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/api_tests/nodes/views/test_node_view_only_links_list.py b/api_tests/nodes/views/test_node_view_only_links_list.py index 68d259262fe..7dd44e4b5c5 100644 --- a/api_tests/nodes/views/test_node_view_only_links_list.py +++ b/api_tests/nodes/views/test_node_view_only_links_list.py @@ -55,6 +55,11 @@ def view_only_link(public_project): return view_only_link +@pytest.fixture() +def components_factory(): + return ProjectFactory + + @pytest.mark.django_db class TestViewOnlyLinksList: @@ -230,10 +235,10 @@ def test_cannot_create_vol( assert res.status_code == 401 def test_create_vol_with_components( - self, app, user, url, public_project, view_only_link): - component1 = ProjectFactory(creator=user) - component2 = ProjectFactory(creator=user) - component3 = ProjectFactory(creator=user) + self, app, user, url, public_project, view_only_link, components_factory): + component1 = components_factory(creator=user) + component2 = components_factory(creator=user) + component3 = components_factory(creator=user) NodeRelationFactory(parent=public_project, child=component1) NodeRelationFactory(parent=public_project, child=component2) NodeRelationFactory(parent=public_project, child=component3) @@ -269,8 +274,8 @@ def test_create_vol_with_components( assert len(data['embeds']['nodes']['data']) == 4 def test_create_vol_no_duplicated_components( - self, app, user, url, public_project, view_only_link): - component1 = ProjectFactory(creator=user) + self, app, user, url, public_project, view_only_link, components_factory): + component1 = components_factory(creator=user) NodeRelationFactory(parent=public_project, child=component1) url = f'{url}?embed=creator&embed=nodes' diff --git a/api_tests/registrations/views/test_registration_view_only_links_list.py b/api_tests/registrations/views/test_registration_view_only_links_list.py index bb87711327e..7946c4fd44f 100644 --- a/api_tests/registrations/views/test_registration_view_only_links_list.py +++ b/api_tests/registrations/views/test_registration_view_only_links_list.py @@ -58,6 +58,11 @@ def view_only_link(public_project): return view_only_link +@pytest.fixture +def components_factory(): + return RegistrationFactory + + class TestRegistrationViewOnlyLinksList(TestViewOnlyLinksList): pass From 104db6863362ca66273e041559a64521b0046150 Mon Sep 17 00:00:00 2001 From: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:13:46 +0300 Subject: [PATCH 14/15] [ENG-7803] Update text on VOL modal for registration (#11279) --- website/templates/project/modal_generate_private_link.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/templates/project/modal_generate_private_link.mako b/website/templates/project/modal_generate_private_link.mako index ce60737e362..30d2d2c88e2 100644 --- a/website/templates/project/modal_generate_private_link.mako +++ b/website/templates/project/modal_generate_private_link.mako @@ -2,7 +2,7 @@