Skip to content

Commit 18602c9

Browse files
committed
Merge branch 'release/26.21.0'
2 parents 90499e4 + ec3baa5 commit 18602c9

62 files changed

Lines changed: 2437 additions & 21 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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+
26.21.0 (2026-09-10)
6+
====================
7+
8+
- Project Creation and Project Read-only
9+
510
26.20.0 (2026-09-08)
611
====================
712

addons/base/views.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,13 +387,23 @@ def _check_resource_permissions(resource, auth, action):
387387
if required_permission == permissions.READ:
388388
has_resource_permissions = resource.can_view_files(auth=auth)
389389
else:
390+
_ensure_resource_not_read_only(resource)
390391
has_resource_permissions = resource.can_edit(auth=auth)
391392

392393
if not (has_resource_permissions or _check_hierarchical_permissions(resource, auth, action)):
393394
raise HTTPError(http_status.HTTP_403_FORBIDDEN)
394395
return True
395396

396397

398+
def _ensure_resource_not_read_only(resource):
399+
"""Block file/folder writes via Waterbutler while the resource is in read-only mode."""
400+
if not isinstance(resource, Node):
401+
return
402+
403+
if flag_is_active(request, features.PROJECT_READ_ONLY):
404+
raise HTTPError(http_status.HTTP_403_FORBIDDEN, message='This project is read-only; file writes are disabled.')
405+
406+
397407
def _get_permission_for_action(action):
398408
if action in _READ_ACTIONS:
399409
return permissions.READ

admin/management/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@
2424
name='remove_orcid_from_user_social'),
2525
re_path(r'^migrate_funder_names_to_ror', views.MigrateFunderNamesToRor.as_view(),
2626
name='migrate_funder_names_to_ror'),
27+
re_path(r'^reject_pending_collection_submissions', views.RejectPendingCollectionSubmissions.as_view(),
28+
name='reject_pending_collection_submissions'),
29+
re_path(r'^reject_pending_node_requests', views.RejectPendingNodeRequests.as_view(),
30+
name='reject_pending_node_requests'),
2731
]

admin/management/views.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from osf.management.commands.sync_doi_metadata import sync_doi_metadata, sync_doi_empty_metadata_dataarchive_registrations
1515
from osf.management.commands.populate_notification_types import populate_notification_types
1616
from osf.management.commands.remove_orcid_from_user_social import remove_orcid_from_user_social
17+
from osf.management.commands.reject_pending_collection_submissions import reject_pending_collection_submissions
18+
from osf.management.commands.reject_pending_node_requests import reject_pending_node_requests
1719
from scripts.find_spammy_content import manage_spammy_content
1820
from django.urls import reverse
1921
from django.shortcuts import redirect
@@ -228,3 +230,35 @@ def post(self, request):
228230
for _line in _out_io.getvalue().split('\n'):
229231
messages.info(request, _line)
230232
return redirect(reverse('management:commands'))
233+
234+
235+
class RejectPendingCollectionSubmissions(ManagementCommandPermissionView):
236+
237+
def post(self, request):
238+
user_guid = request.user._id
239+
comment = request.POST.get('comment', '').strip()
240+
if not user_guid:
241+
messages.error(request, 'A user GUID must be provided.')
242+
return redirect(reverse('management:commands'))
243+
reject_pending_collection_submissions.apply_async(kwargs={
244+
'user_guid': user_guid,
245+
'comment': comment,
246+
})
247+
messages.success(request, 'Pending collection submissions have been queued for rejection.')
248+
return redirect(reverse('management:commands'))
249+
250+
251+
class RejectPendingNodeRequests(ManagementCommandPermissionView):
252+
253+
def post(self, request):
254+
user_guid = request.user._id
255+
comment = request.POST.get('comment', '').strip()
256+
if not user_guid:
257+
messages.error(request, 'A user GUID must be provided.')
258+
return redirect(reverse('management:commands'))
259+
reject_pending_node_requests.apply_async(kwargs={
260+
'user_guid': user_guid,
261+
'comment': comment,
262+
})
263+
messages.success(request, 'Pending project access requests have been queued for rejection.')
264+
return redirect(reverse('management:commands'))

