Django Expense Manager 2 - Creating Our Models and (Slightly) Testing Them

Welcome back and let’s keep going. Fallen slightly behind with my schedule for these series due to an application I was working on. In fact, I will post another series on creating a trader/exchange automatic server/client architecture: hint, half a day is not enough for it. This post continues with the work that was done here. Last time we thought very deeply and carefully about what fields we wanted our models to have. Today we will create them.

As I mentioend in the previous post, we will use a third-party Django-money to deal with moeny fields in our models and forms. why? It’s easier to deal with currencies, decimal places, etc. And most importanty, do not use floating point numbers for money. Just don’t. (Also, if curious, GAAP suggests you use 4 decimal places for currencies. More on this in my upcoming series on the Stock Exchange). So let’s install it.

pip install django-money

And then quickly add it to our expenses_backend/settings.py file.


INSTALLED_APPS = [
    ...

    'djmoney',

    ...
]

If you’re using virtual environments and a requirements.txt file (which you probably should), don’t forget to update it. Ok let’s start with the Retailer and Category models. In the expenses/models.py file:

from django.db import models
from django.contrib.auth import get_user_model

class Retailer(models.Model):
    name = models.CharField(max_length=255)
    # location = models.CharField(max_length=255)
    online = models.BooleanField()
    user = models.ForeignKey(
        get_user_model(),
        on_delete=models.CASCADE
    )

    class Meta:
        verbose_name = "retailer"
        verbose_name_plural = "retailers"

Please note that Django gives each model by default an id field that acts as an auto-incrementing primary key (unless another field is used as the primary key). What else does our Retailer model have? It has an online boolean field (can be yes or no), a user ForeignKey which refers to the user_model. Please note that I am not choosing django.contrib.auth.models.User directly but instead using get_user_model. Why? If the AUTH_USER_MODEL setting was changed, then get_user_model will make sure to return that. It’s essentially a safer way of calling your User model in case you make changes to it. Then, if the user gets deleted, we cascade those changes and delete the user-saved retailers. That may or may not be what you want to do. Think about that before choosing that option. Finally, I manually specified what the singular and plural name for our model should be. Why? I always do it otherwise I forget to do it for the models where I actually need to…(like the Category model). By default, Django would call it a retailer and the plural would be retailers. So what’s the problem? The problem is that for the Category model it would be category and categorys. Let’s also look at the Category and Transactions models.

class Category(models.Model):
    PRODUCT_TYPES = (
        ('P', 'Physical'),
        ('E', 'Electronic')
    )
    name = models.CharField(max_length=255)
    product_type = models.CharField(max_length=1, choices=PRODUCT_TYPES)
    user = models.ForeignKey(
        get_user_model(),
        on_delete=models.CASCADE
    )

    class Meta:
        verbose_name = "category"
        verbose_name_plural = "categories"

So it has a name. It has a product_type that can only be one of the choices I provided. For the user, it calls get_user_model() again and deletes the category if the user is deleted. And for the Transaction model:


class Transaction(models.Model):
    TRANSACTION_TYPES = (
        ('E', 'Expense'),
        ('I', 'Income')
    )
    amount = MoneyField(
        max_digits=19,
        decimal_places=4,
        default_currency=None
    )
    name = models.CharField(max_length=255)
    retailer = models.ForeignKey(
        Retailer,
        on_delete=models.SET_NULL,
        null=True
    )
    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True
    )
    date = models.DateField(
        auto_now=False,
        auto_now_add=False
    )
    transaction_type = models.CharField(
        max_length=1,
        choices=TRANSACTION_TYPES
    )
    recurring = models.BooleanField()
    user = models.ForeignKey(
        get_user_model(),
        on_delete=models.CASCADE
    )

    class Meta:
        verbose_name = "transaction"
        verbose_name_plural = "transactions"

This one has more fields. The first one, amount, is a MoneyField (from the django-money) with the recommended 19 max_digits and 4 decimal places. The name is CharField, the retailer and categories are ForeignKeys to the Retailer and Category models respectively but what’s that? SET_NULL? Why do we use that instead of models.CASCADE? Well, if the user is delete, we want their data to be deleted (probably?) whereas imagine you are the user and you’ve added some categories and retailers and transactions that fall under those. Then, you realise you misspelled the name of the category or the retailer. Now ideally the user would use some kind of form to rename them. But some people, when making a mistake, prefer to delete stuff and then do it again. So let’s say you delete the Amabon retailer and all of a sudden realise that all your transactions under it got deleted as well….you probably wouldn’t be very happy. Sure, we’ll implement some rollback mechanism later on but still, better safe than sorry. Since we do models.SET_NULL, however, we need to ensure that those fields can be Null, so they have the option of null=True.

The date is a DateField (I thought about adding time but most people don’t record their expense right away and if they add it later, they might not care about the exact time). The transaction type is then a CharField that can either be E or I (for Expense or Income). The recurring field is a BooleanField (for now). I still need to figure out how to do a “subscription” type. Finally, the user field calling get_user_model().

Ok we’ve added our models. Let’s create our migrations and migrate.


python manage.py makemigrations
python manage.py migrate

