Build a Task Manager with Django MongoDB Backend

python dev.to

Whilst life can work out beautifully when things are spontaneous, sometimes, a little planning and management help put things into perspective. Today, we are going to build a task manager using the MongoDB Atlas Free Tier and Django MongoDB Backend to help plan out tasks and activities. This task manager, which we will call “Get Going,” features a drag-and-drop Kanban board (To Do, In Progress, Done sections) built with HTMX.

Create a MongoDB Atlas cluster & get connection string

To kick off things, we will create a MongoDB Atlas cluster by creating an account; if you don’t already have one (link to register.

If you already have an account, go ahead and sign in. In the left pane, click Project Overview to display a page with Clusters, Application Development, and a Toolbar.

Next, click on Create cluster.

Select the MongoDB Atlas Free Tier and enter your preferred cluster name. Then select your preferred provider and other settings.
Click create to deploy your new cluster.

After your cluster has been created, click Connect and add a database username and password.

After successfully adding credentials, click on Connect to Cluster, select Drivers, select Python, and install the Python driver via your command line interface.

Copy the Connection URI to use as the HOST setting in settings.py.

Next, we’ll create a virtual env and install dependencies.

Prerequisites

  • Platform used: MacOS Tahoe 26.3
  • Python 3.12+
  • MongoDB Atlas account (free tier)

Project setup

  • Create a project directory, set up a virtual environment, and install dependencies:
  • Create a requirements.txt file with the following:
#requirements.txt

Django>=6.0
django-mongodb-backend
python-dotenv
Enter fullscreen mode Exit fullscreen mode

After, run pip install -r requirements.txt

Next, create a Django project using the MongoDB starter template, then create the tasks app:

#settings.py

INSTALLED_APPS = [
    ...
    'tasks',
]
Enter fullscreen mode Exit fullscreen mode

Environment Variables

Store sensitive credentials in a .env file at the project root (next to manage.py) to keep them out of source control. Then replace , , and with your MongoDB Atlas credentials.

#.env

SECRET_KEY=your-secret-key
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
MONGODB_HOST=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/?retryWrites=true&w=majority&appName=devrel-tutorial-django-taskmanager-devto
MONGODB_NAME=taskmanager
Enter fullscreen mode Exit fullscreen mode

Task Model

To define the Task model, we'll include fields for title, description, status, priority, owner, an optional due date, and auto-managed timestamps. Status choices map to three columns on the Kanban board: To Do, In Progress, and Done.

#models.py

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

class Task(models.Model):
    class Status(models.TextChoices):
        TODO = 'todo', 'To Do'
        IN_PROGRESS = 'in_progress', 'In Progress'
        DONE = 'done', 'Done'

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

    title = models.CharField(max_length=255)
    description = models.TextField(blank=True, default='')
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.TODO,
    )
    priority = models.CharField(
        max_length=10,
        choices=Priority.choices,
        default=Priority.MEDIUM,
    )
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='tasks',
    )
    due_date = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.title
Enter fullscreen mode Exit fullscreen mode

After creating our models, we will create and run migrations:
python manage.py makemigrations tasks
python manage.py migrate

Forms

Before we create views, create a TaskForm using Django's ModelForm, which will handle validation and rendering the creation and editing tasks, with fields for title, description, status, priority, and due date.

#forms.py 

from django import forms
from .models import Task

class TaskForm(forms.ModelForm):
    class Meta:
        model = Task
        fields = ['title', 'description', 'status', 'priority', 'due_date']
        widgets = {
            'due_date': forms.DateTimeInput(
                attrs={'type': 'date'},
                format='%Y-%m-%d',
            ),
        }

Enter fullscreen mode Exit fullscreen mode

Authentication

For this project, we will use Django’s built-in authentication system, which provides user accounts, login, logout, and password handling out of the box.

Login/Logout Views

Regarding authentication when logging in and out, Django provides a LoginView and LogoutView right out of the box. After this, we will register the views in urls.py and point LOGIN_URL and LOGIN_REDIRECT_URL in settings.py so unauthenticated users are sent to the login page, and successful logins land on the task manager board.

