Build an Asset Management System

Build a Django backend to track company hardware with a polished admin panel.

Introduction

30 Second Summary

Every growing company hits the same problem. Laptops go missing, nobody knows who has the spare monitor, and repair requests get lost in email threads.

In this project, you will build a Django backend and frontend that tracks company hardware, assigns assets to employees, manages repair tickets, and presents it all through a login-protected dashboard with charts and role-based views.

What You'll Build

You will log into a professional dashboard, see asset counts on live charts, filter assets by status, assign hardware to employees, and raise repair tickets from a clean web interface that works for both admins and employees.

By the end of this project, you'll have:

  • A login-protected dashboard with live counts of total assets, available hardware, open repair tickets, and low-stock inventory items, visualized with a Chart.js doughnut chart.
  • Role-based views where admins see every asset in the system and employees see only the hardware assigned to them, with search and status filtering built in.
  • Frontend forms for adding new assets and raising repair tickets, backed by Django's relational models connecting assets to employees through assignments and ForeignKey relationships.
  • Secret Mission: Write a custom management command to seed the database with realistic dummy data and add TabularInline views for related objects.

Are there any prerequisites?

This project assumes basic Django familiarity. You should have completed a Django tutorial or built a simple project before. You do not need to be an expert.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Django Project

Every Django project starts with a clean workspace. A virtual environment keeps your project's packages isolated from anything else on your machine, and Django's scaffolding commands generate the files you need to get a server running.

In this step, you will create an isolated Python environment, install Django 6.1, and scaffold the project structure. By the end, you will have a working dev server confirming everything is wired up correctly.

In this step, get ready to:
  • Create a virtual environment and install Django 6.1.
  • Scaffold the asset_manager project and inventory app.
  • Run the dev server to confirm the installation works.
Create a virtual environment and install Django 6.1

A virtual environment gives your project its own copy of Python packages. This means the Django version you install here will not conflict with other projects on your machine.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir asset_manager
cd asset_manager
  • Create a Python virtual environment inside this folder by running:
py -m venv venv
  • Confirm the venv folder was created by running:
dir

You should see a venv folder listed in the output.

  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

.\venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

Not seeing (venv) in your prompt?

On Windows, if you get a "running scripts is disabled" error, open PowerShell as Administrator and run Set-ExecutionPolicy RemoteSigned. Then close and reopen your terminal and try activating again.

On macOS/Linux, make sure you used source before the path. Running the script directly will not activate it in your current shell.

Help me troubleshoot my virtual environment activation

  • Install Django 6.1 by running:
py -m pip install Django==6.1

Why py instead of python3?

On Windows, the py launcher is the recommended way to run Python. On macOS or Linux, replace py with python3 in every command throughout this project.

  • Verify Django installed correctly by running:
py -m django --version

You should see 6.1 printed in your terminal.

Seeing "No module named django"?

Make sure your virtual environment is active. You should see (venv) in your prompt. If not, run the activation command again and then re-run the install command.

Help me fix my Django installation

Scaffold the project and app

Django separates configuration from application code. A "project" holds settings and URL routing. An "app" holds the actual models, views, and logic for one feature area.

  • Scaffold the Django project by running:
django-admin startproject asset_manager .

What does the dot at the end do?

The . tells Django to create the project files in the current directory instead of making a nested folder. Without it, you would end up with asset_manager/asset_manager/ which is confusing to navigate.

  • Create the inventory app by running:
py manage.py startapp inventory
  • Confirm both the project and app were created by running:
dir

You should see manage.py, an asset_manager folder, an inventory folder, and your venv folder in the output.

Django created the app, but it does not know about it yet. You need to register it in the project settings.

  • Open asset_manager/settings.py in VS Code.
  • Find the INSTALLED_APPS list. It looks like this:
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]
  • Add "inventory" as the last item in the list, so it looks like this:
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "inventory",
]

Why register the app?

Django only loads apps that appear in INSTALLED_APPS. Without this line, Django would ignore your inventory models and admin registrations entirely.

  • Save asset_manager/settings.py.
Run the dev server to confirm the installation works

Everything is scaffolded and registered. Time to see if Django can actually start up and serve a page.

Before you run the next command, what do you think will happen? Will Django show an error, or will it start successfully?

  • Start the Django development server by running:
py manage.py runserver
  • Open your browser and navigate to http://127.0.0.1:8000/.

You should see a page with a rocket icon confirming the install worked successfully. This proves Django is installed correctly and your project is running.

What about the migration warning?

Your terminal will show a warning about unapplied migrations. You can safely ignore this for now. You will run migrations in the next step when you create your custom User model.

🙋‍♀️ Seeing an error instead of the welcome page?

Check that your terminal is in the same folder as manage.py. Run dir and confirm you see manage.py listed.

If you see a ModuleNotFoundError, your virtual environment is likely not active. Check for (venv) in your prompt and activate it again if missing.

Help me fix my Django dev server

  • Stop the server by pressing Ctrl+C in your terminal. You will restart it later.

✔️ Awesome, I've got everything!

Great. Make sure you have saved asset_manager/settings.py with "inventory" in INSTALLED_APPS.

ⓧ I'd like to double check the full code

Most files in this step were auto-generated by Django. The only manual change you made is to asset_manager/settings.py. Your INSTALLED_APPS section should look exactly like this:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "inventory",
]

Your SECRET_KEY will be a different random string. That is expected. Django generates a unique key for each new project.

Confirm that your folder structure includes manage.py at the root, an asset_manager/ config folder (with settings.py, urls.py, wsgi.py), and an inventory/ app folder (with models.py, admin.py, apps.py).

Your Django project is alive. You have an isolated environment, a running server, and an app ready for models. Next up, you will define a custom User model with Admin and Employee roles before running your first migration.

Build the Custom User Model

The default Django User model handles usernames, passwords, and emails. It has no concept of job roles.

Your asset management system needs to distinguish between Admins (who manage everything) and Employees (who get assigned assets). You must define this custom User model before running your first migration, because changing AUTH_USER_MODEL after the first migrate breaks Django's auth system.

In this step, get ready to:
  • Define a custom User model with Admin and Employee roles.
  • Register the custom User in the admin panel.
  • Create a superuser and confirm the role field appears.
Define the custom User model

Django's AbstractUser gives you the full built-in User (username, password, email, permissions) as a starting point. You only need to add your custom role field on top.

  • Open inventory/models.py in VS Code and replace its contents with this code:
from django.contrib.auth.models import AbstractUser
from django.conf import settings
from django.db import models


class User(AbstractUser):
    # Define role choices using Django's TextChoices
    class Role(models.TextChoices):
        ADMIN = "ADMIN", "Admin"
        EMPLOYEE = "EMPLOYEE", "Employee"

    # Add a role field with Employee as the default
    role = models.CharField(
        max_length=10,
        choices=Role.choices,
        default=Role.EMPLOYEE,
    )

    def __str__(self):
        return f"{self.username} ({self.get_role_display()})"

What does this code do?

  • class Role(models.TextChoices) defines two role options using Django's type-safe enum pattern. Each entry has an internal value ("ADMIN") and a human-readable label ("Admin").
  • role = models.CharField(...) stores the role as a short string in the database. New users default to Employee.
  • __str__ controls how each user appears in the admin panel and shell. It shows the username with their role in parentheses.
  • Save inventory/models.py.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

from django.contrib.auth.models import AbstractUser
from django.conf import settings
from django.db import models


class User(AbstractUser):
    class Role(models.TextChoices):
        ADMIN = "ADMIN", "Admin"
        EMPLOYEE = "EMPLOYEE", "Employee"

    role = models.CharField(
        max_length=10,
        choices=Role.choices,
        default=Role.EMPLOYEE,
    )

    def __str__(self):
        return f"{self.username} ({self.get_role_display()})"
