Fluent in Django: Get to know Django models better

Fluent in Django: Get to know Django models better

Intro

Django is an MTV framework. Instead of MVC (Model, Views, Controller), it uses Model, Template, and View. The View is a Python function that takes a Web request and returns a Web response (in MVC, this would be a Controller). But the heart of your application is the Model.

Django model is a class that subclasses django.db.models.Model. It contains the essential fields and behaviors of your data.

Usually, each model maps to a single database table and each attribute of the model represents a database field.

In the introductory Django tutorials, you usually create a Model with few basic fields, you mess with it in the View and show it in a template.

But Model is and can do so much more. It is the single, definitive source of information about your data. That means that all the logic about your data should be located in the Model (not in View as too often can be seen).

Prerequisites

You will need the basic knowledge of Django. If you don't feel comfortable with Django yet, try this beginner-friendly tutorial.

Initial setup

Start with setting up a new Django project:

$ mkdir django_models
$ cd django_models
$ python3.9 -m venv venv
$ source venv/bin/activate
(venv)$ pip install Django
(venv)$ django-admin startproject django_models .

Create a new app:

(venv)$ django-admin startapp tutorial

and register it:

# django_models/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'tutorial',
]

Now for the Model. We will be working on a Model for a marathon app. Our Model will be called Runner and at the beginning, it will only have 3 fields. As our business logic will progress, so will our Model.

# tutorial/models.py
from django.db import models


class Runner(models.Model):
    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()

For now, each runner will only have 3 fields:

  • name is basic CharField with max of 50 characters (max_length is mandatory parameter for a CharField)
  • last_name is basic CharField with a max of 50 characters
  • email is an EmailField - that's a CharField that checks that the value is a valid email address using EmailValidator.

Our model doesn't include any additional logic.

Create and run the migrations:

(venv)$ python manage.py makemigrations
(venv)$ python manage.py migrate

If you got lost during the initial setup, I encourage you to go back to basics.

We won't be dealing with templates or views, so this concludes the basic setup. However, we need to be able to see the data somewhere, so include the Runner model in the admin panel.

# tutorial/admin.py

from django.contrib import admin
from .models import Runner


admin.site.register(Runner)

Create a superuser and run the server:

(venv)$ python manage.py createsuperuser
(venv)$ python manage.py runserver

UUID

Universally unique identifier is a 128-bit number, usually represented as 32 hexadecimal characters separated by four hyphens. The probability that a UUID will be duplicated is not zero, but it is close enough to zero to be negligible.

ID that Django uses out-of-the-box is incremented sequentially. That means that the 5th registered user has an id 5 and the 6th one has id 6. So, if I register and figure out that my id is 17, I know there were 16 people registered before me and I can try to get to their data by using their id. That makes your application very vulnerable.

That's where UUID comes in handy. UUID key is randomly generated, so it doesn't carry any information as of how many people are registered to the page or what their id might be.

Django has a special field, called UUIDField for storing UUIDs.

# tutorial/models.py

import uuid
from django.db import models

class Runner(models.Model):
    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

You added a new id field -- UUIDField and you made that field PRIMARY_KEY for the Runner table. UUID can be built in 5 different ways, but you'll probably want a unique and randomly generated version - version 4. You set it as a default with default=uuid.uuid4. You also don't want anyone to change that field, so you've set editable to False.

Run the migrations:

(venv)$ python manage.py makemigrations
(venv)$ python manage.py migrate

Now login with your superuser and add a test runner in the admin and see how it looks on the list of objects - that weird number in the name of the object is a UUID. If you add another runner, the string will be different.

Before adding the uuid the name of the object looked like that:

before_uuid.png

And after adding the uuid, it looks like that:

uuid.png

If you added any object before you've added the UUID, your database will fall apart. Before adding a new primary key, delete all the test object you added.

If you need to add a new primary key in production, things tend to get bloody - so don't do that. Think ahead and start with adding UUID at the beginning of the project.

Choices

