Skip to content
Open
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
21 changes: 21 additions & 0 deletions dandiapi/api/views/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from datetime import date, timedelta
import re
from typing import TYPE_CHECKING, Any

from django.conf import settings
Expand Down Expand Up @@ -43,7 +44,27 @@ class UserDetailSerializer(serializers.Serializer):
status = serializers.CharField()


class DandisetIdentifierField(serializers.Field[int, str | int, str, Any]):
default_error_messages = {'invalid': 'A valid Dandiset identifier is required.'}

def to_internal_value(self, data):
if not isinstance(data, str | int):
self.fail('invalid')
if isinstance(data, str):
if not re.fullmatch(Dandiset.IDENTIFIER_REGEX, data):
self.fail('invalid')
try:
return int(data)
except (ValueError, TypeError):
self.fail('invalid')
Comment on lines +57 to +59
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it stands, this validation will miss negative numbers, as well as numbers greater than 6 digits.

Suggested change
return int(data)
except (ValueError, TypeError):
self.fail('invalid')
data = int(data)
except (ValueError, TypeError):
self.fail('invalid')
if isinstance(data, int) and (data < 1 or data > 999_999): # noqa: PLR2004
self.fail('invalid')

return data

def to_representation(self, value):
return f'{value:06}'


class DandisetSerializer(serializers.ModelSerializer):
identifier = DandisetIdentifierField()
contact_person = serializers.SerializerMethodField(method_name='get_contact_person')
star_count = serializers.SerializerMethodField()
is_starred = serializers.SerializerMethodField()
Expand Down
4 changes: 2 additions & 2 deletions dandiapi/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@
.register(
r'versions',
VersionViewSet,
basename='version',
basename='dandiset-version',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRF 3.15 changes:

Raise ImproperlyConfigured exception if basename is not unique [#8438]

I think this is a sensible new name, since the viewsets are nested.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for this change was that NestedAssetViewSet and AssetViewSet basenames were conflicting. I updated VersionViewSet also, just for consistency.

parents_query_lookups=[f'dandiset__{DandisetViewSet.lookup_field}'],
)
.register(
r'assets',
NestedAssetViewSet,
basename='asset',
basename='dandiset-version-asset',
parents_query_lookups=[
f'versions__dandiset__{DandisetViewSet.lookup_field}',
f'versions__{VersionViewSet.lookup_field}',
Expand Down
14 changes: 9 additions & 5 deletions dandiapi/zarr/tests/test_zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,20 @@ def test_zarr_rest_dandiset_malformed(api_client):
},
)
assert resp.status_code == 400
assert resp.json() == {'dandiset': ['This value does not match the required pattern.']}
assert resp.json() == {'dandiset': ['A valid Dandiset identifier is required.']}


@pytest.mark.django_db
def test_zarr_rest_create_not_an_owner(api_client, zarr_archive):
def test_zarr_rest_create_not_an_owner(api_client):
user = UserFactory.create()
api_client.force_authenticate(user=user)
# Don't make the user an owner
dandiset = DandisetFactory.create()
resp = api_client.post(
'/api/zarr/',
{
'name': zarr_archive.name,
'dandiset': zarr_archive.dandiset.identifier,
'name': 'New Zarr',
'dandiset': dandiset.identifier,
},
)
assert resp.status_code == 403
Expand All @@ -87,7 +89,9 @@ def test_zarr_rest_create_duplicate(api_client, zarr_archive):
},
)
assert resp.status_code == 400
assert resp.json() == ['Zarr already exists']
assert resp.json() == {
'non_field_errors': ['The fields dandiset, name must make a unique set.']
}
Comment on lines -90 to +94
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the new error raised by the serializer?



@pytest.mark.django_db
Expand Down
70 changes: 43 additions & 27 deletions dandiapi/zarr/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
from dandiapi.api.services.exceptions import DandiError
from dandiapi.api.services.permissions.dandiset import get_visible_dandisets, is_dandiset_owner
from dandiapi.api.views.pagination import DandiPagination
from dandiapi.api.views.serializers import DandisetIdentifierField
from dandiapi.zarr.models import ZarrArchive, ZarrArchiveStatus
from dandiapi.zarr.tasks import ingest_zarr_archive

if TYPE_CHECKING:
from django.contrib.auth.models import AnonymousUser, User
from django.db.models.query import QuerySet

logger = logging.getLogger(__name__)
Expand All @@ -37,7 +39,7 @@ class ZarrDeleteFileRequestSerializer(serializers.Serializer):
path = serializers.CharField()


