Build a Django Support Ticket Dashboard

Build a role-based Django dashboard with secure ticket access and live filters.

Introduction

30 Second Summary

A support queue can look completely different depending on who signs in. A manager needs the full workload while each agent needs a focused view of their own tickets.

In this project, you will build a local support ticket dashboard with Django. You'll use authentication, Groups, and model permissions to enforce server-side authorization for each role.

What You'll Build

You'll switch among three demo accounts to watch the browser show the right support queue for each role.

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

  • A role-aware queue where manager can browse all 15 seeded tickets through five-ticket pagination. The agent and agent-two accounts see only assigned tickets.
  • A buttonless filter experience that refreshes the ticket table as you type or change a dropdown. Active filter values stay in place when you move between pages.
  • A protected editing flow where managers can update ticket status, priority, or assignment. Agents receive an HTTP 403 response when they attempt the same edit.
  • Secret Mission: Automated access-control tests that prove agents cannot see other agents' tickets or open manager-only edit pages.

Are there any prerequisites?

You should be comfortable with Django fundamentals. You'll need Python 3.12 or newer, Django 6.1.2, Visual Studio Code, Windows PowerShell, and a web browser on Windows.

Before We Start

This step locks in the server-enforced access rules before any hands-on work begins. Managers can access every ticket. Agents can access only tickets assigned to them. Template checks alone cannot protect data because users can request protected URLs or receive unauthorized queryset results directly.

Set Up the Django Project

Your support dashboard needs an isolated Django environment before you add ticket features. This gives you a reproducible base without adding database server or deployment work.

You will prepare the project in Visual Studio Code with Windows PowerShell. You will also connect the ticket app to Django's authentication routes and a local SQLite database.

In this step, get ready to:
  • Isolate Django 6.1.2 inside an activated .venv environment.
  • Scaffold the supportdesk project with its tickets app.
  • Create the SQLite database and verify the development server.
Prepare your local workspace

A virtual environment gives this project its own Python packages. Installing Django inside .venv protects the dashboard from dependency changes in other projects.

  • Press the Windows key to open the search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.

Visual Studio Code is ready for the folder that holds every project file.

  • Click File in the top menu.
  • Select Open Folder.
  • Select Desktop in the folder picker.
  • Click New Folder.
  • Enter supportdesk as the folder name.
  • Click Select Folder to use the new folder as your workspace.

You should see the empty supportdesk folder in the Explorer sidebar. Every command now has a predictable project location.

  • Click Terminal in the top menu.
  • Select New Terminal to start PowerShell inside the open folder.

The terminal prompt should end with supportdesk. This confirms that the following files stay inside your project folder.

  • Create the .venv environment by running this command:
python -m venv .venv

What does this command do?

Python creates an isolated environment inside the .venv folder. Its package directory belongs only to this project.

  • Activate the new environment by running this command:
.\.venv\Scripts\Activate.ps1

What does activation change?

Activation makes the environment's Python interpreter active in this PowerShell session. Future package installations now go into .venv.

You should see (.venv) at the start of the PowerShell prompt. That visible prefix confirms the isolated environment is active.

PowerShell blocked activation?

PowerShell can block local activation scripts through its execution policy. Use the documented user-level execution policy from the Python virtual environment guide before retrying activation.

Ask for help: Help me activate .venv after PowerShell blocked Activate.ps1.

  • Click the New File icon in the Explorer sidebar.
  • Enter requirements.txt as the filename.
  • Pin the Django dependency by adding this line:
Django==6.1.2

Why pin Django?

The version pin makes future installations use Django 6.1.2. This keeps the project behavior reproducible.

  • Save requirements.txt.
  • Install the pinned dependency by running this command:
python -m pip install -r requirements.txt

What does this command do?

Python runs pip from the active environment. Pip reads requirements.txt and installs its pinned dependency inside .venv.

The terminal should finish with a successful installation message for Django. Your dependency is now isolated inside the project environment.

Did the installation fail?

  • Confirm that (.venv) appears at the start of the terminal prompt.
  • Confirm that requirements.txt contains the version pin exactly as shown.
  • Retry the installation after restoring your internet connection.

Ask for help: Help me diagnose why Django did not install inside .venv.

Connect the Django scaffold

A Django project holds settings shared across the site. The tickets app becomes the home for the support queue in later steps.

  • Create the project scaffold and ticket app by running these commands:
django-admin startproject supportdesk .
python manage.py startapp tickets

What did Django create?

  • The first command creates manage.py with the supportdesk settings package.
  • The second command creates the tickets app with its configuration and migration package.

You should now see manage.py with the supportdesk and tickets folders in the Explorer sidebar. The empty workspace is now a Django project.

  • Select tickets/apps.py in the Explorer sidebar.
  • Replace its contents with this app configuration:
from django.apps import AppConfig


class TicketsConfig(AppConfig):
    name = "tickets"

What does this configuration do?

The TicketsConfig class identifies tickets as an installable Django app. Django uses this class when it loads the app.

  • Save tickets/apps.py.
  • Select the tickets folder in the Explorer sidebar.
  • Click the New File icon.
  • Enter urls.py as the filename.
  • Add this initial ticket URL configuration:
from django.urls import path

urlpatterns = []

Why create the ticket URL file?

This valid URL configuration gives the tickets app a place for its routes. The ticket list and update routes arrive with their views in later steps.

  • Save tickets/urls.py.
  • Select supportdesk/urls.py in the Explorer sidebar.
  • Replace its contents with this project URL configuration:
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("", include("tickets.urls")),
    path("accounts/", include("django.contrib.auth.urls")),
    path("admin/", admin.site.urls),
]

How are the URLs connected?

  • The root path sends requests into tickets.urls.
  • The accounts/ path enables Django's built-in authentication routes.
  • The admin/ path keeps the generated administration site available.
  • Save supportdesk/urls.py.
  • Select supportdesk/settings.py in the Explorer sidebar.
  • Find the block beginning with SECRET_KEY.
  • Replace the block through INSTALLED_APPS with this configuration:
SECRET_KEY = "your-secret-key-here"
DEBUG = True
ALLOWED_HOSTS = []

INSTALLED_APPS = [
    "tickets.apps.TicketsConfig",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

Why register the ticket app?

Adding tickets.apps.TicketsConfig tells Django to load the app. Its future models then participate in migrations and receive default model permissions.

Before you test the scaffold, do you expect Django to load every registered app and URL configuration successfully?

  • Save supportdesk/settings.py.
  • Check the connected scaffold by running this command:
python manage.py runserver

What does the server check?

Django loads the settings before starting the server. It also imports the registered app and every included URL configuration.

The terminal should report that the system check found no issues. The server then remains active while it listens for local browser requests.

  • Press Ctrl+C in the terminal to stop this scaffold check.
  • Return to supportdesk/settings.py.
  • Find the existing STATIC_URL = "static/" line.
  • Replace that line with this final settings block:
STATIC_URL = "static/"

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.console.EmailBackend",
    },
}

LOGIN_REDIRECT_URL = "ticket-list"
LOGOUT_REDIRECT_URL = "login"

What do these settings control?

  • The console mailer keeps development email output on your computer.
  • The login redirect points authenticated users to the named ticket list route.
  • The logout redirect returns signed-out users to Django's named login route.
  • Save supportdesk/settings.py.

✔️ Awesome, I've got everything!

Your dependency file and Django configuration are connected. Double check that every edited file is saved before creating the database.

ⓧ I'd like to double check the full code

Compare these files with your current project. The ticket URL list stays empty until the ticket views arrive in the next step.

Django==6.1.2

Requirements reference

This file pins the third-party dependency used by the project.

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = "your-secret-key-here"
DEBUG = True
ALLOWED_HOSTS = []

INSTALLED_APPS = [
    "tickets.apps.TicketsConfig",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "supportdesk.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "supportdesk.wsgi.application"

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
    },
]

LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.console.EmailBackend",
    },
}

LOGIN_REDIRECT_URL = "ticket-list"
LOGOUT_REDIRECT_URL = "login"

Settings reference

This reference combines the generated Django settings with the registered ticket app and authentication redirects.

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("", include("tickets.urls")),
    path("accounts/", include("django.contrib.auth.urls")),
    path("admin/", admin.site.urls),
]

Project URL reference

This file connects the ticket app with the built-in authentication routes and administration site.

from django.apps import AppConfig


