Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3487223
[ENG-8514] Remove CSRF protection from reset password api v2 POST (#1…
antkryt Aug 15, 2025
7a03e98
add background color prop to Brand (#11254)
antkryt Aug 15, 2025
8cf2f9c
fix flaky test_serialized_metadata
antkryt Aug 18, 2025
8e04d5c
fix throttle test
antkryt Aug 21, 2025
7a53306
reset throttle cache
antkryt Aug 21, 2025
81d4edf
Merge pull request #11265 from antkryt/fix/ENG-8656
Johnetordoff Aug 25, 2025
2678991
Merge branch 'feature/pbs-25-16' of https://github.com/CenterForOpenS…
Johnetordoff Aug 26, 2025
7cdebec
Merge pull request #11268 from Johnetordoff/fix/ENG-8552
Johnetordoff Aug 26, 2025
f6ede63
don't ignore components when create a view-only link
ihorsokhanexoft Aug 27, 2025
cae9889
fixed updating subscribe_osf_general_email subscription
ihorsokhanexoft Aug 28, 2025
fb62bc9
[ENG-7277] Update doc to include version as relationship (#11262)
Vlad0n20 Aug 28, 2025
2d54110
Merge pull request #11270 from ihorsokhanexoft/fix/ENG-8743
Johnetordoff Aug 28, 2025
ae7afd4
added tests
ihorsokhanexoft Aug 29, 2025
3a25592
[ENG-8691] Wrong server on reset password email (#11271)
antkryt Sep 2, 2025
00c74cd
enable filtering for linked-nodes endpoint (#11273)
antkryt Sep 2, 2025
d88bc14
remove deleted users from institutional dashboard (#11261)
ihorsokhanexoft Sep 2, 2025
b498d1b
fixed tests
ihorsokhanexoft Sep 2, 2025
d6b7edb
Merge pull request #11269 from ihorsokhanexoft/fix/ENG-8742
Johnetordoff Sep 2, 2025
104db68
[ENG-7803] Update text on VOL modal for registration (#11279)
Vlad0n20 Sep 4, 2025
efdc005
fixed institution group lookup in admin
opaduchak Sep 11, 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
1 change: 1 addition & 0 deletions admin/brands/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
8 changes: 8 additions & 0 deletions admin/brands/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,17 @@ 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.
For more information, visit https://color.a11y.com/""")
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)


Expand All @@ -109,11 +113,15 @@ 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.
For more information, visit https://color.a11y.com/""")
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)
8 changes: 4 additions & 4 deletions admin/institutions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def get_queryset(self):
def get_context_data(self, **kwargs):
institution = Institution.objects.get(id=self.kwargs['institution_id'])
context = super().get_context_data(**kwargs)
admin_group = Group.objects.filter(name__startswith=f'institution_{institution._id}').first()
admin_group = Group.objects.filter(name=f'institution_{institution._id}_institutional_admins').first()
context['institution'] = institution
context['admins'] = admin_group.user_set.all()
return context
Expand All @@ -246,7 +246,7 @@ def post(self, request, *args, **kwargs):
messages.error(request, f'User for guid: {data["add-admins-form"][0]} could not be found')
return redirect('institutions:list_and_add_admin', institution_id=institution.id)

admin_group = Group.objects.filter(name__startswith=f'institution_{institution._id}').first()
admin_group = Group.objects.filter(name=f'institution_{institution._id}_institutional_admins').first()
admin_group.user_set.add(target_user)

messages.success(request, f'The following admin was successfully added: {target_user.fullname} ({target_user.username})')
Expand All @@ -263,7 +263,7 @@ def post(self, request, *args, **kwargs):
to_be_removed = list(data.keys())
removed_admins = [admin.replace('Admin-', '') for admin in to_be_removed if 'Admin-' in admin]
admins = OSFUser.objects.filter(id__in=removed_admins)
admin_group = Group.objects.filter(name__startswith=f'institution_{institution._id}').first()
admin_group = Group.objects.filter(name=f'institution_{institution._id}_institutional_admins').first()
admin_group.user_set.remove(*admins)

if admins:
Expand Down Expand Up @@ -362,7 +362,7 @@ def form_valid(self, form):
if not osf_user:
raise Http404(f'OSF user with id "{user_id}" not found. Please double check.')

group = Group.objects.filter(name__startswith=f'institution_{target_institution._id}').first()
group = Group.objects.filter(name=f'institution_{target_institution._id}_institutional_admins').first()

group.user_set.add(osf_user)
group.save()
Expand Down
16 changes: 4 additions & 12 deletions admin/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions api/brands/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 6 additions & 2 deletions api/collections/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
"""
Expand Down
1 change: 1 addition & 0 deletions api/institutions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)


Expand Down
5 changes: 4 additions & 1 deletion api/nodes/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 41 additions & 6 deletions api/preprints/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -190,6 +194,7 @@ class PreprintSerializer(TaxonomizableSerializerMixin, MetricsSerializerMixin, J
related_view='providers:preprint-providers:preprint-provider-detail',
related_view_kwargs={'provider_id': '<provider._id>'},
read_only=False,
help_text='Relationship to the preprint provider. Required on creation.',
)

files = NoneIfWithdrawal(
Expand Down Expand Up @@ -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):

Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions api/preprints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.')

Expand Down Expand Up @@ -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')
Expand Down
27 changes: 27 additions & 0 deletions api/users/services.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 17 additions & 25 deletions api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -864,40 +865,31 @@ 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,
},
)

@method_decorator(csrf_protect)
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
Expand Down
Loading