How To Create a Custom manage.py Command In Django

How To Create a Custom manage.py Command In Django

INTRODUCTION

As a Django developer, one of the handiest command line (CL) utilities you will often use are the Django's manage.py management commands. You use it to perform manifold administrative tasks like starting an app, running local host server, synchronizing database models, creating super users, and a lot of other cool tasks.

How nice would it be to add your custom commands to the manage.py commands tailored to your administrative needs, ranging from loading data into a database, to creating new permissions and groups for your user model?

In this technical discourse, I am going to show you how you can add custom commands to the python manage.py commands. We will create a command to load and delete book data from our database in a book app we will create.

Prerequisites

To follow up with this tutorial without hurdles, I expect you to have:

• little experience with Django.

• little experience with the command line.

Django manage.py command

The manage.py is a python file that Django creates whenever you start a Django project with the django-admin command-line utility. For most cases, the django-admin —though sparingly used— and manage.py command-line utilities does the same thing.

Asides from the similarity in their uses, They both have one of the usual Command Line Interface (CLI) Syntax:


<program> <command> [options] [arguments]

django-admin <command> [options] [arguments]

manage.py <command> [options][arguments]

In the above syntax:

• The command specifies the action the program will perform. It must either be one of the file names from the core.management.commands folder or a name of the custom command we created ourselves.

Run python manage.py help --commands to display the list of all manage.py commands

•The options for a given command control how the program will execute the command. They are positional arguments ( optional or required ).

Take for instance:

python manage.py migrate --database = mydb

The above command has a command word of migrate and an option of --database.

The whole command makes migration into the database that the --database option specified ( mydb ) instead of the default database in our settings.py.

The command options modify the behavior of a command.

• The arguments are external values you pass to the command. They are values that you give to the command to work on. For instance, the mydb database passed to the above command is an argument.

Creating the Book App

In your terminal,

  1. Install Django
    $pip install django
    
  2. Start a BookApp Project
    $django-admin startproject BookApp
    
    This command creates a project skeleton for us. It includes BookApp/BookApp/ folder.
  3. Change into the BookApp directory

     $cd BookApp
    
  4. Create a Book Application.

     $python manage.py startapp Book
    
  5. Open the /BookApp/settings.py and add the Book app we installed to the INSTALLED_APP list:
INSTALLED_APPS = [
       ….
    'Book'
]

After running the above commands, You should have a project tree as shown below:

20220515_003949

Run python manage.py runserver to start the development server at port 8000.

20220515_003958

Click on the localhost link — http://127.0.0.1:8000 —, you should get the custom Django welcome page that indicates the installation was successful.

20220515_004004

Creating Model

We will create a simple book model with fields for name, author, and ISBN.

We will load data into the database with the custom command that we will add to the python manage.py managements command.

Open the Book/models.py and let create a simple model Book model with the above fields :

from django.db import models

# Create your models here
class Book(models.Model):
    name = models.CharField(max_length=60)
    author = models.CharField(max_length=50)
    isbn = models.CharField(unique=True, max_length=50)

    def __str__(self):
        return self.name

In the code above:

• We import the models class from django.db

• We created a Book database model that inherits from models.Model.

• We created three Char fields ( name, author, isbn ) in the book database model.

•We provided a unique =True for the isbn field so that we can’t store two different books with the same ISBN.

• We added the __str__ method for a nice representation of our book object. The method returns the name of the book object.

In the terminal, run python manage.py makemigrations then python manage.py migrate to create a Book table for our model in the database.

Adding Custom Command

To add a custom manage.py management command:

  1. Add a management/commands directory In the Book app folder.

    For every python file that does not start with an underscore in that directory, Django will register a manage.py command. That is, if we create a populate.py file and _delete.py, Django will only add the populate command to the manage.py command list and ignore the _delete.py file. (Source, Django docs)

  2. Create a populate.py file in the management/commands directory.