To protect the view, we use the @login_required decorator on every view to ensure only authenticated users can access the board, create tasks, or modify them. Each task is also scoped to its owner — users can only see and manage their own tasks.

We will also add a login page at templates/registration/login.html, which is a form that requires username and password fields.

Next, to get into the system and access the task manager, we need to create a superuser.

Run python manage.py createsuperuser

Follow the prompts and type out your name, email, and password, as we will use them to access the task manager board.

Add urls.py at the project and app level

Finally, we wire everything up in taskmanager/urls.py, which maps the root URL to the board view, /login/ and /logout/ to Django's built-in auth views, and task operations to their respective views. Each task URL uses the task's ID in the path for edit, delete, and move actions.

#taskmanager/urls.py

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path


urlpatterns = [
    path('', include('tasks.urls')),
    path('login/', auth_views.LoginView.as_view(), name='login'),
    path('logout/', auth_views.LogoutView.as_view(), name='logout'),
    path('admin/', admin.site.urls),
]
Enter fullscreen mode Exit fullscreen mode
#tasks/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('', views.board_view, name='board'),
    path('tasks/new/', views.task_create_view, name='task-create'),
    path(
        'tasks/<str:pk>/edit/',
        views.task_edit_view,
        name='task-edit',
    ),
    path(
        'tasks/<str:pk>/delete/',
        views.task_delete_view,
        name='task-delete',
    ),
    path(
        'tasks/<str:pk>/move/',
        views.task_move_view,
        name='task-move',
    ),
]
Enter fullscreen mode Exit fullscreen mode

Building the Task Manager Board (Kanban Board)

To build the task manager board, we will need:
Logic for the board view — a view that queries the current user's tasks and groups them into three columns by status (To Do, In Progress, Done)
Templates — a base layout, the board page with its three-column grid, a reusable card partial for each task, and a form modal for creating and editing tasks
HTMX for interactivity — create, edit, and delete tasks without full page reloads using hx-get, hx-post, and hx-delete attributes

Next, let’s create the views.py, which has a board_view function that filters tasks belonging to the logged-in user and splits them into columns. We also add a COLUMNS constant that defines the three statuses(TO-DO, IN PROGRESS, and DONE) so we can loop through them.

Also, we will add a @login_required decorator that redirects unauthenticated users to /login/. Each column dict holds the status value, a display label, a CSS dot class for the colored indicator, and the filtered queryset of tasks for that status.

Create views

Now, we can go ahead and create views for our taskapp. The board_view queries the user's tasks, groups them by status into three columns, and displays the task manager board. In addition, we will create views for creating, editing, deleting, and moving tasks between columns — each protected with @login_required and scoped to the current user.

#views.py 

import json

from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.views.decorators.http import require_POST

from .forms import TaskForm
from .models import Task

COLUMNS = [
    {'status': 'todo', 'label': 'To Do', 'dot': 'todo'},
    {'status': 'in_progress', 'label': 'In Progress', 'dot': 'progress'},
   {'status': 'done', 'label': 'Done', 'dot': 'done'},
]

@login_required
def board_view(request):
    tasks = Task.objects.filter(owner=request.user)
    columns = []
    for col in COLUMNS:
        columns.append({
            **col,
            'tasks': tasks.filter(status=col['status']),
        })
    return render(request, 'tasks/board.html', {'columns': columns})


@login_required
def task_create_view(request):
    if request.method == 'POST':
        form = TaskForm(request.POST)
        if form.is_valid():
            task = form.save(commit=False)
            task.owner = request.user
            task.save()
            return HttpResponse(headers={'HX-Redirect': '/'})
    else:
        form = TaskForm()
    return render(request, 'tasks/_form_modal.html', {
        'form': form,
        'form_title': 'New Task',
        'form_action': request.path,
        'submit_label': 'Create',
    })