Imagine this amazing marathon runner that last year won the Berlin marathon. Will you squish him between someone who's there just for fun and someone who decided 6 months ago that he'll run the marathon? Of course, not. Marathon start is divided into zones - the faster you are, the closer to the start line you are. If you don't have any previous results, you're in the 5th zone. And if all you do is eat, sleep and run, you're in the first zone.

It would be useful if we'd have a zone saved in our database for each of the runners. We could use an integer field, but then the user would be able to enter any number, even 100. We need to limit the entries to the 5 choices they have.

Since Django 3.0, there is a Choices class that extends Python’s Enum types with extra constraints and functionality.

Python Enumeration type

enumerated_type.png

An enumeration is a set of symbolic names (members) bound to unique, constant values.

Enumeration can be iterated over. Members are immutable and can be compared. They are constants, so the names should be uppercase.

# tutorial/models.py

import uuid
from django.db import models


class Runner(models.Model):

    # this is new:
    class Zone(models.IntegerChoices):
        ZONE_1 = 1, 'Less than 3.10'
        ZONE_2 = 2, 'Less than 3.25'
        ZONE_3 = 3, 'Less than 3.45'
        ZONE_4 = 4, 'Less than 4 hours'
        ZONE_5 = 5, 'More than 4 hours'

    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    start_zone = models.PositiveSmallIntegerField(choices=Zone.choices, default=Zone.ZONE_5, help_text="What was your best time on the marathon in last 2 years?") # this is new

We created a Choice class (an enumeration) called Zone that has 5 members. The first part (eg. ZONE_1) is the name of the enumeration member. The second part (eg. 1) is the actual value. The third part (eg. 'Less than 3.10') is a human-readable name, label. This is what you'll see in the admin.

The Choices class has two subclasses - IntegerChoices and TextChoices. Since we're only interested in the numbers of the zones, we used IntegerChoices (it doesn't matter that constant names and labels are string, you choose the type based on the values).

Now we need to connect those choices to a database field. Because we provided IntegerChoices, the field has to be one of the integer fields. Because we know there'll always be only a handful of zone possibilities, we chose PositiveSmallIntegerField. We connected the Choice class to the field with choices=Zone.choices. We selected the default value for the field and here you can see how we access one of the members (Zone.ZONE_5). The new addition is also help_text - this will show either in form or in the admin to help the user know what data they need to enter.

Run the migrations:

(venv)$ python manage.py makemigrations
(venv)$ python manage.py migrate

You should be logged in from before, open the form to add another runner, and see what changed.

start_zone_added.png

Here you can see the help text you provided and the human-readable labels from the Zone class.

__str__()

If you open 127.0.0.1:8000/admin/tutorial/runner, you'll see the list of the runners you've added (you should at least have one), but you have no idea about the runners.

before_str.png

You can just see an object with some id. If you'd want to edit someone, you'd need to open each of the objects and find the one with the right name and surname. The method, that takes care of how the objects look when presented in a string (like on the list of runners) is the __str__ method.

__str__ is a Python method that returns a string representation of any object. This is what Django uses to display model instances as a plain string.

Django documentation says:

"You’ll always want to define this method; the default isn’t very helpful at all."

Let's obey Django creators and change the representation of the object to something more readable:

import uuid
from django.db import models


class Runner(models.Model):

    class Zone(models.IntegerChoices):
        ZONE_1 = 1, 'Less than 3.10'
        ZONE_2 = 2, 'Less than 3.25'
        ZONE_3 = 3, 'Less than 3.45'
        ZONE_4 = 4, 'Less than 4 hours'
        ZONE_5 = 5, 'More than 4 hours'

    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    start_zone = models.PositiveSmallIntegerField(choices=Zone.choices, default=Zone.ZONE_5, help_text="What was your best time on the marathon in last 2 years?")

    # this is new:
    def __str__(self):
        return '%s %s' % (self.name, self.last_name)

Here you're using Python string formatting to create a string out of two variables - name and last name.

