Django Expense Manager 9 - Individual Instance Endpoints

In this post we will create the individual endpoints for retailers, categories, and transactions. What do I mean by that? Well, at the moment we have endpoints such as:

/api/transactions/
/api/categories/
/api/retailers/

And we can list all transactions/categories/retailers, create new ones, but that’s it. We don’t have an endpoint to get information about a single object (whether it’s a transaction, category or retailer) only nor can we update/delete individual objects. For that, we will use the generic RetrieveUpdateDestroyAPIView view.

Category and Retailer Read, Update an Delete views

The code for it is surprisingly light, it’s largely the same as CategoryList BUT it inherits from the RetrieveUpdateDestroyAPIView class.

class CategoryRUD(generics.RetrieveUpdateDestroyAPIView):
    authentication_classes = [JWTCookieAuthentication]
    permission_classes = [IsAuthenticated]
    serializer_class = CategorySerializer

    def get_queryset(self):
        # this ensures that only a category created by the user can be accessed
        user = self.request.user
        return Category.objects.filter(user=user)

Notice that just like for CategoryList, the overwrote the get_queryset method in order to ensure that the user only has access to their own Category objects. Wait, is that it? well, yea, what else did you want? Tests? you got it.

What tests can you think of? Perhaps:

  • testing authentication required for this new endpoint
  • getting one’s one category when authenticated
  • getting someone else’s category
  • updating a category (changing the name)
  • updating a category (changing the user…shouldn’t be possible)
  • deleting a category

The first test is easy:

@pytest.mark.django_db
def test_authentication_required(api_client: APIClient, category_factory):
    # let's create a category
    category: Category = category_factory()
    category_id: int = category.id
    get_category_url: str = reverse("retrieve_update_delete_category", args=[category_id])
    get_response: Response = api_client.get(
        get_category_url,
    )
    assert get_response.status_code == status.HTTP_401_UNAUTHORIZED

We create a category, get the endpoint location for it, assert we are not authorised to access it.

Adding ID to our serializers

HOOOOOLD ON. We are getting the location of it using the category_id, but we don’t actually return that in the categories list view. You are absolutely right, we need to add that to our serializers. While we’re at it, let’s also change our validation error messages:


class CategorySerializer(serializers.ModelSerializer):
    id = serializers.ReadOnlyField()
    name = serializers.CharField(max_length=255)
    product_type = serializers.ChoiceField(choices=Category.PRODUCT_TYPES)
    slug = serializers.CharField(max_length=255)
    user = serializers.PrimaryKeyRelatedField(
        read_only=True, default=serializers.CurrentUserDefault()
    )

    class Meta:
        model = Category
        fields = ["id", "name", "product_type", "user", "slug"]
        validators = [
            UniqueTogetherValidator(
                queryset=Category.objects.all(),
                fields=["name", "user"],
                message=_(
                    "There is already a Category with this name for the current user"
                ),
            ),
            UniqueTogetherValidator(
                queryset=Category.objects.all(),
                fields=["slug", "user"],
                message=_(
                    "There is already a Category with this slug for the current user"
                ),
            ),
        ]

It’s worth noting we made the id a read only field, since we don’t want to modify it.

Ok back to what we were talking about. If we are authenticated, however, we should be able to access it, and we are.

@pytest.mark.django_db
def test_get_own_category_with_token(api_client: APIClient, user_factory, category_factory):
    user = user_factory()
    category: Category = category_factory(user=user)
    category_id: int = category.id
    get_category_url: str = reverse("retrieve_update_delete_category", args=[category_id])
    api_client.force_authenticate(user=user)
    get_response: Response = api_client.get(
        get_category_url,
    )
    assert get_response.status_code == status.HTTP_200_OK
    assert get_response.json() == {
        "id": category_id,
        "name": category.name,
        "product_type": category.product_type,
        "user": user.id,
        "slug": category.slug,
    }

What if we tried to get someone else’s category? Shame on you!