@login_required
def task_edit_view(request, pk):
    task = get_object_or_404(Task, pk=pk, owner=request.user)
    if request.method == 'POST':
        form = TaskForm(request.POST, instance=task)
        if form.is_valid():
            form.save()
            return HttpResponse(headers={'HX-Redirect': '/'})
    else:
        form = TaskForm(instance=task)
    return render(request, 'tasks/_form_modal.html', {
        'form': form,
        'form_title': 'Edit Task',
        'form_action': request.path,
        'submit_label': 'Save',
    })
@login_required
def task_delete_view(request, pk):
    task = get_object_or_404(Task, pk=pk, owner=request.user)
    task.delete()
    return HttpResponse(headers={'HX-Redirect': '/'})


@login_required
@require_POST
def task_move_view(request, pk):
    """Update a task's status when dragged to a new column."""
    task = get_object_or_404(Task, pk=pk, owner=request.user)
    data = json.loads(request.body)
    new_status = data.get('status')
    valid_statuses = [c['status'] for c in COLUMNS]
    if new_status not in valid_statuses:
        return JsonResponse({'error': 'Invalid status'}, status=400)
    task.status = new_status
    task.save()
    return JsonResponse({'ok': True})

Enter fullscreen mode Exit fullscreen mode

Templates

Now, we move on to templates, which form how our UI is going to look.

  • tasks/base.html — this loads the stylesheet, the HTMX and SortableJS CDN scripts, and sets a global CSRF token on the tag so every HTMX request is authenticated.
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{% block title %}Get Going{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'css/style.css' %}">
  <script src="https://unpkg.com/htmx.org@2.0.4"></script>
  <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
  {% block body %}{% endblock %}
</body>
</html>
Enter fullscreen mode Exit fullscreen mode
  • registration/login.html - for signing into the task manager board
{% extends "base.html" %}
{% block title %}Login — Get Going{% endblock %}

{% block body %}
<div class="login-page">
  <div class="login-card">
    <h1>Get <span style="color:var(--green)">Going</span></h1>
    <p class="subtitle">Sign in to manage your tasks</p>

    {% if form.errors %}
    <div class="error-msg">Invalid username or password.</div>
    {% endif %}

    <form method="post">
      {% csrf_token %}
      <div class="form-group">
        <label for="id_username">Username</label>
        <input type="text" name="username" id="id_username" autofocus required>
      </div>
      <div class="form-group">
        <label for="id_password">Password</label>
        <input type="password" name="password" id="id_password" required>
      </div>
      <button type="submit" class="btn btn-primary">Sign In</button>
    </form>
  </div>
  <footer class="app-footer" style="color:var(--sidebar-muted)">Get Going 2026 &trade;</footer>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode
  • tasks/board.html — extends base.html and contains the sidebar with navigation and the "+ New Task" button, a three-column grid that loops over columns, and an empty #modal-container div where modals get injected. Each column renders its header (colored dot, label, task count badge) and loops through its tasks using the card partial.
{% extends "base.html" %}
{% block title %}Board — Get Going{% endblock %}

{% block body %}
<div class="app-layout">

  <!-- Sidebar -->
  <aside class="sidebar">
    <div class="logo">Get <span>Going</span></div>

    <button class="new-task-btn"
            hx-get="{% url 'task-create' %}"
            hx-target="#modal-container"
            hx-swap="innerHTML">
      + New Task
    </button>

    <div class="sidebar-section">Menu</div>
    <nav class="sidebar-nav">
      <a href="{% url 'board' %}" class="active">📋 My Tasks</a>
    </nav>
 <div class="sidebar-bottom">
      <div class="user-info">
        <span>{{ request.user.username }}</span>
        <a href="{% url 'logout' %}" class="btn btn-ghost btn-small">Sign Out</a>
      </div>
    </div>
  </aside>

  <!-- Main -->
  <main class="main-content">
    <div class="main-header">
      <h2>My Tasks</h2>
    </div>

    <div class="board">
      {% for col in columns %}
      <div class="column">
        <div class="column-header">
          <span class="column-title">
            <span class="dot dot-{{ col.dot }}"></span>
            {{ col.label }}
          </span>
          <span class="column-count">{{ col.tasks|length }}</span>
        </div>
        <div class="card-list" id="col-{{ col.status }}" data-status="{{ col.status }}">
          {% for task in col.tasks %}
            {% include "tasks/_card.html" %}
          {% endfor %}
        </div>
      </div>
      {% endfor %}
    </div>

    <footer class="app-footer">Get Going 2026 &trade;</footer>
  </main>