You don't need to use only variables to create the object's string representation. If you want the runners presented in a last_name, name - zone form, you can do this:

def __str__(self):
  return '%s, %s - %s' % (self.last_name, self.name, self.start_zone)

Refresh the runners' admin page, and now it's way easier to navigate through all the runners:

str.png

Why don't I have to run the migrations for this to work?

Migrations are Django's way to propagate changes you make to your models to your database schema. So you need to run the migrations only when the changes you made, impact your database. Here you only changed the string representation of an object and that doesn't impact it, so there is no need to run the migration.

Meta

As I mentioned at the beginning of this post, Django Model is more than just a class with a bunch of fields. Any additional business logic has a place in the model.

But anything that's not a field, is considered metadata and has a special place inside the model - inside the inner Meta class.

Meta class is optional, but inside it, you can change the ordering, set the verbose name, add permissions...

verbose names

I used a simple name for the class, but maybe I'd like to use a more descriptive name for the users.

Also, if you noticed, in the Django admin, your class with a singular name (Runner) is transformed to plural (Runners) - what if the plural is different than just added "s" (mouse -> mice)?

You can set both, singular and plural human-readable names.

import uuid
from django.db import models


class Runner(models.Model):

    class Zone(models.IntegerChoices):
        ZONE_1 = 1, 'Less than 3.10'
        ZONE_2 = 2, 'Less than 3.25'
        ZONE_3 = 3, 'Less than 3.45'
        ZONE_4 = 4, 'Less than 4 hours'
        ZONE_5 = 5, 'More than 4 hours'

    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    start_zone = models.PositiveSmallIntegerField(choices=Zone.choices, default=Zone.ZONE_5, help_text="What was your best time on the marathon in last 2 years?")

    # this is new:
    class Meta:
        verbose_name = "Runner 42k"
        verbose_name_plural = "Runners 42k"

    def __str__(self):
        return '%s %s' % (self.name, self.last_name)

You've set the verbose_name and verbose_name_plural to something different in the Meta class.

If you refresh the admin, you'll see that both changed.

verbose_name.png

ordering

Inside class Meta, you can select the default ordering of the objects when the list of objects will be retained.

There's a great chance, that whatever you'll do with the Runner objects, you'll care in which zone they are. So it would be a good idea to order them by the zone.

To be able to see that they are sorted by zone, do 2 things:

  1. add 4 more runners from different zones in no particular order.
  2. Change the __str__ method, so it will also show the zone
import uuid
from django.db import models


class Runner(models.Model):

    class Zone(models.IntegerChoices):
        ZONE_1 = 1, 'Less than 3.10'
        ZONE_2 = 2, 'Less than 3.25'
        ZONE_3 = 3, 'Less than 3.45'
        ZONE_4 = 4, 'Less than 4 hours'
        ZONE_5 = 5, 'More than 4 hours'

    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    start_zone = models.PositiveSmallIntegerField(choices=Zone.choices, default=Zone.ZONE_5, help_text="What was your best time on the marathon in last 2 years?")

    class Meta:
        verbose_name = "Runner 42k"
        verbose_name_plural = "Runners 42k"
        ordering = ["start_zone"] # this is new

    def __str__(self):
        return '%s %s %s' % (self.name, self.last_name, self.start_zone) # we added start_zone to the string

Before the order was set:

before_order.png

After ordering = ["start_zone"] was added:

after_order.png

It is possible to order by more than one criterion. You could sort it first by start_zone and then alphabetically by name. You could also sort it in reversed order, so the zone 5 runners are at the top (you add - inside the string before the name to reverse it).

class Meta:
  ordering = ["-start_zone", "name"]

different_order.png

constraints

CheckConstraint

Because long runs for youngsters are advised against, the organizer permits only runners that will be of age this calendar year. You have special constraints classes for that purpose.

You need to add a constraint that won't allow people under 18 (or with their 18th birthday coming this year) to register.

import uuid
import datetime

from django.db import models