class TicketsConfig(AppConfig):
    name = "tickets"

App configuration reference

This file defines the configuration class registered in INSTALLED_APPS.

from django.urls import path

urlpatterns = []

Ticket URL reference

This valid initial URL configuration is ready for the ticket list and update routes.

Create the database and launch the server

Django stores local authentication and session records in SQLite tables. Migrations create those tables from the framework's built-in schema definitions.

  • Create the local database tables by running this command:
python manage.py migrate

What does migrate create?

The command applies Django's built-in migrations to the SQLite database configured in supportdesk/settings.py.

The resulting db.sqlite3 file holds the authentication and session tables. It also records which migrations have completed.

The terminal should list completed migration operations. You should also see db.sqlite3 in the Explorer sidebar.

That is the database foundation complete. Your project now has persistent local storage without a separate database server.

Before you run the final check, do you expect Django to start without a system-check error?

  • Start the local development server by running this command:
python manage.py runserver

What does the server do?

Django loads the project configuration before accepting requests. The command remains active because it is serving the project locally.

  • Press the Windows key to open the search bar.
  • Type the name of your web browser into the search bar.
  • Press Enter to open the browser.
  • Enter http://127.0.0.1:8000/ in the address bar.

You should receive a response from Django while the terminal remains free of system-check errors. The root path may show a missing route response until the ticket list view arrives in the next step.

Can't reach the local server?

  • Confirm that the terminal still shows the running development server.
  • Confirm that (.venv) appeared before you started the server.
  • Confirm that the browser address matches the local URL exactly.

Ask for help: Help me debug why my Django server is unavailable at the local URL.

Your isolated Django project now runs with its URL configuration and SQLite database in place. Next, you will turn this scaffold into a visible ticket dashboard with seeded demo accounts.

Build the Overexposed Ticket List

Your Django 6.1.2 project now runs locally. The next slice turns that empty scaffold into a support queue with real users.

This step deliberately stops at authentication. You will sign in successfully before testing whether the server also enforces authorization.

In this step, get ready to:
  • Define the ticket data stored in SQLite.
  • Seed three demo users with role permissions.
  • Render the first login-protected ticket dashboard.
Create the Ticket model

A model describes the data Django stores for each support request. Status choices keep workflow values consistent.

  • Return to tickets/models.py in the VS Code editor.
  • Replace its current contents with this first part of the model:
from django.conf import settings
from django.db import models


class Ticket(models.Model):
    class Status(models.TextChoices):
        OPEN = "open", "Open"
        IN_PROGRESS = "in_progress", "In progress"
        CLOSED = "closed", "Closed"

    class Priority(models.TextChoices):
        LOW = "low", "Low"
        MEDIUM = "medium", "Medium"
        HIGH = "high", "High"

What does this code define?

  • The Status choices store fixed workflow values such as open.
  • The Priority choices store a consistent severity value for every ticket.
  • Each choice pairs a database value with a readable label for templates.
  • Add the ticket fields directly below the Priority choices:
    title = models.CharField(max_length=120)
    description = models.TextField(blank=True)
    status = models.CharField(
        max_length=20,
        choices=Status,
        default=Status.OPEN,
    )
    priority = models.CharField(
        max_length=10,
        choices=Priority,
        default=Priority.MEDIUM,
    )
    assignee = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="assigned_tickets",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at", "-pk"]

    def __str__(self):
        return self.title

How is each ticket stored?

  • The text fields hold the ticket title plus its description.
  • The choice fields record the current status plus its priority.
  • The assignee foreign key connects each ticket to a user account.
  • The ordering places the newest ticket first. The primary key settles ties deterministically.
  • Save tickets/models.py.
  • Generate the initial ticket migration by running:
python manage.py makemigrations tickets

What did Django generate?

Django compares the model with the current database schema. It records the new table definition in tickets/migrations/0001_initial.py.

The migration output identifies Ticket as the model being created.

  • Apply the generated migration to the local database by running:
python manage.py migrate

What did migrate change?

The migration creates the ticket table inside db.sqlite3. Django can now save rows that match your model.

Your terminal should confirm that the new ticket migration applied successfully.

Migration not applying?

  • Check that Ticket remains inside tickets/models.py.
  • Check the indentation beneath the nested Status plus Priority classes.
  • Confirm your PowerShell prompt still shows the activated .venv environment.

Ask for help: Help me diagnose why the initial Ticket migration is failing.

Seed the demo roles and tickets

Django Groups collect users into roles. Default model permissions describe actions such as viewing or changing a ticket.

Why use permissions for roles?

The Managers group receives permission to view plus change tickets. The Agents group receives permission to view tickets.

This keeps access attached to capabilities that Django already understands.

  • Select the tickets folder in the VS Code Explorer sidebar.
  • Create a folder named management inside tickets.
  • Create an empty __init__.py file inside tickets/management.
  • Create a folder named commands inside tickets/management.
  • Create an empty __init__.py file inside tickets/management/commands.
  • Create seed_demo.py inside tickets/management/commands.

The new file path tells Django to expose the module as a management command. You can build the command in small runnable slices.

  • Add the command imports plus its first group-creation logic to tickets/management/commands/seed_demo.py using this code:
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.management.base import BaseCommand

from tickets.models import Ticket


class Command(BaseCommand):
    help = "Create local demo roles, users, and support tickets"

    def handle(self, *args, **options):
        User = get_user_model()

        managers, _ = Group.objects.get_or_create(name="Managers")
        agents, _ = Group.objects.get_or_create(name="Agents")

What does this command start with?

  • The command asks Django for the active user model.
  • The get_or_create() calls make repeated runs safe for the two groups.
  • The command class gives manage.py a callable handle() method.
  • Save tickets/management/commands/seed_demo.py.
  • Confirm Django discovers the new command by running:
python manage.py seed_demo

What did this first run prove?

PowerShell returns to the prompt without a traceback. Django discovered the command plus created the two groups.

Command not discovered?

  • Check that the file path is exactly tickets/management/commands/seed_demo.py.
  • Check that both management folders contain __init__.py.
  • Check that tickets.apps.TicketsConfig remains registered in your project settings.

Ask for help: Help me find why Django cannot discover seed_demo.

  • Add the permission lookup directly below the two group declarations:
        view_ticket = Permission.objects.get(
            content_type__app_label="tickets",
            content_type__model="ticket",
            codename="view_ticket",
        )
        change_ticket = Permission.objects.get(
            content_type__app_label="tickets",
            content_type__model="ticket",
            codename="change_ticket",
        )

        managers.permissions.set([view_ticket, change_ticket])
        agents.permissions.set([view_ticket])

How are the role capabilities assigned?

  • The permission queries select Django's default ticket permissions.
  • Managers receive both viewing plus changing capability.
  • Agents receive viewing capability only.
  • Save tickets/management/commands/seed_demo.py.
  • Apply the permissions by running the command again:
python manage.py seed_demo

What changed on this run?

The command finds the existing groups. It now assigns their ticket capabilities without creating duplicate groups.

  • Add the three demo users below the permission assignments:
        manager, _ = User.objects.get_or_create(username="manager")
        manager.set_password("demo-pass-123")
        manager.save()
        manager.groups.set([managers])

        agent, _ = User.objects.get_or_create(username="agent")
        agent.set_password("demo-pass-123")
        agent.save()
        agent.groups.set([agents])

        agent_two, _ = User.objects.get_or_create(username="agent-two")
        agent_two.set_password("demo-pass-123")
        agent_two.save()
        agent_two.groups.set([agents])

How are the demo users prepared?

  • Each username is created once or reused on later command runs.
  • The set_password() calls store hashed passwords through Django's authentication system.
  • The group assignments give each account its role capabilities.
  • Save tickets/management/commands/seed_demo.py.
  • Create or refresh the demo users by running:
python manage.py seed_demo

What changed on this run?

