Django REST framework extension

Serializers

BaseModelSerializer

This serializers extends the restframework ModelSerializer but ensures that the clean() method in the model is called. By default, only django forms call the model-level clean, not the serializers.

# models.py
class MyModel(models.Model):
    ...

    def clean():
        # here lives the model-level validation
        ...

# serializers.py
class MyModelSerializer(ModelSerializer)
    class Meta:
        model = MyModel

    # do your stuff here!

Just derive your serializer from the BaseModelSerializer and you are good to go!

CommonInfoSerializer

In addition to the CommonInfo model class which provides a neat way to set the creator and last editor of any database object, this serializers takes care of setting those fields if a request user was found.

Furthermore, it extends the BaseModelSerializer to ensure that model-level validation is called on serializer validation.

# models.py
class MyOwnershipRelevantModel(CommonInfo):
    ...

# serializers.py
class MyOwnershipRelevantModelSerializer(CommonInfoSerializer)
    class Meta:
        model = MyOwnershipRelevantModel

    # do your stuff here!

Fields

RecursiveField

In some cases you’ll have to define a foreign key from and to the same model. One popular use-case is a simple tree structure. If you want to use the same serializer for the parent and all children, you can utilise our RecursiveField:

class MyModelSerializer(serializers.ModelSerializer):
    ...
    children = RecursiveField(many=True)

    class Meta:
        model = MyModel
        fields = [
            'id',
            ...
            'children',
        ]

Testing of ViewSets

If you want to test ViewSets you can use the handy mixin BaseViewSetTestMixin from this package. It encapsulates common use-cases and provides a wrapper to be able to easily focus on your tests and not the syntax of how to call a viewset manually.

Setup

It is easy to use this mixin, just follow these steps. You will probably have some base test class to encapsulate your general test setup. So create a new base api test class like this:

class BaseApiTest(BaseViewSetTestMixin, BaseTest):
    def get_default_api_user(self) -> AbstractUser:
        return baker.make_recipe('apps.account.tests.user')

Note that the content of get_default_api_user() is just a suggestion. You have to provide the method, but it is totally up to you how you generate a base request user.

The idea behind this method is, that you define a base user for your API requests. You still have to pass the user manually to all of your tests in the execute_request() method. We did not set this user as a default because the user is often an essential part of the test and should be selected consciously and with care.

If you do not need this default user, simply return None in the described method.

Test that authentication is required for an endpoint

Permissions are one of the most vital things to test in your application. So this mixin provides a helper method, so you are able to test what you require without thinking about how to test it.

class MyFancyViewsetApiTest(BaseApiTest):
    def test_list_authentication_required(self):
        self.validate_authentication_required(
            url=reverse('my-fancy-viewset-list'),
            method='get',
            view='list',
        )

Testing CRUD views and custom actions

Here is an example of how to test a CRUD action. The helper method execute_request() encapsulates all necessary logic to create an API response. This response can then be asserted. It is wise to always at first assert the status code. In this case, if the API does not return valid data, but an error code, the reason of failure is obvious. If you on the other hand start with asserting response data, you might get an IndexError which will point the assigned developer in the wrong direction at first.

class MyFancyViewsetApiTest(BaseApiTest):
    def test_list_regular(self):
        response = self.execute_request(
            method='get',
            url=reverse('my-fancy-viewset-list'),
            viewset_kwargs={'get': 'list'},
            user=self.user,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data), 3)

Notice that you need to tell the viewset which CRUD view you want to test. Here is a list of valid default parameters you can pass to viewset_kwargs:

Name

Key

Value

List

get

list

Detail

get

retrieve

Create

post

create

Update

put

update

Delete

delete

destroy

The “Key” is the HTTP method to be used, and the value is the name of the action. If you have a custom action, just choose the corresponding method and pass the action name as “value”.

Validate that a CRUD method is deactivated

It might be vital for your application that some CRUD (create, retrieve, update, delete) views are exposed with a given viewset but others are not. It happens very easily that a generic DRF mixin is copy-and-pasted and opens an unwanted list, retrieve, create, etc. endpoint. That is why we recommend testing if all unwanted CRUD methods are deactivated.

With this mixin you can ensure this easily like this:

class MyFancyViewsetApiTest(BaseApiTest):
    view_class = views.MyFancyViewset
    ...

    def test_create_not_activated(self):
        with self.assertRaises(AttributeError):
            self.execute_request(
                method='post',
                url=reverse('my-fancy-viewset-list'),
                viewset_kwargs={'post': 'create'},
                user=self.user,
            )

Testing file uploads

Here is an example of how to test file uploads. Feel free to adjust the data kwarg to suit your needs.

from django.core.files.uploadedfile import SimpleUploadedFile

class MyFancyViewsetApiTest(BaseApiTest):
    view_class = views.MyFancyViewset
    ...

    def test_file_upload_action(self):
        response = self.execute_request(
            method='post',
            url=reverse('my-fancy-viewset-list'),
            viewset_kwargs={'post': 'my-custom-action'},
            user=self.user,
            data={'file': SimpleUploadedFile("file.txt", b"some fancy content")},
            data_format='multipart',
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)