class Runner(models.Model):

    class Zone(models.IntegerChoices):
        ZONE_1 = 1, 'Less than 3.10'
        ZONE_2 = 2, 'Less than 3.25'
        ZONE_3 = 3, 'Less than 3.45'
        ZONE_4 = 4, 'Less than 4 hours'
        ZONE_5 = 5, 'More than 4 hours'

    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    start_zone = models.PositiveSmallIntegerField(choices=Zone.choices, default=Zone.ZONE_5, help_text="What was your best time on the marathon in last 2 years?")
    year_born = models.PositiveSmallIntegerField(default=1990) # new field added

    class Meta:
        verbose_name = "Runner 42k"
        verbose_name_plural = "Runners 42k"
        ordering = ["-start_zone", "name"]
        constraints = [ # constraints added
            models.CheckConstraint(check=models.Q(year_born__lte=datetime.date.today().year-18), name='will_be_of_age'),
        ]

    def __str__(self):
        return '%s %s %s' % (self.name, self.last_name, self.start_zone)

To be able to check if the birth year is valid, you need to add the field year_born to your Runner model.

Inside class Meta, you add constraints in a form of a list. Since you're checking if a person is old enough, you're using CheckConstraint.

CheckConstraint consist of two mandatory parts:

  • check - A Q object or boolean expression that specifies the check you want the constraint to enforce.
  • name - An unique name for the constraint

Q encapsulates filters as objects that can then be combined logically (using & and |), thus making it possible to use conditions in database-related operations.

The check in our case checks if the year entered in the field year_born is less or equal(year_born__lte=) to today's year minus 18 years(datetime.date.today().year-18).

Run the migrations:

(venv)$ python manage.py makemigrations
(venv)$ python manage.py migrate

Try to add a runner with year_born 2010. You'll get an integrity error:

age_constraint_error.png

UniqueConstraint

Let's add another constraint. Because there have been some mixups in previous runs, the organizer wants to forbid 2 persons with the same name start in the same zone.

You need to add the constraint that will prevent adding another runner with the same name, surname, and start_zone. A person with the same name can start in another zone.

import uuid
import datetime

from django.db import models


class Runner(models.Model):

    class Zone(models.IntegerChoices):
        ZONE_1 = 1, 'Less than 3.10'
        ZONE_2 = 2, 'Less than 3.25'
        ZONE_3 = 3, 'Less than 3.45'
        ZONE_4 = 4, 'Less than 4 hours'
        ZONE_5 = 5, 'More than 4 hours'

    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    start_zone = models.PositiveSmallIntegerField(choices=Zone.choices, default=Zone.ZONE_5, help_text="What was your best time on the marathon in last 2 years?")
    year_born = models.PositiveSmallIntegerField(default=1990)

    class Meta:
        verbose_name = "Runner 42k"
        verbose_name_plural = "Runners 42k"
        ordering = ["-start_zone", "name"]
        constraints = [
            models.CheckConstraint(check=models.Q(year_born__lte=datetime.date.today().year-18), name='will_be_of_age'),
            models.UniqueConstraint(fields=['name', 'last_name', 'start_zone'], name='unique_person') # new constraint added
        ]

    def __str__(self):
        return '%s %s %s' % (self.name, self.last_name, self.start_zone)

Since you want a set of fields to be unique, you're using UniqueConstraint. You have to specify which combination of fields you want to be unique in a list (in our case fields=['name', 'last_name', 'start_zone']), and as in the previous case, you have to set a unique name for the constraint.

Try to add another person with the same name, surname, and in the same zone. This is what you'll get:

unique_error.png

If you change any of those fields, the record will be added to DB without any problem.

Why do I get different errors?

You might have noticed that in the case of a unique constraint, there was a notice inside Django admin, and in the case of age constraint, you've been redirected to an error page.

That's because, in UniqueConstraints, you were comparing fields that were already there, so the error happened prior to saving the record to the database. But in the case of CheckConstraints, you were dealing with the database (Q() represents an SQL condition).

