diff --git a/api/base/urls.py b/api/base/urls.py index b2a1771b03d..f7e4cb74e71 100644 --- a/api/base/urls.py +++ b/api/base/urls.py @@ -23,6 +23,7 @@ re_path(r'^crossref/', include('api.crossref.urls', namespace='crossref')), re_path(r'^chronos/', include('api.chronos.urls', namespace='chronos')), re_path(r'^cedar_metadata_templates/', include('api.cedar_metadata_templates.urls', namespace='cedar-metadata-templates')), + re_path(r'^cedar_metadata_records/', include('api.cedar_metadata_records.urls', namespace='cedar-metadata-records')), re_path(r'^meetings/', include('api.meetings.urls', namespace='meetings')), re_path(r'^metrics/', include('api.metrics.urls', namespace='metrics')), re_path(r'^registries/(?P\w+)/bulk_create/(?P.*)/$', RegistrationBulkCreate.as_view(), name='bulk_create_csv'), diff --git a/api/cedar_metadata_records/__init__.py b/api/cedar_metadata_records/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/cedar_metadata_records/permissions.py b/api/cedar_metadata_records/permissions.py new file mode 100644 index 00000000000..2a78b932f6c --- /dev/null +++ b/api/cedar_metadata_records/permissions.py @@ -0,0 +1,29 @@ +import logging + +from rest_framework import permissions + +from api.base.utils import get_user_auth + +from osf.models import BaseFileNode, CedarMetadataRecord, Node, Registration + +logger = logging.getLogger(__name__) + + +class CedarMetadataRecordPermission(permissions.BasePermission): + + def has_object_permission(self, request, view, obj): + + assert isinstance(obj, CedarMetadataRecord), 'obj must be a CedarMetadataRecord' + + auth = get_user_auth(request) + + permission_source = obj.guid.referent + if isinstance(permission_source, BaseFileNode): + permission_source = permission_source.target + elif not isinstance(permission_source, (Node, Registration)): + return False + + if request.method in permissions.SAFE_METHODS: + is_public = permission_source.is_public and obj.is_published + return is_public or permission_source.can_view(auth) + return permission_source.can_edit(auth) diff --git a/api/cedar_metadata_records/serializers.py b/api/cedar_metadata_records/serializers.py new file mode 100644 index 00000000000..1b6c29c4e69 --- /dev/null +++ b/api/cedar_metadata_records/serializers.py @@ -0,0 +1,116 @@ +import logging + +from django.db import IntegrityError +from rest_framework import serializers as ser + +from api.base.exceptions import InvalidModelValueError, JSONAPIException +from api.base.serializers import JSONAPISerializer, LinksField, RelationshipField +from api.base.utils import absolute_reverse + +from osf.exceptions import ValidationError +from osf.models import CedarMetadataRecord, CedarMetadataTemplate, Guid + +logger = logging.getLogger(__name__) + + +class TargetRelationshipField(RelationshipField): + + def get_object(self, _id): + return Guid.load(_id) + + def to_internal_value(self, data): + return self.get_object(data) + + +class CedarMetadataTemplateRelationshipField(RelationshipField): + + def get_object(self, _id): + return CedarMetadataTemplate.load(_id) + + def to_internal_value(self, data): + return self.get_object(data) + + +class CedarMetadataRecordsSerializer(JSONAPISerializer): + + class Meta: + type_ = 'cedar-metadata-records' + + filterable_fields = frozenset(['is_published']) + + id = ser.CharField(source='_id', read_only=True) + + target = RelationshipField( + source='guid', + related_view='guids:guid-detail', + related_view_kwargs={'guids': ''}, + # always_embed=True, + read_only=True, + ) + + template = RelationshipField( + related_view='cedar-metadata-templates:cedar-metadata-template-detail', + related_view_kwargs={'template_id': ''}, + # always_embed=True, + read_only=True, + ) + + metadata = ser.DictField(read_only=False) + + is_published = ser.BooleanField(read_only=False) + + links = LinksField({'self': 'get_absolute_url'}) + + def get_absolute_url(self, obj): + return absolute_reverse('cedar-metadata-records:cedar-metadata-record-detail', kwargs={'record_id': obj._id}) + + def update(self, instance, validated_data): + assert isinstance(instance, CedarMetadataRecord), 'instance must be a CedarMetadataRecord' + for key, value in validated_data.items(): + if key == 'metadata': + instance.metadata = value + elif key == 'is_published': + instance.is_published = value + else: + continue # ignore other attributes + instance.save() + return instance + + +class CedarMetadataRecordsCreateSerializer(CedarMetadataRecordsSerializer): + + target = TargetRelationshipField( + source='guid', + related_view='guids:guid-detail', + related_view_kwargs={'guids': ''}, + # always_embed=True, + read_only=False, + required=True, + ) + + template = CedarMetadataTemplateRelationshipField( + related_view='cedar-metadata-templates:cedar-metadata-template-detail', + related_view_kwargs={'template_id': ''}, + # always_embed=True, + read_only=False, + required=True, + ) + + metadata = ser.DictField(read_only=False, required=True) + + is_published = ser.BooleanField(read_only=False, required=True) + + def create(self, validated_data): + + guid = validated_data.pop('guid') + template = validated_data.pop('template') + metadata = validated_data.pop('metadata') + is_published = validated_data.pop('is_published') + record = CedarMetadataRecord(guid=guid, template=template, metadata=metadata, is_published=is_published) + try: + record.save() + except ValidationError as e: + raise InvalidModelValueError(detail=e.messages[0]) + except IntegrityError: + raise JSONAPIException(detail=f'Cedar metadata record already exists: guid=[{guid._id}], template=[{template._id}]') + return record diff --git a/api/cedar_metadata_records/urls.py b/api/cedar_metadata_records/urls.py new file mode 100644 index 00000000000..3a869a9a371 --- /dev/null +++ b/api/cedar_metadata_records/urls.py @@ -0,0 +1,10 @@ +from django.urls import re_path + +from api.cedar_metadata_records import views + +app_name = 'osf' + +urlpatterns = [ + re_path(r'^$', views.CedarMetadataRecordList.as_view(), name=views.CedarMetadataRecordList.view_name), + re_path(r'^(?P[0-9A-Za-z]+)/$', views.CedarMetadataRecordDetail.as_view(), name=views.CedarMetadataRecordDetail.view_name), +] diff --git a/api/cedar_metadata_records/views.py b/api/cedar_metadata_records/views.py new file mode 100644 index 00000000000..8fde6cdeea8 --- /dev/null +++ b/api/cedar_metadata_records/views.py @@ -0,0 +1,73 @@ +from __future__ import unicode_literals +import logging + +from rest_framework import permissions as drf_permissions +from rest_framework.exceptions import NotFound +from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView + +from api.base import permissions as base_permissions +from api.base.filters import ListFilterMixin +from api.base.parsers import ( + JSONAPIMultipleRelationshipsParser, + JSONAPIMultipleRelationshipsParserForRegularJSON, +) +from api.base.versioning import PrivateVersioning +from api.base.views import JSONAPIBaseView +from api.cedar_metadata_records.permissions import CedarMetadataRecordPermission +from api.cedar_metadata_records.serializers import CedarMetadataRecordsSerializer, CedarMetadataRecordsCreateSerializer + +from framework.auth.oauth_scopes import CoreScopes + +from osf.models import CedarMetadataRecord + +logger = logging.getLogger(__name__) + + +class CedarMetadataRecordList(JSONAPIBaseView, ListCreateAPIView, ListFilterMixin): + + permission_classes = ( + CedarMetadataRecordPermission, + drf_permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + ) + required_read_scopes = [CoreScopes.CEDAR_METADATA_RECORD_READ] + required_write_scopes = [CoreScopes.CEDAR_METADATA_RECORD_WRITE] + + serializer_class = CedarMetadataRecordsCreateSerializer + parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON, ) + model_class = CedarMetadataRecord + + # This view goes under the _/ namespace + versioning_class = PrivateVersioning + view_category = 'cedar-metadata-records' + view_name = 'cedar-metadata-record-list' + + def get_default_queryset(self): + return CedarMetadataRecord.objects.filter(is_published=True) + + def get_queryset(self): + return self.get_queryset_from_request() + + +class CedarMetadataRecordDetail(JSONAPIBaseView, RetrieveUpdateDestroyAPIView): + + permission_classes = ( + CedarMetadataRecordPermission, + drf_permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + ) + required_read_scopes = [CoreScopes.CEDAR_METADATA_RECORD_READ] + required_write_scopes = [CoreScopes.CEDAR_METADATA_RECORD_WRITE] + + serializer_class = CedarMetadataRecordsSerializer + + # This view goes under the _/ namespace + versioning_class = PrivateVersioning + view_category = 'cedar-metadata-records' + view_name = 'cedar-metadata-record-detail' + + def get_object(self): + try: + return CedarMetadataRecord.objects.get(_id=self.kwargs['record_id']) + except CedarMetadataRecord.DoesNotExist: + raise NotFound diff --git a/api/cedar_metadata_templates/serializers.py b/api/cedar_metadata_templates/serializers.py index 626f99ca057..cfb94533190 100644 --- a/api/cedar_metadata_templates/serializers.py +++ b/api/cedar_metadata_templates/serializers.py @@ -21,4 +21,4 @@ class Meta: links = LinksField({'self': 'get_absolute_url'}) def get_absolute_url(self, obj): - return absolute_reverse('cedar-metadata-templates:cedar-metadata-templates-detail', kwargs={'template_id': obj._id}) + return absolute_reverse('cedar-metadata-templates:cedar-metadata-template-detail', kwargs={'template_id': obj._id}) diff --git a/api/cedar_metadata_templates/views.py b/api/cedar_metadata_templates/views.py index 73bdca565af..896f3a8be39 100644 --- a/api/cedar_metadata_templates/views.py +++ b/api/cedar_metadata_templates/views.py @@ -19,7 +19,7 @@ class CedarMetadataTemplateList(JSONAPIBaseView, generics.ListAPIView, ListFilte drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, ) - required_read_scopes = [CoreScopes.ALWAYS_PUBLIC] + required_read_scopes = [CoreScopes.CEDAR_METADATA_RECORD_READ] required_write_scopes = [CoreScopes.NULL] serializer_class = CedarMetadataTemplateSerializer @@ -28,7 +28,7 @@ class CedarMetadataTemplateList(JSONAPIBaseView, generics.ListAPIView, ListFilte # This view goes under the _/ namespace versioning_class = PrivateVersioning view_category = 'cedar-metadata-templates' - view_name = 'cedar-metadata-templates-list' + view_name = 'cedar-metadata-template-list' def get_default_queryset(self): return CedarMetadataTemplate.objects.all() @@ -51,7 +51,7 @@ class CedarMetadataTemplateDetail(JSONAPIBaseView, generics.RetrieveAPIView): # This view goes under the _/ namespace versioning_class = PrivateVersioning view_category = 'cedar-metadata-templates' - view_name = 'cedar-metadata-templates-detail' + view_name = 'cedar-metadata-template-detail' def get_object(self): try: diff --git a/framework/auth/oauth_scopes.py b/framework/auth/oauth_scopes.py index 9bcd68efdc9..8068e0f6466 100644 --- a/framework/auth/oauth_scopes.py +++ b/framework/auth/oauth_scopes.py @@ -160,6 +160,9 @@ class CoreScopes(object): WAFFLE_READ = 'waffle_read' + CEDAR_METADATA_RECORD_READ = 'cedar_metadata_record_read' + CEDAR_METADATA_RECORD_WRITE = 'cedar_metadata_record_write' + NULL = 'null' # NOTE: Use with extreme caution. @@ -316,6 +319,7 @@ class ComposedScopes(object): + PREPRINT_ALL_READ\ + GROUP_READ\ + ( + CoreScopes.CEDAR_METADATA_RECORD_READ, CoreScopes.MEETINGS_READ, CoreScopes.INSTITUTION_READ, CoreScopes.SEARCH, @@ -336,6 +340,7 @@ class ComposedScopes(object): + PREPRINT_ALL_WRITE\ + GROUP_WRITE\ + ( + CoreScopes.CEDAR_METADATA_RECORD_WRITE, CoreScopes.WRITE_COLLECTION_SUBMISSION_ACTION, CoreScopes.WRITE_COLLECTION_SUBMISSION ) diff --git a/osf/models/__init__.py b/osf/models/__init__.py index 3b2f3603e0d..cad31ea323f 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -18,7 +18,7 @@ Guid, ) from .brand import Brand -from .cedar_metadata import CedarMetadataTemplate +from .cedar_metadata import CedarMetadataRecord, CedarMetadataTemplate from .chronos import ChronosJournal, ChronosSubmission from .citation import CitationStyle from .collection import Collection