Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
128585a
linkset initial implementation
mkovalua Jan 30, 2026
de92868
linkset initial implementation (updating data extraction and adding …
mkovalua Feb 2, 2026
fa9ac8d
and \n in EOF instead of %
mkovalua Feb 2, 2026
4ef2bd3
update code
mkovalua Feb 2, 2026
622312e
get file mediatype from metadata
mkovalua Feb 2, 2026
6bc12d2
add unittests for linkset approach (TODO: ?)
mkovalua Feb 3, 2026
1ed8982
update code
mkovalua Feb 3, 2026
0861739
update code and unit tests and
mkovalua Feb 4, 2026
6552224
update file related code and unit tests
mkovalua Feb 4, 2026
21c3ced
refactor code after CR | implement 'describes' | check file type dif…
mkovalua Feb 6, 2026
6c2395e
update files to compare on unittests run
mkovalua Feb 6, 2026
a162e27
implement datacite - scheme type mapping for linkset draft version (n…
mkovalua Feb 11, 2026
3efb1c1
Update osf/metadata/osf_gathering.py
mkovalua Feb 11, 2026
321d3e0
Update osf/metadata/serializers/linkset.py
mkovalua Feb 11, 2026
6e7fc79
Merge pull request #11571 from mkovalua/feature/ENG-10157
aaxelb Feb 11, 2026
2e535b4
Merge branch 'develop' into feature/fair-signposting
futa-ikeda Feb 11, 2026
ccba91d
implement datacite - schema mapping
mkovalua Feb 12, 2026
e9938bd
update tests expected results
mkovalua Feb 12, 2026
177cf4b
resolve CR
mkovalua Feb 12, 2026
989dff4
refactor code | resolve CR
mkovalua Feb 12, 2026
c736d08
Merge pull request #11586 from mkovalua/feature/ENG-10234
aaxelb Feb 13, 2026
22d2abd
remove redundant describes from file response (#11590)
mkovalua Feb 13, 2026
9fc1c84
[ENG-10256] 2.1.9 BE: Fix permission issue where users without permis…
mkovalua Feb 16, 2026
d83c623
[ENG-10167] 2.1.6 BE: add link header to guid metadata endpoints (#11…
mkovalua Feb 17, 2026
7d60801
avoid Component Registration/Project JSON Contains type: null (#11597)
mkovalua Feb 19, 2026
7e09aa4
[ENG-10168] 2.1.7 BE: add link header to cedar metadata records endpo…
mkovalua Feb 19, 2026
f2d5f88
change ResearchProject for CreativeWork for projects (#11603)
mkovalua Feb 23, 2026
4d177bb
[ENG-10169] 2.1.8 BE: add link header to file download URL (#11600)
mkovalua Feb 23, 2026
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
9 changes: 6 additions & 3 deletions addons/base/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1035,9 +1035,12 @@ def persistent_file_download(auth, **kwargs):

query_params = request.args.to_dict()

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


Expand Down
10 changes: 9 additions & 1 deletion addons/osfstorage/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1551,11 +1551,19 @@ def test_download_file(self):
# Test download works with path
url = base_url.format(file._id)
redirect = self.app.get(url, auth=self.user.auth)
link_header = (f'<{settings.DOMAIN}metadata/{file._id}/?format=linkset> ; rel="linkset" ; type="application/linkset", '
f'<{settings.DOMAIN}metadata/{file._id}/?format=linkset-json"> ; rel="linkset-json" ; type="application/linkset+json"')
assert link_header == redirect.headers['Link']
assert redirect.status_code == 302

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

# Test nonexistent file 404's
Expand Down
11 changes: 8 additions & 3 deletions api/cedar_metadata_records/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
CedarMetadataRecordsDetailSerializer,
)
from framework.auth.oauth_scopes import CoreScopes

from osf.models import CedarMetadataRecord
from osf.models import CedarMetadataRecord, Node, Registration
from website import settings

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -99,5 +99,10 @@ def get_serializer_class(self):

def get(self, request, *args, **kwargs):
record = self.get_object()
is_referent_project_or_registration = isinstance(record.guid.referent, (Node, Registration))
file_name = f'{record._id}-{record.get_template_name()}-v{record.get_template_version()}.json'
return Response(record.metadata, headers={'Content-Disposition': f'attachment; filename={file_name}'})
headers = {'Content-Disposition': f'attachment; filename={file_name}'}
if is_referent_project_or_registration:
guid_id = record.guid._id
headers['link'] = f'<{settings.DOMAIN}{guid_id}/>; rel="describes"; type="text/html"'
return Response(record.metadata, headers=headers)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from .test_record import TestCedarMetadataRecord
from osf.utils.permissions import READ, WRITE
from osf_tests.factories import AuthUserFactory
from website import settings


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

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

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

def test_record_metadata_download_for_node_with_write_auth(self, app, node_alt, cedar_draft_record_for_file_alt, cedar_record_metadata_json):
Expand Down
23 changes: 23 additions & 0 deletions framework/auth/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,29 @@ def wrapped(*args, **kwargs):

return wrapped


def is_contributor_or_public_resource(resource_kw='resource'):
"""
Require that user be contributor or resource be public.
"""
def decorator(func):
@wraps(func)
def wrapped(*args, **kwargs):
from osf.models import BaseFileNode, Guid
referent = kwargs.get(resource_kw)
if isinstance(referent, Guid):
referent = referent.referent
target_resource = referent.target if isinstance(referent, BaseFileNode) else referent
if target_resource.is_public:
return func(*args, **kwargs)
auth = Auth.from_kwargs(request.args.to_dict(), {})
if auth.logged_in and target_resource.is_contributor(auth.user):
return func(*args, **kwargs)
raise HTTPError(http_status.HTTP_403_FORBIDDEN)
return wrapped
return decorator


# TODO Can remove after Waterbutler is sending requests to V2 endpoints.
# This decorator has been adapted for use in an APIv2 parser - HMACSignedParser
def must_be_signed(func):
Expand Down
15 changes: 14 additions & 1 deletion osf/metadata/osf_gathering.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from django.contrib.contenttypes.models import ContentType
from django import db
from mimetypes import MimeTypes
import rdflib

from api.caching.tasks import get_storage_usage_total
Expand Down Expand Up @@ -44,6 +45,8 @@

logger = logging.getLogger(__name__)

mime = MimeTypes()


##### BEGIN "public" api #####

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


def osfguid_from_iri(iri):
def osfguid_from_iri(iri: str) -> str:
if iri.startswith(OSFIO):
return without_namespace(iri, OSFIO)
raise ValueError(f'expected iri starting with "{OSFIO}" (got "{iri}")')
Expand Down Expand Up @@ -702,6 +705,16 @@ def gather_files(focus):
yield (DCTERMS.requires, file_focus)


@gather.er(DCAT.mediaType)
def gather_file_mediatype(focus):
(mime_type, _) = mime.guess_type(focus.dbmodel.name)
yield (DCAT.mediaType, (
'application/octet-stream'
if mime_type is None
else mime_type
))


@gather.er(DCTERMS.hasPart, DCTERMS.isPartOf)
def gather_parts(focus):
if isinstance(focus.dbmodel, osfdb.AbstractNode):
Expand Down
48 changes: 47 additions & 1 deletion osf/metadata/rdfutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
PROV = rdflib.Namespace('http://www.w3.org/ns/prov#') # "provenance"
# non-standard namespace for datacite terms (resolves to datacite docs)
DATACITE = rdflib.Namespace('https://schema.datacite.org/meta/kernel-4/#')

SCHEMA = rdflib.Namespace('https://schema.org/')

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


DATACITE_SCHEMA_RESOURCE_TYPE_GENERAL_MAPPING = {
DATACITE.Audiovisual: SCHEMA.MediaObject,
DATACITE.Book: SCHEMA.Book,
DATACITE.BookChapter: SCHEMA.Chapter,
DATACITE.Collection: SCHEMA.Collection,
DATACITE.ComputationalNotebook: SCHEMA.SoftwareSourceCode,
DATACITE.ConferencePaper: SCHEMA.Article,
DATACITE.ConferenceProceeding: SCHEMA.Periodical,
DATACITE.DataPaper: SCHEMA.Article,
DATACITE.Dataset: SCHEMA.Dataset,
DATACITE.Dissertation: SCHEMA.Thesis,
DATACITE.Event: SCHEMA.Event,
DATACITE.Image: SCHEMA.ImageObject,
DATACITE.InteractiveResource: SCHEMA.CreativeWork,
DATACITE.Journal: SCHEMA.Periodical,
DATACITE.JournalArticle: SCHEMA.ScholarlyArticle,
DATACITE.Model: SCHEMA.CreativeWork,
DATACITE.OutputManagementPlan: SCHEMA.HowTo,
DATACITE.PeerReview: SCHEMA.Review,
DATACITE.PhysicalObject: SCHEMA.Thing,
DATACITE.Preprint: SCHEMA.ScholarlyArticle,
DATACITE.Report: SCHEMA.Report,
DATACITE.Service: SCHEMA.Service,
DATACITE.Software: SCHEMA.SoftwareSourceCode,
DATACITE.Sound: SCHEMA.AudioObject,
DATACITE.Standard: SCHEMA.CreativeWork,
DATACITE.Text: SCHEMA.Text,
DATACITE.Workflow: SCHEMA.HowTo,
DATACITE.Other: SCHEMA.CreativeWork,
DATACITE.Instrument: SCHEMA.MeasurementMethodEnum,
DATACITE.StudyRegistration: SCHEMA.Text,
OSF.Project: SCHEMA.CreativeWork,
OSF.Preprint: SCHEMA.ScholarlyArticle,
OSF.Registration: SCHEMA.Text,
OSF.File: SCHEMA.DigitalDocument,
OSF.ProjectComponent: SCHEMA.CreativeWork,
OSF.RegistrationComponent: SCHEMA.Text,
}


DEFAULT_SCHEMADOTORG_RESOURCE_TYPE = SCHEMA.CreativeWork


def contextualized_graph(graph=None) -> rdflib.Graph:
'''bind default namespace prefixes to a new (or given) rdf graph
'''
Expand Down Expand Up @@ -147,3 +190,6 @@ def smells_like_iri(maybe_iri: str) -> bool:
isinstance(maybe_iri, str)
and '://' in maybe_iri
)

def map_resource_type_general_datacite_to_scheme(_type_iri: rdflib.URIRef, resource_rdftype: rdflib.URIRef) -> str:
return DATACITE_SCHEMA_RESOURCE_TYPE_GENERAL_MAPPING.get(_type_iri) or DATACITE_SCHEMA_RESOURCE_TYPE_GENERAL_MAPPING.get(resource_rdftype, DEFAULT_SCHEMADOTORG_RESOURCE_TYPE)
3 changes: 3 additions & 0 deletions osf/metadata/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
from .datacite import DataciteJsonMetadataSerializer, DataciteXmlMetadataSerializer
from .google_dataset_json_ld import GoogleDatasetJsonLdSerializer
from .turtle import TurtleMetadataSerializer
from .linkset import SignpostLinkset, SignpostLinksetJSON


METADATA_SERIALIZER_REGISTRY = {
'turtle': TurtleMetadataSerializer,
'datacite-json': DataciteJsonMetadataSerializer,
'datacite-xml': DataciteXmlMetadataSerializer,
'google-dataset-json-ld': GoogleDatasetJsonLdSerializer,
'linkset': SignpostLinkset,
'linkset-json': SignpostLinksetJSON
}


Expand Down
Loading
Loading