Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

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

25.16.0 (2025-09-17)
====================

- Misc. fixes for Angular migration

25.15.2 (2025-09-10)
====================

Expand Down
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
16 changes: 16 additions & 0 deletions admin/brands/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ class BrandChangeForm(PermissionRequiredMixin, UpdateView):
raise_exception = True
model = Brand
form_class = BrandForm
template_name = 'brands/detail.html'

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['change_form'] = context.get('form')
brand_obj = self.get_object()
context['brand'] = model_to_dict(brand_obj)
return context

def get_object(self, queryset=None):
brand_id = self.kwargs.get('brand_id')
Expand All @@ -81,13 +89,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 +121,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)
5 changes: 4 additions & 1 deletion admin/static/js/banners/banners.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ $(document).ready(function() {
}
});

$(".colorpicker").colorpicker();
$(".colorpicker").colorpicker({
format: 'hex',
useAlpha: false
});

});
5 changes: 4 additions & 1 deletion admin/static/js/brands/brands.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ require('bootstrap-colorpicker/dist/css/bootstrap-colorpicker.min.css');

$(document).ready(function() {

$(".colorpicker").colorpicker();
$(".colorpicker").colorpicker({
format: 'hex',
useAlpha: false
});

});
14 changes: 3 additions & 11 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 @@ -48,6 +47,7 @@
AddSystemTagForm
)
from admin.base.views import GuidView
from api.users.services import send_password_reset_email
from website.settings import DOMAIN, OSF_SUPPORT_EMAIL
from django.urls import reverse_lazy

Expand Down Expand Up @@ -532,17 +532,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
8 changes: 6 additions & 2 deletions admin_tests/users/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from admin.users import views
from admin.users.forms import UserSearchForm, MergeUserForm
from osf.models.admin_log_entry import AdminLogEntry
from tests.utils import assert_notification
from tests.utils import assert_notification, capture_notifications
from osf.models.notification_type import NotificationType

pytestmark = pytest.mark.django_db
Expand Down Expand Up @@ -101,7 +101,11 @@ def test_correct_view_permissions(self):
request.POST = {'emails': ', '.join(user.emails.all().values_list('address', flat=True))}
request.user = user

response = views.ResetPasswordView.as_view()(request, guid=guid)
with capture_notifications() as notifications:
response = views.ResetPasswordView.as_view()(request, guid=guid)

assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.USER_FORGOT_PASSWORD
self.assertEqual(response.status_code, 302)


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
5 changes: 3 additions & 2 deletions api/custom_metadata/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import rest_framework.serializers as ser

from framework.auth.core import Auth
from osf.metadata.definitions.datacite import DATACITE_RESOURCE_TYPES_GENERAL
from api.base.serializers import (
IDField,
JSONAPISerializer,
Expand Down Expand Up @@ -80,10 +81,10 @@ class CustomItemMetadataSerializer(JSONAPISerializer):
allow_blank=True,
max_length=REASONABLE_MAX_LENGTH,
)
resource_type_general = ser.CharField(
resource_type_general = ser.ChoiceField(
required=False,
allow_blank=True,
max_length=REASONABLE_MAX_LENGTH,
choices=sorted(DATACITE_RESOURCE_TYPES_GENERAL),
)
funders = FundingInfoSerializer(
many=True,
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 @@ -1698,12 +1698,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
48 changes: 42 additions & 6 deletions api/preprints/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class PreprintSerializer(TaxonomizableSerializerMixin, MetricsSerializerMixin, J
'reviews_state',
'node_is_public',
'tags',
'description',
])
available_metrics = frozenset([
'downloads',
Expand Down Expand Up @@ -155,6 +156,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 @@ -191,6 +196,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 @@ -501,11 +507,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):
creator = self.context['request'].user
Expand All @@ -527,11 +552,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 @@ -230,6 +230,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 @@ -284,6 +291,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
1 change: 0 additions & 1 deletion api/providers/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,6 @@ def create(self, validated_data):
context['provider__id'] = provider._id
context['is_reviews_moderator_notification'] = True
context['referrer_fullname'] = user.fullname
context['user_fullname'] = user.fullname
context['is_reviews_moderator_notification'] = True
context['is_admin'] = perm_group == ADMIN

Expand Down
2 changes: 1 addition & 1 deletion api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,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 @@ -617,7 +618,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
Loading
Loading