Configure settings and run migrations

Django needs to know you are replacing its built-in User model. You do this by setting AUTH_USER_MODEL in your project settings before running any migrations.

  • Open asset_manager/settings.py and scroll to the very bottom of the file.
  • Add this line at the end, below DEFAULT_AUTO_FIELD:
AUTH_USER_MODEL = "inventory.User"

Why must this come before migrate?

Django's migration system builds its auth tables around whatever AUTH_USER_MODEL points to. If you run migrate first with the default User, then try to swap in your custom model, Django cannot cleanly undo the built-in auth tables. Setting it now avoids that pain entirely.

  • Save asset_manager/settings.py.
  • If your dev server is still running in the terminal, stop it by pressing Ctrl+C.
  • Generate the migration file for your new User model by running:
py manage.py makemigrations

You should see output confirming a migration was created for the inventory app:

Migrations for 'inventory':
  inventory/migrations/0001_initial.py
    - Create model User

Seeing an error instead?

  • If you see No installed app with label 'inventory', make sure "inventory" is listed in INSTALLED_APPS in settings.py.
  • If you see ModuleNotFoundError, check that your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.

Still stuck? Help me debug my makemigrations error.

Register the User in admin and apply migrations

Before applying migrations, set up the admin registration so everything is ready when you log in. Django's built-in UserAdmin class gives you the standard user management interface. You just need to extend it with your new role field.

  • Open inventory/admin.py and replace its contents with this code:
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .models import User


@admin.register(User)
class CustomUserAdmin(UserAdmin):
    # Columns shown in the user list
    list_display = ["username", "email", "role", "is_staff"]
    # Sidebar filters
    list_filter = ["role", "is_staff"]
    # Add the role field to the existing user edit form
    fieldsets = UserAdmin.fieldsets + (
        ("Role", {"fields": ("role",)}),
    )
    # Add the role field to the user creation form
    add_fieldsets = UserAdmin.add_fieldsets + (
        ("Role", {"fields": ("role",)}),
    )

What does this code do?

  • @admin.register(User) is the modern decorator approach to registering a model with Django's admin site.
  • list_display controls which columns appear in the user list table. Without it, you only see the __str__ output.
  • fieldsets and add_fieldsets extend the built-in user form sections with a new "Role" section containing your custom field.
  • Save inventory/admin.py.
  • Apply all pending migrations to create the database tables by running:
py manage.py migrate

You should see Django applying multiple migrations (for auth, admin, sessions, and your custom inventory User model), each ending with OK.

Seeing a migration error?

  • If you see an error about auth.User or inconsistent migration history, you may have run migrate before setting AUTH_USER_MODEL. Delete db.sqlite3 and the inventory/migrations/ folder, then re-run py manage.py makemigrations followed by py manage.py migrate.
  • If you see ImportError from admin.py, double-check that from .models import User matches the class name in your models.py exactly.

Need more help? Help me fix my Django migration error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both inventory/admin.py and asset_manager/settings.py.

ⓧ I'd like to double check the full code

Here is the full inventory/admin.py:

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .models import User


@admin.register(User)
class CustomUserAdmin(UserAdmin):
    list_display = ["username", "email", "role", "is_staff"]
    list_filter = ["role", "is_staff"]
    fieldsets = UserAdmin.fieldsets + (
        ("Role", {"fields": ("role",)}),
    )
    add_fieldsets = UserAdmin.add_fieldsets + (
        ("Role", {"fields": ("role",)}),
    )

And the last line of asset_manager/settings.py should be:

AUTH_USER_MODEL = "inventory.User"
Create a superuser and verify the admin panel

Your database tables exist. Now create an admin account so you can log into the Django admin panel and see your custom role field in action.

  • Create your superuser account by running:
py manage.py createsuperuser

What will this command ask?

The command prompts you for a username, email address, and password (entered twice). Your password input stays hidden as you type. Pick something simple you will remember, like admin for the username and a short password.

Django may warn that your password is too common. Type y to confirm and continue. This is just a local development database.

  • Enter a username, email, and password when prompted.
  • Start the development server by running:
py manage.py runserver

Before you open the admin panel, what do you expect to see when you look at a user's edit form? Do you think the role field will appear automatically?

  • Open your browser and navigate to http://127.0.0.1:8000/admin/.
  • Log in with the superuser credentials you just created.
  • Click Users in the left sidebar.
  • Click on your superuser's username in the list.
  • Scroll down to the bottom of the form.

You should see a Role section at the bottom of the user form with a dropdown showing Admin and Employee as options. The user list also shows columns for username, email, role, and staff status.

Don't see the Role field?

  • Make sure you saved inventory/admin.py after adding the CustomUserAdmin class.
  • Check that your fieldsets line uses UserAdmin.fieldsets + (...) with a trailing comma after the inner tuple. A missing comma causes a silent failure.
  • If the Users section is missing entirely, confirm AUTH_USER_MODEL = "inventory.User" is in your settings.py and restart the server.

Still stuck? Help me figure out why my Role field isn't showing in the Django admin.

Your custom User model is locked in. You can now distinguish between Admins and Employees across your entire system. Next up, you will define the Asset model and get your first taste of the admin panel's default list view.

Create the Core Asset Model

Your admin panel can manage users, but it has no concept of the hardware you need to track. Laptops, monitors, keyboards all need a structured record in the database.

In this step, you will define an Asset model with categorized types and statuses, register it in the admin panel, and immediately see it in action. This is your early visible win.

In this step, get ready to:
  • Define the Asset model with type choices, status choices, and core fields.
  • Register the Asset model in admin and run migrations.
  • Add test assets via the admin panel and observe the default list view.
Define the Asset model

Each asset in your system needs a type (laptop, monitor, etc.) and a status (available, assigned, under repair, retired). Django's TextChoices classes give you a clean, type-safe way to define these options directly inside the model.

  • In VS Code, open inventory/models.py.
  • Below the User class, add the following Asset model with its TextChoices and fields:
class Asset(models.Model):
    class AssetType(models.TextChoices):
        LAPTOP = "LAPTOP", "Laptop"
        MONITOR = "MONITOR", "Monitor"
        KEYBOARD = "KEYBOARD", "Keyboard"
        PHONE = "PHONE", "Phone"
        OTHER = "OTHER", "Other"

    class Status(models.TextChoices):
        AVAILABLE = "AVAILABLE", "Available"
        ASSIGNED = "ASSIGNED", "Assigned"
        UNDER_REPAIR = "UNDER_REPAIR", "Under Repair"
        RETIRED = "RETIRED", "Retired"

    # Core fields every asset needs
    name = models.CharField(max_length=200)
    asset_type = models.CharField(
        max_length=20,
        choices=AssetType.choices,
        default=AssetType.OTHER,
    )
    serial_number = models.CharField(max_length=100, unique=True)
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.AVAILABLE,
    )
    purchase_date = models.DateField()

What does this code do?

  • AssetType and Status are inner classes that define the allowed values for dropdown fields. Each entry maps a database value (like "LAPTOP") to a human-readable label (like "Laptop").
  • serial_number has unique=True so no two assets can share the same serial number.
  • purchase_date is a DateField that stores when the asset was bought.

The model still needs a way to display itself and a default sort order. Add the __str__ method and Meta class directly below purchase_date:

    def __str__(self):
        return f"{self.name} ({self.serial_number})"

    class Meta:
        ordering = ["-purchase_date"]

What does this code do?

  • __str__ controls how the asset appears in the admin panel and anywhere Django prints the object. It will show something like MacBook Pro 16 (MBP-2024-001).
  • The Meta class with ordering = ["-purchase_date"] sorts assets by purchase date descending (newest first) by default.
  • Save inventory/models.py.
  • Generate the migration for your new model by running this command:
py manage.py makemigrations