@pytest.mark.django_db
def test_get_someone_elses_category_with_token(api_client: APIClient, user_factory, category_factory):
    user = user_factory()
    user_2 = user_factory()
    category: Category = category_factory(user=user_2)
    category_id: int = category.id
    get_category_url: str = reverse("retrieve_update_delete_category", args=[category_id])
    api_client.force_authenticate(user=user)
    get_response: Response = api_client.get(
        get_category_url,
    )
    assert get_response.status_code == status.HTTP_404_NOT_FOUND

    # let's now log in the client with user_2
    api_client.force_authenticate(user=user_2)
    get_response: Response = api_client.get(
        get_category_url,
    )
    assert get_response.status_code == status.HTTP_200_OK
    assert get_response.json() == {
        "id": category_id,
        "name": category.name,
        "product_type": category.product_type,
        "user": user_2.id,
        "slug": category.slug,
    }

Since we don’t have access to it, we will return a 404 response. We don’t want to tell someone that this Category exists. Then, they could figure out how many categories we have by trying increasingly high numbers.

You have a typo in the category name? Let’s change it then!

@pytest.mark.django_db
def test_update_category(api_client: APIClient, user_factory, category_factory):
    user = user_factory()
    category: Category = category_factory(user=user, name="original_nme")
    category_id: int = category.id
    category_url: str = reverse("retrieve_update_delete_category", args=[category_id])
    api_client.force_authenticate(user=user)
    response: Response = api_client.put(
        category_url,
        data={
            "name": "new_name",
            "product_type": category.product_type,
            "slug": category.slug
        }
    )
    assert response.status_code == status.HTTP_200_OK
    category.refresh_from_db()
    assert category.name == "new_name"

What if we tried to change a category’s user? It’s in the same test, but nothing will change.

...

    # let's create a new user and see if we can change the user of this category
    user_2 = user_factory()
    response: Response = api_client.put(
        category_url,
        data={
            "name": category.name,
            "product_type": category.product_type,
            "user": user_2.id,
            "slug": category.slug
        }
    )
    category.refresh_from_db()
    assert response.status_code == status.HTTP_200_OK
    assert category.user == user
    # good, it did not change

Finally, we delete the category.

@pytest.mark.django_db
def test_delete_category(api_client: APIClient, user_factory, category_factory):
    user = user_factory()
    category: Category = category_factory(user=user, name="gonna_have_to_go")
    category_id: int = category.id
    category_url: str = reverse("retrieve_update_delete_category", args=[category_id])
    api_client.force_authenticate(user=user)
    response: Response = api_client.delete(category_url)
    assert response.status_code == status.HTTP_204_NO_CONTENT
    with pytest.raises(Category.DoesNotExist):
        Category.objects.get(id=category_id)

We will also create the equivalent code for Retailer. We won’t go into the tests, given that the code is almost entirely equivalent.

class RetailerRUD(generics.RetrieveUpdateDestroyAPIView):
    authentication_classes = [JWTCookieAuthentication]
    permission_classes = [IsAuthenticated]
    serializer_class = RetailerSerializer

    def get_queryset(self):
        # this ensures that only a retailer created by the user can be accessed
        user = self.request.user
        return Retailer.objects.filter(user=user)

Transactions - Read, Update, Delete

For transactions, the view will be very similar.

class TransactionRUD(generics.RetrieveUpdateDestroyAPIView):
    authentication_classes = [JWTCookieAuthentication]
    permission_classes = [IsAuthenticated]
    serializer_class = TransactionSerializer

    def get_queryset(self):
        """
        this ensures that only a transaction created by the user can be accessed
        """
        user = self.request.user
        return Transaction.objects.filter(user=user)

The tests will also be very similar so I do not think it’s worth going into it. That way our post will be shorter today. The full commit can be found here

In the next post, we will do some CI stuff (just like we did for the FastAPI project, maybe no mypy though), we’ll do some package upgrades, and we will also clean up a bit. At the moment it’s a little bit messy (especially the tests), and I would like to rewrite some of the factories (I don’t like how they are now)