Skip to main content

Command Palette

Search for a command to run...

Django cheat sheet

Updated
18 min read
Django cheat sheet

I move around frameworks quite a bit, usually between Golang, Next.js, Laravel, and Django. So I forget stuff all the time. In this article, I want to throw together a cheat sheet of sorts with some random things I use in Django; hopefully, it’s also useful to you!

I’ll keep this article updated as I go along, so please consider joining my newsletter. The purpose of the newsletter is just to auto email you whenever I add new content on the blog or update existing articles.

Model basics

In Django, we use a model class to represent the structure of a database table, and each field represents a column in that table. When you create an instance of this class, you effectively represent one row of data from that table.

Note: By default, Django creates the id field as the primary key, so you don't need to manually set a primary key field.

class ContactForm(models.Model): 
   full_name = models.CharField(max_length=100) 
   message = models.CharField(max_length=500) 
   contact_number = models.CharField(max_length=15, blank=True, null=True)
   email = models.CharField(max_length=255)
   date_submitted = models.DateTimeField(auto_now_add=True, null=True)

Blank versus null?

  1. null - Can store NULL in the database. If False Then the DB column will be created with "not null", e.g. full_name VARCHAR(100) NOT NULL. Django default: False.

  2. blank - Means the field can be empty in forms if set to True . Django default: False

When adding/changing model fields, you must run the following to generate a migration:

python manage.py makemigrations

If there are migrations, then also run:

python manage.py migrate

Run this to access your database's shell, so that you can use RAW SQL and navigate your database directly:

python manage.py dbshell

Django automatically assigns a primary key to your table, and the default field name is id. You can off course, change this by setting primary_key=True on another field in your model:

class Order(models.Model): 
    total_price = models.DecimalField(max_digits=18, decimal_places=2) 
    order_reference = models.CharField(max_length=50, primary_key=True)

If you want to represent a relationship between models, you can set this on children as follows:

class OrderItem(models.Model): 
    order = models.ForeignKey(Order, on_delete=models.CASCADE) 
    item_name = models.CharField(max_length=100) 
    item_price = models.DecimalField(max_digits=18, decimal_places=2) 
    quantity = models.IntegerField(default=1)

class UserProfile(models.Model): 
    user = models.OneToOneField(User, on_delete=models.CASCADE)

class Course(models.Model): 
    course_name = models.CharField(max_length=100)

class Student(models.Model): 
    student_name = models.CharField(max_length=100) 
    courses = models.ManyToManyField(Course)

models.CASCADE , models.DO_NOTHING (more options like PROTECT also supported) are constraits that tell Django what to do with children when you delete a parent. Cascade will delete all children, and "DO_NOTHING" will not delete anything.

Django will automatically slugify and create a table name using this format:

app_name_modelclassname

example: websites_contactform

You can change the table name if you need, as follows:

class ContactForm(models.Model):
    class Meta:
        db_table = "my_contact_form"

# table name in the db is: my_contact_form

User model - override the default:

class CustomUser(AbstractUser):
    username = None
    email = models.EmailField(unique=True, max_length=255)
    ... other fields

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []

    def __str__(self):
        return self.email

# Settings file
  AUTH_USER_MODEL = 'accounts.CustomUser'

Model Queries

When you inherit from models.Model Django automatically sets up a default model manager named objects on every model class. Model managers are the interface between your Django code and the database; they allow you to query and manipulate data in a Pythonic way. Under the hood, the manager sits on top of a complex chain of classes that handles everything from building queries to communicating with the database backend.

⚠️ I simplified some of the SQL queries to make it easier to understand. Django optimizes and generates more explicit SQL than my simple "SELECT * " versions.

To perform queries, you can use methods of the manager as follows:

users = User.objects.filter(email="test@test.com")
orders = Order.objects.filter(status="paid", order_total__gte=500)

The filter method is basically translating your query to something similar in SQL:

SELECT * FROM users where email="test@test.com"
SELECT * FROM orders where status="paid" and order_total >= 500

# For simplicity, left out the full table name that Django would normally generate.

The __gte is a field lookup; these allow you to perform queries that are more advanced than a simple = . They support the typical math variants __gt , __lt , __gte , string matching: __icontains , in list lookups __in and many more operations.

In addition to filter You can also use exclude which also supports the same querying style but does the reverse, i.e. filter ensures results must match the criteria, and exclude ensures results don't match the relevant expressions passed to it.

By default filter/exclude will do an AND query, and you can chain multiple of these together because the manager class will create a QuerySet object (sort of like an in-memory wherestatement). The QuerySet is basically lazy-loaded, meaning Django doesn't actually fetch the data until you evaluate this by calling count , or perform some kind of evaluation like looping through the records.

filter(status="paid", order_total__gte=500)
 ---> status="paid" and order_total >= 500

You can make it a SQL OR by using the special Q object:

from django.db.models import Q
orders = Order.objects.filter(
    Q(status="paid") | Q(order_total__gte=500)
)

---> status="paid" OR order_total >= 500

Aggregate queries

While filter allows you to perform common where type queries in SQL, you sometimes need to do aggregates like SUM, COUNT, AVG etc... Count is fairly easy:

Order.objects.filter(
    Q(status="paid") | Q(order_total__gte=500)
).count() # just add .count at the end of any query

For other aggregate types, Django provides special classes for these, and a model manager method aggregate:

 from django.db.models import Avg, Max, Sum

Order.objects.aggregate(Max("order_total", default=0))
Order.objects.aggregate(Avg("order_total", default=0))
Order.objects.aggregate(Sum("order_total", default=0))

Aggregate will basically generate a similar query:

SELECT SUM(order_total) FROM orders;
# returns as a result: {"order_total__sum": 1500.00}

Essentially a single value, but what if you want to group by And count records? For example, you have a bunch of e-commerce merchants, and you want to know how many orders each merchant received. In this case, we can use annotate:

# logic
from django.db.models import Avg, Max, Sum, Count

merchants = Merchant.objects.annotate(Count("orders", distinct=True))
# Django uses the related_name field here for "orders"

for m in merchants:
    print(m.orders__count)

The model:

class Order(models.Model): 
    merchant = models.ForeignKey(Merchant, on_delete=models.DO_NOTHING, related_name="orders")

Example SQL:

SELECT merchant.*, COUNT(DISTINCT orders.id) 
FROM merchants
LEFT JOIN orders ON orders.merchant_id = merchant.id
GROUP BY merchant.id;

aggregate and annotate operate on a QuerySet So you can also chain filter and exclude before aggregating/annotating your data.

Order.objects.filter(status="paid").aggregate(Sum("order_total", default=0))

You can print the RAW SQL of any QuerySet by simply printing as follows:

query_set = User.objects.annotate(Count("order", distinct=True))
print(query_set.query)

# Output
SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined", COUNT(DISTINCT "website_order"."order_reference") AS "order__count" FROM "auth_user" LEFT OUTER JOIN "website_order" ON ("auth_user"."id" = "website_order"."user_id") GROUP BY "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined"

Generate fake test data

To generate fake data, you can use an excellent third-party library called Faker. If you do this in a loop, Faker will automatically generate dynamic values, which is perfect for testing, as you can generate hundreds, even thousands, of fake users and other objects.

from faker import Faker  # pip install Faker
from django.contrib.auth.models import User

fake = Faker()

user = User.objects.create_user(
    username= fake.user_name(),
    email=fake.email(),
    first_name=fake.first_name(),
    last_name=fake.last_name(),
    password=fake.password()
)

These are just basic fields; the library can generate loads more with all kinds of customizations.

Model managers

Whenever you perform a DB query, you will notice we always chain methods to the objects property on our model, e.g. : MyModel.objects.filter . The "objects" property is an instance of a manager class. As the name suggests, this object will "manage" all the interactions between high-level API's we developers use, such as filter, exclude (etc...) and low-level Django ORM objects that eventually build SQL queries and execute them on the database server.

For example, we can build a custom manager that would ensure every time we use filterWe automatically append a where clause such as: team_id=x.

Instead of using:

User.objects.filter(department="Accounting")

We could use:

User.for_team.filter(department="Accounting")

In the first query, the SQL will look similar to:

SELECT * from users where department = 'accounting'

However, the second query could look like:

SELECT * from users where department = 'accounting' and team_id = 2

A model manager example:

class TeamManager(models.Manager):
    def get_queryset(self):
        team_id = get_team_id()
        return super().get_queryset().filter(team_id=team_id)

class User(models.Model):
    for_team = TeamManager()

Remember queryset is a lazy representation of a database query, i.e., sort of like a virtual SQL statement that Django maintains in memory while you keep chaining filter, exclude and various other model methods.

So essentially, when you invoke the default model manager .objects It sets up a blank queryset , and manages all the changes to it until you finally execute that query on the database server.

So in this case, instead of returning a blank querysetWe give this queryset a where clause.

ℹ️ get_team_id() under the hood, is a custom function (i.e. not part of Django) that's using a ContextVar We set in our middleware, but this logic is beyond the scope of managers, so I'll not go further on that for now, but just assume that, regardless of whatever this function does, it returns a team_id, which we then use in our queryset.

Simple OTP generator

You can use this for 2-factor auth. Store the OTP in a temp table, and then send the user an email with the PIN for them to confirm. Encryption is for storage, so the PIN isn’t easily readable in the DB table.

import base64
import random
from hashlib import sha256

from cryptography.fernet import Fernet
from django.conf import settings


def generate_otp():
    return str(random.randint(100000, 999999))


def get_encryption_key():
    key_hashed = sha256(settings.SECRET_KEY.encode()).digest()
    return base64.urlsafe_b64encode(key_hashed)


def encrypt_otp(otp_code):
    key = get_encryption_key()
    fernet = Fernet(key)
    encrypted = fernet.encrypt(otp_code.encode())
    return encrypted.decode("utf-8")


def decrypt_otp(encrypted_otp):
    if not encrypted_otp:
        return None

    try:
        key = get_encryption_key()
        fernet = Fernet(key)
        decrypted = fernet.decrypt(encrypted_otp.encode())
        return decrypted.decode("utf-8")
    except Exception:
        return None

Decorator for protecting views

While middleware is usually better, sometimes you just need to run some logic only for a handful of views, in which case it might be easier to use a decorator.

def is_account_verified(view_func):
    @wraps(view_func)
    def _wrapped_view(request, *args, **kwargs):
        if not request.user.is_authenticated:
            return redirect("login")

        if request.user.user_profile.is_confirmed is False:
            request.user.user_profile.send_verify_email(request)
            return redirect("must-verify")
        response = view_func(request, *args, **kwargs)
        return response

    return _wrapped_view


####### Use in views.py #######
from accounts.decorators import is_account_verified

@is_account_verified
def change_password(request):
    pass

Auth views

Customizing the auth. If you want to use Django’s default auth system but not admin, you can extend and customize the existing auth to use your own template and your own custom register/login.

from django.contrib.auth import views as auth_views
urlpatterns = [
    path("login/", login_view, name="login"),
    path("register/", register_view, name="register"),
    path("logout/", logout_view, name="logout"),
    path(
        "password_reset/",
        auth_views.PasswordResetView.as_view(
            template_name="auth/password_reset_form.html",
            html_email_template_name="auth/emails/password_reset_email.html",
            subject_template_name="auth/emails/password_reset_subject.txt",
        ),
        name="password_reset",
    ),
    path(
        "password_reset/done/",
        auth_views.PasswordResetDoneView.as_view(
            template_name="auth/password_reset_done.html"
        ),
        name="password_reset_done",
    ),
    path(
        "reset/<uidb64>/<token>/",
        auth_views.PasswordResetConfirmView.as_view(
            template_name="auth/password_reset_confirm.html"
        ),
        name="password_reset_confirm",
    ),
    path(
        "reset/done/",
        auth_views.PasswordResetCompleteView.as_view(
            template_name="auth/password_reset_complete.html"
        ),
        name="password_reset_complete",
    )
]
def login_view(request):
    if request.user.is_authenticated:
        return redirect("dashboard")

    if request.method == "POST":
        username = request.POST.get("username")
        password = request.POST.get("password")
        user = authenticate(request, username=username, password=password)
        if user is not None and user.user_profile.is_active is False:
            messages.error(
                request, "Sorry, but your administrator has disabled your account."
            )
        elif user is not None:
            if user.user_profile.is_twofactor_enabled:
                request.session["2fa_user_id"] = user.id
                request.session["2fa_timestamp"] = timezone.now().isoformat()
                return redirect("send_2fa_email")
            else:
                login(request, user)
                if user.user_profile.needs_password_change:
                    return redirect("change_password")
                return redirect("dashboard")
        else:
            messages.error(request, "Invalid username or password.")

    return render(request, "auth/login.html")


def register_view(request):
    if request.user.is_authenticated:
        return redirect("dashboard")

    context = {}

    if request.method == "POST":
        email = request.POST.get("email")
        password1 = request.POST.get("password1")
        password2 = request.POST.get("password2")
        full_name = request.POST.get("name")

        context = {
            "email": email,
            "name": full_name
        }

        if password1 != password2:
            messages.error(request, "Passwords do not match.")
            return render(request, "auth/register.html", context)

        if len(password1) < 8:
            messages.error(request, "Password must be at least 8 characters long.")
            return render(request, "auth/register.html", context)

        if not re.search(r"\d", password1):
            messages.error(request, "Password must include at least one number.")
            return render(request, "auth/register.html", context)

        if not re.search(r"[A-Z]", password1):
            messages.error(
                request, "Password must include at least one uppercase letter."
            )
            return render(request, "auth/register.html", context)

        if not full_name:
            messages.error(request, "Oops! Please tell us your name?")
            return render(request, "auth/register.html", context)

        # probably want a more generic message
        if User.objects.filter(email=email).exists():
            messages.error(request, "Email already in use.")
            return render(request, "auth/register.html", context)

        try:
            user = User.objects.create_user(
                email=email, password=password1
            )
            full_name = full_name.split(" ")
            if len(full_name) > 1:
                user.first_name = full_name[0]
                user.last_name = " ".join(full_name[1:])
            else:
                user.first_name = full_name[0]
                user.last_name = ""

            user.save()
            messages.success(
                request, "Account created successfully. You can now log in."
            )

            return redirect("login")
        except Exception as e:
            messages.error(request, f"Error creating account: {e}")
            return render(request, "auth/register.html", context)

    return render(request, "auth/register.html", context)


def logout_view(request):
    logout(request)
    return redirect("login")

Management commands

# --- in app_name/management/commands
from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help = "Some important task description here"

    def add_arguments(self, parser):
        parser.add_argument("--email", type=str, help="Email address of the user")

    def handle(self, *args, **options):
        email = options.get("email")
        if not email:
            email = input("Enter email address of the user: ")
        # .... rest of the code

        self.stdout.write(
            self.style.SUCCESS(f"Successfully did whatever I was suppose to do.")
        )

Dead simple background tasks without Celery complication

The purpose of this is to run simple tasks in the background; it’s not meant for high concurrency or complex jobs. For that, just use Celery. The beauty of this way is that there’s no external service to manage (besides the systemd script), so it’s easier to manage.

In an app “background_tasks”. models.py:

from django.db import models
from django.utils import timezone


class Statues(models.TextChoices):
    PENDING = "pending", "pending"
    IN_FLIGHT = "in_flight", "in_flight"
    FAILED = "failed", "failed"
    COMPLETE = "complete", "complete"


class Queues(models.TextChoices):
    HIGH = "high", "high"
    LOW = "low", "low"
    NORMAL = "normal", "normal"


class QueueTask(models.Model):
    job_data = models.JSONField("TASK", default=dict)
    status = models.CharField(max_length=20, choices=Statues, default=Statues.PENDING)
    scheduled_for = models.DateTimeField(default=timezone.now)
    queue = models.CharField(max_length=20, choices=Queues, default=Queues.NORMAL)
    callable = models.CharField(max_length=100, null=False, blank=False)

    @classmethod
    def queueTask(cls, job_data, callable, queue=None, scheduled_for=None):
        if callable is None:
            raise Exception(
                "Callable needs to be a task declared in background_jobs/tasks."
            )

        q = QueueTask()
        q.status = Statues.PENDING
        q.scheduled_for = timezone.now() if scheduled_for is None else scheduled_for
        queue = Queues.NORMAL if queue is None else queue
        q.callable = callable
        q.job_data = job_data
        q.save()

Example background_tasks/tasks/notification.py:

from django.conf import settings
from django.core.mail import EmailMessage
from django.utils import timezone

from background_jobs.models import QueueTask

def send_email(task: QueueTask):
    json_data = task.job_data
    json_data["to_emails"]
    for to_email in json_data["to_emails"]:
        email = EmailMessage(
            json_data["subject"],
            json_data["message"],
            (
                json_data["from"]
                if "from" in json_data.keys()
                else settings.DEFAULT_FROM_EMAIL
            ),
            [to_email],
        )
        email.content_subtype = json_data["format"] if "format" in json_data else "html"
        email.send()

In background_tasks/management/commands/worker.py:

import importlib
import logging
import time
from typing import List

from django.core.management.base import BaseCommand
from django.utils import timezone

from background_jobs.models import Queues, QueueTask, Statues

logger = logging.getLogger(__name__)


class Command(BaseCommand):
    help = "Process pending tasks from a specified queue"

    def handle(self, *args, **options):
        while True:
            try:
                tasks = QueueTask.objects.filter(
                    status=Statues.PENDING,
                    scheduled_for__lte=timezone.now(),
                    queue=Queues.NORMAL,
                ).order_by("-scheduled_for")[:20]

                for t in tasks:
                    self.execute_task(t)
            except Exception as ex:
                print(ex)
            finally:
                time.sleep(15 / 1000)

    def execute_task(self, task: QueueTask):
        """Execute a single task"""
        try:
            self.stdout.write(f"Executing task {task.id}")
            task.status = Statues.IN_FLIGHT
            task.save()

            module_name, function_name = task.callable.split(".")
            module = importlib.import_module("background_jobs.tasks." + module_name)

            getattr(module, function_name)(task)

            task.status = Statues.COMPLETE
            task.save()

            logger.info(self.style.SUCCESS(f"Task {task.id} completed successfully"))

        except Exception as e:
            task.status = Statues.FAILED
            task.save()
            logger.error(self.style.ERROR(f"Task {task.id} failed: {str(e)}"))

Then just set up a systemd on Ubuntu / Debian (/etc/systemd/system/worker.service):

[Unit]
Description=Worker daemon for my django app queue
After=network.target

[Service]
User=pythonapp
Group=pythonapp
WorkingDirectory=/home/pythonapp/myproject
ExecStart=/home/pythonapp/.venv/bin/python manage.py worker

Restart=on-failure
RestartSec=5s

Environment=DJANGO_SETTINGS_MODULE=myproject.settings

[Install]
WantedBy=multi-user.target

Then in Ubuntu:

systemctl daemon-reload 
systemctl enable worker.service
systemctl start worker.service

Check the status of the worker:

systemctl status worker.service

Now, throughout your application, you can then queue work like so:

QueueTask.queueTask(
    {
        "subject": subject,
        "message": message,
        "to_emails": [self.user.username],
        "format": "html",
    },
    "notifications.send_email",
)

NGINX to reverse proxy to Gunicorn worker:

server {
    listen 80;
    real_ip_header CF-Connecting-IP;
    server_name mydomain.com www.mydomain.com;

    # Redirect HTTP to HTTPS
    return 301 https://\(host\)request_uri;
}

server {
    listen 443 ssl;
    server_name mydomain.com
    real_ip_header CF-Connecting-IP;
    # SSL configuration
    ssl_certificate /etc/ssl/mydomain.com.cert;
    ssl_certificate_key /etc/ssl/mydomain.com.key;
    ssl_protocols TLSv1.2 TLSv1.3;

    # Security headers
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Static file serving
    location /static/ {
        autoindex on;
        alias /var/www/static/;
        expires 30d;
        access_log off;
    }

    # Proxy connections to Gunicorn
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 75s;
        proxy_read_timeout 300s;
        client_max_body_size 10m;
    }

    # Logging
    access_log /var/log/nginx/mydomain.com.access.log;
    error_log /var/log/nginx/mydomain.com.error.log;
}

Gunicorn worker config:

[Unit]
Description=Gunicorn daemon for myproject.com
After=network.target

[Service]
User=pythonapp
Group=pythonapp
WorkingDirectory=/home/pythonapp/myproject
ExecStart=/home/pythonapp/.venv/bin/gunicorn \
          --access-logfile - \
          --workers 8 \
          --threads 2 \
          --timeout 180 \
          --max-requests 1000 \
          --max-requests-jitter 100 \
          --bind 127.0.0.1:8000 \
          myproject.wsgi:application

Restart=on-failure
RestartSec=5s

# Environment variables if needed
Environment=DJANGO_SETTINGS_MODULE=myproject.settings

[Install]
WantedBy=multi-user.target

Forms - implement the change user password form

from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django import forms


class CustomPasswordChangeForm(forms.Form):
    current_password = forms.CharField(
        label="Current Password", 
        widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
        required=True
    )
    new_password1 = forms.CharField(
        label="New Password", 
        widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
        required=True
    )
    new_password2 = forms.CharField(
        label="Confirm New Password", 
        widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
        required=True
    )

    def __init__(self, user, *args, **kwargs):
        self.user = user
        super().__init__(*args, **kwargs)

    def clean_current_password(self):
        current_password = self.cleaned_data.get("current_password")
        if not self.user.check_password(current_password):
            raise forms.ValidationError(
                "Your current password was entered incorrectly."
            )
        return current_password

    def clean_new_password1(self):
        """Validate new password against Django's validators"""
        password = self.cleaned_data.get("new_password1")
        if password:
            try:
                validate_password(password, self.user)
            except DjangoValidationError as e:
                raise forms.ValidationError(e.messages)
        return password

    def clean_new_password2(self):
        password1 = self.cleaned_data.get("new_password1")
        password2 = self.cleaned_data.get("new_password2")

        if password1 and password2 and password1 != password2:
            raise forms.ValidationError("The two password fields didn't match.")

        return password2
    
    def clean(self):
        cleaned_data = super().clean()
        current = cleaned_data.get("current_password")
        new = cleaned_data.get("new_password1")
   
        if current == new:
            raise forms.ValidationError(
                "Your new password cannot be the same as your current password."
            )
        
        return cleaned_data

    def save(self, commit=True):
        password = self.cleaned_data["new_password1"]
        self.user.set_password(password)
        if commit:
            self.user.save()
        return self.user
@login_required
@require_http_methods(["POST"])
def change_password(request):
    if request.headers.get("X-Requested-With") == "XMLHttpRequest":
        form = CustomPasswordChangeForm(request.user, request.POST)
        if form.is_valid():
            form.save()
            update_session_auth_hash(request, form.user)
            return JsonResponse(
                {"success": True, "message": "Your password was successfully changed."}
            )
        else:
            return JsonResponse({"success": False, "errors": form.errors}, status=400)

    return JsonResponse({"success": False, "message": "Invalid request"}, status=400)

Setting up S3 storage in settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django_redis',
    'shared',
    "storages",   <----
]
pip install django-storages
# Public S3 Storage Configuration
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME', 'mybucket')
AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'fsn1')
AWS_S3_ENDPOINT_URL = os.getenv('AWS_S3_ENDPOINT_URL', f'https://{AWS_S3_REGION_NAME}.your-objectstorage.com')
AWS_S3_CUSTOM_DOMAIN = os.getenv('AWS_S3_CUSTOM_DOMAIN', f'{AWS_STORAGE_BUCKET_NAME}.{AWS_S3_REGION_NAME}.your-objectstorage.com')
AWS_S3_OBJECT_PARAMETERS = {
    'CacheControl': 'max-age=86400',
}
AWS_DEFAULT_ACL = 'public-read'
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False

# Private S3 Storage Configuration
PRIVATE_BUCKET_NAME = os.getenv('PRIVATE_BUCKET_NAME', 'myprivatebucket')
PRIVATE_S3_REGION_NAME = os.getenv('PRIVATE_S3_REGION_NAME', 'nbg1')
PRIVATE_S3_ENDPOINT_URL = os.getenv('PRIVATE_S3_ENDPOINT_URL', f'https://{PRIVATE_S3_REGION_NAME}.your-objectstorage.com')
PRIVATE_S3_CUSTOM_DOMAIN = os.getenv('PRIVATE_S3_CUSTOM_DOMAIN', f'{PRIVATE_BUCKET_NAME}.{PRIVATE_S3_REGION_NAME}.your-objectstorage.com')

STORAGES = {
    "default": {
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
        "OPTIONS": {
            "access_key": AWS_ACCESS_KEY_ID,
            "secret_key": AWS_SECRET_ACCESS_KEY,
            "bucket_name": AWS_STORAGE_BUCKET_NAME,
            "region_name": AWS_S3_REGION_NAME,
            "endpoint_url": AWS_S3_ENDPOINT_URL,
            "custom_domain": AWS_S3_CUSTOM_DOMAIN,
            "object_parameters": AWS_S3_OBJECT_PARAMETERS,
            "default_acl": AWS_DEFAULT_ACL,
            "querystring_auth": AWS_QUERYSTRING_AUTH,
            "file_overwrite": AWS_S3_FILE_OVERWRITE,
            "location": "",
        },
    },
    "private": {
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
        "OPTIONS": {
            "access_key": AWS_ACCESS_KEY_ID,
            "secret_key": AWS_SECRET_ACCESS_KEY,
            "bucket_name": PRIVATE_BUCKET_NAME,
            "region_name": PRIVATE_S3_REGION_NAME,
            "endpoint_url": PRIVATE_S3_ENDPOINT_URL,
            "custom_domain": PRIVATE_S3_CUSTOM_DOMAIN,
            "object_parameters": AWS_S3_OBJECT_PARAMETERS,
            "default_acl": 'private',
            "querystring_auth": True,
            "file_overwrite": AWS_S3_FILE_OVERWRITE,
            "location": "",
        },
    },
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
}

MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/'

Global shared template tag

Sometimes you want to access branding information, and other important information in templates, so instead of hardcoding them or repeating yourself all the time. Put those settings in the settings.py and then reference them using this shared template tag.

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            "builtins": [
                "shared.templatetags.global_settings_tags",  <------
            ],
            'context_processors': [
                'shared.context_processors.global_settings',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

# Only allow these settings to be accessed from template tag
ALLOWED_TEMPLATE_SETTING_ACCESS = {
    'APPLICATION_LOGO',
    'APPLICATION_NAME',
    'CONTACT_EMAIL',
    'SUPPORT_URL',
    'MEDIA_URL',
    'STATIC_URL',
    # Add other settings you want templates to have access to
}

In shared/templatetags/global_settings_tags.py

from django import template
from django.conf import settings

register = template.Library()

@register.simple_tag
def get_setting(name):
    name = name.strip()
    if name not in settings.ALLOWED_TEMPLATE_SETTING_ACCESS:
        return ""

    return getattr(settings, name, "")

Now, anywhere in your templates, you can access:

   <img src="{% get_setting 'APPLICATION_LOGO' %}" alt="logo" />

Note: You probably want to break up your settings file into multiple config files. For larger projects, I normally create a “/config” folder and split up my settings files into there.

Django and Tailwind

tailwind.config.js at the root of your project

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./templates/**/*.{html,js}"
  ],
  theme: {
    extend: {
      colors: {
        primary: '#5DDED6',
        secondary: '#8b5cf6',
        dark: '#1e293b',
      },
      fontFamily: {
        sans: ['Inter', 'sans-serif'],
      },
      animation: {
        'float': 'float 3s ease-in-out infinite',
      },
      keyframes: {
        float: {
          '0%, 100%': { transform: 'translateY(0)' },
          '50%': { transform: 'translateY(-10px)' },
        }
      }
    },
  },
  plugins: [],
}

postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

package.json:

{
  ----
  "scripts": {
    "build:css:prod": "tailwindcss -i ./static/css/app.css -o ./static/build/app.css --minify",
    "watch:css": "tailwindcss -i ./static/css/app.css -o ./static/build/app.css --watch",
    "watch:django": "python manage.py runserver",
    "dev": "npm-run-all --parallel watch:*"
  },
  "devDependencies": {
    "@fortawesome/fontawesome-free": "^6.7.2",
    "autoprefixer": "^10.4.21",
    "postcss": "^8.5.3",
    "tailwindcss": "^3.3.3"
  },
  "dependencies": {
    "npm-run-all": "^4.1.5"
  }
  ----
}
npm run dev  ~ to hot reload python and tailwind
npm run build:css:prod  ~ for production builds