Skip to content
Merged
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.

26.3.0 (2026-02-24)
===================

- FAIR Signposting

26.2.1 (2026-02-09)
===================

Expand Down
9 changes: 6 additions & 3 deletions addons/base/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,9 +1034,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)
4 changes: 4 additions & 0 deletions api/providers/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
WhitelistedSHAREPreprintProvider,
)
from osf.models.action import RegistrationAction, CollectionSubmissionAction
from osf.models.spam import SpamStatus
from osf.registrations.utils import (
BulkRegistrationUpload,
InvalidHeadersError,
Expand Down Expand Up @@ -778,6 +779,9 @@ def get_default_queryset(self):

return Registration.objects.filter(
provider=provider,
deleted__isnull=True,
).exclude(
spam_status__in=[SpamStatus.FLAGGED, SpamStatus.SPAM],
).annotate(
revision_state=registration_annotations.REVISION_STATE,
**resource_annotations.make_open_practice_badge_annotations(),
Expand Down
6 changes: 5 additions & 1 deletion api/registrations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from website.project import signals as project_signals

from osf.models import Registration, OSFUser, RegistrationProvider, OutcomeArtifact, CedarMetadataRecord
from osf.models.spam import SpamStatus
from osf.utils.permissions import WRITE_NODE
from osf.utils.workflows import ApprovalStates

Expand Down Expand Up @@ -922,7 +923,10 @@ def get_registration(self):
return registration

def get_default_queryset(self):
return self.get_registration().actions.all()
registration = self.get_registration()
if registration.deleted or registration.spam_status in [SpamStatus.FLAGGED, SpamStatus.SPAM]:
return registration.actions.none()
return registration.actions.all()

def get_queryset(self):
return self.get_queryset_from_request()
Expand Down
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