The populate.py python file must define a Command class that extends Django's BaseCommand class. These are the only requirements required to create and add the populate command to the manage.py command list.

With the populate command we will create, We will populate the database with the following data :

book_data = [
    {
    "name": "Introduction to Django",
      "author": "Mustapha Ahmad",
      "isbn": 1298349059
    },
    {
    "name": "A Practical Guild to Technical Writing",
    "author": "AbdulAzeez AbdulAzeez",
      "isbn": 145378263
    },
    {
    "name": "Building API with Fast API",
      "author": "AbdulRahman Habeeb",
      "isbn": 1293279067
    },
    {
   "name": "Training a Machine Learning Model with Keras",
      "author": "Lawal Afeez",
      "isbn": 127490345
    },

]

Add the data above to the populate.py python file.

Code Snippet

from django.core.management.base import BaseCommand, CommandError
from django.db.utils import IntegrityError
from Book.models import Book


class Command(BaseCommand):
    help = "Populates the database with the data above"

    def handle(self, *args, **options):
        try:
            for data in book_data:
                new_book = Book.objects.create(name=data["name"],
                                               author=data["author"],
                                               isbn = str(data["isbn"]))
                new_book.save()
        except for IntegrityError:
            raise CommandError(f" Book with the {data['isbn']} ISBN already exist in the book database")
        self.stdout.write(self.style.SUCCESS("All book data added successfully !"))
 successfully !"))

Imports

  1. From django.core.management.base, We import the CommandError and the BaseCommand class that the required Command class will subclass.

  2. From django.db.utils, we import the IntegrityError that we handled in the try and except block whenever the UNIQUE constraint failed ( adding data that is already in the database to the database ) due to the unique requirements of the ISBN on our model. if we try to add a book with an ISBN that is already in the database, Django will generate an IntergrityError.

  3. From the Book app, we import the Book database model we created earlier.

Command Class

The Command class as I said earlier, is the only requirement for this to work.

The help variable describes what the populate command does and it's optional.

The logic of the operation we want our command to perform will be in the function body of the handle method. The method takes the usual class argument self and wildcard arguments*args and **options. Let's take a look at the wildcard **option argument, We’ll come back to the handle method a bit later.

Our command can accept both positional argument and keyword options, and aside from that, Django adds additional options like --help to our command. All these are passed into the handle method via the wildcard **options. The choice of the name of **options rather than the usual **kwargs is merely for descriptive purposes.

Back in the handle method,

  1. We loop through the book_data with a for loop.

  2. We tryto create a book data for every data in the book_data and save it.

  3. If the current data's ISBN already exists in the database, we raise a CommandError with the error message —f" Book with the {data['isbn']} ISBN already exist in the book database" — to the terminal in the except block.

  4. If there is no error, we output a success message to the standard output ( Screen ) with the following line:

self.stdout.write(self.style.SUCCESS("All book data added successfully !"))

The command for our populate command will be python manage.py populate.

Open the terminal and type in the command. If you type in the codes correctly, you should see the success message ( All book data added successfully ) we specified as shown below:

20220515_4012

If we try to run the populate command again with the same data, we will get the error message because books with the ISBN exist already in the database :

20220515_4014

To confirm if our book data were created successfully;

  1. open the python shell:

    $ python manage.py shell
    
  2. import the Book database from the Book/models file

  3. query for all the book data in the database

  4. get the total number of books

    #Import the Book Database from the Book/model file
In [1]: from Book.models import Book

#Query for all the Book Objects 
In [2]: Book.objects.all()
Out[2]: <QuerySet [<Book: Introduction to Django>, <Book: A Practical Guild to Technical Writing>, <Book: Building API with Fast API>, <Book: Training a
 Machine Learning Model with Keras>]>

#Get the counts of the book objects
In [3]: Book.objects.all().count()
Out[3]: 4

As seen above, we have loaded the book data with the custom command we created.