You should see output showing a new migration was created, including Create model Asset.

Seeing an error about syntax or indentation?

Make sure the Asset class is at the same indentation level as the User class (not nested inside it). The __str__ method and Meta class must be indented one level inside Asset.

Help me debug my Asset model

Register Asset in the admin panel

Django does not automatically show models in the admin panel. You need to explicitly register each model you want to manage.

  • Open inventory/admin.py.
  • Find this import line:
from .models import User
  • Add Asset to the import so it looks like this:
from .models import User, Asset
  • At the very bottom of the file, below the CustomUserAdmin class, add this line to register the Asset model:
admin.site.register(Asset)

Why not use @admin.register here?

The plain admin.site.register(Asset) call gives you the completely default admin experience with zero customization. You will see exactly how bare-bones it is in a moment. In a later step, you will replace this with a full ModelAdmin class.

  • Save inventory/admin.py.
  • Apply the migration to create the Asset table by running:
py manage.py migrate

You should see output confirming the migration was applied.

Seeing an import error?

Check that inventory/admin.py imports Asset from .models and that the class name matches exactly (capital A).

Help me fix my admin registration

Add test assets and observe the default admin

Your Asset model is live. Time to add some records and see how the default admin handles them.

  • Start the dev server by running:
py manage.py runserver
  • Open http://127.0.0.1:8000/admin/ in your browser and log in with your superuser credentials.
  • You should now see an Assets section in the admin dashboard.
  • Click Assets, then click Add Asset in the top right.
  • Fill in the form with these values:
  • Name: MacBook Pro 16
  • Asset type: Laptop
  • Serial number: MBP-2024-001
  • Status: Available
  • Purchase date: 2024-06-15
  • Click Save and add another.
  • Add a second asset: Name Dell UltraSharp 27, type Monitor, serial MON-2024-001, status Assigned, date 2024-03-10.
  • Click Save and add another.
  • Add a third asset: Name Logitech MX Keys, type Keyboard, serial KB-2024-001, status Under Repair, date 2024-01-20.
  • Click Save.

Now go back to the asset list. Before you look, what do you expect the list to show? Columns for each field? Filters in a sidebar?

  • Click Assets in the breadcrumb navigation at the top to return to the list view.

You should see your three assets listed as plain text strings like MacBook Pro 16 (MBP-2024-001). No columns. No sidebar filters. No search box.

Why does the list look so bare?

The plain admin.site.register(Asset) call gives you the absolute minimum admin interface. Django only shows the __str__ output for each record in a single column.

With three assets this is manageable. Imagine 50 or 100 records with no way to filter by status or search by serial number. You would spend minutes scrolling to find one laptop. This is the gap you will close in Step 5 with full admin customization.

✔️ Awesome, I've got everything!

Great. Double check you have saved both inventory/models.py and inventory/admin.py.

ⓧ I'd like to double check the full code

Here is the full inventory/models.py at this point:

from django.contrib.auth.models import AbstractUser
from django.conf import settings
from django.db import models


class User(AbstractUser):
    class Role(models.TextChoices):
        ADMIN = "ADMIN", "Admin"
        EMPLOYEE = "EMPLOYEE", "Employee"

    role = models.CharField(
        max_length=10,
        choices=Role.choices,
        default=Role.EMPLOYEE,
    )

    def __str__(self):
        return f"{self.username} ({self.get_role_display()})"
class Asset(models.Model):
    class AssetType(models.TextChoices):
        LAPTOP = "LAPTOP", "Laptop"
        MONITOR = "MONITOR", "Monitor"
        KEYBOARD = "KEYBOARD", "Keyboard"
        PHONE = "PHONE", "Phone"
        OTHER = "OTHER", "Other"

    class Status(models.TextChoices):
        AVAILABLE = "AVAILABLE", "Available"
        ASSIGNED = "ASSIGNED", "Assigned"
        UNDER_REPAIR = "UNDER_REPAIR", "Under Repair"
        RETIRED = "RETIRED", "Retired"

    name = models.CharField(max_length=200)
    asset_type = models.CharField(
        max_length=20,
        choices=AssetType.choices,
        default=AssetType.OTHER,
    )
    serial_number = models.CharField(max_length=100, unique=True)
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.AVAILABLE,
    )
    purchase_date = models.DateField()

    def __str__(self):
        return f"{self.name} ({self.serial_number})"

    class Meta:
        ordering = ["-purchase_date"]

And here is the full inventory/admin.py:

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .models import User, Asset


@admin.register(User)
class CustomUserAdmin(UserAdmin):
    list_display = ["username", "email", "role", "is_staff"]
    list_filter = ["role", "is_staff"]
    fieldsets = UserAdmin.fieldsets + (
        ("Role", {"fields": ("role",)}),
    )
    add_fieldsets = UserAdmin.add_fieldsets + (
        ("Role", {"fields": ("role",)}),
    )


admin.site.register(Asset)

Your Asset model works and you can create records through the admin. But that list view is barely usable. In the next step, you will add the remaining models that connect assets to employees and repair logs. Then in Step 5, you will transform the entire admin into a polished management interface.

Add the Remaining Models

The Asset model alone cannot answer questions like "who has this laptop?" or "what's wrong with that monitor?" You need models that create relationships between assets, people, and maintenance records.

In this step, you'll define three new models that use ForeignKey fields to connect back to Asset and your custom User model. Each model represents a different part of the asset lifecycle: stock tracking, employee assignments, and repair logging.

In this step, get ready to:
  • Define InventoryItem, Assignment, and RepairTicket models with ForeignKey relationships.
  • Register all new models in admin and run migrations.
  • Verify relationships work by creating an Assignment that links an asset to a user.
Define the InventoryItem and Assignment models

InventoryItem tracks consumable stock like cables, chargers, and peripherals. Assignment records which employee has which asset and when they received it.

  • In inventory/models.py, scroll to the bottom of the file (below the Asset class) and add the InventoryItem model:
class InventoryItem(models.Model):
    item_name = models.CharField(max_length=200)
    item_type = models.CharField(max_length=100)
    quantity = models.IntegerField(default=0)
    threshold = models.IntegerField(default=5)

    def __str__(self):
        return f"{self.item_name} (Qty: {self.quantity})"

    @property
    def is_below_threshold(self):
        return self.quantity < self.threshold

What does this code do?

InventoryItem stores consumable supplies that get restocked, not unique tracked assets.

  • quantity and threshold are IntegerFields that track current stock vs. the minimum acceptable level.
  • The is_below_threshold property lets you check if an item needs restocking without writing a query each time.
  • Directly below the InventoryItem class, add the Assignment model:
class Assignment(models.Model):
    # Link to the asset being assigned
    asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="assignments")
    # Link to the employee receiving the asset
    employee = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="assignments",
    )
    date_assigned = models.DateField()
    date_returned = models.DateField(null=True, blank=True)

    def __str__(self):
        return f"{self.asset.name} -> {self.employee.username}"

    class Meta:
        ordering = ["-date_assigned"]

What does this code do?

  • asset and employee are ForeignKey fields that create relationships to the Asset and User models. If either is deleted, the assignment is also deleted (CASCADE).
  • related_name="assignments" lets you access all assignments from the other side of the relationship (e.g. some_asset.assignments.all()).
  • date_returned is nullable because an asset that is currently assigned has not been returned yet.
  • Save inventory/models.py.
  • Confirm Django detects the new models by running:
py manage.py makemigrations

You should see output mentioning Create model InventoryItem and Create model Assignment.

Seeing an error about syntax or indentation?

Make sure both new classes are at the same indentation level as the Asset class (no extra indentation). Check that there is a blank line between the end of one class and the start of the next.

Also verify you have from django.conf import settings at the top of your file. It was added in a previous step.

Help me debug my makemigrations error for InventoryItem and Assignment.

