This repository has been archived by the owner on Jan 29, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Rework FolderSerializer to use native DRF validation #130
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
82ffbbe
Add tests for POSTing invalid Folder values
brianhelba 80d6e03
Rework FolderSerializer to use native DRF validation
brianhelba 1c29237
Test folder updates with a PATCH request
brianhelba 9bfe4be
Use simpler error messages on validator failure
brianhelba b095121
Check for duplicate sibling files during FolderSerializer validation
brianhelba 5c2fd26
Don't use NON_FIELD_ERRORS_KEY for FolderSerializer 'name' failures
brianhelba c879bbe
Use consistent wording for errors from non-unique folder names
brianhelba 458eeae
Fix a harmless bug in folder name validation
brianhelba File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,7 +12,7 @@ | |
from rest_framework.response import Response | ||
from rest_framework.viewsets import ModelViewSet | ||
|
||
from dkc.core.models import Folder, Terms, TermsAgreement, Tree | ||
from dkc.core.models import File, Folder, Terms, TermsAgreement, Tree | ||
from dkc.core.permissions import ( | ||
HasAccess, | ||
IsAdmin, | ||
|
@@ -23,10 +23,10 @@ | |
) | ||
|
||
from .filtering import ActionSpecificFilterBackend, IntegerOrNullFilter | ||
from .utils import FullCleanModelSerializer | ||
from .utils import FormattableDict | ||
|
||
|
||
class FolderSerializer(FullCleanModelSerializer): | ||
class FolderSerializer(serializers.ModelSerializer): | ||
public: bool = serializers.BooleanField(read_only=True) | ||
access: Dict[str, bool] = serializers.SerializerMethodField() | ||
|
||
|
@@ -43,14 +43,51 @@ class Meta: | |
'public', | ||
'access', | ||
] | ||
# ModelSerializer cannot auto-generate validators for model-level constraints | ||
validators = [ | ||
serializers.UniqueTogetherValidator( | ||
queryset=Folder.objects.all(), | ||
fields=['parent', 'name'], | ||
message=FormattableDict({'name': 'A folder with that name already exists here.'}), | ||
), | ||
# This could also be implemented as a UniqueValidator on 'name', | ||
# but its easier to not explicitly redefine the whole serializer field | ||
serializers.UniqueTogetherValidator( | ||
queryset=Folder.objects.filter(parent=None), | ||
fields=['name'], | ||
message=FormattableDict({'name': 'A root folder with that name already exists.'}), | ||
), | ||
# folder_max_depth and unique_root_folder_per_tree are internal sanity constraints, | ||
# and do not need to be enforced as validators | ||
] | ||
brianhelba marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
def get_access(self, folder: Folder) -> Dict[str, bool]: | ||
return folder.tree.get_access(self.context.get('user')) | ||
|
||
def validate(self, attrs): | ||
self._validate_unique_file_siblings(attrs) | ||
return attrs | ||
|
||
def _validate_unique_file_siblings(self, attrs): | ||
if self.instance is None: | ||
# Create | ||
# By this point, other validators will have run, ensuring that 'name' and 'parent' exist | ||
name = attrs['name'] | ||
parent_id = attrs['parent'] | ||
else: | ||
# Update | ||
# On a partial update, 'name' and 'parent' might be absent, so use the existing instance | ||
name = attrs['name'] if 'name' in attrs else self.instance.name | ||
parent_id = attrs['parent'] if 'parent' in attrs else self.instance.parent_id | ||
if parent_id is not None and File.objects.filter(name=name, folder_id=parent_id).exists(): | ||
raise serializers.ValidationError( | ||
{'name': 'A file with that name already exists here.'}, code='unique' | ||
) | ||
|
||
|
||
class FolderUpdateSerializer(FolderSerializer): | ||
class Meta(FolderSerializer.Meta): | ||
fields = ['id', 'name', 'description'] | ||
read_only_fields = ['parent'] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Specifying |
||
|
||
|
||
class TermsSerializer(serializers.ModelSerializer): | ||
|
@@ -134,18 +171,15 @@ def get_serializer_context(self): | |
# Atomically roll back the tree creation if folder creation fails | ||
@transaction.atomic | ||
def perform_create(self, serializer: serializers.ModelSerializer): | ||
parent: Folder = serializer.validated_data.get('parent') | ||
parent: Folder = serializer.validated_data['parent'] | ||
zachmullen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
user: User = self.request.user | ||
if parent: | ||
tree = parent.tree | ||
if not tree.has_permission(serializer.context['user'], permission=Permission.write): | ||
zachmullen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if not tree.has_permission(user, permission=Permission.write): | ||
raise PermissionDenied() | ||
else: | ||
tree = Tree.objects.create() | ||
tree.grant_permission( | ||
PermissionGrant( | ||
user_or_group=serializer.context['user'], permission=Permission.admin | ||
) | ||
) | ||
tree.grant_permission(PermissionGrant(user_or_group=user, permission=Permission.admin)) | ||
serializer.save(tree=tree) | ||
|
||
@swagger_auto_schema( | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,9 +24,77 @@ def test_folder_rest_path(admin_api_client, folder, folder_factory): | |
assert [f['name'] for f in resp.data] == [folder.name, child.name, grandchild.name] | ||
|
||
|
||
@pytest.mark.django_db | ||
@pytest.mark.parametrize( | ||
'name', ['', 'ten_chars_' * 30, 'foo/bar'], ids=['empty', 'too_long', 'forward_slash'] | ||
) | ||
def test_folder_rest_create_invalid_name(admin_api_client, name): | ||
resp = admin_api_client.post('/api/v2/folders', data={'name': name}) | ||
assert resp.status_code == 400 | ||
assert 'name' in resp.data | ||
|
||
|
||
@pytest.mark.django_db | ||
@pytest.mark.parametrize('description', ['ten_chars_' * 301], ids=['too_long']) | ||
def test_folder_rest_create_invalid_description(admin_api_client, description): | ||
resp = admin_api_client.post( | ||
'/api/v2/folders', data={'name': 'test folder', 'description': description} | ||
) | ||
assert resp.status_code == 400 | ||
assert 'description' in resp.data | ||
|
||
|
||
@pytest.mark.django_db | ||
@pytest.mark.parametrize('parent', ['foo', -1, 9000], ids=['non_int', 'negative', 'nonexistent']) | ||
def test_folder_rest_create_invalid_parent(admin_api_client, parent): | ||
resp = admin_api_client.post('/api/v2/folders', data={'name': 'test folder', 'parent': parent}) | ||
assert resp.status_code == 400 | ||
assert 'parent' in resp.data | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_folder_rest_create_invalid_duplicate_root(admin_api_client, folder): | ||
resp = admin_api_client.post('/api/v2/folders', data={'name': folder.name, 'parent': None}) | ||
assert resp.status_code == 400 | ||
assert 'name' in resp.data | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_folder_rest_create_invalid_duplicate_sibling_folder(admin_api_client, child_folder): | ||
resp = admin_api_client.post( | ||
'/api/v2/folders', data={'name': child_folder.name, 'parent': child_folder.parent.id} | ||
) | ||
assert resp.status_code == 400 | ||
assert 'name' in resp.data | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_folder_rest_create_invalid_duplicate_sibling_file(admin_api_client, folder, file_factory): | ||
child_file = file_factory(folder=folder) | ||
resp = admin_api_client.post( | ||
'/api/v2/folders', data={'name': child_file.name, 'parent': folder.id} | ||
) | ||
assert resp.status_code == 400 | ||
assert 'name' in resp.data | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_folder_rest_create_invalid_duplicate_sibling_file_update( | ||
admin_api_client, folder, file_factory, folder_factory | ||
): | ||
# Since this is implemented with an explicitly separate code path, it deserves its own test | ||
child_file = file_factory(folder=folder) | ||
child_folder = folder_factory(parent=folder) | ||
resp = admin_api_client.patch( | ||
f'/api/v2/folders/{child_folder.id}', data={'name': child_file.name} | ||
) | ||
assert resp.status_code == 400 | ||
assert 'name' in resp.data | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_folder_rest_create_root(admin_api_client): | ||
resp = admin_api_client.post('/api/v2/folders', data={'name': 'test folder'}) | ||
resp = admin_api_client.post('/api/v2/folders', data={'name': 'test folder', 'parent': None}) | ||
assert resp.status_code == 201 | ||
assert Folder.objects.count() == 1 | ||
|
||
|
@@ -51,7 +119,7 @@ def test_folder_rest_retrieve(admin_api_client, folder): | |
|
||
@pytest.mark.django_db | ||
def test_folder_rest_update(admin_api_client, folder): | ||
resp = admin_api_client.put( | ||
resp = admin_api_client.patch( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We might want to outright disallow |
||
f'/api/v2/folders/{folder.id}', data={'name': 'New name', 'description': 'New description'} | ||
) | ||
assert resp.status_code == 200 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually,
folder_max_depth
is something we might want to check at the REST layer (rather than allowing the database to do it). I'm not sure how to do this, so I'll probably file a bug for a future fix if nobody else has suggestions.