Skip to content

A Real Django Project

By now, you hopefully have a solid understanding Git and Linux basics, and we can start applying that knowledge. In this chapter, we will create a new Django project, and push it to GitLab.

We will use direnv to manage our environmental variables, poetry to manage dependencies and virtual environment, and Git to track changes.

Pre-requisites

Before you begin this chapter, you should have:

  • Git installed and configured, as per the instructions in the Using Git chapter.
  • A GitLab account, with the polls repository created, as per the instructions in the previous chapter.
  • Poetry, git, and direnv installed.

Installing pre-requisites

Arch Linux

If you need to install any of the pre-requisites on Arch Linux, you can do so via pacman:

$ sudo pacman -S direnv git python-poetry

Ubuntu

In order to install the pre-requisites on Ubuntu, you can use the apt package manager:

$ sudo apt install direnv git python3-poetry

Gotchas

  • Make sure you registered the direnv hooks in your shell, as per the instructions on the direnv website.
  • Make sure you have configured your name and email in the Git config as per the Using Git chapter.
  • Make sure your initial branch name is set to main rather than master.

Integrating Poetry

First, decide where you wish to create the project. I will use ~/projects/django-polls. You can create the directory and open it as follows:

$ mkdir -p ~/projects/django-polls
$ cd ~/projects/django-polls

Info

The name django-polls is arbitrary. Personally, I like using different names for the project root and the Django project itself. Also, note how it matches the name we used on GitLab in the previous chapter.

Next, initialize the Poetry project, and answer the questions when prompted (or leave them blank if you are satisfied with the default values):

$ poetry init
This command will guide you through creating your pyproject.toml config.

Package name [django-polls]:  polls
Version [0.1.0]:
Description []:  A Django Polls application.
Author [Your Name <your@email>>, n to skip]:
License []:
Compatible Python versions [^3.12]:

Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Generated file

