Skip to content

Commit 75ecc60

Browse files
committed
Merge branch 'release/26.3.0'
2 parents 4e23670 + 247494c commit 75ecc60

30 files changed

Lines changed: 881 additions & 13 deletions

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.3.0 (2026-02-24)
6+
===================
7+
8+
- FAIR Signposting
9+
510
26.2.1 (2026-02-09)
611
===================
712

addons/base/views.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,9 +1035,12 @@ def persistent_file_download(auth, **kwargs):
10351035

10361036
query_params = request.args.to_dict()
10371037

1038-
return redirect(
1039-
file.generate_waterbutler_url(**query_params),
1040-
code=http_status.HTTP_302_FOUND
1038+
return make_response(
1039+
'', http_status.HTTP_302_FOUND, {
1040+
'Location': file.generate_waterbutler_url(**query_params),
1041+
'Link': f'<{settings.DOMAIN}metadata/{id_or_guid}/?format=linkset> ; rel="linkset" ; type="application/linkset",'
1042+
f' <{settings.DOMAIN}metadata/{id_or_guid}/?format=linkset-json"> ; rel="linkset-json" ; type="application/linkset+json"',
1043+
}
10411044
)
10421045

10431046

addons/osfstorage/tests/test_views.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1551,11 +1551,19 @@ def test_download_file(self):
15511551
# Test download works with path
15521552
url = base_url.format(file._id)
15531553
redirect = self.app.get(url, auth=self.user.auth)
1554+
link_header = (f'<{settings.DOMAIN}metadata/{file._id}/?format=linkset> ; rel="linkset" ; type="application/linkset", '
1555+
f'<{settings.DOMAIN}metadata/{file._id}/?format=linkset-json"> ; rel="linkset-json" ; type="application/linkset+json"')
1556+
assert link_header == redirect.headers['Link']
15541557
assert redirect.status_code == 302
15551558

15561559
# Test download works with guid
1557-
url = base_url.format(file.get_guid(create=True)._id)
1560+
guid = file.get_guid(create=True)._id
1561+
url = base_url.format(guid)
15581562
redirect = self.app.get(url, auth=self.user.auth)
1563+
link_header = (
1564+
f'<{settings.DOMAIN}metadata/{guid}/?format=linkset> ; rel="linkset" ; type="application/linkset", '
1565+
f'<{settings.DOMAIN}metadata/{guid}/?format=linkset-json"> ; rel="linkset-json" ; type="application/linkset+json"')
1566+
assert link_header == redirect.headers['Link']
15591567
assert redirect.status_code == 302
15601568

15611569
# Test nonexistent file 404's

api/cedar_metadata_records/views.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
CedarMetadataRecordsDetailSerializer,
2020
)
2121
from framework.auth.oauth_scopes import CoreScopes
22-
23-
from osf.models import CedarMetadataRecord
22+
from osf.models import CedarMetadataRecord, Node, Registration
23+
from website import settings
2424

2525
logger = logging.getLogger(__name__)
2626

@@ -99,5 +99,10 @@ def get_serializer_class(self):
9999

100100
def get(self, request, *args, **kwargs):
101101
record = self.get_object()
102+
is_referent_project_or_registration = isinstance(record.guid.referent, (Node, Registration))
102103
file_name = f'{record._id}-{record.get_template_name()}-v{record.get_template_version()}.json'
103-
return Response(record.metadata, headers={'Content-Disposition': f'attachment; filename={file_name}'})
104+
headers = {'Content-Disposition': f'attachment; filename={file_name}'}
105+
if is_referent_project_or_registration:
106+
guid_id = record.guid._id
107+
headers['link'] = f'<{settings.DOMAIN}{guid_id}/>; rel="describes"; type="text/html"'
108+
return Response(record.metadata, headers=headers)

api_tests/cedar_metadata_records/views/test_record_metadata_download_get.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from .test_record import TestCedarMetadataRecord
44
from osf.utils.permissions import READ, WRITE
55
from osf_tests.factories import AuthUserFactory
6+
from website import settings
7+
68