Additional Argument

Like most command-line commands, the custom management command we created can accept arguments and perform varying operations based on the arguments we pass in.

The argument could either be optional arguments or positional arguments.

For our command to accept arguments we will have to add an add_arguments method to our Command class.

I will show you how to add positional arguments and optional arguments in this section.

When our custom command accepts positional arguments, the positional arguments are required and must be provided.

Positional argument

TO-DO

For the positional argument;

  1. we will create a new delete command to delete a specific book. The specific book will be identified by its ID. Therefore, the positional argument our command will accept will be an ID which is an integer.

  2. We will query for the Book with the ID that we passed to our command.

  3. We will delete the book If there is a book with the ID. Else,

  4. we will return an error message that there is no book with such ID.

To create our custom delete command, follow the procedure we followed earlier when we created the populate command. Do you still remember the procedure?

• Create a delete.py file in the management/command folder.

• Import BaseCommand and CommandError from django.core.management.base:

from django.core.management.base import BaseCommand, CommandError

• Import Book model from Book.models:

from Book.models import Book

• Create the required Command class and add the handle method.

For our custom command to accept Positional arguments, We will add an add_arguments method to the Command class, it will take self and parser as its arguments. The parser holds all the necessary information required to parse the command line arguments into Python data types .

The parser argument is an object of the Python Argparse library and the parser has an add_argument method that allows us to add a single command-line argument and define the behavior of the argument we will add.

Add the method below to the Command class:

def add_arguments(self, parser):
    parser.add_argument("id", nargs=1, type=int, help= “delete a database data with the id that is passed in”)

In the above code snippet, we created the add_arguments method and we passed self and parser to it as an argument.

In the add_arguments method, the parser object calls its add_argument method with the following :

id: which is the name of the argument that our command will receive.

nargs: which is the number of arguments we can pass to the command.

type: which is the data type of the argument that our command will receive.

Aside from these three, the parser.add_argument() accept several other arguments listed below with their purpose in the official Argparse documentation

TO-DO

In the handle method of the Command class we created for our custom delete command, we will do the following:

  1. we will query for the Book with the ID argument we passed to the parser.add_argument.

  2. If a book with the ID is present in our database, we will delete it.

  3. If it's not present, we will catch the error in a Try-Except block and print a custom friendly error:

Code Snippet


def handle(self, *args, **options):
    book_id = options["id"][0]
    try:
        book = Book.objects.get(id = book_id)
    except:
        raise CommandError(f" Book with the id number {book_id} does not exist in the book database")

    book.delete()
    self.stdout.write(self.style.SUCCESS("Book data deleted successfully !"))

The value of the id that we will pass to our command is stored in the wildcard **options argument. The **options is a dictionary of key-value pairs of data.

The id is a key in the options method parameter and id has a value of the list of arguments we passed to our command. That is, if we passed 1 to our command, the value of id in the **options method parameter will be [ 1 ], if we passed 1 and 4, the value of id will be [ 1, 4 ].

In our case, we specified the parser.add_argument’s nargs to 1 — indicating we will pass one argument to our command— hence the id will be a python list with a list item of the id value that we passed to our command.

To get the value from the list, we need the following line:

book_id = options[“id”][0]

The line gets the first index of the list since the list will have a length of 1.

In the try and except block, we queried the database for the Book object which has an ID of the id value we will pass to our command :

book = Book.objects.get(id=book_id)

If a book with the id of book_id does not exist, It will trigger an error telling us the book does not exist:

Book.models.DoesNotExist: Book matching query does not exist.

A wildcard except ( except with no error specified ) will catch this and other errors and raise the CommandError with the error message we specified.

raise CommandError(f" Book with the id number {book_id} does not exist in the book database")

If the book exists, the query will be successful with no error, hence the exception will not be raised.

we then delete the book:

$ book.delete()