</div>
<div id="modal-container"></div>

<script>
document.addEventListener('DOMContentLoaded', function() {
  const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value
    || document.querySelector('body').getAttribute('hx-headers')
    && JSON.parse(document.querySelector('body').getAttribute('hx-headers'))['X-CSRFToken'];

  document.querySelectorAll('.card-list').forEach(function(list) {
    new Sortable(list, {
      group: 'board',
      animation: 150,
      ghostClass: 'drag-ghost',
      dragClass: 'drag-active',
      onEnd: function(evt) {
        const taskId = evt.item.dataset.id;
        const newStatus = evt.to.dataset.status;
        // Update column counts
        updateCounts();
        // Persist to server
        fetch('/tasks/' + taskId + '/move/', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-CSRFToken': csrfToken,
          },
          body: JSON.stringify({ status: newStatus }),
        }).then(function(res) {
          if (!res.ok) {
            evt.from.insertBefore(evt.item, evt.from.children[evt.oldIndex]);
            updateCounts();
          }
        });
      }
    });
  });

  function updateCounts() {
    document.querySelectorAll('.column').forEach(function(col) {
      const count = col.querySelector('.card-list').children.length;
      col.querySelector('.column-count').textContent = count;
    });
  }
});
</script>
{% endblock %}

Enter fullscreen mode Exit fullscreen mode
  • tasks/_card.html — a partial template for a single task card. It displays the title, description (clamped to two lines), due date, a color-coded priority badge, and edit/delete action buttons. Each card has a data-id="{{ task.id }}" attribute that SortableJS uses later for drag-and-drop.
<div class="task-card" id="task-{{ task.id }}" data-id="{{ task.id }}">
  <div class="task-card-title">{{ task.title }}</div>
  {% if task.description %}
  <div class="task-card-desc">{{ task.description }}</div>
  {% endif %}
  {% if task.due_date %}
  <div class="task-card-due">{{ task.due_date|date:'M d, Y' }}</div>
  {% endif %}
  <div class="task-card-footer">
    <span class="priority-badge priority-{{ task.priority }}">{{ task.get_priority_display }}</span>
    <div class="task-actions">
      <button title="Edit"
              hx-get="{% url 'task-edit' task.id %}"
              hx-target="#modal-container"
              hx-swap="innerHTML"></button>
      <button title="Delete" class="delete-btn"
              hx-delete="{% url 'task-delete' task.id %}"
              hx-target="#task-{{ task.id }}"
              hx-swap="outerHTML"
              hx-confirm="Delete this task?"></button>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode
  • tasks/_form_modal.html — a reusable modal for both creating and editing tasks. It receives form_title, form_action, and submit_label through the template context, so the same template can be rendered as "New Task" with a "Create" button or "Edit Task" with a "Save" button. Clicking the backdrop dismisses the modal.