[tool.poetry]
name = "polls"
version = "0.1.0"
description = "A Django Polls application."
authors = ["Your Name <your@email>>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Do you confirm generation? (yes/no) [yes] yes

This will generate a pyproject.toml file containing project metadata in the current directory.

Note that you could add packages, but I chose to skip it for now. This is simply a matter of personal preference. Naturally, this means we do not currently have Django installed in this project, so let's add it now, along with any other packages we might want. I will also be adding Django Classy Settings to easily work with environment variables.

$ poetry add django django-classy-settings

Poetry will automatically create a virtual env for us, so we can omit using the venv module.

Also, note that this created a poetry.lock file containing file hash information for the project's dependencies. This allows to ensure that the same version of each dependency will be installed when we deploy the project to a production server.

Working with Git

It's important to commit your work often to ensure you can easily revert to a previous working state. As we have generated a few files, let's initialize our Git repo so we can track our current work:

$ git init .

Of course, no repository is complete without high quality documentation, so let's add a README.md file. You can use any text or code editor to do that, such as VS Code, or neovim. Open the file in your editor and add some text in markdown format, e.g.:

# Django Polls

This repository contains the Django Polls application.

This is very terse, and ideally, you should add more detailed information, including how to install and run the application, how others can contribute to your project if it is open-source, etc.

We are now ready to stage and commit our changes:

$ git add -v .
$ git commit -m "feat: initial commit"

We can now push our project to the repository we created on GitLab. Make sure you use your own URL, as you will not have permissions to push to my repository. You can find it in the instructions of the empty repository created in the previous chapter.

$ git remote add origin git@gitlab.com:theepic.dev/django-polls.git
$ git push -u origin main

The git remote add command added a remote repo named origin. We can then use that name as an argument to git push. We also use the -u flag so Git remembers it as the default remote for the current branch.

You can now refresh the open repository in your browser, and should see the files you committed, as well as the content of your README.

GitLab repository with the changes pushed

Integrating Django

Before we start working on a new feature, i.e. Django integration, it's a good idea to create a new branch in our repository to keep our main branch clean. Let's switch to a new branch called feat/django:

$ git checkout -b feat/django

We can run commands in the Poetry managed virtual environment with poetry run, but prefixing commands gets tedious quickly, so let's activate the virtual environment instead, then start our Django project:

$ poetry shell
$ django-admin startproject polls .

Tip

Running poetry shell is similar to sourcing the activate script when using the venv module.

Configuration via env vars

The default Django project template is a good starting point, but there are a few things we can improve.

To begin with, we should remove the default SECRET_KEY setting, and read that along with the DEBUG setting from an environment variable. Make the following changes to the file polls/settings.py:

@@ -12,6 +12,12 @@

 from pathlib import Path

+# Django Classy Settings imports
+from cbs import BaseSettings, env
+
+# Load env vars with the prefix DJANGO_
+denv = env["DJANGO_"]
+
 # Build paths inside the project like this: BASE_DIR / 'subdir'.
 BASE_DIR = Path(__file__).resolve().parent.parent

@@ -19,11 +25,13 @@
 # Quick-start development settings - unsuitable for production
 # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/

-# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = "django-insecure-((*4a0lowtv%+fdgq)ybu=r((&)o3(xgt2*mc%3rq)l(i23up_"

-# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
+class Settings(BaseSettings):
+    """Settings for Django Polls."""
+
+    DEBUG = denv.bool(False)
+    SECRET_KEY = denv(env.Required)
+

 ALLOWED_HOSTS = []

@@ -121,3 +129,6 @@
 # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

 DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+# Load CBS Settings
+__getattr__, __dir__ = BaseSettings.use()

Note

The first part of the changes are made near the top of the file, while the final two lines are added at the very end, below the last line.

With these changes, the denv object can now read environment variables like DJANGO_DEBUG and DJANGO_SECRET_KEY. As we made the SECRET_KEY setting required, we can try starting the server now, and see it fail:

$ python manage.py runserver
Traceback (most recent call last):
...
ValueError: Environment variable DJANGO_SECRET_KEY is required but not set.

We see a long error output, letting us know that our variable is not set. Let's fix that by defining it in our .envrc file, allowing direnv to read it, and starting the server again:

$ echo export DJANGO_SECRET_KEY=django-insecure-local-dev-secret >> .envrc
direnv: error ~/projects/django-polls/.envrc is blocked. Run `direnv allow` to approve its content
$ direnv allow
direnv: loading ~/projects/django-polls/.envrc
direnv: export +DJANGO_SECRET_KEY
$ python manage.py runserver
CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False.

We now have a different error. As we set the default for the DEBUG setting to False, we either need to define a list of allowed hosts, or set the variable to enable DEBUG. As the .envrc file will only ever be used locally, let's use it to turn DEBUG back on, and restart the server. Note that you will need to tell direnv to accept the new file after modifying it.

$ echo export DJANGO_DEBUG=True >> .envrc
$ direnv allow
$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
You have 18 unapplied migration(s).
Run 'python manage.py migrate' to apply them.

Django version 5.1.2, using settings 'polls.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Success

You should now be able to see the server running on http://127.0.0.1:8000/.

Django default landing page

Generating our database

One of Django's key features is its ORM, object-relational mapper, which allows us to define data models using Python classes. Django has a management command, makemigrations, that generates migration files, and a migrate command that applies them.

Django ships with some apps included, such as the django.contrib.auth app that provides authentication, which is why it complained about some unapplied migrations.

As our project currently uses the SQLite database, applying migrations will automatically create a new database if needed.

SQLite is a single-file based database, and while it is often undervalued, it's perfectly fine to use, even in production, for most small to medium-sized projects. We will take a look at a different database, PostreSQL, in a later chapter, but for now, let's apply the migrations to our SQLite database. You can go back to the terminal where the Django server is running, and press ctrl+c to stop the server, then run:

$ python manage.py migrate
$ python manage.py runserver

The message about unapplied migrations is now gone, and if we list the files in the current directory, we should now see a file named db.sqlite3, which contains our data.

Keeping our repository clean

We have made a lot of progress, getting Django up and running, and generating our initial database. Now would be a great time to commit our changes, but first we want to ensure that we only commit things that belong in a repo. We can run git add with the --dry-run or -n flag to see which files Git would add to staging.

$ git add -n .
add '.envrc'
add 'db.sqlite3'
add 'manage.py'
add 'polls/__init__.py'
add 'polls/__pycache__/__init__.cpython-312.pyc'
add 'polls/__pycache__/settings.cpython-312.pyc'
add 'polls/__pycache__/urls.cpython-312.pyc'
add 'polls/__pycache__/wsgi.cpython-312.pyc'
add 'polls/asgi.py'
add 'polls/settings.py'
add 'polls/urls.py'
add 'polls/wsgi.py'

We can see a few files we should never commit to Git, such as our .envrc file, which can contain secrets, our database, and Python bytecode files. Create a new file named .gitignore in the root of the repo, and use a code editor to give it the following content:

# Local settings
.envrc
# The database
db.sqlite3
# Bytecode
*.pyc

Save the file, and do another dry run:

$ git add -n .
add '.gitignore'
add 'manage.py'
add 'polls/__init__.py'
add 'polls/asgi.py'
add 'polls/settings.bak'
add 'polls/settings.py'
add 'polls/urls.py'
add 'polls/wsgi.py'

This looks much better. Let's commit our changes and push them to GitLab now:

$ git add -v .
$ git commit -m "feat: add django project"
$ git push -u origin feat/django

Merging changes into main

As you can see, we pushed to a new branch named feat/django on GitLab. We've contained our new feature entirely in this branch, both locally, and when pushing our changes. This is known as trunk-based development, where you work on small pieces of code, and frequently merge them into the main branch.

If you open your GitLab repository now, you should be given the option to create a merge request in a notification. Click the "Create merge request" button, and fill up the form.

Create merge request notification

You need to provide a title, which should describe the changes briefly. You can provide a description, which is especially important when working with others, and you can assign someone to manage the merge request or reviewers.

Assuming you are working by yourself at this time, just make sure to enter a title, and click the "Create merge request" button. You will be taken to a new page, where you can comment on the request, merge, or close the request (assuming you have the appropriate permissions).

Note that some merge requests may require extra work, e.g. due to conflicts, but as this one should say "Ready to merge!", you can go ahead, and click that "Merge" button.

Merge the request

Once done, the main branch on GitLab now contains the latest changes and the Django project.

It's time to fetch the changes to your local main branch.

$ git checkout main
$ git pull

Finally, we can check that the feat/django branch has been merged, and delete it:

$ git log --oneline
$ git branch -d feat/django

Adding our first Django app

Project layouts really come down to a matter of personal preference. The official Django tutorial recommends simply naming the app directory "polls", while some recommend skipping the app altogether, and using the project directory instead as the primary, or only, app.

One downside of the former is that it may cause conflicts if you are not careful with your app names. For example, the name, naming an app math would conflict with the math module in the Python standard library, while my_school.math should always work.

The Zen of Python

Namespaces are one honking great idea -- let's do more of those!

One downside of the latter is that it may not scale very well. Above a certain size, you may decide to split up your models.py file into a models package. You may then end up with a mixture of packages, some of which are apps, while others may be models, views, tests. etc.

For these reasons, I personally recommend namespacing the app inside of our polls directory. As we already used "polls" for the project name, we can use the common name "core" for our first app that will contain the key models and other features of our Django project.

In order to namespace our app, we need to first create the directory, run the startapp command, then change the app name in apps.py.

Tip

Remember to use new branches when working on new features.

$ git checkout -b feat/core-app
$ mkdir polls/core
$ python manage.py startapp core polls/core

Open the file polls/core/apps.py in your code editor, and make the following change:

@@ -3,4 +3,4 @@

 class CoreConfig(AppConfig):
     default_auto_field = "django.db.models.BigAutoField"
-    name = "core"
+    name = "polls.core"

You can now add "polls.core" to the INSTALLED_APPS list in settings.py.

Adding models, views, etc.

By now, you should know how to use Git effectively, how to create feature branches, and merge them to main.

In order for your project to do useful things, you should now add models, views, tests, etc.

As this book is focused more on the Linux aspect than the Django aspect, you can learn all about models and views in the official Django tutorial, and I highly recommend you take a break from reading this, and complete at least parts one through three of the Django tutorial linked below.

Tip

Be sure to commit your changes regularly, and to always keep your main branch in a working state. Also, make sure to check which files would be committed, to keep your repository clean.

Conclusion

After reading this, and completing the first parts of the Django tutorial, you should be able to start building serious projects with Django, and feel comfortable using Git to manage your code.

In the next section, we will look at running Linux locally and in the cloud, and be one step closer to deploying your project to production.