Are you saying it’s time for tests? I agree. You even want coverage?? Fine, let’s do it. Let’s install coverage and pytest. Why not use Django’s in-built test? Less boilerplate but really it’s because I use pytest for quite a few things so I’m more used to it. Use whichever you’d like :)

pip install coverage pytest-django

That should install both pytest and pytest-django. If you try to run pytest in our project directory, probably nothing will happen since it needs to find the tests. You will see collected 0 items and no tests ran in 0.06s. Why? We need to create a pytest.ini file in the main folder and paste the following:

[pytest]
DJANGO_SETTINGS_MODULE = expenses_backend.settings
python_files = tests.py test_*.py *_tests.py

Now what follows now is my personal preference. Usually, I like having my tests all in one place. There are some projects where I have them in each of the Django apps. In this case, however, I will keep them together. Like I said, personal preference. So I created a tests directory in the project directory, added a __init__.py file (not necessary for now, but will be long time from now) and a conftest.py file. Let’s start with this file.

import pytest
from django.contrib.auth import get_user_model
from djmoney.money import Money
from expenses.models import Category, Retailer, Transaction

User = get_user_model()


@pytest.fixture(scope="session")
def django_db_setup(django_db_setup, django_db_blocker):
    with django_db_blocker.unblock():
        user_1: User = User.objects.create(
            email="hi@there.com", password="themostsecurepassword"
        )
        retailer_1 = Retailer.objects.create(name="AmazIn", online=True, user=user_1)
        category_1 = Category.objects.create(
            name="Groceries", product_type="P", user=user_1
        )
        transaction_1 = Transaction.objects.create(
            amount=Money(420.00, "GBP"),
            name="Yoga Mat for Meditation",
            retailer=retailer_1,
            category=category_1,
            date="2021-04-20",
            transaction_type="E",
            recurring=False,
            user=user_1,
        )

A few things going on in here. The django_db_setup is a top-level fixture that makes sure that the test database is created and available. I overrode it in order to inject some initial data into the database. Please note that the tests in this post will simply check against that initial data and if it was created as expected. I first create a User object, then use than User object in the Retailer, Category and Transaction objects. The Transaction object is then created where both the just-created Retailer and Category objects are used. In essence, the tests in this post deal with valid objects that I created. In the next post we will use some factories to create test data and check any validation errors that could occur.

I then created four files: test_users.py, test_retailers.py, test_categories.py and test_transactions.py. The first one tests the User object created, the second the Retailer object, the third the Category object and the last one….could it be the Transaction object? Indeed.

I will paste the four below in order:

import pytest
from django.contrib.auth import get_user_model

User = get_user_model()


@pytest.mark.django_db
def test_user_1():
    user_1: User = User.objects.get(pk=1)
    assert user_1.email == "hi@there.com"
    assert user_1.password == "themostsecurepassword"

What is that decorator doing there? The @pytest.mark.django_db decorator indicates to pytest-django that we need database access. Then, we get the User object with a primary key of 1 and test that it is in fact what we created earlier. It is. Yay!

import pytest
from django.contrib.auth import get_user_model
from expenses.models import Retailer

User = get_user_model()


@pytest.mark.django_db
def test_retailer_1():
    retailer_1 = Retailer.objects.get(pk=1)
    user_1 = User.objects.get(pk=1)
    assert retailer_1.name == "AmazIn"
    assert retailer_1.online == True
    assert retailer_1.user == user_1

Same thng as above. Nothing different really except that we also verify that the User for the Retailer object matches with the User object.


import pytest
from django.contrib.auth import get_user_model
from expenses.models import Category

User = get_user_model()


@pytest.mark.django_db
def test_category_1():
    category_1 = Category.objects.get(pk=1)
    user_1 = User.objects.get(pk=1)
    assert category_1.name == "Groceries"
    assert category_1.product_type == "P"
    assert category_1.user == user_1

Same here. And finally, the transaction test:

import pytest
from django.contrib.auth import get_user_model
from expenses.models import Transaction, Category, Retailer
from djmoney.money import Money

User = get_user_model()


@pytest.mark.django_db
def test_transaction_1():
    # get the Category, Retailer and User from the database
    category_1 = Category.objects.get(pk=1)
    retailer_1 = Retailer.objects.get(pk=1)
    user_1 = User.objects.get(pk=1)
    # get the transaction and test it
    transaction_1 = Transaction.objects.get(pk=1)
    assert transaction_1.amount == Money(420.00, "GBP")
    assert transaction_1.name == "Yoga Mat for Meditation"
    assert transaction_1.retailer == retailer_1
    assert transaction_1.category == category_1
    assert transaction_1.date.isoformat() == "2021-04-20"
    assert transaction_1.transaction_type == "E"
    assert transaction_1.recurring == False
    assert transaction_1.user == user_1

Slighty more code but that’s because there are more fields. Please note that for testing the amount field, I had to import the Money field. Similarly, when testing the date, I made sure to convert it into an ISO format string. Et voila!

In the next post we will continue by writing a few more tests using factory_boy in order to test every single one of the fields. We will also create automatic testing using GitHub Actions. Stay tuned!

Oh, and by the way, to run the tests, run pytest in the main directory.

Also, the git commit for this post is here