<div class="modal-backdrop" onclick="if(event.target===this)this.remove()">
  <div class="modal">
    <h3>{{ form_title }}</h3>
    <form hx-post="{{ form_action }}"
          hx-target="body"
          hx-swap="innerHTML"
          hx-push-url="false">
      {% csrf_token %}
      <div class="form-group">
        <label for="id_title">Title</label>
        <input type="text" name="title" id="id_title"
               value="{{ form.title.value|default:'' }}" required>
      </div>
      <div class="form-group">
        <label for="id_description">Description</label>
        <textarea name="description" id="id_description">{{ form.description.value|default:'' }}</textarea>
      </div>
      <div class="form-group">
        <label for="id_status">Status</label>
        <select name="status" id="id_status">
          {% for value, label in form.fields.status.choices %}
          <option value="{{ value }}" {% if form.status.value == value %}selected{% endif %}>{{ label }}</option>
          {% endfor %}
        </select>
      </div>
      <div class="form-group">
        <label for="id_priority">Priority</label>
        <select name="priority" id="id_priority">
          {% for value, label in form.fields.priority.choices %}
          <option value="{{ value }}" {% if form.priority.value == value %}selected{% endif %}>{{ label }}</option>
          {% endfor %}
        </select>
      </div>
      <div class="form-group">
        <label for="id_due_date">Due Date</label>
        <input type="date" name="due_date" id="id_due_date"
               value="{{ form.due_date.value|date:'Y-m-d'|default:'' }}">
      </div>
      <div class="modal-actions">
        <button type="button" class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
        <button type="submit" class="btn btn-primary">{{ submit_label }}</button>
      </div>
    </form>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

NB: for the CRUD methods:

  • Create method — The "+ New Task" button in the sidebar uses hx-get to fetch the empty form modal from the server and inject it into #modal-container. When the form is submitted, hx-post sends the data to the create endpoint.
  • Edit method — The edit button on each card uses hx-get to fetch the form modal pre-filled with that task's data.
  • Delete method— The delete button (✕) uses hx-delete with hx-confirm="Delete this task?" for browser-native confirmation. It targets only the specific card element using hx-target="#task-{{ task.id }}" and hx-swap="outerHTML" to remove just that card from the DOM.

Drag & Drop

To bring some flexibility into the task manager, we will add drag and drop functionality. To achieve this, we will use SortableJS to handle the drag interaction in the browser and a dedicated endpoint to persist the status change to MongoDB.

SortableJS Setup

  • CDN load in base.html: SortableJS is loaded as a global script from the jsDelivr CDN, making the Sortable constructor available on every page that extends the base template (with this line <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script> )
  • Initialization + drag handler in board.html: On DOMContentLoaded, each .card-list element gets a Sortable instance with group: 'board' so cards can cross columns. The onEnd callback reads data-id and data-status to POST the new status to the move endpoint, with rollback on failure.
  • Drag feedback styles in style.css: .drag-ghost renders the drop placeholder with a dashed green border and reduced opacity, while .drag-active gives the card being dragged an elevated shadow and slight rotation for depth.

Styling

For styling, we keep it simple and use a MongoDB-inspired color palette defined as CSS custom properties; a dark sidebar layout, and a responsive breakpoint for mobile. Colors used for status indicator dots: gray for To Do, amber for In Progress, forest green for Done; priority badges: teal for Low, gold for Medium, red for High, and interactive elements like buttons and card hover states.

Responsiveness - CSS

To keep the board usable on smaller screens, we specify a single breakpoint at 768px to restructure the layout:

@media (max-width: 768px) {
    .sidebar {
        width: 100%;
        position: relative;
        flex-direction: row;
        padding: .75rem 1rem;
    }
    .sidebar-nav, .sidebar-section { display: none; }
    .main-content { margin-left: 0; }
    .board { grid-template-columns: 1fr; }
}
Enter fullscreen mode Exit fullscreen mode

On mobile, the fixed sidebar collapses into a horizontal top bar with the logo and "+ New Task" button inline, hiding the nav links to save space. The main content drops its left margin, and the board switches from a three-column grid to a single stacked column.

Final Look at Task Manager

Adding tasks for display

We can create, read, update, and delete tasks via the website or Python Shell; however, we will add tasks via the Add Task button on the Task Manager board.

After adding sample tasks,

The same data is displayed under the task collection of the MongoDB cluster tier that was initially created. Example shown below.

This brings us to the end of this tutorial using Django MongoDB Backend to create a Task Manager with MongoDB Atlas Free Tier. To download the project, kindly access this link to project. If you liked this tutorial, kindly like this tutorial and share your thoughts and/or contributions in the comments section.

Thank you, and see you in the next one.

References

Read Full Tutorial open_in_new
arrow_back Back to Tutorials