Define the RepairTicket model and register in admin

RepairTicket logs maintenance issues for assets that need fixing. It tracks the problem description, the repair status, and which technician is handling it.

  • In inventory/models.py, add the RepairTicket model below the Assignment class:
class RepairTicket(models.Model):
    class TicketStatus(models.TextChoices):
        OPEN = "OPEN", "Open"
        IN_PROGRESS = "IN_PROGRESS", "In Progress"
        RESOLVED = "RESOLVED", "Resolved"

    asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="repair_tickets")
    issue = models.TextField()
    status = models.CharField(
        max_length=20,
        choices=TicketStatus.choices,
        default=TicketStatus.OPEN,
    )
    assigned_technician = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="repair_tickets",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"Ticket: {self.asset.name} - {self.get_status_display()}"

    class Meta:
        ordering = ["-created_at"]

What does this code do?

  • TicketStatus uses TextChoices just like your Asset status field. The three states track a ticket from open to resolved.
  • assigned_technician uses SET_NULL instead of CASCADE. If a technician's account is deleted, the ticket stays in the system with the technician field set to null.
  • created_at with auto_now_add=True automatically records the timestamp when the ticket is first created.
  • Save inventory/models.py.
  • Now switch to inventory/admin.py to register the three new models.
  • Find this import line near the top of inventory/admin.py:
from .models import User, Asset
  • Replace it with this expanded import that includes your new models:
from .models import User, Asset, InventoryItem, Assignment, RepairTicket
  • Then scroll to the bottom of the file (below the admin.site.register(Asset) line) and add these three registration lines:
admin.site.register(InventoryItem)
admin.site.register(Assignment)
admin.site.register(RepairTicket)

Why plain register instead of ModelAdmin classes?

For now, admin.site.register() gives you a working admin page for each model with zero configuration. You will replace these with fully customized ModelAdmin classes in the next step.

  • Save inventory/admin.py.
  • Create the migration for RepairTicket and apply all pending migrations by running:
py manage.py makemigrations
py manage.py migrate

You should see a new migration created for RepairTicket, followed by all migrations being applied with no errors.

Seeing an error about related_name conflicts?

If Django reports a clash on related_name, make sure Assignment uses related_name="assignments" and RepairTicket uses related_name="repair_tickets". Each related_name must be unique across all ForeignKey fields pointing to the same target model.

Help me fix my related_name conflict in Django.

Verify relationships by creating an Assignment

The real test of your ForeignKey fields is whether Django's admin shows working dropdown selectors that pull data from the related models.

  • Start the dev server by running:
py manage.py runserver
  • Open http://127.0.0.1:8000/admin/ in your browser.
  • You should now see Inventory items, Assignments, and Repair tickets listed alongside your existing Assets and Users.
  • Click Assignments.
  • Click Add assignment.

Before you fill in the form, do you expect the Asset and Employee dropdowns to contain data? Think about what you created in earlier steps.

  • In the Asset dropdown, select one of the test assets you added in the previous step.
  • In the Employee dropdown, select your superuser account.
  • Set Date assigned to today's date.
  • Leave Date returned empty (the asset is still assigned).
  • Click Save.

You should see your new Assignment in the list, displayed using the __str__ format: the asset name followed by an arrow and the username. The ForeignKey dropdowns pulled real data from your existing Asset and User records.

Dropdowns are empty or the Assignment section is missing?

  • If the section is missing from the admin sidebar, confirm you saved inventory/admin.py with the admin.site.register(Assignment) line and restarted the server.
  • If the Asset dropdown is empty, make sure you added test assets in the previous step. Go to Assets and add one first.
  • If the Employee dropdown is empty, check that you have at least one user (your superuser) in the system.

Help me figure out why my Assignment admin dropdowns are empty.

✔️ Awesome, I've got everything!

Great. Double check you have saved both inventory/models.py and inventory/admin.py.

ⓧ I'd like to double check the full code

Here is the full inventory/admin.py at this point:

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .models import User, Asset, InventoryItem, Assignment, RepairTicket


@admin.register(User)
class CustomUserAdmin(UserAdmin):
    list_display = ["username", "email", "role", "is_staff"]
    list_filter = ["role", "is_staff"]
    fieldsets = UserAdmin.fieldsets + (
        ("Role", {"fields": ("role",)}),
    )
    add_fieldsets = UserAdmin.add_fieldsets + (
        ("Role", {"fields": ("role",)}),
    )


admin.site.register(Asset)
admin.site.register(InventoryItem)
admin.site.register(Assignment)
admin.site.register(RepairTicket)

For inventory/models.py, scroll up and confirm your file contains (in order): the User class, the Asset class, the InventoryItem class, the Assignment class, and the RepairTicket class. Each should have its own __str__ method.

Your data model is now complete. You have assets, inventory, assignments, and repair tickets all connected through ForeignKey relationships. Next up, you will transform the bare-bones admin list views into a polished management interface with columns, filters, and search.

Customize the Admin Panel

In Step 3, you saw the problem firsthand. The default admin list view shows nothing but Asset object (1) repeated for every row. No columns, no filters, no search.

Now you transform it into a polished internal tool. Django's ModelAdmin class lets you configure list_display, list_filter, search_fields, and ordering to turn the admin from a developer tool into something a non-technical manager could actually use.

In this step, get ready to:
  • Replace the basic Asset registration with a full ModelAdmin class featuring columns, filters, and search.
  • Add ModelAdmin classes for InventoryItem, Assignment, and RepairTicket.
  • Test the filters and search to confirm the admin UX is dramatically improved.
Replace the basic Asset registration with AssetAdmin

The @admin.register() decorator replaces the plain admin.site.register() call and connects your custom configuration class to the model.

  • In inventory/admin.py, find this line:
admin.site.register(Asset)
  • Delete that line and replace it with the following AssetAdmin class:
@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
    # Show these fields as columns in the list view
    list_display = ["name", "asset_type", "serial_number", "status", "purchase_date"]
    # Add sidebar filters for status and type
    list_filter = ["status", "asset_type"]
    # Enable search by name or serial number
    search_fields = ["name", "serial_number"]
    # Newest purchases appear first
    ordering = ["-purchase_date"]

What does this code do?

  • list_display controls which fields appear as columns on the change list page. Instead of just the __str__ output, you now see five separate columns.
  • list_filter activates a filter sidebar on the right side of the page. Clicking a filter value narrows the list instantly.
  • search_fields adds a search box at the top that uses case-insensitive matching by default.
  • ordering sets the default sort. The - prefix means descending (newest first).
  • Save inventory/admin.py.
  • Return to your browser and navigate to the Assets section in the admin panel.

Before you refresh, think back to what the Asset list looked like in Step 3. What do you expect to see differently now?

  • Refresh the page.

You should see a clean table with columns for name, type, serial number, status, and purchase date. A filter sidebar appears on the right with options for status and asset type. A search box sits at the top of the list.

Still seeing the old plain list?

  • Make sure you deleted the admin.site.register(Asset) line. Having both the old registration and the new decorator causes a conflict.
  • Confirm you saved the file. Unsaved changes in VS Code show a dot on the file tab.

Why isn't my AssetAdmin class showing up in the Django admin?

Add ModelAdmin classes for the remaining models

The same pattern applies to InventoryItem, Assignment, and RepairTicket. Each model gets its own set of columns, filters, and search fields tailored to what matters most for that data.

  • In inventory/admin.py, find and delete these three lines:
admin.site.register(InventoryItem)
admin.site.register(Assignment)
admin.site.register(RepairTicket)
  • Replace those three lines with the following InventoryItemAdmin class:
@admin.register(InventoryItem)
class InventoryItemAdmin(admin.ModelAdmin):
    # Show quantity alongside threshold for at-a-glance stock checks
    list_display = ["item_name", "item_type", "quantity", "threshold"]
    list_filter = ["item_type"]
    search_fields = ["item_name"]
  • Below the InventoryItemAdmin class, add the AssignmentAdmin class:
@admin.register(Assignment)
class AssignmentAdmin(admin.ModelAdmin):
    # Show the asset-employee relationship with both dates
    list_display = ["asset", "employee", "date_assigned", "date_returned"]
    list_filter = ["date_assigned"]
    # Use __ to search across ForeignKey relationships
    search_fields = ["asset__name", "employee__username"]
    ordering = ["-date_assigned"]
  • Below AssignmentAdmin, add the final RepairTicketAdmin class:
@admin.register(RepairTicket)
class RepairTicketAdmin(admin.ModelAdmin):
    # Show ticket status and technician at a glance
    list_display = ["asset", "status", "assigned_technician", "created_at"]
    list_filter = ["status"]
    # Search the issue text or the asset name via FK
    search_fields = ["asset__name", "issue"]
    ordering = ["-created_at"]

What is the double-underscore syntax in search_fields?

The __ notation lets you traverse ForeignKey relationships. Writing asset__name means "follow the asset FK, then search its name field."

Without this, you could only search fields that live directly on the model. The __ syntax unlocks searching across related tables in one query.

  • Save inventory/admin.py.
  • In the admin panel, navigate to Assignments and confirm you see columns for asset, employee, date assigned, and date returned.
  • Navigate to Repair tickets and confirm you see columns for asset, status, technician, and created date.

Seeing an error about duplicate model registration?

  • This happens when you still have an admin.site.register() call for a model that also uses @admin.register(). Make sure you deleted all three old registration lines.
  • Check that the only remaining admin.site.register calls in your file are gone. Every model should use the @admin.register() decorator pattern instead.

Why am I getting a duplicate admin registration error in Django?

Test filters and search to verify the improvement

Time to put the customized admin through its paces. You will use the filters and search to prove the UX is dramatically better than what you saw in Step 3.

  • Navigate to the Assets section in admin.
  • In the right sidebar, click Available under the status filter. The list narrows to show only available assets.
  • Type the serial number of one of your test assets into the search box at the top and press Enter. The list filters down to that single asset.

What do you expect to see if you clear the search and filter? Before you do it, predict the order the assets will appear in.

  • Clear the search and filter by clicking All in the status filter and removing your search text.

Your assets appear sorted by purchase date with the newest first. This is the ordering = ["-purchase_date"] at work.

Compare this to Step 3

In Step 3, the asset list showed only the __str__ output with no way to filter, sort, or search. Four lines of configuration turned it into a tool someone could use daily without touching code.

✔️ Awesome, I've got everything!

Double check you have saved your file and all four model sections (Assets, Inventory items, Assignments, Repair tickets) show formatted columns in the admin.

ⓧ I'd like to double check the full code

Here is the complete inventory/admin.py at this point. Scroll through and confirm each class matches yours.

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .models import User, Asset, InventoryItem, Assignment, RepairTicket


@admin.register(User)
class CustomUserAdmin(UserAdmin):
    list_display = ["username", "email", "role", "is_staff"]
    list_filter = ["role", "is_staff"]
    fieldsets = UserAdmin.fieldsets + (
        ("Role", {"fields": ("role",)}),
    )
    add_fieldsets = UserAdmin.add_fieldsets + (
        ("Role", {"fields": ("role",)}),
    )
@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
    list_display = ["name", "asset_type", "serial_number", "status", "purchase_date"]
    list_filter = ["status", "asset_type"]
    search_fields = ["name", "serial_number"]
    ordering = ["-purchase_date"]


@admin.register(InventoryItem)
class InventoryItemAdmin(admin.ModelAdmin):
    list_display = ["item_name", "item_type", "quantity", "threshold"]
    list_filter = ["item_type"]
    search_fields = ["item_name"]
@admin.register(Assignment)
class AssignmentAdmin(admin.ModelAdmin):
    list_display = ["asset", "employee", "date_assigned", "date_returned"]
    list_filter = ["date_assigned"]
    search_fields = ["asset__name", "employee__username"]
    ordering = ["-date_assigned"]


@admin.register(RepairTicket)
class RepairTicketAdmin(admin.ModelAdmin):
    list_display = ["asset", "status", "assigned_technician", "created_at"]
    list_filter = ["status"]
    search_fields = ["asset__name", "issue"]
    ordering = ["-created_at"]

Your admin panel is now a polished management interface. But only superusers can access it. Next up, you will build a login-protected frontend so admins and employees can access the system through their own dashboard.

Build the Login and Dashboard

Your admin panel is polished, but only superusers can access it through Django's built-in admin URL. A real asset management system needs a frontend where both admins and employees can log in with their own credentials and see relevant data.

In this step, you will set up Django templates, build a login page using Django's built-in authentication, and create a dashboard that shows live asset counts with a Chart.js chart.

In this step, get ready to:
  • Set up Django templates, static files, and URL routing.
  • Create a login and logout system using Django's built-in auth views.
  • Build a dashboard view with live counts and a Chart.js doughnut chart.
Set up templates, static files, and URL routing

Django looks for templates inside each app's templates/ directory. You also need a URL configuration file so Django knows which views to serve at which paths.

  • Stop the dev server by pressing Ctrl+C in your terminal.
  • Create the template directories by running these commands:
mkdir inventory/templates
mkdir inventory/templates/inventory
mkdir inventory/templates/registration
mkdir inventory/static
mkdir inventory/static/inventory
  • Confirm the directories exist by running:
dir inventory/templates
dir inventory/static

You should see inventory and registration folders inside templates/, and an inventory folder inside static/.

  • Create inventory/urls.py and add the URL configuration:
from django.urls import path
from django.contrib.auth import views as auth_views

from . import views

# URL patterns for the inventory app
urlpatterns = [
    path("", views.dashboard, name="dashboard"),
    path("login/", auth_views.LoginView.as_view(), name="login"),
    path("logout/", auth_views.LogoutView.as_view(), name="logout"),
]

What does this code do?

  • auth_views.LoginView.as_view() uses Django's built-in login view. It automatically looks for a template at registration/login.html.
  • auth_views.LogoutView.as_view() handles logout and redirects the user. No custom template needed.
  • views.dashboard is a view you will create shortly to serve the main dashboard page.
  • Save inventory/urls.py.
  • Open asset_manager/urls.py and replace its contents to include your app's URLs:
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    # Include all inventory app URLs at the root
    path("", include("inventory.urls")),
]

What does include() do?

The include() function delegates URL matching to the inventory app's own urls.py file. This keeps your URL configuration modular. The empty string "" means inventory URLs start at the root of your site.

  • Save asset_manager/urls.py.
  • Open asset_manager/settings.py and add these three lines at the very bottom, below AUTH_USER_MODEL:
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "login"

What do these settings do?

  • LOGIN_URL tells Django where to redirect unauthenticated users who try to access a protected page.
  • LOGIN_REDIRECT_URL is where Django sends users after a successful login. The "/" points to your dashboard.
  • LOGOUT_REDIRECT_URL sends users back to the login page after logging out.
  • Save asset_manager/settings.py.

Seeing an import error for views?

If you try to run the server now, you will get an error because views.dashboard does not exist yet. You will create it in the next substep. This is expected.

Help me fix my URL configuration.

Create the base template and login page

Every page in your app will share a common layout: a navigation bar, a container for content, and consistent styling. Django's template inheritance lets you define this once in a base template and extend it everywhere else.

  • Create inventory/static/inventory/style.css and add the application styles:
/* Reset and base styles */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; background: #f4f6f9; color: #333; }