admin/templates/management/commands.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,37 @@ <h4><u>Update ROR funder names to be consistent and proper.</u></h4>
225225
<input class="btn btn-success" type="submit" value="Run" />
226226
</nav>
227227
</form>
228+
</section>
229+
<section>
230+
<h4><u>Reject pending collection submissions</u></h4>
231+
<p>
232+
Use this management command to reject all collection submissions currently in the pending state.
233+
</p>
234+
<form method="post"
235+
action="{% url 'management:reject_pending_collection_submissions'%}">
236+
{% csrf_token %}
237+
Comment: <input type="text" name="comment" style="width: 550px;"
238+
placeholder="Leave blank for default rejection message 'This collection submission has been rejected.'" />
239+
<nav>
240+
<input class="btn btn-success" type="submit" value="Run" />
241+
</nav>
242+
</form>
243+
</section>
244+
<section>
245+
<h4><u>Reject pending project access requests</u></h4>
246+
<p>
247+
Use this management command to reject all project access requests (including institutional
248+
curator requests) currently in the pending state.
249+
</p>
250+
<form method="post"
251+
action="{% url 'management:reject_pending_node_requests'%}">
252+
{% csrf_token %}
253+
Comment: <input type="text" name="comment" style="width: 550px;"
254+
placeholder="Leave blank for default rejection message 'This project is now read-only, so this access request has been automatically rejected.'" />
255+
<nav>
256+
<input class="btn btn-success" type="submit" value="Run" />
257+
</nav>
258+
</form>
228259
</section>
229260
</div>
230261
</section>

api/cedar_metadata_records/permissions.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import logging
22

3-
from rest_framework import permissions
3+
import waffle
4+
from rest_framework import exceptions, permissions
45

56
from api.base.utils import get_user_auth
67

8+
from osf import features
79
from osf.models import BaseFileNode, CedarMetadataRecord, Node, Registration
810

911
logger = logging.getLogger(__name__)
@@ -27,3 +29,11 @@ def has_object_permission(self, request, view, obj):
2729
return permission_source.can_edit(auth)
2830
return permission_source.is_public or permission_source.can_view(auth)
2931
return permission_source.can_edit(auth)
32+
33+
34+
class CedarMetadataRecordsNotAllowed(permissions.BasePermission):
35+
36+
def has_permission(self, request, view):
37+
if request.method in ('POST', 'PUT', 'PATCH') and waffle.flag_is_active(request, features.PROJECT_READ_ONLY):
38+
raise exceptions.MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.')
39+
return True

api/cedar_metadata_records/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
)
1414
from api.base.versioning import PrivateVersioning
1515
from api.base.views import JSONAPIBaseView
16-
from api.cedar_metadata_records.permissions import CedarMetadataRecordPermission
16+
from api.cedar_metadata_records.permissions import CedarMetadataRecordPermission, CedarMetadataRecordsNotAllowed
1717
from api.cedar_metadata_records.serializers import (
1818
CedarMetadataRecordsCreateSerializer,
1919
CedarMetadataRecordsDetailSerializer,
@@ -30,6 +30,7 @@ class CedarMetadataRecordCreate(JSONAPIBaseView, CreateAPIView):
3030
permission_classes = (
3131
drf_permissions.IsAuthenticatedOrReadOnly,
3232
base_permissions.TokenHasScope,
33+
CedarMetadataRecordsNotAllowed,
3334
)
3435
required_read_scopes = [CoreScopes.NULL]
3536
required_write_scopes = [CoreScopes.CEDAR_METADATA_RECORD_WRITE]
@@ -50,6 +51,7 @@ class CedarMetadataRecordDetail(JSONAPIBaseView, RetrieveUpdateDestroyAPIView):
5051
CedarMetadataRecordPermission,
5152
drf_permissions.IsAuthenticatedOrReadOnly,
5253
base_permissions.TokenHasScope,
54+
CedarMetadataRecordsNotAllowed,
5355
)
5456
required_read_scopes = [CoreScopes.CEDAR_METADATA_RECORD_READ]
5557
required_write_scopes = [CoreScopes.CEDAR_METADATA_RECORD_WRITE]

