Skip to content

Commit 24e2ca1

Browse files
committed
Move validation logic into ZarrArchiveSerializer
The latest DRF now enforces uniqueness constraints in serializer validation, so all other validation should happen at the same time to avoid incorrect error codes being returned.
1 parent d403012 commit 24e2ca1

File tree

3 files changed

+73
-32
lines changed

3 files changed

+73
-32
lines changed

dandiapi/api/views/serializers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from datetime import date, timedelta
4+
import re
45
from typing import TYPE_CHECKING, Any
56

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

4546

47+
class DandisetIdentifierField(serializers.Field[int, str | int, str, Any]):
48+
default_error_messages = {'invalid': 'A valid Dandiset identifier is required.'}
49+
50+
def to_internal_value(self, data):
51+
if not isinstance(data, str | int):
52+
self.fail('invalid')
53+
if isinstance(data, str):
54+
if not re.fullmatch(Dandiset.IDENTIFIER_REGEX, data):
55+
self.fail('invalid')
56+
try:
57+
return int(data)
58+
except (ValueError, TypeError):
59+
self.fail('invalid')
60+
return data
61+
62+
def to_representation(self, value):
63+
return f'{value:06}'
64+
65+
4666
class DandisetSerializer(serializers.ModelSerializer):
67+
identifier = DandisetIdentifierField()
4768
contact_person = serializers.SerializerMethodField(method_name='get_contact_person')
4869
star_count = serializers.SerializerMethodField()
4970
is_starred = serializers.SerializerMethodField()

dandiapi/zarr/tests/test_zarr.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,20 @@ def test_zarr_rest_dandiset_malformed(api_client):
5757
},
5858
)
5959
assert resp.status_code == 400
60-
assert resp.json() == {'dandiset': ['This value does not match the required pattern.']}
60+
assert resp.json() == {'dandiset': ['A valid Dandiset identifier is required.']}
6161

6262

6363
@pytest.mark.django_db
64-
def test_zarr_rest_create_not_an_owner(api_client, zarr_archive):
64+
def test_zarr_rest_create_not_an_owner(api_client):
6565
user = UserFactory.create()
6666
api_client.force_authenticate(user=user)
67+
# Don't make the user an owner
68+
dandiset = DandisetFactory.create()
6769
resp = api_client.post(
6870
'/api/zarr/',
6971
{
70-
'name': zarr_archive.name,
71-
'dandiset': zarr_archive.dandiset.identifier,
72+
'name': 'New Zarr',
73+
'dandiset': dandiset.identifier,
7274
},
7375
)
7476
assert resp.status_code == 403
@@ -87,7 +89,9 @@ def test_zarr_rest_create_duplicate(api_client, zarr_archive):
8789
},
8890
)
8991
assert resp.status_code == 400
90-
assert resp.json() == ['Zarr already exists']
92+
assert resp.json() == {
93+
'non_field_errors': ['The fields dandiset, name must make a unique set.']
94+
}
9195

9296

9397
@pytest.mark.django_db

dandiapi/zarr/views/__init__.py

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
from dandiapi.api.services.exceptions import DandiError
2020
from dandiapi.api.services.permissions.dandiset import get_visible_dandisets, is_dandiset_owner
2121
from dandiapi.api.views.pagination import DandiPagination
22+
from dandiapi.api.views.serializers import DandisetIdentifierField
2223
from dandiapi.zarr.models import ZarrArchive, ZarrArchiveStatus
2324
from dandiapi.zarr.tasks import ingest_zarr_archive
2425

2526
if TYPE_CHECKING:
27+
from django.contrib.auth.models import AnonymousUser, User
2628
from django.db.models.query import QuerySet
2729

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

3941

40-
class ZarrSerializer(serializers.ModelSerializer):
42+
class ZarrArchiveSerializer(serializers.ModelSerializer):
4143
class Meta:
4244
model = ZarrArchive
4345
read_only_fields = [
@@ -49,7 +51,35 @@ class Meta:
4951
]
5052
fields = ['name', 'dandiset', *read_only_fields]
5153

