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 basicCharField
with max of 50 characters (max_length
is mandatory parameter for aCharField
)last_name
is basicCharField
with a max of 50 charactersemail
is anEmailField
- that's aCharField
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:
And after adding the uuid, it looks like that:
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
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.
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.
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:
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.
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:
- add 4 more runners from different zones in no particular order.
- 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:
After ordering = ["start_zone"]
was added:
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"]
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:
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:
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 ofCheckConstraints
, 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 ofDateTimeField
(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:
After saving:
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