Skip to content

Commit 1cff004

Browse files
committed
Merge branch 'release/26.16.0'
2 parents a35a439 + 04228d5 commit 1cff004

5 files changed

Lines changed: 226 additions & 75 deletions

File tree

CHANGELOG

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22

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

5+
26.16.0 (2026-07-20)
6+
====================
7+
8+
- IDS Project Release - BE Part
9+
510
26.15.0 (2026-07-16)
611
====================
712

813
- Bug-fixes & improvements
9-
- ES2 code cleanup
14+
- ES2 code cleanup
1015

1116
26.14.0 (2026-07-06)
1217
====================
@@ -20,7 +25,6 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.
2025
- add migrate_osfmetrics_fix_6to8 command to correct monthly usage reports
2126
- temporarily re-add elasticsearch6 and some es6-based osf-metrics models
2227

23-
2428
26.12.0 (2026-06-23)
2529
====================
2630

@@ -32,7 +36,6 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.
3236
- Stop using elasticsearch6
3337
- Change all osfmetrics-consuming code to use es8 from data migration in 26.10.0
3438

35-
3639
26.10.7 (2026-06-10)
3740
====================
3841

addons/osfstorage/tests/test_views.py

Lines changed: 142 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,6 @@ def send_hook(self, view_name, view_kwargs, payload, target, method='get', **kwa
6767
@pytest.mark.django_db
6868
class TestGetMetadataHook(HookTestCase):
6969

70-
def test_empty(self):
71-
res = self.send_hook(
72-
'osfstorage_get_children',
73-
{'fid': self.node_settings.get_root()._id, 'user_id': self.user._id},
74-
{},
75-
self.node
76-
)
77-
assert isinstance(res.json, list)
78-
assert res.json == []
79-
8070
def test_file_metadata(self):
8171
path = 'kind/of/magíc.mp3'
8272
record = recursively_create_file(self.node_settings, path)
@@ -107,6 +97,95 @@ def test_preprint_primary_file_metadata(self):
10797
assert isinstance(res.json, dict)
10898
assert res.json == record.parent.serialize(True)
10999

100+
def test_osf_storage_root(self):
101+
auth = Auth(self.project.creator)
102+
result = osf_storage_root(self.node_settings.config, self.node_settings, auth)
103+
node = self.project
104+
expected = rubeus.build_addon_root(
105+
node_settings=self.node_settings,
106+
name='',
107+
permissions=auth,
108+
user=auth.user,
109+
nodeUrl=node.url,
110+
nodeApiUrl=node.api_url,
111+
)
112+
root = result[0]
113+
assert root == expected
114+
115+
def test_root_default(self):
116+
res = self.send_hook('osfstorage_get_metadata', {}, {}, self.node)
117+
118+
assert res.json['fullPath'] == '/'
119+
assert res.json['id'] == self.node_settings.get_root()._id
120+
121+
def test_root_preprint_default(self):
122+
preprint = PreprintFactory()
123+
res = self.send_hook('osfstorage_get_metadata', {}, {}, preprint)
124+
125+
assert res.json['fullPath'] == '/'
126+
assert res.json['id'] == preprint.root_folder._id
127+
128+
def test_metadata_not_found(self):
129+
res = self.send_hook(
130+
'osfstorage_get_metadata',
131+
{'fid': 'somebogusid'}, {},
132+
self.node,
133+
)
134+
assert res.status_code == 404
135+
136+
def test_metadata_not_found_lots_of_slashes(self):
137+
res = self.send_hook(
138+
'osfstorage_get_metadata',
139+
{'fid': '/not/fo/u/nd/'}, {},
140+
self.node,
141+
)
142+
assert res.status_code == 302
143+
assert '/login?service=' in res.location
144+
145+
self.node.is_public = True
146+
self.node.save()
147+
res = self.send_hook(
148+
'osfstorage_get_metadata',
149+
{'fid': '/not/fo/u/nd/'}, {},
150+
self.node,
151+
)
152+
assert res.status_code == 404
153+
154+
155+
@pytest.mark.django_db
156+
class TestGetChildrenHook(HookTestCase):
157+
158+
def get_children(self, parent, target=None, **query_params):
159+
params = {
160+
'fid': parent._id,
161+
'user_id': self.user._id,
162+
'minimal': 'true',
163+
**query_params,
164+
}
165+
return self.send_hook(
166+
'osfstorage_get_children',
167+
params,
168+
{},
169+
target or self.node,
170+
)
171+
172+
def _create_child_file(self, parent, name):
173+
record = parent.append_file(name)
174+
version = factories.FileVersionFactory()
175+
record.add_version(version)
176+
record.save()
177+
return record
178+
179+
def test_empty(self):
180+
res = self.send_hook(
181+
'osfstorage_get_children',
182+
{'fid': self.node_settings.get_root()._id, 'user_id': self.user._id},
183+
{},
184+
self.node
185+
)
186+
assert isinstance(res.json, list)
187+
assert res.json == []
188+
110189
def test_children_metadata(self):
111190
path = 'kind/of/magíc.mp3'
112191
record = recursively_create_file(self.node_settings, path)
@@ -176,59 +255,60 @@ def test_children_metadata_preprint(self):
176255
assert res_date_modified == expected_date_modified
177256
assert res_date_created == expected_date_created
178257

179-
def test_osf_storage_root(self):
180-
auth = Auth(self.project.creator)
181-
result = osf_storage_root(self.node_settings.config, self.node_settings, auth)
182-
node = self.project
183-
expected = rubeus.build_addon_root(
184-
node_settings=self.node_settings,
185-
name='',
186-
permissions=auth,
187-
user=auth.user,
188-
nodeUrl=node.url,
189-
nodeApiUrl=node.api_url,
190-
)
191-
root = result[0]
192-
assert root == expected
193-
194-
def test_root_default(self):
195-
res = self.send_hook('osfstorage_get_metadata', {}, {}, self.node)
196-
197-
assert res.json['fullPath'] == '/'
198-
assert res.json['id'] == self.node_settings.get_root()._id
199-
200-
def test_root_preprint_default(self):
201-
preprint = PreprintFactory()
202-
res = self.send_hook('osfstorage_get_metadata', {}, {}, preprint)
203-
204-
assert res.json['fullPath'] == '/'
205-
assert res.json['id'] == preprint.root_folder._id
206-
207-
def test_metadata_not_found(self):
208-
res = self.send_hook(
209-
'osfstorage_get_metadata',
210-
{'fid': 'somebogusid'}, {},
211-
self.node,
212-
)
213-
assert res.status_code == 404
214-
215-
def test_metadata_not_found_lots_of_slashes(self):
216-
res = self.send_hook(
217-
'osfstorage_get_metadata',
218-
{'fid': '/not/fo/u/nd/'}, {},
219-
self.node,
220-
)
221-
assert res.status_code == 302
222-
assert '/login?service=' in res.location
223-
224-
self.node.is_public = True
225-
self.node.save()
226-
res = self.send_hook(
227-
'osfstorage_get_metadata',
228-
{'fid': '/not/fo/u/nd/'}, {},
229-
self.node,
258+
def test_minimal_child_fields(self):
259+
parent = self.node_settings.get_root().append_folder('minimal-child-fields')
260+
record = self._create_child_file(parent, 'magíc.mp3')
261+
folder = parent.append_folder('nested')
262+
res = self.get_children(parent)
263+
assert res.status_code == 200
264+
assert len(res.json) == 2
265+
assert {item['name'] for item in res.json} == {record.name, folder.name}
266+
for item in res.json:
267+
assert set(item.keys()) == {'id', 'kind', 'name', 'path', 'storage'}
268+
if item['kind'] == 'file':
269+
assert item['storage'] == {
270+
'data': {
271+
'name': record.name,
272+
'path': record.versions.first().location_hash,
273+
},
274+
'settings': {
275+
storage_settings.WATERBUTLER_RESOURCE: record.versions.first().location[
276+
storage_settings.WATERBUTLER_RESOURCE
277+
],
278+
},
279+
}
280+
assert item['path'] == f'/{record._id}'
281+
assert item['id'] == record.id
282+
else:
283+
assert item['storage'] is None
284+
assert item['path'] == f'/{folder._id}/'
285+
assert item['id'] == folder.id
286+
287+
def test_minimal_pagination(self):
288+
parent = self.node_settings.get_root().append_folder('pagination')
289+
children = [
290+
self._create_child_file(parent, name)
291+
for name in ('a.mp3', 'b.mp3', 'c.mp3')
292+
]
293+
children.sort(key=lambda c: c.id)
294+
first_page = self.get_children(parent, limit=2)
295+
assert first_page.status_code == 200
296+
assert [item['id'] for item in first_page.json] == [children[0].id, children[1].id]
297+
second_page = self.get_children(
298+
parent,
299+
limit='2',
300+
after=str(children[1].id),
230301
)
231-
assert res.status_code == 404
302+
assert second_page.status_code == 200
303+
assert len(second_page.json) == 1
304+
assert second_page.json[0]['id'] == children[2].id
305+
306+
def test_minimal_pagination_after_beyond_last_child(self):
307+
parent = self.node_settings.get_root()
308+
record = self._create_child_file(parent, 'only.mp3')
309+
res = self.get_children(parent, after=str(record.id))
310+
assert res.status_code == 200
311+
assert res.json == []
232312

233313

234314
@pytest.mark.django_db

addons/osfstorage/views.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@
22
import logging
33

44
from django.core.exceptions import ValidationError
5+
from django.contrib.contenttypes.models import ContentType
56
from django.db import IntegrityError
67
from django.db import connection
78
from django.db import transaction
8-
9+
from django.db.models import Case, CharField, F, JSONField, OuterRef, Subquery, Value, When
10+
from django.db.models.fields.json import KeyTextTransform
11+
from django.db.models.functions import Coalesce, Concat, JSONObject
912
from flask import request
1013

1114
from framework.auth import Auth
1215
from framework.exceptions import HTTPError
1316
from framework.auth.decorators import must_be_signed, must_be_logged_in
1417

18+
from api.base.utils import is_truthy
1519
from osf.exceptions import InvalidTagError, TagNotFoundError
16-
from osf.models import FileVersion, Node, OSFUser
20+
from osf.models import BaseFileVersionsThrough, FileVersion, Node, OSFUser
1721
from osf.utils.permissions import WRITE
1822
from osf.utils.requests import check_select_for_update
1923
from website.project.decorators import (
@@ -25,7 +29,7 @@
2529
from website.settings import StorageLimits
2630
from addons.osfstorage import utils
2731
from addons.osfstorage import decorators
28-
from addons.osfstorage.models import OsfStorageFolder
32+
from addons.osfstorage.models import OsfStorageFileNode, OsfStorageFolder
2933
from addons.osfstorage import settings as osf_storage_settings
3034

3135

@@ -183,12 +187,64 @@ def osfstorage_get_metadata(file_node, **kwargs):
183187
return file_node.serialize(version=version, include_full=True)
184188

185189

186-
@must_be_signed
187-
@decorators.autoload_filenode(must_be='folder')
188-
def osfstorage_get_children(file_node, **kwargs):
189-
from django.contrib.contenttypes.models import ContentType
190+
def _osfstorage_minimal_metadata(file_node, *, limit=None, after=None):
191+
waterbutler_resource = osf_storage_settings.WATERBUTLER_RESOURCE
192+
latest_version = BaseFileVersionsThrough.objects.filter(
193+
basefilenode_id=OuterRef('pk'),
194+
).order_by('-fileversion__created')
195+
196+
file_storage = Subquery(
197+
latest_version.annotate(
198+
_storage=JSONObject(
199+
data=JSONObject(
200+
name=Coalesce(F('version_name'), OuterRef('name')),
201+
path=KeyTextTransform('object', 'fileversion__location'),
202+
),
203+
settings=JSONObject(**{
204+
waterbutler_resource: KeyTextTransform(
205+
waterbutler_resource, 'fileversion__location',
206+
),
207+
}),
208+
),
209+
).values('_storage')[:1],
210+
)
211+
212+
qs = OsfStorageFileNode.objects.filter(
213+
parent_id=file_node.id,
214+
type__in=('osf.osfstoragefile', 'osf.osfstoragefolder'),
215+
).annotate(
216+
kind=Case(
217+
When(type='osf.osfstoragefile', then=Value('file')),
218+
default=Value('folder'),
219+
output_field=CharField(),
220+
),
221+
path=Case(
222+
When(type='osf.osfstoragefile', then=Concat(Value('/'), F('_id'))),
223+
default=Concat(Value('/'), F('_id'), Value('/')),
224+
output_field=CharField(),
225+
),
226+
storage=Case(
227+
When(type='osf.osfstoragefile', then=file_storage),
228+
default=Value(None, output_field=JSONField()),
229+
output_field=JSONField(),
230+
),
231+
).values('id', 'name', 'kind', 'path', 'storage')
232+
233+
if after is not None:
234+
qs = qs.filter(id__gt=after)
235+
236+
# Always return files/folders in the same order
237+
# It helps make waterbutler file streaming more stable
238+
qs = qs.order_by('id')
239+
240+
if limit is not None:
241+
qs = qs[:limit]
242+
243+
return list(qs)
244+
245+
246+
def _osfstorage_full_metadata(file_node, user_id):
190247
from osf.models.preprint import Preprint
191-
user_id = request.args.get('user_id')
192248
user_content_type_id = ContentType.objects.get_for_model(OSFUser).id
193249
user_pk = OSFUser.objects.filter(guids___id=user_id, guids___id__isnull=False).values_list('pk', flat=True).first()
194250
guid_id = file_node.target.get_guid().id if isinstance(file_node.target, Preprint) else file_node.target.guids.first().id,
@@ -294,6 +350,18 @@ def osfstorage_get_children(file_node, **kwargs):
294350
return cursor.fetchone()[0] or []
295351

296352

353+
@must_be_signed
354+
@decorators.autoload_filenode(must_be='folder')
355+
def osfstorage_get_children(file_node, **kwargs):
356+
if is_truthy(request.args.get('minimal')):
357+
return _osfstorage_minimal_metadata(
358+
file_node,
359+
limit=request.args.get('limit', type=int, default=None),
360+
after=request.args.get('after', type=int, default=None),
361+
)
362+
return _osfstorage_full_metadata(file_node, request.args.get('user_id'))
363+
364+
297365
@must_be_signed
298366
@decorators.autoload_filenode(must_be='folder')
299367
def osfstorage_create_child(file_node, payload, **kwargs):

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "OSF",
3-
"version": "26.15.0",
3+
"version": "26.16.0",
44
"description": "Facilitating Open Science",
55
"repository": "https://github.com/CenterForOpenScience/osf.io",
66
"author": "Center for Open Science",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "osf-io"
3-
version = "26.14.0"
3+
version = "26.16.0"
44
description = "The code for [https://osf.io](https://osf.io)."
55
authors = [
66
"Brian J. Geiger <bgeiger@pobox.com>",

0 commit comments

Comments
 (0)