This post contains a few code snippets I came up with when starting development of a Django web application.
Using Django with Jinja Templates
By default Django uses its own template engine to generate HTML output. While it might be fast and sufficient for most tasks, I think it lacks one really useful feature: macros. Macros in Jinja are parameterized templates which can be "called" in other templates. This helps very much to ensure a consistent structure and layout of the HTML.
To enable the Jinja template engine in addition to the Django template engine (you will stil need this, e.g. for admin pages, etc.), you need to update your sessings file and additionally write a function to provide the Environment instance to Jinja:
Here's the settings file:
TEMPLATES = [
{
# Configuration for Django templates
'BACKEND': 'django.template.backends.jinja2.Jinja2',
'DIRS': [os.path.join(BASE_DIR, 'loc/templates')],
'APP_DIRS': True,
'OPTIONS': {
'environment': 'dbloc.jinja2.environment',
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
{
# Configuration for Jinja2 templates
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
The line 'environment': 'dbloc.jinja2.environment',
references a function
which supplies the Jinja environment. I have implemented it in a file
jinja2.py
in my project directory (here called dbloc
):
from django.templatetags.static import static
from django.urls import reverse
from jinja2 import Environment
def environment(**options):
env = Environment(**options)
env.globals.update({
'static': static,
'url': reverse,
})
return env
Logging Setup
As I run my web application inside a Docker container, I came up with the
following setup for logging. All messages are logged to STDOUT, so they are
captured by the docker daemon. And I decided to colorize messages, so especially
during debugging sessions it is easier to distinguish a flood of debug messages
from any important warning or error messages. For colored logs to work you need
to install the colorlog
package.
Here's the fragment from the Django project settings related to logging setup:
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
# verbose log message formatter
'verbose': {
'()': 'colorlog.ColoredFormatter',
'format': '[{asctime}] {log_color}{levelname:<8}{reset} {name}.{funcName}:{lineno} {message}',
'datefmt': '%Y-%m-%d %H:%M:%S',
'style': '{',
},
# simple log message format, currently not in use
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
# log messages to console (stdout)
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
# Handler to log everything up to DEBUG level to file with log
# rotation. Currently not in use, as docker container handles log data
# written to console
'file': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.abspath(os.path.join(BASE_DIR, 'log', 'varweb.log')),
'formatter': 'verbose',
'maxBytes': 1024 * 1024 * 5, # 5 MB
'backupCount': 5,
},
},
# root logger catches all log messages. Everything warning and above is sent
# to the console and to the log file. Change the level e.g. to 'DEBUG' for
# the development settings
'root': {
'handlers': ['console'],
'level': 'WARNING',
},
# Loggers below the django hierarchy log to console with warning level, but
# do not propagate to the root logger
'loggers': {
'django': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'WARNING'),
'propagate': False,
},
},
}
Dockerfile for Django Project
Here's and example to package your Django project into a Docker container based on Alpine linux. This leads to nice small container images.
Here's the Dockerfile
:
FROM python:3.8-alpine
ENV PYTHONUNBUFFERED 1
ENV APP_DIR /usr/src/app
# Patches for apk and pip to use Chinese mirrors
COPY tools/patches/pip.conf /etc/xdg/pip/pip.conf
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories
# Install nginx
RUN apk update \
&& apk --no-cache add nginx \
&& mkdir /run/nginx
COPY tools/nginx.conf /etc/nginx/conf.d/default.conf
RUN mkdir -p ${APP_DIR} ${APP_DIR}/media
WORKDIR /${APP_DIR}
# Install python packages (needs -dev packages for building, can be removed later)
ADD requirements ${APP_DIR}/requirements
RUN apk --no-cache add build-base jpeg-dev zlib-dev musl-dev \
&& python3 -m pip install --no-cache-dir -r requirements/prod.txt \
&& apk --no-cache del build-base jpeg-dev zlib-dev musl-dev \
&& apk --no-cache add musl zlib jpeg \
&& rm -rf /var/cache/apk/*
# Add the application
ADD . ${APP_DIR}
RUN python3 manage.py collectstatic --no-input
EXPOSE 80
CMD ["./tools/start-server.sh"]
As I'm living in China, I patch the package sources for apk
(the Alpine Linux
package manager) and pip
to fetch packages from fast Chinese mirrors, as the
default sources are extremely slow when accessed from the Chinese internet.
In this example I have Pillow
, the Python image library, as one
dependency. When it is installed for Alpine Linux, it is built from source
(parts of it are implemented in C). To be able to do this while building the
container image, I have one RUN command with these steps:
-
first install all build dependencies (
apt add jpeg-dev zlib-dev ...
), -
then install the required Python packages with
python3 -m pip install ...
, -
then remove the build dependencies (
apk del build-base ...
) and replace them by the corresponding runtime libraries (apt add musl zlib jpeg
)
I did not fully trust the --no-cache
option to apk
and still remove the
complete cache directory at the end.
Currently with Python 3.8 and a very simple Django application, the Docker image size is 154MB.
Application Start Script respecting CTRL-C
Initially I had some trouble to set up the Docker container, so that I can easily
terminate it with Ctrl-C
. Most of the time the container waits for ca. 10
seconds before (I guess forcefully) terminating the application.
To fix this, there are two things to observe:
-
The
CMD
line in theDockerfile
must use the Javascript array notation["..."]
. -
The actual start script, a shell script in my case, must use
exec
.
This ensures that the main process of your application is the one receiving
signals like SIGTERM or SIGINT (which is sent when you press Ctrl-C
) instead
of any wrapper process or a shell instance.
Here's the start-server.sh
script I use:
#!/usr/bin/env sh
#
# Start script for running inside docker container
#
# Set up database
python3 manage.py migrate
# Create admin user (password is shown during startup in the logs)
python3 manage.py initadmin
# If nothing else is set already, we run the testing config for the container
if [ -z "${DJANGO_SETTINGS_MODULE}" ]
then
export DJANGO_SETTINGS_MODULE=dbloc.settings.testing
fi
# Start nginx for serving static and media files and act as a reverse proxy for
# gunicorn. It starts as a daemon automatically.
nginx -c /etc/nginx/nginx.conf
exec gunicorn dbloc.wsgi
Creating an Admin User at First Start
Usually you use the manage.py createsuperuser
command to create the first
user with admin rights for your application. I wanted to automate this, so I
created an additional command for manage.py
to automatically create and admin
user with a random password. The user is only created if no users exist in the
database yet.
'''Admin command implementation for 'initadmin' command.'''
import random
import string
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
def gen_password(length=8):
'''Generate a random password.'''
# we use upper and lower case letters and digits for the password
letters = string.ascii_letters + string.digits
return ''.join(random.choices(letters, k=length))
class Command(BaseCommand):
'''Admin command to create an admin user with random password.
The admin user is only created if no other users are already in the
database.
'''
def handle(self, *args, **options):
if User.objects.count() == 0:
print('No users yet in database. Creating admin user.')
username = 'admin'
email = 'admin@localhost'
password = gen_password(8)
print('Creating account for %s (%s)' % (username, email))
admin = User.objects.create_superuser(
email=email,
username=username,
password=password)
admin.is_active = True
admin.is_admin = True
admin.save()
print("Account crated with password '{0}'.".format(password))
print("Please change password immediately.")
else:
print('Admin accounts can only be initialized if no Accounts exist')
When starting the application the first time, you can see the initial admin password in the log output. Of course you should immediately change it.