79
@pytest.mark.django_db
810
class TestCedarMetadataRecordMetadataDownloadPrivateProjectPublishedMetadata(TestCedarMetadataRecord):
@@ -13,6 +15,7 @@ def test_record_metadata_download_for_node_with_admin_auth(self, app, node, user
1315
resp = app.get(f'/_/cedar_metadata_records/{cedar_record_for_node._id}/metadata_download/', auth=admin.auth)
1416
assert resp.status_code == 200
1517
assert resp.headers['Content-Disposition'] == f'attachment; filename={self.get_record_metadata_download_file_name(cedar_record_for_node)}'
18+
assert resp.headers.get('Link') == f'<{settings.DOMAIN}{node._id}/>; rel="describes"; type="text/html"'
1619
assert resp.json == cedar_record_metadata_json
1720

1821
def test_record_metadata_download_for_node_with_write_auth(self, app, node, cedar_record_for_node, cedar_record_metadata_json):
@@ -179,6 +182,7 @@ def test_record_metadata_download_for_registration_with_admin_auth(self, app, us
179182
resp = app.get(f'/_/cedar_metadata_records/{cedar_record_for_registration._id}/metadata_download/', auth=admin.auth)
180183
assert resp.status_code == 200
181184
assert resp.headers['Content-Disposition'] == f'attachment; filename={self.get_record_metadata_download_file_name(cedar_record_for_registration)}'
185+
assert resp.headers.get('Link') == f'<{settings.DOMAIN}{cedar_record_for_registration.guid._id}/>; rel="describes"; type="text/html"'
182186
assert resp.json == cedar_record_metadata_json
183187

184188
def test_record_metadata_download_for_registration_with_write_auth(self, app, registration, cedar_record_for_registration, cedar_record_metadata_json):
@@ -307,6 +311,7 @@ def test_record_metadata_download_for_node_with_admin_auth(self, app, user, ceda
307311
resp = app.get(f'/_/cedar_metadata_records/{cedar_draft_record_for_file_alt._id}/metadata_download/', auth=admin.auth)
308312
assert resp.status_code == 200
309313
assert resp.headers['Content-Disposition'] == f'attachment; filename={self.get_record_metadata_download_file_name(cedar_draft_record_for_file_alt)}'
314+
assert not resp.headers.get('Link')
310315
assert resp.json == cedar_record_metadata_json
311316

312317
def test_record_metadata_download_for_node_with_write_auth(self, app, node_alt, cedar_draft_record_for_file_alt, cedar_record_metadata_json):

framework/auth/decorators.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,29 @@ def wrapped(*args, **kwargs):
7676

7777
return wrapped
7878

79+
80+
def is_contributor_or_public_resource(resource_kw='resource'):
81+
"""
82+
Require that user be contributor or resource be public.
83+
"""
84+
def decorator(func):
85+
@wraps(func)
86+
def wrapped(*args, **kwargs):
87+
from osf.models import BaseFileNode, Guid
88+
referent = kwargs.get(resource_kw)
89+
if isinstance(referent, Guid):
90+
referent = referent.referent
91+
target_resource = referent.target if isinstance(referent, BaseFileNode) else referent
92+
if target_resource.is_public:
93+
return func(*args, **kwargs)
94+
auth = Auth.from_kwargs(request.args.to_dict(), {})
95+
if auth.logged_in and target_resource.is_contributor(auth.user):
96+
return func(*args, **kwargs)
97+
raise HTTPError(http_status.HTTP_403_FORBIDDEN)
98+
return wrapped
99+
return decorator
100+
101+
79102
# TODO Can remove after Waterbutler is sending requests to V2 endpoints.
80103
# This decorator has been adapted for use in an APIv2 parser - HMACSignedParser
81104
def must_be_signed(func):

osf/metadata/osf_gathering.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from django.contrib.contenttypes.models import ContentType
88
from django import db
9+
from mimetypes import MimeTypes
910
import rdflib
1011

1112
from api.caching.tasks import get_storage_usage_total
@@ -44,6 +45,8 @@
4445

4546
logger = logging.getLogger(__name__)
4647

48+
mime = MimeTypes()
49+
4750

4851
##### BEGIN "public" api #####
4952

@@ -373,7 +376,7 @@ def osf_iri(guid_or_model):
373376
return OSFIO[guid._id]
374377

375378

376-
def osfguid_from_iri(iri):
379+
def osfguid_from_iri(iri: str) -> str:
377380
if iri.startswith(OSFIO):
378381
return without_namespace(iri, OSFIO)
379382
raise ValueError(f'expected iri starting with "{OSFIO}" (got "{iri}")')
@@ -702,6 +705,16 @@ def gather_files(focus):
702705
yield (DCTERMS.requires, file_focus)
703706

704707

708+
@gather.er(DCAT.mediaType)
709+
def gather_file_mediatype(focus):
710+
(mime_type, _) = mime.guess_type(focus.dbmodel.name)
711+
yield (DCAT.mediaType, (
712+
'application/octet-stream'
713+
if mime_type is None
714+
else mime_type
715+
))
716+
717+
705718
@gather.er(DCTERMS.hasPart, DCTERMS.isPartOf)
706719
def gather_parts(focus):
707720
if isinstance(focus.dbmodel, osfdb.AbstractNode):

osf/metadata/rdfutils.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
PROV = rdflib.Namespace('http://www.w3.org/ns/prov#') # "provenance"
2727
# non-standard namespace for datacite terms (resolves to datacite docs)
2828
DATACITE = rdflib.Namespace('https://schema.datacite.org/meta/kernel-4/#')
29-
29+
SCHEMA = rdflib.Namespace('https://schema.org/')
3030

3131
# namespace prefixes that will be shortened by default
3232
# when serialized, instead of displaying the full iri
@@ -43,6 +43,49 @@
4343
}
4444

4545

46+
DATACITE_SCHEMA_RESOURCE_TYPE_GENERAL_MAPPING = {
47+
DATACITE.Audiovisual: SCHEMA.MediaObject,
48+
DATACITE.Book: SCHEMA.Book,
49+
DATACITE.BookChapter: SCHEMA.Chapter,
50+
DATACITE.Collection: SCHEMA.Collection,
51+
DATACITE.ComputationalNotebook: SCHEMA.SoftwareSourceCode,
52+
DATACITE.ConferencePaper: SCHEMA.Article,
53+
DATACITE.ConferenceProceeding: SCHEMA.Periodical,
54+
DATACITE.DataPaper: SCHEMA.Article,
55+
DATACITE.Dataset: SCHEMA.Dataset,
56+
DATACITE.Dissertation: SCHEMA.Thesis,
57+
DATACITE.Event: SCHEMA.Event,
58+
DATACITE.Image: SCHEMA.ImageObject,
59+
DATACITE.InteractiveResource: SCHEMA.CreativeWork,
60+
DATACITE.Journal: SCHEMA.Periodical,
61+
DATACITE.JournalArticle: SCHEMA.ScholarlyArticle,
62+
DATACITE.Model: SCHEMA.CreativeWork,
63+
DATACITE.OutputManagementPlan: SCHEMA.HowTo,
64+
DATACITE.PeerReview: SCHEMA.Review,
65+
DATACITE.PhysicalObject: SCHEMA.Thing,
66+
DATACITE.Preprint: SCHEMA.ScholarlyArticle,
67+
DATACITE.Report: SCHEMA.Report,
68+
DATACITE.Service: SCHEMA.Service,
69+
DATACITE.Software: SCHEMA.SoftwareSourceCode,
70+
DATACITE.Sound: SCHEMA.AudioObject,
71+
DATACITE.Standard: SCHEMA.CreativeWork,
72+
DATACITE.Text: SCHEMA.Text,
73+
DATACITE.Workflow: SCHEMA.HowTo,
74+
DATACITE.Other: SCHEMA.CreativeWork,
75+
DATACITE.Instrument: SCHEMA.MeasurementMethodEnum,
76+
DATACITE.StudyRegistration: SCHEMA.Text,
77+
OSF.Project: SCHEMA.CreativeWork,
78+
OSF.Preprint: SCHEMA.ScholarlyArticle,
79+
OSF.Registration: SCHEMA.Text,
80+
OSF.File: SCHEMA.DigitalDocument,
81+
OSF.ProjectComponent: SCHEMA.CreativeWork,
82+
OSF.RegistrationComponent: SCHEMA.Text,
83+
}
84+
85+
86+
DEFAULT_SCHEMADOTORG_RESOURCE_TYPE = SCHEMA.CreativeWork
87+
88+
4689
def contextualized_graph(graph=None) -> rdflib.Graph:
4790
'''bind default namespace prefixes to a new (or given) rdf graph
4891
'''
@@ -147,3 +190,6 @@ def smells_like_iri(maybe_iri: str) -> bool:
147190
isinstance(maybe_iri, str)
148191
and '://' in maybe_iri
149192
)
193+
194+
def map_resource_type_general_datacite_to_scheme(_type_iri: rdflib.URIRef, resource_rdftype: rdflib.URIRef) -> str:
195+
return DATACITE_SCHEMA_RESOURCE_TYPE_GENERAL_MAPPING.get(_type_iri) or DATACITE_SCHEMA_RESOURCE_TYPE_GENERAL_MAPPING.get(resource_rdftype, DEFAULT_SCHEMADOTORG_RESOURCE_TYPE)

osf/metadata/serializers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
from .datacite import DataciteJsonMetadataSerializer, DataciteXmlMetadataSerializer
1010
from .google_dataset_json_ld import GoogleDatasetJsonLdSerializer
1111
from .turtle import TurtleMetadataSerializer
12+
from .linkset import SignpostLinkset, SignpostLinksetJSON
1213

1314

1415
METADATA_SERIALIZER_REGISTRY = {
1516
'turtle': TurtleMetadataSerializer,
1617
'datacite-json': DataciteJsonMetadataSerializer,
1718
'datacite-xml': DataciteXmlMetadataSerializer,
1819
'google-dataset-json-ld': GoogleDatasetJsonLdSerializer,
20+
'linkset': SignpostLinkset,
21+
'linkset-json': SignpostLinksetJSON
1922
}
2023

2124

0 commit comments

Comments
 (0)