api/collections/permissions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import io
22

3+
import waffle
34
from rest_framework import permissions
45
from rest_framework.exceptions import NotFound, MethodNotAllowed
56

67
from api.base.exceptions import Gone
78
from api.base.parsers import JSONSchemaParser
89
from api.base.utils import get_user_auth, assert_resource_type, get_object_or_error
10+
from osf import features
911
from osf.models import AbstractNode, Preprint, Collection, CollectionSubmission, CollectionProvider
1012
from osf.utils.permissions import WRITE, ADMIN
1113

@@ -47,6 +49,13 @@ def has_object_permission(self, request, view, obj):
4749
return request.method in permissions.SAFE_METHODS
4850
return True
4951

52+
class CollectionSubmissionsNotAllowed(permissions.BasePermission):
53+
def has_permission(self, request, view):
54+
if request.method == 'POST' and waffle.flag_is_active(request, features.PROJECT_READ_ONLY):
55+
raise MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.')
56+
return True
57+
58+
5059
class CanSubmitToCollectionOrPublic(permissions.BasePermission):
5160
def has_object_permission(self, request, view, obj):
5261
assert isinstance(obj, (CollectionSubmission, Collection, CollectionProvider)), f'obj must be a Collection or CollectionSubmission, got {obj}'

api/collections/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
CollectionWriteOrPublicForRelationshipPointers,
2222
CanSubmitToCollectionOrPublic,
2323
CanUpdateDeleteCollectionSubmissionOrPublic,
24+
CollectionSubmissionsNotAllowed,
2425
ReadOnlyIfCollectedRegistration,
2526
)
2627
from api.collections.serializers import (
@@ -324,6 +325,7 @@ def perform_destroy(self, instance):
324325
class CollectionSubmissionList(JSONAPIBaseView, generics.ListCreateAPIView, CollectionMixin, ListFilterMixin):
325326
permission_classes = (
326327
drf_permissions.IsAuthenticatedOrReadOnly,
328+
CollectionSubmissionsNotAllowed,
327329
CanSubmitToCollectionOrPublic,
328330
base_permissions.TokenHasScope,
329331
)

api/custom_metadata/permissions.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
from rest_framework import permissions
1+
import waffle
2+
from rest_framework import exceptions, permissions
23

34
from api.base.utils import get_user_auth
4-
from osf.models import GuidMetadataRecord, BaseFileNode
5+
from osf import features
6+
from osf.models import GuidMetadataRecord, BaseFileNode, Node
57

68

79
class CustomMetadataPermission(permissions.BasePermission):
@@ -17,3 +19,18 @@ def has_object_permission(self, request, view, obj):
1719
return delegate_obj.is_public or delegate_obj.can_view(auth)
1820
else:
1921
return delegate_obj.can_edit(auth)
22+
23+
24+
class ItemMetadataEditingNotAllowed(permissions.BasePermission):
25+
26+
def has_object_permission(self, request, view, obj):
27+
assert isinstance(obj, GuidMetadataRecord)
28+
if request.method in permissions.SAFE_METHODS:
29+
return True
30+
delegate_obj = obj.guid.referent
31+
if isinstance(delegate_obj, Node) and waffle.flag_is_active(request, features.PROJECT_READ_ONLY):
32+
raise exceptions.MethodNotAllowed(
33+
request.method,
34+
detail='This action is no longer available. Contact support if you have any questions.',
35+
)
36+
return True

0 commit comments

Comments
 (0)