52-
dandiset = serializers.RegexField(f'^{Dandiset.IDENTIFIER_REGEX}$')
54+
dandiset = serializers.PrimaryKeyRelatedField(
55+
queryset=Dandiset.objects.all(),
56+
pk_field=DandisetIdentifierField(),
57+
)
58+
59+
def __init__(self, instance=None, data=serializers.empty, **kwargs):
60+
super().__init__(instance=instance, data=data, **kwargs)
61+
62+
# If this was loaded via ".get_serializer", then the request is available in context
63+
self.user: User | AnonymousUser | None = (
64+
self._context['request'].user if 'request' in self._context else None
65+
)
66+
67+
# A user is only necessary for validating input, but not to serialize output
68+
if self.user is not None:
69+
# Set the queryset here, before it's actually evaluated
70+
self.fields['dandiset'].queryset = get_visible_dandisets(self.user)
71+
72+
def validate_dandiset(self, dandiset: Dandiset) -> Dandiset:
73+
if self.user is None:
74+
raise ValueError('Serializer user not set')
75+
if not is_dandiset_owner(dandiset, self.user):
76+
raise PermissionDenied
77+
if dandiset.unembargo_in_progress:
78+
raise DandiError(
79+
message='Cannot add zarr to dandiset during unembargo',
80+
http_status_code=status.HTTP_400_BAD_REQUEST,
81+
)
82+
return dandiset
5383

5484

5585
class ZarrListSerializer(serializers.ModelSerializer):
@@ -91,7 +121,7 @@ class ZarrListQuerySerializer(serializers.Serializer):
91121

92122

93123
class ZarrViewSet(ReadOnlyModelViewSet):
94-
serializer_class = ZarrSerializer
124+
serializer_class = ZarrArchiveSerializer
95125
pagination_class = DandiPagination
96126

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

129159
@swagger_auto_schema(
130-
request_body=ZarrSerializer,
131-
responses={200: ZarrSerializer},
160+
request_body=ZarrArchiveSerializer,
161+
responses={200: ZarrArchiveSerializer},
132162
operation_summary='Create a new zarr archive.',
133163
operation_description='',
134164
)
135165
def create(self, request):
136166
"""Create a new zarr archive."""
137-
serializer = ZarrSerializer(data=request.data)
167+
serializer = self.get_serializer(data=request.data)
138168
serializer.is_valid(raise_exception=True)
139169

140-
name = serializer.validated_data['name']
141-
dandiset = get_object_or_404(
142-
get_visible_dandisets(request.user), id=serializer.validated_data['dandiset']
143-
)
144-
if not is_dandiset_owner(dandiset, request.user):
145-
raise PermissionDenied
146-
147-
# Prevent addition to dandiset during unembargo
148-
if dandiset.unembargo_in_progress:
149-
raise DandiError(
150-
message='Cannot add zarr to dandiset during unembargo',
151-
http_status_code=status.HTTP_400_BAD_REQUEST,
152-
)
153-
154-
zarr_archive: ZarrArchive = ZarrArchive(name=name, dandiset=dandiset)
155170
with transaction.atomic():
156-
# Use nested transaction block to prevent zarr creation race condition
157171
try:
158-
with transaction.atomic():
159-
zarr_archive.save()
172+
zarr_archive: ZarrArchive = serializer.save()
160173
except IntegrityError as e:
161174
raise ValidationError('Zarr already exists') from e
162175

163-
audit.create_zarr(dandiset=dandiset, user=request.user, zarr_archive=zarr_archive)
176+
audit.create_zarr(
177+
dandiset=serializer.validated_data['dandiset'],
178+
user=request.user,
179+
zarr_archive=zarr_archive,
180+
)
164181

165-
serializer = ZarrSerializer(instance=zarr_archive)
166182
return Response(serializer.data, status=status.HTTP_200_OK)
167183

168184
@swagger_auto_schema(
@@ -328,7 +344,7 @@ def create_files(self, request, zarr_id):
328344
@swagger_auto_schema(
329345
request_body=ZarrDeleteFileRequestSerializer(many=True),
330346
responses={
331-
200: ZarrSerializer(many=True),
347+
200: ZarrArchiveSerializer(many=True),
332348
400: ZarrArchive.INGEST_ERROR_MSG,
333349
},
334350
operation_summary='Delete files from a zarr archive.',

0 commit comments

Comments
 (0)