change the save method

There will be times when you'll want to overwrite the predefined save method. Maybe you'll want to add an additional field (eg. creation date), change the format (eg. string to date), or prevent a certain type of data.

If you want to start running in the start zone that is not the slowest (nr. 5 in our case), you have to have a previous record to prove that you can run so fast and you won't obstruct other runners. We'll add a field for entering the previous record and in the custom save method check if the field has value. If not, the runner will automatically be assigned to zone nr. 5, regardless of what they chose.

import uuid
import datetime

from django.db import models


class Runner(models.Model):

    class Zone(models.IntegerChoices):
        ZONE_1 = 1, 'Less than 3.10'
        ZONE_2 = 2, 'Less than 3.25'
        ZONE_3 = 3, 'Less than 3.45'
        ZONE_4 = 4, 'Less than 4 hours'
        ZONE_5 = 5, 'More than 4 hours'

    name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    start_zone = models.PositiveSmallIntegerField(choices=Zone.choices, default=Zone.ZONE_5, help_text="What was your best time on the marathon in last 2 years?")
    year_born = models.PositiveSmallIntegerField(default=1990)
    best_run_time = models.DurationField(default=datetime.timedelta(hours=4)) # new field added

    class Meta:
        verbose_name = "Runner 42k"
        verbose_name_plural = "Runners 42k"
        ordering = ["-start_zone", "name"]
        constraints = [
            models.CheckConstraint(check=models.Q(year_born__lte=datetime.date.today().year-18), name='will_be_of_age'),
            models.UniqueConstraint(fields=['name', 'last_name', 'start_zone'], name='unique_person')
        ]

    def __str__(self):
        return '%s %s %s' % (self.name, self.last_name, self.start_zone)

    # this is new:
    def save(self, *args, **kwargs):

        if self.best_run_time >= datetime.timedelta(hours=4):
            self.start_zone = self.Zone.ZONE_5

        super(Runner, self).save(*args, **kwargs)

You added a new field, best_run_time. Here we're also introducing a new type of field - DurationField.

DurationField is a field for storing periods of time - modeled in Python by timedelta. In Django admin, it's shown as an empty field, which is a little impractical, so we provided the default value, so you can edit it easily. We've automatically set it to 4 hours with Python's timedelta class.

You can't compare instances DurationField to instances of DateTimeField (the only exception is if using PostgreSQL).

At the bottom, you've overwritten the save method. You check if the best_run_time is more than or equal to 4 hours (datetime.timedelta(hours=4)) and if it is, you automatically set the start_zone to zone 5 (self.start_zone = self.Zone.ZONE_5).

Run the migrations:

(venv)$ python manage.py makemigrations
(venv)$ python manage.py migrate

And try to add a runner with zone selected to something less than 5. Leave the best_run_time as it is (4 hours). The runner will save without any problems, but if you check the added runner, you'll see that their start zone changed to "More than 4 hours".

Prior to saving:

save_method_prior_save.png

After saving:

save_after_save.png

As you can see, the start zone has changed. If Charlie's best_run_time would be less than 4 hours, his selected start zone would be left as he selected it.

Conclusion

Django models are very powerful and they hide much more than you got to know here.

Nevertheless, you got to know a lot about Django models:

  • less known fields
    • EmailField
    • UUIDField
    • DurationField
  • how to add Choices field with the newest Django practice, using enumerators
  • how to change the method for the string representation of the object
  • how to work with the model's metadata:
    • verbose names
    • ordering
    • constraints
  • how to overwrite the default save() method

Needless to say, some of the use cases are inappropriate for production. You can't prohibit a runner with the same name from running or automatically assign a runner who forgot to enter their best time to the slowest zone without notifying them. However, those cases are real-life enough to give you a fair idea of what you might use them for in your real-life application.

Image by wal_172619 from Pixabay

Did you find this article valuable?

Support GirlThatLovesToCode by becoming a sponsor. Any amount is appreciated!