class ZarrSerializer(serializers.ModelSerializer):
class ZarrArchiveSerializer(serializers.ModelSerializer):
class Meta:
model = ZarrArchive
read_only_fields = [
Expand All @@ -49,7 +51,35 @@ class Meta:
]
fields = ['name', 'dandiset', *read_only_fields]

dandiset = serializers.RegexField(f'^{Dandiset.IDENTIFIER_REGEX}$')
dandiset = serializers.PrimaryKeyRelatedField(
queryset=Dandiset.objects.all(),
pk_field=DandisetIdentifierField(),
)
Comment on lines +54 to +57
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this automatically raise an exception if the specified dandiset ID is not found? And if so, what's the response code (seems like it might be 400 instead of 404)?


def __init__(self, instance=None, data=serializers.empty, **kwargs):
super().__init__(instance=instance, data=data, **kwargs)

# If this was loaded via ".get_serializer", then the request is available in context
self.user: User | AnonymousUser | None = (
self._context['request'].user if 'request' in self._context else None
)
Comment on lines +62 to +65
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the assumption here is that if self._context['request'] is not available, this must be called by internal code, and we're okay with keeping the dandiset queryset as Dandiset.objects.all()?


# A user is only necessary for validating input, but not to serialize output
if self.user is not None:
# Set the queryset here, before it's actually evaluated
self.fields['dandiset'].queryset = get_visible_dandisets(self.user)

def validate_dandiset(self, dandiset: Dandiset) -> Dandiset:
if self.user is None:
raise ValueError('Serializer user not set')
if not is_dandiset_owner(dandiset, self.user):
raise PermissionDenied
if dandiset.unembargo_in_progress:
raise DandiError(
message='Cannot add zarr to dandiset during unembargo',
http_status_code=status.HTTP_400_BAD_REQUEST,
)
return dandiset


class ZarrListSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -91,7 +121,7 @@ class ZarrListQuerySerializer(serializers.Serializer):


class ZarrViewSet(ReadOnlyModelViewSet):
serializer_class = ZarrSerializer
serializer_class = ZarrArchiveSerializer
pagination_class = DandiPagination

queryset = ZarrArchive.objects.select_related('dandiset').order_by('created').all()
Expand Down Expand Up @@ -127,42 +157,28 @@ def list(self, request, *args, **kwargs):
return self.get_paginated_response(serializer.data)

@swagger_auto_schema(
request_body=ZarrSerializer,
responses={200: ZarrSerializer},
request_body=ZarrArchiveSerializer,
responses={200: ZarrArchiveSerializer},
operation_summary='Create a new zarr archive.',
operation_description='',
)
def create(self, request):
"""Create a new zarr archive."""
serializer = ZarrSerializer(data=request.data)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)

name = serializer.validated_data['name']
dandiset = get_object_or_404(
get_visible_dandisets(request.user), id=serializer.validated_data['dandiset']
)
if not is_dandiset_owner(dandiset, request.user):
raise PermissionDenied

# Prevent addition to dandiset during unembargo
if dandiset.unembargo_in_progress:
raise DandiError(
message='Cannot add zarr to dandiset during unembargo',
http_status_code=status.HTTP_400_BAD_REQUEST,
)

zarr_archive: ZarrArchive = ZarrArchive(name=name, dandiset=dandiset)
with transaction.atomic():
# Use nested transaction block to prevent zarr creation race condition
try:
with transaction.atomic():
zarr_archive.save()
zarr_archive: ZarrArchive = serializer.save()
except IntegrityError as e:
raise ValidationError('Zarr already exists') from e

audit.create_zarr(dandiset=dandiset, user=request.user, zarr_archive=zarr_archive)
audit.create_zarr(
dandiset=serializer.validated_data['dandiset'],
user=request.user,
zarr_archive=zarr_archive,
)

serializer = ZarrSerializer(instance=zarr_archive)
return Response(serializer.data, status=status.HTTP_200_OK)

@swagger_auto_schema(
Expand Down Expand Up @@ -328,7 +344,7 @@ def create_files(self, request, zarr_id):
@swagger_auto_schema(
request_body=ZarrDeleteFileRequestSerializer(many=True),
responses={
200: ZarrSerializer(many=True),
200: ZarrArchiveSerializer(many=True),
400: ZarrArchive.INGEST_ERROR_MSG,
},
operation_summary='Delete files from a zarr archive.',
Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ dependencies = [
"django-resonant-settings[allauth,celery]",
"django-resonant-utils",
"django-stubs-ext",
# TODO: pin djangorestframework until we figure out what the cause of
# https://github.com/dandi/dandi-archive/issues/1896 is.
"djangorestframework<3.15.0",
"djangorestframework",
"drf-extensions",
"drf-yasg",
"fsspec[http]",
Expand Down
9 changes: 4 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading