How to create custom commands in django

When developing django projects, there comes a need to write one-off scripts that automate a particular task. Here are some use cases I've found myself applying before we continue with the implementation.

  1. Cleaning up faulty data columns en masse.
  2. Migrating multiple schemas in a multitenant application

There are two ways we can go about running these types of commands in django. Write a normal python script which you can then call by running python file_name.py, and the other is by utilizing django-admin commands. These are run by calling python manage.py command_name.

For this post, I'll demonstrate with a blog app having just 3 database tables, User, Category, and Post. I've assumed you are comfortable initializing a django project, but in case you aren't, this post should help you out.

The source code for this post can be found here.

Normal python script method

For the first example, we'll try to list all system users with the script below

from django.contrib.auth import get_user_model

User = get_user_model()

# retrieve all users
users = User.objects.all()

# loop through all users
for user in users:
    print(f'user is {user.get_full_name()} and their username is {user.get_username()}')

You can name the script list_users.py and run it by python list_users.py

Immediately you run this, you'll be met with an error,

django.core.exceptions.ImproperlyConfigured: Requested setting AUTH_USER_MODEL, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.

One would have assumed that since you are in django's project directory, the script would run with no problem. However, this is not the case. This is because the script is not aware of which project the script is to apply to. You could have multiple projects in a single machine or virtual environment. So it's important to give the script some context.

We'll do this by slightly modifying our script.

import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'projectname.settings')

import django
django.setup()

from django.contrib.auth import get_user_model

User = get_user_model()

users = User.objects.all()

for user in users:
    print(f'user is {user.get_full_name()} and their username is {user.get_username()}')

Here, we specify the project's settings and not only that, call django.setup() method. The method configures the settings, logging and populates the app registry. In short, we are making the script aware of our project context.

In case you're curious about the os module, my previous post provides more insight.

Note that the order of import is important and must remain like so.

If we run the script again, all our users should be printed to the terminal 👯‍♂️.

Next up we'll initialize an app called posts by running django-admin startapp posts.

The app will house our blog posts models.

from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
# Create your models here.
class CommonInfo(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
ordering = ('-created_at',)
# blog post category
class Category(CommonInfo):
name = models.CharField(max_length=255)
def __str__(self):
return self.name
# blog post instance
class Post(CommonInfo):
title = models.CharField(max_length=255)
category = models.ForeignKey(Category,related_name='posts',on_delete=models.PROTECT)
author = models.ForeignKey(User,related_name='posts',on_delete=models.PROTECT)
content = models.TextField()
published = models.BooleanField(default=False)
def __str__(self):
return f'{self.title} by {self.author.get_full_name()}'
view raw blog.py hosted with ❤ by GitHub

For this example, we'll create an instance of a blog post from the command line. Initialize a script and name it create_post.py

import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'commands.settings')

import django
django.setup()

from django.contrib.auth import get_user_model
from posts.models import Category, Post

User = get_user_model()

def select_category():
    # retrieve categories. (You can create some examples from the django admin)
    categories = Category.objects.all().order_by('created_at')
    print('Please select a category for your post: ')
    for category in categories:
        print(f'{category.id}: {category}')
    category_id = input()
    category = Category.objects.get(id=category_id)
    return category


def select_author():
    # retrieve all users
    users = User.objects.all()
    print('Please select an author for your post: ')
    for user in users:
        print(f'{user.id}: {user}')
    user_id = input()
    user = User.objects.get(id=user_id)
    return user



def create_post():
    title = input("Title of your post: ")
    content = input("Long post content: ")
    category = select_category()
    author = select_author()
    Post(**locals()).save()
    print('Post created successfully!')

if __name__ == "__main__":
    create_post()

Here, we are creating an instance of a blog post. Notice how we handle the ForeignKey relations? Ensure that you assign an object instance of the relevant DB table to that field.

By running python create_post.py, we are then prompted for some input.

Writing custom django management commands method

As aforementioned, django-admin commands are executed by running python manage.py command_name an example of these are runserver, migrate and collectstatic. To get a list of available commands run python manage.py help. This displays a list of the available commands as well as the django app folder in which they're housed.

To register a custom admin command, add a management\commands directory in your django app folder. In our case, it's going to be in posts\management\commands.

Once this is set up, we can then initialize our custom scripts in the commands folder. For the first example, we'll write a command that flags the blog posts created before as published.

To do this create a file and name it publish_post.py

from django.core.management.base import BaseCommand, CommandError
from posts.models import Category, Post

class Command(BaseCommand):
    help = 'Marks the specified blog post as published.'

    # allows for command line args
    def add_arguments(self, parser):
        parser.add_argument('post_id', type=int)

    def handle(self, *args, **options):
        try:
            post = Post.objects.get(id=options['post_id'])
        except Post.DoesNotExist:
            raise CommandError(f'Post with id {options["post_id"]} does not exist')
        if post.published:
            self.stdout.write(self.style.ERROR(f'Post: {post.title} was already published'))
        else:
            post.published = True
            post.save()
            self.stdout.write(self.style.SUCCESS(f'Post: {post.title} successfully published'))

Django management command is composed of a class named Command which inherits from BaseCommand.

To handle arguments, the class utilizes argparse. The method add_arguments allows our function to receive arguments.

In our case, the function is expecting an argument that will be assigned the key post_id

The handle() function then evaluates the input and executes our logic.

In the example above, the kind of argument expected is called a positional argument and must be provided for the function to run. To do this, we run python manage.py publish_post 1 (or any post primary key)

Another type of argument called Optional argument can be applied to the methods, as the name implies, the lack of these will not hinder the function execution.

An example is provided below. We'll initialize a file and name it edit_post.py. Populating it with the code below.

from django.core.management.base import BaseCommand, CommandError
from posts.models import Category, Post

class Command(BaseCommand):
    help = 'Edits the specified blog post.'

    def add_arguments(self, parser):
        parser.add_argument('post_id', type=int)

        # optional arguments
        parser.add_argument('-t', '--title',type=str, help='Indicate new name of the blog post.')
        parser.add_argument('-c', '--content',type=str, help='Indicate new blog post content.')

    def handle(self, *args, **options):
        title = options['title']
        content = options['content']
        try:
            post = Post.objects.get(id=options['post_id'])
        except Post.DoesNotExist:
            raise CommandError(f'Post with id {options["post_id"]} does not exist')

        if title or content:
            if title:
                old_title = post.title
                post.title = title
                post.save()
                self.stdout.write(self.style.SUCCESS(f'Post: {old_title} has been update with a new title, {post.title}'))
            if content:
                post.content = content
                post.save()
                self.stdout.write(self.style.SUCCESS('Post: has been update with new text content.'))
        else:
            self.stdout.write(self.style.NOTICE('Post content remains the same as no arguments were given.'))

Here we are just editing a blog post title or content. To do this we can run python manage.py edit_post 2 -t "new title" to edit just the title

or python manage.py edit_post -c "new content" to edit just the content. We can provide both arguments should we wish to edit both title and content by `python manage.py edit_post 2 -t "new title again" -c "new content again".

Extra resources

  1. Django docs
  2. Simple is better than complex

That's it from me, should you have any questions, twitter dm is always open.

Sponsors

Please note that some of the links below are affiliate links and at no additional cost to you. Know that I only recommend products, tools and learning services I've personally used and believe are genuinely helpful. Most of all, I would never advocate for buying something you can't afford or that you aren't ready to implement.

Scraper API

Scraper API is a startup specializing in strategies that'll ease the worry of your IP address from being blocked while web scraping.They utilize IP rotation so you can avoid detection. Boasting over 20 million IP addresses and unlimited bandwidth.

In addition to this, they provide CAPTCHA handling for you as well as enabling a headless browser so that you'll appear to be a real user and not get detected as a web scraper. Usage is not limited to scrapy but works with requests, BeautifulSoup and selenium in the python ecosystem. Integration with other popular platforms such as node.js, bash, PHP and ruby is also supported. All you have to do is concatenate your target URL with their API endpoint on the HTTP get request then proceed as you normally would on any web scraper. Don't know how to webscrape? Don't worry, I've covered that topic extensively on the webscraping series. All entirely free!

scraperapi

Using this scraper api link and the promo code lewis10, you'll get a 10% discount on your first purchase!! You can always start on their generous free plan and upgrade when the need arises.

Digital Ocean

Looking for cloud hosting for your projects? Digital Ocean is just the right partner for you.

They make it simple to launch in the cloud and scale up as you grow – with predictable pricing, team accounts, and more. Their intuitive control panel and API give you time to build more and spend less time managing your infrastructure.

Using this digitalocean link to sign up, you'll get $100 in credit for the first two months!

Share to social media