The command now creates the three local accounts. Repeated runs also reset their demo password plus group membership.

  • Add the deterministic ticket definitions below the agent-two group assignment:
        Ticket.objects.all().delete()

        ticket_specs = [
            ("VPN access fails", "Remote employee cannot connect to VPN.", "open", "high"),
            ("Printer queue stuck", "Finance printer jobs remain queued.", "in_progress", "medium"),
            ("Reset payroll password", "User needs a payroll password reset.", "closed", "high"),
            ("Laptop camera unavailable", "Camera is missing during meetings.", "open", "medium"),
            ("Shared drive permission", "Marketing cannot open the shared drive.", "open", "high"),
            ("Email signature update", "Apply the new company signature.", "closed", "low"),
            ("VPN client upgrade", "Upgrade the VPN client before travel.", "in_progress", "medium"),
            ("Monitor flickering", "Second monitor flickers after login.", "open", "medium"),
            ("Archive mailbox", "Archive an employee mailbox.", "closed", "low"),
            ("Security key enrollment", "Enroll a replacement security key.", "open", "high"),
            ("Software license request", "Assign a design software license.", "in_progress", "medium"),
            ("Wi-Fi drops", "Office Wi-Fi disconnects every hour.", "open", "high"),
            ("Calendar sharing", "Team calendar is not visible.", "closed", "low"),
            ("Browser certificate warning", "Internal site shows a certificate warning.", "open", "high"),
            ("Docking station audio", "Audio output is missing through the dock.", "in_progress", "medium"),
        ]

Why define the tickets as data?

The list keeps all 15 support requests in a predictable order. Deleting existing rows makes each seed run produce the same demo queue.

  • Add the ticket creation loop directly below ticket_specs list:
        for index, (title, description, status, priority) in enumerate(ticket_specs):
            assignee = agent if index % 2 == 0 else agent_two
            Ticket.objects.create(
                title=title,
                description=description,
                status=status,
                priority=priority,
                assignee=assignee,
            )

        self.stdout.write(
            self.style.SUCCESS(
                "Created manager, agent, and agent-two with password demo-pass-123"
            )
        )

How are the tickets assigned?

  • Even-numbered positions go to agent.
  • Odd-numbered positions go to agent-two.
  • The alternating assignment gives both agents rows that are easy to distinguish during the access test.
  • Save tickets/management/commands/seed_demo.py.
  • Populate the complete demo database by running:
python manage.py seed_demo

What should you see?

PowerShell prints Created manager, agent, and agent-two with password demo-pass-123. The local database now contains the three accounts plus 15 alternating-assignee tickets.

Render the first ticket dashboard