If the delete operation was successful ( No error ), We write a success message to the standard output which is the screen:

self.stdout.write(self.style.SUCCESS("Book data deleted successfully !"))

Testing

Just like the populate command, The syntax for our delete command will be:

$ python manage.py delete <id>

With an additional placeholder for the id value.

To delete a book with the id of 37, type the command below in the terminal:

$ python manage.py delete 37

We get the success message indicating that the command we created deleted the book successfully:

20220515_4023

In the python shell environment, let's confirm if the command deletes the book indeed :

$ Book.objects.get(id=35)

20220515_004023

So, we got the DoesNotExist Error with an error message `Book matching the query does not exist since we have already deleted the book. This shows the command work!

Optional Arguments

We must provide the positional argument that we added to our delete command anytime we use the command.

We can also add an optional command argument to our delete command which can modify the operation of the delete command. Optional command-line arguments which are also called flag has-or--` preceding them.

TODO

I will add an option -i optional argument to our delete command.

When we pass it to our command in addition to the ID value:

• the delete command
will interact with us and ask if we want to delete the book with the ID value.

• If we type in yes or y, the book will be deleted.

• If we type in No or N, the command will cancel the operation.

CODE and CODE Description

def add_arguments(self, parser):
    parser.add_argument("data", nargs=3)
    parser.add_argument("-i", action="store-true",required=False)

We modified the add_arguments method by adding the line below to add a new option to our command:

parser.add_argument(“-i”, action=”store-true”, required=False)

In the line above,

-i is the flag name

action=”store-true” stores Python Boolean value True if we pass the optional flag -i to our command and False otherwise.

required=False means we can choose to leave out the flag -i in our delete command

Modify the handle method as shown below:

def handle(self, *args, **options):
    book_id = options["id"][0]
    try:
        book = Book.objects.get(id = book_id)
    except:
        raise CommandError(f" Book with the id number {options['id']} does not exist in the book database")
    is_i = options["i"]
    if is_i:
        yes_no = input(f"Are sure you want to delete \'{book}\' book from the book database (y/n): ")
        if "y" in yes_no:
            book.delete()
        else:
            self.stdout.write(self.style.SUCCESS("Operation Canceled"))
            return
    else:
        book.delete()
    self.stdout.write(self.style.SUCCESS("Book data deleted successfully !"))

In the code above,

  1. We queried for the book and handled the error if the book does not exist.

  2. We get the value of the flag -i from the options and assign it to the variable is_i. The value of i can either be True or False. It is True if we pass the ‘-i’ flag to our delete command and false otherwise.

  3. If is_i is True, we used the Python input function to prompt the user for confirmation before our command deletes the book we specified.

  4. If the user input yes or a string that contains y, the command delete the book.

  5. Else, if the user inputs any other words, the command cancels the operation.

Testing

Let's test if everything works.

We will try and delete the book with an ID of 38 with an interactive option by adding the -i flag.

In the terminal, enter the delete command with the flag -i for interaction:

$ python manage.py delete 38 -i

As seen below, the command prompts us for a confirmation:

$ python manage.py delete 38 -i
Are sure you want to delete A Practical Guild to Technical Writing from the book database (y/n): n

Screenshot (42)

When we input n or no, the command cancels the operation.

When we initialized the command again with the -i flag and input yes when the command prompts us for confirmation, the command deletes the book successfully.

Conclusion

Though Django seems to have built most of what we need to build a functional web application, It also comes with a lot of flexibility. It allows us to create custom things that suit our requirements perfectly. One of the flexibility that Django comes with is adding custom management commands. In this tutorial, I showed you how you can add to the Django manage.py commands.

We added a populate command that populates the database with data and a delete command to delete a book we specified. We also add an interactive option to the delete command. The interactive option prompts the user for confirmation before performing the delete operation.

Django’s command framework is just an API wrapper on the python Argparse library.

The full code used in this tutorial can be found on Github.