/* Navigation bar */
.navbar {
    background: #2c3e50;
    color: white;
    padding: 1rem 2rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
}
.navbar a { color: white; text-decoration: none; margin-left: 1rem; }
.navbar a:hover { text-decoration: underline; }
  • Below the navbar styles, add the container, card, and form styles:
/* Main container */
.container { max-width: 1100px; margin: 2rem auto; padding: 0 1rem; }

/* Dashboard cards */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.card {
    background: white;
    padding: 1.5rem;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    text-align: center;
}
.card h3 { font-size: 2rem; color: #2c3e50; }
.card p { color: #777; margin-top: 0.5rem; }

/* Forms and tables */
.form-box {
    background: white;
    padding: 2rem;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    max-width: 400px;
    margin: 4rem auto;
}
.form-box h2 { margin-bottom: 1rem; text-align: center; }
.form-box input, .form-box select, .form-box textarea {
    width: 100%;
    padding: 0.6rem;
    margin-bottom: 1rem;
    border: 1px solid #ccc;
    border-radius: 4px;
}
.form-box button {
    width: 100%;
    padding: 0.7rem;
    background: #2c3e50;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}
.form-box button:hover { background: #34495e; }

What does this CSS do?

The .cards grid creates a responsive row of stat cards for the dashboard. The .form-box centers and styles the login form. Both use a subtle box-shadow for a clean, professional look.

  • Save style.css.
  • Create inventory/templates/inventory/base.html and add the base layout:
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Asset Manager</title>
    <!-- Link to the app stylesheet -->
    <link rel="stylesheet" href="{% static 'inventory/style.css' %}">
</head>
<body>
    {% if user.is_authenticated %}
    <!-- Navigation bar shown only to logged-in users -->
    <nav class="navbar">
        <span>Asset Manager</span>
        <div>
            <a href="{% url 'dashboard' %}">Dashboard</a>
            <a href="{% url 'logout' %}">Logout</a>
        </div>
    </nav>
    {% endif %}

    <!-- Page-specific content goes here -->
    {% block content %}{% endblock %}
</body>
</html>

What does this template do?

  • {% load static %} enables the {% static %} tag for linking CSS and JavaScript files.
  • {% if user.is_authenticated %} shows the navbar only to logged-in users. The login page will not display navigation.
  • {% block content %} defines a placeholder that child templates fill with their own content.
  • Save base.html.
  • Create inventory/templates/registration/login.html and add the login form:
{% extends 'inventory/base.html' %}

{% block content %}
<div class="form-box">
    <h2>Asset Manager Login</h2>
    <!-- Display login errors -->
    {% if form.errors %}
    <p style="color:red;">Invalid username or password.</p>
    {% endif %}

    <form method="post">
        {% csrf_token %}
        <label for="id_username">Username</label>
        <input type="text" name="username" id="id_username" required>
        <label for="id_password">Password</label>
        <input type="password" name="password" id="id_password" required>
        <button type="submit">Log In</button>
    </form>
</div>
{% endblock %}

What does this template do?

  • {% extends 'inventory/base.html' %} inherits the base layout. Since the user is not logged in, the navbar is hidden.
  • {% csrf_token %} adds a security token that Django requires on every POST form to prevent cross-site request forgery.
  • form.errors is automatically populated by Django's LoginView when the credentials are wrong.
  • Save login.html.
Build the dashboard view with counts and a chart

The dashboard is the first thing users see after login. It should show at-a-glance counts for total assets, available assets, open repair tickets, and low-stock inventory items.

  • Open inventory/views.py and replace its contents with the dashboard view:
from django.shortcuts import render
from django.contrib.auth.decorators import login_required

from .models import Asset, InventoryItem, RepairTicket


@login_required
def dashboard(request):
    # Gather counts for the dashboard cards
    total_assets = Asset.objects.count()
    available = Asset.objects.filter(status=Asset.Status.AVAILABLE).count()
    open_tickets = RepairTicket.objects.filter(
        status=RepairTicket.TicketStatus.OPEN
    ).count()
    # Count inventory items where quantity is below threshold
    low_stock = sum(
        1 for item in InventoryItem.objects.all()
        if item.is_below_threshold
    )

What does this code do?

  • @login_required redirects unauthenticated users to the login page. Only logged-in users can see the dashboard.
  • Asset.objects.count() and .filter().count() run efficient SQL COUNT queries against your database.
  • The low stock count uses the is_below_threshold property you defined on InventoryItem in Step 4.
  • Below the low_stock line, add the status breakdown and context dictionary:
    # Count assets per status for the chart
    assigned = Asset.objects.filter(status=Asset.Status.ASSIGNED).count()
    under_repair = Asset.objects.filter(status=Asset.Status.UNDER_REPAIR).count()
    retired = Asset.objects.filter(status=Asset.Status.RETIRED).count()

    context = {
        "total_assets": total_assets,
        "available": available,
        "open_tickets": open_tickets,
        "low_stock": low_stock,
        "assigned": assigned,
        "under_repair": under_repair,
        "retired": retired,
    }
    return render(request, "inventory/dashboard.html", context)

What does this code do?

The context dictionary passes all the counts to the template. The render() function combines the template with the context data and returns the finished HTML page to the browser.

  • Save inventory/views.py.
  • Create inventory/templates/inventory/dashboard.html and add the dashboard layout:
{% extends 'inventory/base.html' %}

{% block content %}
<div class="container">
    <h1>Dashboard</h1>
    <p>Welcome, {{ user.username }} ({{ user.get_role_display }}).</p>

    <!-- Stat cards row -->
    <div class="cards">
        <div class="card">
            <h3>{{ total_assets }}</h3>
            <p>Total Assets</p>
        </div>
        <div class="card">
            <h3>{{ available }}</h3>
            <p>Available</p>
        </div>
        <div class="card">
            <h3>{{ open_tickets }}</h3>
            <p>Open Tickets</p>
        </div>
        <div class="card">
            <h3>{{ low_stock }}</h3>
            <p>Low Stock Items</p>
        </div>
    </div>
  • Below the cards div, add the Chart.js doughnut chart:
    <!-- Chart section -->
    <div class="card" style="max-width:400px;">
        <h2>Asset Status</h2>
        <canvas id="statusChart"></canvas>
    </div>
</div>

<!-- Load Chart.js from CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Render the doughnut chart with status counts
new Chart(document.getElementById('statusChart'), {
    type: 'doughnut',
    data: {
        labels: ['Available', 'Assigned', 'Under Repair', 'Retired'],
        datasets: [{
            data: [{{ available }}, {{ assigned }}, {{ under_repair }}, {{ retired }}],
            backgroundColor: ['#27ae60','#2980b9','#f39c12','#95a5a6']
        }]
    }
});
</script>
{% endblock %}

What does this template do?

  • {{ user.username }} and {{ user.get_role_display }} show the logged-in user's name and their role (Admin or Employee).
  • The {{ available }} variables inside the Chart.js data array are filled by Django's template engine before the page reaches the browser.
  • Chart.js loads from a CDN, so no local install is needed. The doughnut chart visualizes the four asset statuses as colored segments.
  • Save dashboard.html.
  • Start the dev server by running:
py manage.py runserver
  • Open your browser and navigate to http://127.0.0.1:8000/.

You should be redirected to the login page. Enter your superuser credentials and press Log In.

After logging in, you should see the dashboard with four stat cards showing your asset counts and a doughnut chart breaking down assets by status.

Seeing a TemplateDoesNotExist error?

  • Make sure your template files are in the correct directories. The login template must be at inventory/templates/registration/login.html (not inside the inventory/ subfolder).
  • Confirm "inventory" is listed in INSTALLED_APPS in settings.py. Django uses this to find app template directories.
  • Check that the APP_DIRS setting inside TEMPLATES in settings.py is set to True. This is the default, but verify it was not changed.

Help me fix my Django template error.

Your login system and dashboard are live. Users log in, see their role displayed, and get an instant overview of the asset fleet. Next up, you will add views for listing assets, adding new ones, and raising repair tickets.

Add Asset Tracking Views

Your dashboard gives a high-level overview, but teams need to drill into the details. An admin needs to search for a specific laptop by serial number. An employee needs to see which assets are assigned to them. Both roles need a way to add new assets and raise repair tickets.

In this step, you will build an asset list with search and filtering, create forms for adding assets and raising repair tickets, and add a role-based My Assets page so employees see only their own hardware.

In this step, get ready to:
  • Build an asset list view with search and status filtering.
  • Create forms for adding assets and raising repair tickets.
  • Add a role-based My Assets page for employees.
Build the asset list with search and filtering

The asset list needs to let users search by name or serial number and filter by status. Django's QuerySet API makes this straightforward with .filter() and .icontains lookups.

  • Stop the dev server by pressing Ctrl+C in your terminal.
  • Open inventory/views.py and add the asset list view below the dashboard function:
@login_required
def asset_list(request):
    # Start with all assets
    assets = Asset.objects.all()

    # Apply search filter if a query parameter exists
    search = request.GET.get("search", "")
    if search:
        assets = assets.filter(
            models.Q(name__icontains=search)
            | models.Q(serial_number__icontains=search)
        )

    # Apply status filter if provided
    status = request.GET.get("status", "")
    if status:
        assets = assets.filter(status=status)

    context = {
        "assets": assets,
        "search": search,
        "status": status,
        "status_choices": Asset.Status.choices,
    }
    return render(request, "inventory/asset_list.html", context)

What does this code do?

  • request.GET.get("search", "") reads the search query from the URL parameters (e.g. ?search=macbook). If no search parameter exists, it defaults to an empty string.
  • models.Q objects let you combine filters with OR logic using the | operator. The search matches either the asset name or the serial number.
  • icontains performs a case-insensitive substring match, so searching for mac will find MacBook Pro 16.
  • You also need to import models at the top of the file. Find this import line:
from .models import Asset, InventoryItem, RepairTicket
  • Replace it with this expanded import that includes models and the additional model classes you will need:
from django.db import models
from .models import Asset, InventoryItem, RepairTicket, Assignment
  • Save inventory/views.py.
  • Create inventory/templates/inventory/asset_list.html and add the asset list template:
{% extends 'inventory/base.html' %}

{% block content %}
<div class="container">
    <h1>Assets</h1>

    <!-- Search and filter form -->
    <form method="get" style="margin-bottom:1rem;display:flex;gap:0.5rem;">
        <input type="text" name="search" value="{{ search }}"
               placeholder="Search by name or serial...">
        <select name="status">
            <option value="">All Statuses</option>
            {% for value, label in status_choices %}
            <option value="{{ value }}"
                {% if value == status %}selected{% endif %}>
                {{ label }}
            </option>
            {% endfor %}
        </select>
        <button type="submit"
                style="padding:0.5rem 1rem;background:#2c3e50;
                       color:white;border:none;border-radius:4px;
                       cursor:pointer;">Filter</button>
    </form>
  • Below the form, add the results table and close the template:
    <!-- Results table -->
    <table style="width:100%;border-collapse:collapse;background:white;
                  border-radius:8px;overflow:hidden;
                  box-shadow:0 2px 4px rgba(0,0,0,0.1);">
        <thead style="background:#2c3e50;color:white;">
            <tr>
                <th style="padding:0.8rem;text-align:left;">Name</th>
                <th style="padding:0.8rem;text-align:left;">Type</th>
                <th style="padding:0.8rem;text-align:left;">Serial</th>
                <th style="padding:0.8rem;text-align:left;">Status</th>
                <th style="padding:0.8rem;text-align:left;">Purchase Date</th>
            </tr>
        </thead>
        <tbody>
            {% for asset in assets %}
            <tr style="border-bottom:1px solid #eee;">
                <td style="padding:0.8rem;">{{ asset.name }}</td>
                <td style="padding:0.8rem;">{{ asset.get_asset_type_display }}</td>
                <td style="padding:0.8rem;">{{ asset.serial_number }}</td>
                <td style="padding:0.8rem;">{{ asset.get_status_display }}</td>
                <td style="padding:0.8rem;">{{ asset.purchase_date }}</td>
            </tr>
            {% empty %}
            <tr>
                <td style="padding:0.8rem;" colspan="5">No assets found.</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
{% endblock %}

What does this template do?

  • The form uses method="get" so the search and filter values appear in the URL. Users can bookmark a filtered view or share it.
  • {% for value, label in status_choices %} dynamically populates the dropdown from your model's TextChoices, so adding a new status in Python automatically updates the filter.
  • {% empty %} provides a fallback message when no assets match the search or filter.
  • Save asset_list.html.
Add forms for creating assets and repair tickets

Django forms handle input validation and rendering. You will create two forms: one for adding new assets and one for raising repair tickets.

  • Create inventory/forms.py and add the two form classes:
from django import forms

from .models import Asset, RepairTicket


class AssetForm(forms.ModelForm):
    class Meta:
        model = Asset
        # Fields shown on the form
        fields = ["name", "asset_type", "serial_number", "status", "purchase_date"]
        widgets = {
            # Render purchase_date as a date picker
            "purchase_date": forms.DateInput(attrs={"type": "date"}),
        }


class RepairTicketForm(forms.ModelForm):
    class Meta:
        model = RepairTicket
        # Only expose fields the user should fill in
        fields = ["asset", "issue"]

What does this code do?

  • forms.ModelForm automatically generates form fields from your model's field definitions, including validation rules.
  • The widgets dictionary overrides the default text input for purchase_date with an HTML5 date picker.
  • RepairTicketForm only exposes asset and issue. The status defaults to OPEN and the timestamp is set automatically by auto_now_add.
  • Save inventory/forms.py.
  • Open inventory/views.py and add the form imports at the top, below the existing imports:
from django.shortcuts import render, redirect
from .forms import AssetForm, RepairTicketForm

What changed in the imports?

You added redirect alongside render. After a successful form submission, you redirect the user to a different page instead of re-rendering the form. You also imported both form classes from your new forms.py file.

  • Below the asset_list view, add the add asset and repair ticket views:
@login_required
def add_asset(request):
    if request.method == "POST":
        form = AssetForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect("asset_list")
    else:
        form = AssetForm()
    return render(request, "inventory/add_asset.html", {"form": form})


@login_required
def raise_ticket(request):
    if request.method == "POST":
        form = RepairTicketForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect("dashboard")
    else:
        form = RepairTicketForm()
    return render(request, "inventory/raise_ticket.html", {"form": form})

What does this code do?

  • Both views follow the same pattern: if the request is a POST, validate the form and save it. If valid, redirect to a success page. If not a POST (first page load), show an empty form.
  • form.is_valid() checks all field constraints (required fields, max length, unique serial number) and populates form.errors if anything fails.
  • redirect("asset_list") uses the URL name from your urls.py to redirect, so if you change the URL path later, the redirect still works.
  • Below the raise_ticket view, add the My Assets view for employees:
@login_required
def my_assets(request):
    # Show assets currently assigned to the logged-in user
    assignments = Assignment.objects.filter(
        employee=request.user,
        date_returned__isnull=True,
    ).select_related("asset")
    return render(request, "inventory/my_assets.html", {"assignments": assignments})

What does this code do?

  • employee=request.user filters assignments to only those belonging to the currently logged-in user.
  • date_returned__isnull=True excludes returned assets, showing only actively assigned hardware.
  • select_related("asset") joins the Asset table in a single SQL query instead of making a separate query per assignment. This is a performance optimization.
  • Save inventory/views.py.
  • Open inventory/urls.py and add the new URL paths below the existing ones:
from django.urls import path
from django.contrib.auth import views as auth_views

from . import views

urlpatterns = [
    path("", views.dashboard, name="dashboard"),
    path("login/", auth_views.LoginView.as_view(), name="login"),
    path("logout/", auth_views.LogoutView.as_view(), name="logout"),
    # Asset management pages
    path("assets/", views.asset_list, name="asset_list"),
    path("assets/add/", views.add_asset, name="add_asset"),
    path("tickets/raise/", views.raise_ticket, name="raise_ticket"),
    path("my-assets/", views.my_assets, name="my_assets"),
]
  • Save inventory/urls.py.
  • Update the navbar in inventory/templates/inventory/base.html to include links to the new pages. Replace the <nav> section with:
    <nav class="navbar">
        <span>Asset Manager</span>
        <div>
            <a href="{% url 'dashboard' %}">Dashboard</a>
            <a href="{% url 'asset_list' %}">Assets</a>
            <a href="{% url 'add_asset' %}">Add Asset</a>
            <a href="{% url 'raise_ticket' %}">Raise Ticket</a>
            <a href="{% url 'my_assets' %}">My Assets</a>
            <a href="{% url 'logout' %}">Logout</a>
        </div>
    </nav>
  • Save base.html.
Create the remaining templates and test the views

You have three views that still need templates: add asset, raise ticket, and my assets. Each one extends the base template and uses Django's form rendering.

  • Create inventory/templates/inventory/add_asset.html and add the form template:
{% extends 'inventory/base.html' %}

{% block content %}
<div class="form-box" style="max-width:500px;">
    <h2>Add New Asset</h2>
    <form method="post">
        {% csrf_token %}
        {% for field in form %}
        <label for="{{ field.id_for_label }}">{{ field.label }}</label>
        {{ field }}
        {% if field.errors %}
        <p style="color:red;">{{ field.errors.0 }}</p>
        {% endif %}
        {% endfor %}
        <button type="submit">Save Asset</button>
    </form>
</div>
{% endblock %}
  • Save add_asset.html.
  • Create inventory/templates/inventory/raise_ticket.html with the repair ticket form:
{% extends 'inventory/base.html' %}

{% block content %}
<div class="form-box" style="max-width:500px;">
    <h2>Raise Repair Ticket</h2>
    <form method="post">
        {% csrf_token %}
        {% for field in form %}
        <label for="{{ field.id_for_label }}">{{ field.label }}</label>
        {{ field }}
        {% if field.errors %}
        <p style="color:red;">{{ field.errors.0 }}</p>
        {% endif %}
        {% endfor %}
        <button type="submit">Submit Ticket</button>
    </form>
</div>
{% endblock %}
  • Save raise_ticket.html.
  • Create inventory/templates/inventory/my_assets.html with the employee's assigned assets view:
{% extends 'inventory/base.html' %}

{% block content %}
<div class="container">
    <h1>My Assigned Assets</h1>
    <table style="width:100%;border-collapse:collapse;background:white;
                  border-radius:8px;overflow:hidden;
                  box-shadow:0 2px 4px rgba(0,0,0,0.1);">
        <thead style="background:#2c3e50;color:white;">
            <tr>
                <th style="padding:0.8rem;text-align:left;">Asset</th>
                <th style="padding:0.8rem;text-align:left;">Type</th>
                <th style="padding:0.8rem;text-align:left;">Serial</th>
                <th style="padding:0.8rem;text-align:left;">Assigned On</th>
            </tr>
        </thead>
        <tbody>
            {% for a in assignments %}
            <tr style="border-bottom:1px solid #eee;">
                <td style="padding:0.8rem;">{{ a.asset.name }}</td>
                <td style="padding:0.8rem;">{{ a.asset.get_asset_type_display }}</td>
                <td style="padding:0.8rem;">{{ a.asset.serial_number }}</td>
                <td style="padding:0.8rem;">{{ a.date_assigned }}</td>
            </tr>
            {% empty %}
            <tr>
                <td style="padding:0.8rem;" colspan="4">
                    No assets assigned to you.
                </td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
{% endblock %}
  • Save my_assets.html.
  • Start the dev server by running:
py manage.py runserver

Before you test, think about what you expect to see on each page. Will the Asset list show the assets you added in Step 3? Will My Assets show anything for your superuser?

  • Open http://127.0.0.1:8000/ and log in with your superuser credentials.
  • Click Assets in the navbar. You should see your test assets in a table with columns for name, type, serial, status, and purchase date.
  • Type a serial number into the search box and click Filter. The table narrows to matching assets.
  • Click Add Asset in the navbar.
  • Fill in a new asset (e.g. Name iPhone 15 Pro, type Phone, serial PH-2024-001, status Available, date 2024-08-01) and click Save Asset.
  • You should be redirected to the asset list with your new asset visible in the table.
  • Click Raise Ticket in the navbar. Select an asset, describe an issue, and click Submit Ticket.
  • You should be redirected to the dashboard. The Open Tickets count should increase by one.

Seeing a NoReverseMatch error?

  • Make sure every URL name in your templates (dashboard, asset_list, add_asset, raise_ticket, my_assets) matches exactly what you defined in inventory/urls.py.
  • Check that you saved the updated urls.py with all seven URL patterns.

Help me fix my NoReverseMatch error.

Your frontend is complete. Admins and employees can log in, view the dashboard, browse assets, add new hardware, raise repair tickets, and check their own assigned assets. Next up, tackle the Secret Mission to seed the database with realistic data and add inline admin views.

Secret mission

Seed Data and Inline Admin

Your admin panel is polished, but it only has 2-3 hand-entered records. A real portfolio project needs realistic data to prove the system works at scale. In this secret mission, you will write a custom management command that seeds the database programmatically and add TabularInline views so related objects appear directly on the asset detail page.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources on your machine, or delete them entirely to free disk space.

Resources you used:

  • The asset_manager project folder on your Desktop (contains all source code, the db.sqlite3 database, and your virtual environment).

Keep everything running

No action needed. Choose this if you plan to extend this project further, such as adding a Django REST Framework API layer or connecting a frontend.

  • Your asset_manager folder stays on your Desktop with all code, database, and virtual environment intact.
  • The db.sqlite3 file holds all your seeded data and superuser account.
  • To start working again later, activate your virtual environment and run py manage.py runserver.

Pause - I'll come back to this later

Since everything is local and there are no running processes to stop (the dev server only runs while your terminal is open), pausing is the same as keeping. No action needed.

  • If the dev server is still running in your terminal, press Ctrl+C to stop it.
  • Your project files and database remain on disk until you decide to delete them.

Delete - I don't want to use this again

Remove the entire project folder to free up disk space. This deletes all source code, the virtual environment, and the SQLite database.

  • If the dev server is still running, press Ctrl+C to stop it.
  • Deactivate the virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the project folder.

On macOS/Linux:

cd ~/Desktop
rm -rf asset_manager

On Windows:

cd ~\Desktop
rmdir /s /q asset_manager
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see asset_manager listed.

Nice Work!

Nice Work!

Nice work! You just built a fully functional asset management system using Django 6.1, complete with a login-protected frontend, a dashboard with live charts, asset tracking views, and a polished admin panel that could genuinely serve as an internal tool for managing company hardware.

You've learned how to:

  • Built a Django project from scratch with a custom User model supporting Admin and Employee roles, and four interconnected models linked by ForeignKey relationships.
  • Created a login-protected frontend with a dashboard showing live asset counts and a Chart.js doughnut chart, plus views for listing, adding, and filtering assets.
  • Built role-based views where employees see only their assigned assets on the My Assets page, and forms for raising repair tickets backed by Django's ModelForm validation.
  • Secret Mission: Created a reusable seed_data management command and added TabularInline views to display related objects directly on the asset detail page.

Ready to quiz yourself?