A class-based view connects the ticket model to a template. This first version requires a signed-in user before returning the complete ticket collection.

  • Create the nested folder path tickets/templates/registration from the VS Code Explorer sidebar.
  • Create the nested folder path tickets/templates/tickets from the VS Code Explorer sidebar.
  • Create base.html inside tickets/templates.
  • Add the document head plus shared styles to tickets/templates/base.html using this code:
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}Support Desk{% endblock %}</title>
    <style>
        :root { font-family: system-ui, sans-serif; color: #172033; background: #f4f7fb; }
        body { margin: 0; }
        header { background: #172033; color: white; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; }
        header a { color: white; text-decoration: none; }
        main { width: min(1100px, 92%); margin: 2rem auto; }
        .card { background: white; border-radius: 12px; padding: 1.25rem; box-shadow: 0 8px 24px rgba(23, 32, 51, 0.08); }
        input, select, button { font: inherit; padding: 0.65rem; border: 1px solid #ccd4e0; border-radius: 8px; }
        button, .button { background: #275efe; color: white; border: 0; cursor: pointer; text-decoration: none; display: inline-block; padding: 0.65rem 0.9rem; border-radius: 8px; }
        .logout { display: inline; margin-left: 1rem; }
        .logout button { padding: 0.4rem 0.65rem; }
        table { width: 100%; border-collapse: collapse; }
        th, td { text-align: left; padding: 0.8rem; border-bottom: 1px solid #e6eaf0; }
        th { color: #586174; }
        .pill { display: inline-block; padding: 0.25rem 0.55rem; background: #edf2ff; border-radius: 999px; }
        .error { color: #a61b1b; }
    </style>
</head>

What does the shared head provide?

  • The title block lets each page supply its browser-tab title.
  • The shared styles give the dashboard a readable card plus table layout.
  • The form styles cover the sign-in controls plus logout button.
  • Add the page body directly below the closing head tag:
<body>
<header>
    <a href="{% url 'ticket-list' %}">Support Desk</a>
    {% if user.is_authenticated %}
        <div>
            Signed in as {{ user.username }}
            <form class="logout" method="post" action="{% url 'logout' %}">
                {% csrf_token %}
                <button type="submit">Log out</button>
            </form>
        </div>
    {% endif %}
</header>
<main>
    {% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

What does the shared body provide?

  • The header links back to the ticket list.
  • Signed-in users see their username plus a POST logout form.
  • The content block gives each child template a place to render its page.
  • Save tickets/templates/base.html.
  • Confirm the running server reloads without reporting a template syntax error.
  • Create login.html inside tickets/templates/registration.
  • Add the local demo sign-in form using this code:
{% extends "base.html" %}

{% block title %}Sign in | Support Desk{% endblock %}

{% block content %}
<div class="card">
    <h1>Sign in</h1>
    <p>Local demo accounts use password <code>demo-pass-123</code>.</p>
    <p>Usernames: <code>manager</code>, <code>agent</code>, and <code>agent-two</code>.</p>
    {% if form.errors %}
        <p class="error">The username or password was not accepted.</p>
    {% endif %}
    <form method="post" action="{% url 'login' %}">
        {% csrf_token %}
        {{ form.as_p }}
        <input type="hidden" name="next" value="{{ next }}">
        <button type="submit">Sign in</button>
    </form>
</div>
{% endblock %}

How does the login page work?

  • The template extends the shared page layout.
  • Django supplies the authentication form through its built-in login view.
  • The CSRF token protects the submitted form.
  • The hidden next value sends the user back to the protected page after sign-in.
  • Save tickets/templates/registration/login.html.
  • Open http://127.0.0.1:8000/accounts/login/ in your browser.

You should see the Support Desk sign-in card with the three demo usernames. That is your first rendered application page.

  • Create ticket_list.html inside tickets/templates/tickets.
  • Add the page heading plus table headings using this code:
{% extends "base.html" %}

{% block title %}Tickets | Support Desk{% endblock %}

{% block content %}
<div class="card">
    <h1>Support tickets</h1>
    <table>
        <thead>
            <tr>
                <th>Title</th>
                <th>Status</th>
                <th>Priority</th>
                <th>Assignee</th>
            </tr>
        </thead>

What does the table header establish?

The table presents the four values needed for the access check. The assignee column makes leaked rows visible immediately.

  • Complete the table directly below its thead section:
        <tbody>
            {% for ticket in tickets %}
                <tr>
                    <td>{{ ticket.title }}</td>
                    <td>{{ ticket.get_status_display }}</td>
                    <td>{{ ticket.get_priority_display }}</td>
                    <td>{{ ticket.assignee.username }}</td>
                </tr>
            {% empty %}
                <tr>
                    <td colspan="5">No tickets match these filters.</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
{% endblock %}

How are ticket rows rendered?

  • The loop creates one table row for each ticket returned by the view.
  • The display methods convert stored status plus priority values into readable labels.
  • The assignee username reveals which account owns each row.
  • Save tickets/templates/tickets/ticket_list.html.
  • Return to tickets/views.py in the VS Code editor.
  • Replace its current contents with this initial list view:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView

from .models import Ticket


class TicketListView(LoginRequiredMixin, ListView):
    model = Ticket
    template_name = "tickets/ticket_list.html"
    context_object_name = "tickets"

What does the initial view do?

  • The login mixin redirects anonymous visitors to the sign-in page.
  • The model setting gives the list view the complete ticket collection.
  • The context name exposes that collection as tickets inside the template.
  • Save tickets/views.py.
  • Return to the existing tickets/urls.py file.
  • Replace its contents with the ticket list route:
from django.urls import path

from .views import TicketListView

urlpatterns = [
    path("", TicketListView.as_view(), name="ticket-list"),
]

How does the route connect the page?

The empty path maps the local site root to TicketListView. Its name also matches the redirect setting created earlier.

  • Save tickets/urls.py.
  • Confirm the running development server reloads without a system-check error.

Page failing to load?

  • Check that ticket_list.html is inside tickets/templates/tickets.
  • Check that login.html is inside tickets/templates/registration.
  • Check that the route name is exactly ticket-list.

Ask for help: Help me debug why my Django ticket list or login template is not loading.

Before you test the dashboard, do you think a successful sign-in alone determines which ticket rows this account may read?

  • Return to http://127.0.0.1:8000/ in your browser.
  • Enter agent in the Username field.
  • Enter demo-pass-123 in the Password field.
  • Click Sign in.

You will see the complete 15-ticket queue. The table includes Printer queue stuck with agent-two in the assignee column.

This data leak is intentional

The sign-in requirement proves the visitor's identity. The view still returns every ticket because its queryset has no user-specific scope.

You have now witnessed the gap between authentication plus authorization. The next step closes that server-side access gap.

✔️ Awesome, I've got everything!

Great. Save every file before completing the access checkpoint.

ⓧ I'd like to double check the full code

Compare these files with your current project. The two __init__.py files inside the management command folders remain empty.

from django.conf import settings
from django.db import models


class Ticket(models.Model):
    class Status(models.TextChoices):
        OPEN = "open", "Open"
        IN_PROGRESS = "in_progress", "In progress"
        CLOSED = "closed", "Closed"

    class Priority(models.TextChoices):
        LOW = "low", "Low"
        MEDIUM = "medium", "Medium"
        HIGH = "high", "High"

    title = models.CharField(max_length=120)
    description = models.TextField(blank=True)
    status = models.CharField(
        max_length=20,
        choices=Status,
        default=Status.OPEN,
    )
    priority = models.CharField(
        max_length=10,
        choices=Priority,
        default=Priority.MEDIUM,
    )
    assignee = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="assigned_tickets",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at", "-pk"]

    def __str__(self):
        return self.title
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.management.base import BaseCommand

from tickets.models import Ticket


class Command(BaseCommand):
    help = "Create local demo roles, users, and support tickets"

    def handle(self, *args, **options):
        User = get_user_model()

        managers, _ = Group.objects.get_or_create(name="Managers")
        agents, _ = Group.objects.get_or_create(name="Agents")

        view_ticket = Permission.objects.get(
            content_type__app_label="tickets",
            content_type__model="ticket",
            codename="view_ticket",
        )
        change_ticket = Permission.objects.get(
            content_type__app_label="tickets",
            content_type__model="ticket",
            codename="change_ticket",
        )

        managers.permissions.set([view_ticket, change_ticket])
        agents.permissions.set([view_ticket])

        manager, _ = User.objects.get_or_create(username="manager")
        manager.set_password("demo-pass-123")
        manager.save()
        manager.groups.set([managers])

        agent, _ = User.objects.get_or_create(username="agent")
        agent.set_password("demo-pass-123")
        agent.save()
        agent.groups.set([agents])

        agent_two, _ = User.objects.get_or_create(username="agent-two")
        agent_two.set_password("demo-pass-123")
        agent_two.save()
        agent_two.groups.set([agents])

        Ticket.objects.all().delete()

        ticket_specs = [
            ("VPN access fails", "Remote employee cannot connect to VPN.", "open", "high"),
            ("Printer queue stuck", "Finance printer jobs remain queued.", "in_progress", "medium"),
            ("Reset payroll password", "User needs a payroll password reset.", "closed", "high"),
            ("Laptop camera unavailable", "Camera is missing during meetings.", "open", "medium"),
            ("Shared drive permission", "Marketing cannot open the shared drive.", "open", "high"),
            ("Email signature update", "Apply the new company signature.", "closed", "low"),
            ("VPN client upgrade", "Upgrade the VPN client before travel.", "in_progress", "medium"),
            ("Monitor flickering", "Second monitor flickers after login.", "open", "medium"),
            ("Archive mailbox", "Archive an employee mailbox.", "closed", "low"),
            ("Security key enrollment", "Enroll a replacement security key.", "open", "high"),
            ("Software license request", "Assign a design software license.", "in_progress", "medium"),
            ("Wi-Fi drops", "Office Wi-Fi disconnects every hour.", "open", "high"),
            ("Calendar sharing", "Team calendar is not visible.", "closed", "low"),
            ("Browser certificate warning", "Internal site shows a certificate warning.", "open", "high"),
            ("Docking station audio", "Audio output is missing through the dock.", "in_progress", "medium"),
        ]

        for index, (title, description, status, priority) in enumerate(ticket_specs):
            assignee = agent if index % 2 == 0 else agent_two
            Ticket.objects.create(
                title=title,
                description=description,
                status=status,
                priority=priority,
                assignee=assignee,
            )

        self.stdout.write(
            self.style.SUCCESS(
                "Created manager, agent, and agent-two with password demo-pass-123"
            )
        )
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView

from .models import Ticket


class TicketListView(LoginRequiredMixin, ListView):
    model = Ticket
    template_name = "tickets/ticket_list.html"
    context_object_name = "tickets"
from django.urls import path

from .views import TicketListView

urlpatterns = [
    path("", TicketListView.as_view(), name="ticket-list"),
]
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}Support Desk{% endblock %}</title>
    <style>
        :root { font-family: system-ui, sans-serif; color: #172033; background: #f4f7fb; }
        body { margin: 0; }
        header { background: #172033; color: white; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; }
        header a { color: white; text-decoration: none; }
        main { width: min(1100px, 92%); margin: 2rem auto; }
        .card { background: white; border-radius: 12px; padding: 1.25rem; box-shadow: 0 8px 24px rgba(23, 32, 51, 0.08); }
        input, select, button { font: inherit; padding: 0.65rem; border: 1px solid #ccd4e0; border-radius: 8px; }
        button, .button { background: #275efe; color: white; border: 0; cursor: pointer; text-decoration: none; display: inline-block; padding: 0.65rem 0.9rem; border-radius: 8px; }
        .logout { display: inline; margin-left: 1rem; }
        .logout button { padding: 0.4rem 0.65rem; }
        table { width: 100%; border-collapse: collapse; }
        th, td { text-align: left; padding: 0.8rem; border-bottom: 1px solid #e6eaf0; }
        th { color: #586174; }
        .pill { display: inline-block; padding: 0.25rem 0.55rem; background: #edf2ff; border-radius: 999px; }
        .error { color: #a61b1b; }
    </style>
</head>
<body>
<header>
    <a href="{% url 'ticket-list' %}">Support Desk</a>
    {% if user.is_authenticated %}
        <div>
            Signed in as {{ user.username }}
            <form class="logout" method="post" action="{% url 'logout' %}">
                {% csrf_token %}
                <button type="submit">Log out</button>
            </form>
        </div>
    {% endif %}
</header>
<main>
    {% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
{% extends "base.html" %}

{% block title %}Sign in | Support Desk{% endblock %}

{% block content %}
<div class="card">
    <h1>Sign in</h1>
    <p>Local demo accounts use password <code>demo-pass-123</code>.</p>
    <p>Usernames: <code>manager</code>, <code>agent</code>, and <code>agent-two</code>.</p>
    {% if form.errors %}
        <p class="error">The username or password was not accepted.</p>
    {% endif %}
    <form method="post" action="{% url 'login' %}">
        {% csrf_token %}
        {{ form.as_p }}
        <input type="hidden" name="next" value="{{ next }}">
        <button type="submit">Sign in</button>
    </form>
</div>
{% endblock %}
{% extends "base.html" %}

{% block title %}Tickets | Support Desk{% endblock %}

{% block content %}
<div class="card">
    <h1>Support tickets</h1>
    <table>
        <thead>
            <tr>
                <th>Title</th>
                <th>Status</th>
                <th>Priority</th>
                <th>Assignee</th>
            </tr>
        </thead>
        <tbody>
            {% for ticket in tickets %}
                <tr>
                    <td>{{ ticket.title }}</td>
                    <td>{{ ticket.get_status_display }}</td>
                    <td>{{ ticket.get_priority_display }}</td>
                    <td>{{ ticket.assignee.username }}</td>
                </tr>
            {% empty %}
                <tr>
                    <td colspan="5">No tickets match these filters.</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
{% endblock %}

You have a working support dashboard plus direct evidence of its access-control gap. Next, you will scope the queryset on the server and protect ticket editing with permissions.

Enforce Server-Side Role Access

Your Django dashboard now proves the risk from the previous step. Authentication confirms who signed in. Authorization still needs to limit which tickets that person can access.

This step moves the access rules into the server-side QuerySet. Managers receive the full queue. Agents receive only tickets assigned to their account.

In this step, get ready to:
  • Require the ticket viewing permission before returning the list.
  • Scope ticket rows according to each user's editing capability.
  • Protect ticket updates with a separate manager permission.
Scope the ticket queryset

Django model permissions represent capabilities such as viewing or changing a ticket. The view can use those capabilities before it sends any rows to the template.

  • Select tickets/views.py in the VS Code Explorer sidebar.
  • Replace the file contents with this secured list view:
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView

from .models import Ticket


class TicketListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
    model = Ticket
    template_name = "tickets/ticket_list.html"
    context_object_name = "tickets"
    permission_required = "tickets.view_ticket"

    def get_queryset(self):
        queryset = Ticket.objects.select_related("assignee").order_by(
            "-created_at", "-pk"
        )

        if not self.request.user.has_perm("tickets.change_ticket"):
            queryset = queryset.filter(assignee=self.request.user)

        return queryset

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["is_manager"] = self.request.user.has_perm("tickets.change_ticket")

        return context

How does the access rule work?

  • The permission_required value requires every user to have tickets.view_ticket before the list view responds.
  • The has_perm() check treats tickets.change_ticket as the manager capability.
  • The agent branch applies filter(assignee=self.request.user) before any ticket rows reach the template.
  • The is_manager context value gives the template a role-aware message.
  • Save tickets/views.py.

Before you refresh, predict whether any tickets assigned to agent-two remain visible.

  • Switch back to the ticket dashboard from the previous step.
  • Refresh the browser page.

You should now see eight tickets assigned to agent. Tickets assigned to agent-two no longer appear.

Still seeing every ticket?

  • Check that TicketListView inherits from PermissionRequiredMixin.
  • Check that the agent branch filters with assignee=self.request.user.
  • Confirm that you are signed in as agent.

Help me debug why the agent still sees tickets assigned to another user.

The queryset now enforces the security boundary. The template can show which side of that boundary the signed-in user occupies.

  • Select tickets/templates/tickets/ticket_list.html in the VS Code Explorer sidebar.
  • Replace the heading area inside the existing card with this role-aware block:
    <div>
        <span class="pill">
            {% if is_manager %}Manager view{% else %}Agent view{% endif %}
        </span>
    </div>
    <h1>Support tickets</h1>
    <p>
        {% if is_manager %}
            You can see and edit the full queue.
        {% else %}
            You can see tickets assigned to you.
        {% endif %}
    </p>

What does this template block show?

The is_manager value controls the role label. It also gives each user a precise description of their queue access.

  • Save tickets/templates/tickets/ticket_list.html.
  • Refresh the ticket dashboard in your browser.

You should see Agent view above the ticket list. The message should confirm that you can see tickets assigned to your account.

Role label missing?

  • Confirm that the role block sits inside the template's existing card element.
  • Check that the template condition spells is_manager exactly like the context key in tickets/views.py.

Help me find why the Django template does not display the manager or agent label.

Add manager-only ticket editing

The existing Groups grant model permissions to their members. The update page can enforce the change capability without depending on a particular group name.

Why check a permission instead of a group name?

A permission describes the action a user may perform. The access rule keeps working if the Managers group is renamed or another trusted group receives the same capability.

  • Return to tickets/views.py in the VS Code Explorer sidebar.
  • Place this update view below TicketListView:
class TicketUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    model = Ticket
    fields = ["status", "priority", "assignee"]
    template_name = "tickets/ticket_form.html"
    permission_required = "tickets.change_ticket"
    success_url = reverse_lazy("ticket-list")

What does the update view protect?

  • The view creates an editing form for status, priority, and assignee.
  • The tickets.change_ticket permission protects every request to the update page.
  • A successful update redirects the manager to ticket-list.
  • Save tickets/views.py.
  • Switch back to the terminal panel from earlier.

You should see the development server complete its reload without a system-check error.

Server reload failed?

  • Check that UpdateView appears in the import from django.views.generic.
  • Check that reverse_lazy appears in the import from django.urls.

Help me debug the Django server reload after adding TicketUpdateView.

The update view expects a template that renders Django's generated form. That template also needs CSRF protection before it can submit changes.

  • Select the tickets/templates/tickets folder in the VS Code Explorer sidebar.
  • Create ticket_form.html with the New File control.
  • Paste this ticket form into the new file:
{% extends "base.html" %}

{% block title %}Edit ticket | Support Desk{% endblock %}

{% block content %}
<div class="card">
    <h1>Edit {{ object.title }}</h1>
    <form method="post">
        {% csrf_token %}
        {{ form.as_p }}
        <button type="submit">Save changes</button>
        <a class="button" href="{% url 'ticket-list' %}">Cancel</a>
    </form>
</div>
{% endblock %}

How does the edit form work?

  • The object.title value identifies the ticket being edited.
  • The form.as_p value renders the three fields declared by TicketUpdateView.
  • The csrf_token tag protects the submitted form.
  • Save tickets/templates/tickets/ticket_form.html.
  • Confirm that ticket_form.html appears under tickets/templates/tickets in the Explorer sidebar.

Form template in the wrong folder?

Check that the complete path is tickets/templates/tickets/ticket_form.html. The repeated tickets segment matches the template name used by the update view.

Help me check the location of my Django ticket form template.

A URL pattern connects each ticket identifier to the protected update view. The identifier selects the ticket that the manager wants to edit.

  • Select tickets/urls.py in the VS Code Explorer sidebar.
  • Replace the file contents with these list and update routes:
from django.urls import path

from .views import TicketListView, TicketUpdateView

urlpatterns = [
    path("", TicketListView.as_view(), name="ticket-list"),
    path("tickets/<int:pk>/edit/", TicketUpdateView.as_view(), name="ticket-update"),
]

What does the update route do?

The <int:pk> segment captures a ticket's numeric primary key. Django passes that value to TicketUpdateView so it can load the correct ticket.

  • Save tickets/urls.py.
  • Switch back to the terminal panel from earlier.

You should see the development server complete another reload without a system-check error.

Update route not loading?

  • Check that TicketUpdateView appears in the views import.
  • Check that the route uses the name ticket-update.

Help me debug why Django cannot resolve the ticket update route.

The server now protects the edit route. The list should expose an edit link only to users who hold the same change permission.

  • Return to tickets/templates/tickets/ticket_list.html in the VS Code Explorer sidebar.
  • Replace the existing ticket table with this permission-aware table:
    <table>
        <thead>
            <tr>
                <th>Title</th>
                <th>Status</th>
                <th>Priority</th>
                <th>Assignee</th>
                {% if perms.tickets.change_ticket %}<th>Action</th>{% endif %}
            </tr>
        </thead>
        <tbody>
            {% for ticket in tickets %}
                <tr>
                    <td>{{ ticket.title }}</td>
                    <td>{{ ticket.get_status_display }}</td>
                    <td>{{ ticket.get_priority_display }}</td>
                    <td>{{ ticket.assignee.username }}</td>
                    {% if perms.tickets.change_ticket %}
                        <td><a href="{% url 'ticket-update' ticket.pk %}">Edit</a></td>
                    {% endif %}
                </tr>
            {% empty %}
                <tr>
                    <td colspan="5">No tickets match these filters.</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>

How are edit links controlled?

The perms.tickets.change_ticket condition controls the Action column. The server still checks the permission when someone requests an update URL directly.

The template condition improves the interface. PermissionRequiredMixin enforces the security boundary.

  • Save tickets/templates/tickets/ticket_list.html.

Before you refresh, predict whether the agent account receives an Action column.

  • Refresh the agent dashboard in your browser.

You should still see only the agent's eight tickets. You should see no Action column or Edit links.

Agent can see edit links?

  • Check that both edit-link conditions use perms.tickets.change_ticket.
  • Confirm that the signed-in account is agent.

Help me debug why an agent can see manager-only edit links in the Django template.

✔️ Awesome, I've got everything!

Great. Double check that all four changed files are saved before testing both roles.

ⓧ I'd like to double check the full code

Compare these cumulative files with your project.

from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView

from .models import Ticket


class TicketListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
    model = Ticket
    template_name = "tickets/ticket_list.html"
    context_object_name = "tickets"
    permission_required = "tickets.view_ticket"

    def get_queryset(self):
        queryset = Ticket.objects.select_related("assignee").order_by(
            "-created_at", "-pk"
        )

        if not self.request.user.has_perm("tickets.change_ticket"):
            queryset = queryset.filter(assignee=self.request.user)

        return queryset

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["is_manager"] = self.request.user.has_perm("tickets.change_ticket")

        return context


class TicketUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    model = Ticket
    fields = ["status", "priority", "assignee"]
    template_name = "tickets/ticket_form.html"
    permission_required = "tickets.change_ticket"
    success_url = reverse_lazy("ticket-list")

Views checkpoint

This file contains the scoped list view plus the protected update view.

from django.urls import path

from .views import TicketListView, TicketUpdateView

urlpatterns = [
    path("", TicketListView.as_view(), name="ticket-list"),
    path("tickets/<int:pk>/edit/", TicketUpdateView.as_view(), name="ticket-update"),
]

URL checkpoint

This file maps the dashboard and ticket edit pages to their views.

{% extends "base.html" %}

{% block title %}Edit ticket | Support Desk{% endblock %}

{% block content %}
<div class="card">
    <h1>Edit {{ object.title }}</h1>
    <form method="post">
        {% csrf_token %}
        {{ form.as_p }}
        <button type="submit">Save changes</button>
        <a class="button" href="{% url 'ticket-list' %}">Cancel</a>
    </form>
</div>
{% endblock %}

Form checkpoint

This template renders the manager's ticket update form.

{% extends "base.html" %}

{% block title %}Tickets | Support Desk{% endblock %}

{% block content %}
<div class="card">
    <div>
        <span class="pill">
            {% if is_manager %}Manager view{% else %}Agent view{% endif %}
        </span>
    </div>
    <h1>Support tickets</h1>
    <p>
        {% if is_manager %}
            You can see and edit the full queue.
        {% else %}
            You can see tickets assigned to you.
        {% endif %}
    </p>

    <table>
        <thead>
            <tr>
                <th>Title</th>
                <th>Status</th>
                <th>Priority</th>
                <th>Assignee</th>
                {% if perms.tickets.change_ticket %}<th>Action</th>{% endif %}
            </tr>
        </thead>
        <tbody>
            {% for ticket in tickets %}
                <tr>
                    <td>{{ ticket.title }}</td>
                    <td>{{ ticket.get_status_display }}</td>
                    <td>{{ ticket.get_priority_display }}</td>
                    <td>{{ ticket.assignee.username }}</td>
                    {% if perms.tickets.change_ticket %}
                        <td><a href="{% url 'ticket-update' ticket.pk %}">Edit</a></td>
                    {% endif %}
                </tr>
            {% empty %}
                <tr>
                    <td colspan="5">No tickets match these filters.</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
{% endblock %}

List template checkpoint

This template shows role-aware messaging plus manager-only edit links.

Verify both role boundaries

A reliable access check uses both demo roles. The manager should receive the broad capability. The agent should encounter the protected boundary even with a direct edit URL.

  • Click Log out in the dashboard header.
  • Enter manager in the username field.
  • Enter demo-pass-123 in the password field.
  • Click Sign in.

You should see Manager view above all 15 tickets. The table should include an Action column with Edit links.

  • Click the first Edit link.

You should see a form for the selected ticket. The form should contain status, priority, and assignee controls.

  • Record the current edit page URL here: http://127.0.0.1:8000/tickets/1/edit/.
  • Click Cancel to return to the dashboard.
  • Click Log out.
  • Enter agent in the username field.
  • Enter demo-pass-123 in the password field.
  • Click Sign in.

Before loading the saved edit URL, predict whether hiding the Edit links alone would stop this direct request.

  • Paste http://127.0.0.1:8000/tickets/1/edit/ into the browser address bar.
  • Press Enter.

You should receive an HTTP 403 response. The server rejects the request because agent does not have tickets.change_ticket.

Agent can open the edit page?

  • Check that TicketUpdateView inherits from PermissionRequiredMixin.
  • Check that permission_required equals tickets.change_ticket.
  • Confirm that the agent belongs only to the Agents group created by seed_demo.

Help me debug why an agent can open the manager-only ticket update page.

That access leak is closed. Managers can operate across the queue while agents remain confined to their assigned tickets.

Your role boundary now holds at the queryset and update-view layers. Next, you'll make the secured queue easier to scan with pagination and live filters.

Add Pagination and Live Filters

Your authorization boundary is secure. Managers can work across the queue while agents remain limited to their assigned tickets.

A growing queue still becomes difficult to scan. In this step, Django pagination limits each page to five tickets. GET filters narrow the authorized queryset before an HTML fragment refreshes through vanilla JavaScript.

In this step, get ready to:
  • Paginate the authorized ticket queryset into five-ticket pages.
  • Add search, status, and priority filters that persist across pages.
  • Refresh the ticket results automatically without an Apply button.
Paginate and filter the authorized queryset

The view must preserve its authorization boundary before applying any user-controlled filters. This ordering prevents an agent from searching beyond their assigned tickets.

  • In the VS Code Explorer sidebar, return to tickets/views.py from the previous step.
  • Find this import near the top of the file:
from django.urls import reverse_lazy

Why Locate This Import?

The new query logic needs Django's Q object. Placing its import beside the existing Django imports keeps the dependency visible.

  • Replace that import with these two imports:
from django.db.models import Q
from django.urls import reverse_lazy

What Does Q Enable?

The Q object combines the title search with the description search. A ticket can match either field.

  • In TicketListView, find the existing permission setting:
    permission_required = "tickets.view_ticket"

Where Pagination Belongs

The list view already declares its access requirement as a class attribute. Pagination uses the same pattern.

  • Replace that line with the permission setting plus the page size:
    permission_required = "tickets.view_ticket"
    paginate_by = 5

What Does paginate_by Do?

The paginate_by setting limits each response to five tickets. Django also supplies page_obj for navigation controls.

  • Save tickets/views.py.
  • Refresh the dashboard in the browser from the previous step.

You should now see only the first five authorized tickets. The remaining tickets are available through pagination once the navigation links are added.

Still Seeing More Than Five Tickets?

  • Check that paginate_by = 5 is indented inside TicketListView.
  • Confirm that the local Django server reloaded after you saved the file.

Ask for help: Why is my Django ListView still showing more than five tickets?

  • In tickets/views.py, select the complete existing get_queryset() method.
  • Replace the selected method by copying this code:
    def get_queryset(self):
        queryset = Ticket.objects.select_related("assignee").order_by("-created_at", "-pk")
        if not self.request.user.has_perm("tickets.change_ticket"):
            queryset = queryset.filter(assignee=self.request.user)
        query = self.request.GET.get("q", "").strip()
        status = self.request.GET.get("status", "")
        priority = self.request.GET.get("priority", "")
        if query:
            queryset = queryset.filter(Q(title__icontains=query) | Q(description__icontains=query))
        if status:
            queryset = queryset.filter(status=status)
        if priority:
            queryset = queryset.filter(priority=priority)
        return queryset

How Does Filtering Stay Secure?

  • The permission check scopes agents to their assigned tickets before any filter runs.
  • The q value searches the title or description with case-insensitive containment.
  • The status value narrows the scoped queryset to one workflow state.
  • The priority value narrows the scoped queryset to one urgency level.
  • Add the following get_context_data() method directly below get_queryset() by copying this code:
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["q"] = self.request.GET.get("q", "")
        context["status"] = self.request.GET.get("status", "")
        context["priority"] = self.request.GET.get("priority", "")
        context["status_choices"] = Ticket.Status.choices
        context["priority_choices"] = Ticket.Priority.choices
        context["is_manager"] = self.request.user.has_perm("tickets.change_ticket")
        params = self.request.GET.copy()
        if "page" in params:
            params.pop("page")
        context["filter_query"] = params.urlencode()
        return context

What Does This Context Provide?

  • The current filter values return to the template so each control can keep its selection.
  • The model choices populate the status and priority dropdowns from the same values used by Ticket.
  • The filter_query value removes the old page number while preserving every active filter.

Before you test the queryset, do you expect a search to run across all tickets or only the tickets already allowed for the signed-in account?

  • Save tickets/views.py.
  • Return to the dashboard from earlier as manager.
  • Add ?q=VPN to the dashboard address in the browser.
  • Press Enter to load the filtered queue.

You should see VPN access fails plus VPN client upgrade. The search is running on the server inside the manager's authorized queryset.

Search Returning the Wrong Tickets?

  • Check that the three filter blocks appear after the permission-based assignee filter.
  • Confirm that the title lookup and description lookup are joined with the | operator.
  • Check that return queryset appears after every filter.

Ask for help: Why is my Django queryset search returning unexpected tickets?

Render reusable ticket results

A fragment template holds the result count, table, and pagination links in one reusable response. Normal page loads include it inside the dashboard while live requests receive only that fragment.

  • In tickets/views.py, add the following method directly below get_context_data() by copying this code:
    def get_template_names(self):
        if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
            return ["tickets/_ticket_results.html"]
        return [self.template_name]

How Does the View Choose a Template?

A request carrying the X-Requested-With header receives only tickets/_ticket_results.html. A normal browser request still receives the complete dashboard template.

  • In the VS Code Explorer sidebar, expand tickets/templates/tickets.
  • Create _ticket_results.html inside tickets/templates/tickets.
  • Build the result count and ticket table by copying this code into the new file:
<p>{{ page_obj.paginator.count }} matching ticket{{ page_obj.paginator.count|pluralize }}</p>

<table>
    <thead>
        <tr>
            <th>Title</th>
            <th>Status</th>
            <th>Priority</th>
            <th>Assignee</th>
            {% if perms.tickets.change_ticket %}<th>Action</th>{% endif %}
        </tr>
    </thead>
    <tbody>
        {% for ticket in tickets %}
            <tr>
                <td>{{ ticket.title }}</td>
                <td>{{ ticket.get_status_display }}</td>
                <td>{{ ticket.get_priority_display }}</td>
                <td>{{ ticket.assignee.username }}</td>
                {% if perms.tickets.change_ticket %}
                    <td><a href="{% url 'ticket-update' ticket.pk %}">Edit</a></td>
                {% endif %}
            </tr>
        {% empty %}
            <tr>
                <td colspan="5">No tickets match these filters.</td>
            </tr>
        {% endfor %}
    </tbody>
</table>

What Does the Result Table Render?

  • The first line reports the total number of tickets across every matching page.
  • The loop renders only the five tickets supplied for the current page.
  • The permission check keeps manager edit links out of agent responses.
  • The empty row gives a visible result when no ticket matches the filters.
  • Save tickets/templates/tickets/_ticket_results.html.

You should see _ticket_results.html inside the tickets/templates/tickets folder in the Explorer sidebar.

Fragment File Missing?

  • Confirm that the filename begins with one underscore.
  • Check that the file sits beside ticket_list.html.

Ask for help: Why can Django not find my ticket result fragment template?

  • Add the pagination navigation below the closing </table> tag by copying this code:
{% if is_paginated %}
<nav class="pagination" aria-label="Ticket pages">
    {% if page_obj.has_previous %}
        <a href="?{% if filter_query %}{{ filter_query }}&{% endif %}page={{ page_obj.previous_page_number }}">Previous</a>
    {% endif %}
    <span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
    {% if page_obj.has_next %}
        <a href="?{% if filter_query %}{{ filter_query }}&{% endif %}page={{ page_obj.next_page_number }}">Next</a>
    {% endif %}
</nav>
{% endif %}

How Do Pagination Links Keep Filters?

Each link begins with filter_query before adding its new page number. Search, status, and priority values therefore survive normal pagination.

  • In tickets/templates/tickets/ticket_list.html, select the existing ticket table.
  • Replace the selected table with the filter form and fragment container by copying this code:
    <form id="filters" class="filters" method="get" action="{% url 'ticket-list' %}">
        <label>
            Search
            <input type="search" name="q" value="{{ q }}" placeholder="Title or description">
        </label>
        <label>
            Status
            <select name="status">
                <option value="">All statuses</option>
                {% for value, label in status_choices %}
                    <option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
                {% endfor %}
            </select>
        </label>
        <label>
            Priority
            <select name="priority">
                <option value="">All priorities</option>
                {% for value, label in priority_choices %}
                    <option value="{{ value }}" {% if priority == value %}selected{% endif %}>{{ label }}</option>
                {% endfor %}
            </select>
        </label>
    </form>

    <div id="ticket-results">
        {% include "tickets/_ticket_results.html" %}
    </div>

How Is the Dashboard Composed?

  • The form submits q, status, and priority as query parameters.
  • The controls reuse their current context values after a normal page load.
  • The ticket-results container provides one replacement target for the browser script.
  • Save tickets/views.py.
  • Save tickets/templates/tickets/_ticket_results.html.
  • Save tickets/templates/tickets/ticket_list.html.
  • Refresh the dashboard in the browser.

You should see Search, Status, and Priority controls above five tickets. As manager, you should also see a Next link for the remaining tickets.

Dashboard Template Not Rendering?

  • Check that the include path is exactly tickets/_ticket_results.html.
  • Confirm that the result container uses id="ticket-results".
  • Check the terminal from earlier for a template syntax problem.

Ask for help: Why is my Django ticket fragment not appearing inside the dashboard?

Refresh filters in the browser

The filter form already works through ordinary query parameters. A small browser script now sends the same values automatically whenever search text or a dropdown changes.

  • In the VS Code Explorer sidebar, create a static folder inside tickets.
  • Create a tickets folder inside tickets/static.

You should now see the tickets/static/tickets path in the Explorer sidebar.

  • Create dashboard.js inside tickets/static/tickets.
  • Add the live refresh behavior by copying this code into dashboard.js:
const filterForm = document.querySelector("#filters");
const ticketResults = document.querySelector("#ticket-results");

if (filterForm && ticketResults) {
    async function refreshTickets() {
        const params = new URLSearchParams(new FormData(filterForm));
        const response = await fetch(`${filterForm.action}?${params.toString()}`, {
            headers: {"X-Requested-With": "XMLHttpRequest"},
        });
        ticketResults.innerHTML = await response.text();
    }

    const searchInput = filterForm.querySelector('[name="q"]');
    searchInput.addEventListener("input", refreshTickets);

    for (const select of filterForm.querySelectorAll("select")) {
        select.addEventListener("change", refreshTickets);
    }
}

How Does Live Filtering Work?

  • The script locates the filter form and the result container by their element IDs.
  • FormData reads the current values from every filter control.
  • URLSearchParams converts those values into the same query string used by a normal form request.
  • The response text replaces only the markup inside ticket-results.
  • Save tickets/static/tickets/dashboard.js.

You should see dashboard.js inside tickets/static/tickets in the Explorer sidebar.

  • At the top of tickets/templates/tickets/ticket_list.html, find this line:
{% extends "base.html" %}

Why Load Static Files Here?

The dashboard template extends the shared page layout. It also needs Django's static template tag before it can resolve the browser script path.

  • Replace that line with these two template tags:
{% extends "base.html" %}
{% load static %}

What Does load static Provide?

The static tag generates the development URL for tickets/dashboard.js from its namespaced app folder.

  • Add the script block below the closing {% endblock %} for the page content by copying this code:
{% block scripts %}
<script src="{% static 'tickets/dashboard.js' %}"></script>
{% endblock %}

Where Does the Script Load?

The base template exposes a scripts block after the main page content. Loading the script there ensures the filter form and result container already exist.

  • Save tickets/templates/tickets/ticket_list.html.

Before you try the finished dashboard, do you expect the ticket table to wait for an Apply button or update as soon as a filter changes?

  • Return to the manager dashboard in the browser.
  • Refresh the page.
  • Type VPN into the Search field.

You should see two matching tickets without clicking a button. The rest of the dashboard stays in place while the result fragment changes.

  • Select Open from the Status dropdown.

You should now see only VPN access fails because the other VPN ticket is in progress.

  • Clear the Search field.

You should see seven matching open tickets. The Next link returns because the filtered result now spans two pages.

Before you open the next page, do you expect the Status dropdown to keep Open selected?

  • Click Next below the ticket table.

You should see Page 2 of 2. The Status dropdown remains set to Open because the pagination link carries the active filter value.

Filters Not Updating Live?

  • Confirm that dashboard.js is saved inside tickets/static/tickets.
  • Check that the form uses id="filters".
  • Check that the result wrapper uses id="ticket-results".

Ask for help: Why are my Django filters only working after a full page load?

✔️ Awesome, I've got everything!

Great. Double check that all four changed files are saved before you capture your result.

ⓧ I'd like to double check the full code

Compare these final files against yours. Each one should match exactly.

from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.db.models import Q
from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView

from .models import Ticket


class TicketListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
    model = Ticket
    template_name = "tickets/ticket_list.html"
    context_object_name = "tickets"
    permission_required = "tickets.view_ticket"
    paginate_by = 5

    def get_queryset(self):
        queryset = Ticket.objects.select_related("assignee").order_by("-created_at", "-pk")
        if not self.request.user.has_perm("tickets.change_ticket"):
            queryset = queryset.filter(assignee=self.request.user)
        query = self.request.GET.get("q", "").strip()
        status = self.request.GET.get("status", "")
        priority = self.request.GET.get("priority", "")
        if query:
            queryset = queryset.filter(Q(title__icontains=query) | Q(description__icontains=query))
        if status:
            queryset = queryset.filter(status=status)
        if priority:
            queryset = queryset.filter(priority=priority)
        return queryset

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["q"] = self.request.GET.get("q", "")
        context["status"] = self.request.GET.get("status", "")
        context["priority"] = self.request.GET.get("priority", "")
        context["status_choices"] = Ticket.Status.choices
        context["priority_choices"] = Ticket.Priority.choices
        context["is_manager"] = self.request.user.has_perm("tickets.change_ticket")
        params = self.request.GET.copy()
        if "page" in params:
            params.pop("page")
        context["filter_query"] = params.urlencode()
        return context

    def get_template_names(self):
        if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
            return ["tickets/_ticket_results.html"]
        return [self.template_name]


class TicketUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    model = Ticket
    fields = ["status", "priority", "assignee"]
    template_name = "tickets/ticket_form.html"
    permission_required = "tickets.change_ticket"
    success_url = reverse_lazy("ticket-list")
{% extends "base.html" %}
{% load static %}

{% block title %}Tickets | Support Desk{% endblock %}

{% block content %}
<div class="card">
    <div>
        <span class="pill">
            {% if is_manager %}Manager view{% else %}Agent view{% endif %}
        </span>
    </div>
    <h1>Support tickets</h1>
    <p>
        {% if is_manager %}
            You can see and edit the full queue.
        {% else %}
            You can see tickets assigned to you.
        {% endif %}
    </p>

    <form id="filters" class="filters" method="get" action="{% url 'ticket-list' %}">
        <label>
            Search
            <input type="search" name="q" value="{{ q }}" placeholder="Title or description">
        </label>
        <label>
            Status
            <select name="status">
                <option value="">All statuses</option>
                {% for value, label in status_choices %}
                    <option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
                {% endfor %}
            </select>
        </label>
        <label>
            Priority
            <select name="priority">
                <option value="">All priorities</option>
                {% for value, label in priority_choices %}
                    <option value="{{ value }}" {% if priority == value %}selected{% endif %}>{{ label }}</option>
                {% endfor %}
            </select>
        </label>
    </form>

    <div id="ticket-results">
        {% include "tickets/_ticket_results.html" %}
    </div>
</div>
{% endblock %}

{% block scripts %}
<script src="{% static 'tickets/dashboard.js' %}"></script>
{% endblock %}
<p>{{ page_obj.paginator.count }} matching ticket{{ page_obj.paginator.count|pluralize }}</p>

<table>
    <thead>
        <tr>
            <th>Title</th>
            <th>Status</th>
            <th>Priority</th>
            <th>Assignee</th>
            {% if perms.tickets.change_ticket %}<th>Action</th>{% endif %}
        </tr>
    </thead>
    <tbody>
        {% for ticket in tickets %}
            <tr>
                <td>{{ ticket.title }}</td>
                <td>{{ ticket.get_status_display }}</td>
                <td>{{ ticket.get_priority_display }}</td>
                <td>{{ ticket.assignee.username }}</td>
                {% if perms.tickets.change_ticket %}
                    <td><a href="{% url 'ticket-update' ticket.pk %}">Edit</a></td>
                {% endif %}
            </tr>
        {% empty %}
            <tr>
                <td colspan="5">No tickets match these filters.</td>
            </tr>
        {% endfor %}
    </tbody>
</table>

{% if is_paginated %}
<nav class="pagination" aria-label="Ticket pages">
    {% if page_obj.has_previous %}
        <a href="?{% if filter_query %}{{ filter_query }}&{% endif %}page={{ page_obj.previous_page_number }}">Previous</a>
    {% endif %}
    <span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
    {% if page_obj.has_next %}
        <a href="?{% if filter_query %}{{ filter_query }}&{% endif %}page={{ page_obj.next_page_number }}">Next</a>
    {% endif %}
</nav>
{% endif %}
const filterForm = document.querySelector("#filters");
const ticketResults = document.querySelector("#ticket-results");

if (filterForm && ticketResults) {
    async function refreshTickets() {
        const params = new URLSearchParams(new FormData(filterForm));
        const response = await fetch(`${filterForm.action}?${params.toString()}`, {
            headers: {"X-Requested-With": "XMLHttpRequest"},
        });
        ticketResults.innerHTML = await response.text();
    }

    const searchInput = filterForm.querySelector('[name="q"]');
    searchInput.addEventListener("input", refreshTickets);

    for (const select of filterForm.querySelectorAll("select")) {
        select.addEventListener("change", refreshTickets);
    }
}

You have turned the secured queue into a responsive dashboard that remains manageable as ticket volume grows. Search, dropdown filters, and pagination now share one server-side source of truth.

Secret mission

Prove the Access Rules with Tests

Your access rules work in the browser today. Add repeatable tests for ticket visibility. Add a final test that protects the manager-only edit boundary.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your Windows computer. There are no cloud resources or ongoing costs.

You can keep the project ready, pause it for later, or delete every local file.

Resources you used:

  • Running local Django development server.
  • Project virtual environment at .venv.
  • Dependency list at requirements.txt.
  • Django project files under supportdesk.
  • Ticket application files under tickets.
  • Generated migration at tickets/migrations/0001_initial.py.
  • Persistent SQLite database at db.sqlite3.

Keep everything running

No local files need deleting. Choose this option if you want to demonstrate the dashboard again or continue improving its access tests.

  • Return to the PowerShell terminal from earlier.
  • Press Ctrl+C to stop the local Django server.

You'll return to the PowerShell prompt. Your seeded dashboard and TicketAccessTests remain ready for another session.

Pause - I'll come back to this later

Shut down the running process to free the terminal. Deactivating the virtual environment leaves every project file available for your return.

  • Return to the PowerShell terminal that is running the server.
  • Press Ctrl+C to stop the local Django server.
  • Deactivate the project virtual environment by running:
deactivate

What Does This Command Do?

The deactivate command returns the current PowerShell session to its regular Python environment. The .venv folder and its installed packages remain on your computer.

  • Reactivate the existing virtual environment when you return by running this command from the project folder:
.\.venv\Scripts\Activate.ps1

What Should You See?

This script activates the existing environment for the new PowerShell session. You'll see .venv in the terminal prompt again.

Delete - I don't want to use this again

Remove all project resources if you want to start fresh. Deleting the project folder is permanent.

This option removes the dashboard, demo database, virtual environment, migrations, and test suite from your computer.

  • Return to the terminal that is running the local Django server.
  • Press Ctrl+C to stop the server.
  • Close Visual Studio Code.
  • Use the folder icon on the Windows taskbar to open the file manager.
  • Locate the project folder that contains requirements.txt.
  • Select the project folder.
  • Press Shift+Delete to delete the selected folder permanently.
  • Confirm the deletion when Windows asks.

You should no longer see the project folder in its parent location. The .venv, db.sqlite3, requirements.txt, source files, migrations, and tickets/tests.py are removed together.

Cleanup is complete. No cloud resources remain to stop or delete.

Folder Still Present?

  • Confirm that the local Django server has stopped.
  • Close any PowerShell window that still points to the project folder.
  • Try deleting the project folder again.

Help me remove a local Django project folder that Windows still considers in use.

Nice Work!

Nice Work!

Fantastic work. You built a local Django support desk that gives each signed-in person the correct ticket view.

You've learned how to:

  • Proved why authentication alone could expose the full queue. Enforced role-based access control with Groups. Mapped each role to Django model permissions.
  • Applied permission-based queryset scoping so managers can see every ticket. Limited each agent to assigned rows. Protected editing with PermissionRequiredMixin. Unauthorized agent requests receive an HTTP 403 response.
  • Added five-ticket pagination that keeps active GET filters across pages. Used vanilla JavaScript to fetch server-rendered fragments. Replaced #ticket-results after search or dropdown changes.
  • Secret Mission: Added repeatable database-backed access tests. Proved agents see only assigned tickets. Proved managers see both test tickets. Confirmed agent edit attempts receive HTTP 403.

Ready to quiz yourself?