# Tutorial: Build a Task Manager with Tenant-Specific Options
In this tutorial, you'll build a task management application where different tenants (organizations) can customize their task priorities and statuses. By the end, you'll have a working application where:
- Developers define mandatory and optional default priorities and statuses
- Tenant admins choose which optional defaults to enable and create custom options
- End users see only the options their tenant has selected
The [example project](https://github.com/OmenApps/django-tenant-options/tree/main/example_project) in the repository contains the complete working version of what you'll build here. Reference it if you get stuck.
```{note}
This tutorial uses plain Django (no auto_prefetch, no crispy-forms) to keep dependencies minimal. See [Customization](customization.md) for integrating those packages.
```
## What you'll build
A multi-tenant task manager with:
- **Tenant** and **User** models
- **Task Priority** options (Critical, High, Medium, Low) with customization per tenant
- **Task Status** options (New, In Progress, Completed, Archived) with customization per tenant
- **Task** model with ForeignKey fields to both option types
- Forms for creating tasks, managing options, and managing selections
- Views and templates that respect tenant boundaries

## Prerequisites
- Python 3.11+
- Basic familiarity with Django models, forms, and views
- A fresh Django project (or an existing one you want to add options to)
## Step 1: Install and configure
Install the package:
```bash
pip install django-tenant-options
```
Add it to `INSTALLED_APPS` in your `settings.py`:
```python
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_tenant_options",
"tasks", # your app
]
```
## Step 2: Create your Tenant and User models
In `tasks/models.py`, start with a basic tenant model and a User model with a tenant ForeignKey:
```python
from django.contrib.auth.models import AbstractUser
from django.db import models
class Tenant(models.Model):
"""Represents an organization in the SaaS application."""
name = models.CharField(max_length=100)
subdomain = models.CharField(max_length=100, unique=True)
def __str__(self):
return self.name
class User(AbstractUser):
"""Custom user with a tenant association."""
tenant = models.ForeignKey(
Tenant,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="users",
)
```
Point your `AUTH_USER_MODEL` setting at this custom user:
```python
# settings.py
AUTH_USER_MODEL = "tasks.User"
```
And configure the tenant model for `django-tenant-options`:
```python
# settings.py
DJANGO_TENANT_OPTIONS = {
"TENANT_MODEL": "tasks.Tenant",
}
```
## Step 3: Define Task Priority models
Now create the Option and Selection models for task priorities. Each set of customizable options uses a pair of models -- an Option model that stores all available choices, and a Selection model that tracks which options each tenant has enabled.
Add to `tasks/models.py`:
```python
from django_tenant_options.models import AbstractOption, AbstractSelection
from django_tenant_options.choices import OptionType
class TaskPriorityOption(AbstractOption):
"""Available task priority options."""
tenant_model = "tasks.Tenant"
selection_model = "tasks.TaskPrioritySelection"
default_options = {
"Critical": {"option_type": OptionType.OPTIONAL},
"High": {"option_type": OptionType.MANDATORY},
"Medium": {"option_type": OptionType.OPTIONAL},
"Low": {}, # Empty dict defaults to MANDATORY
}
class Meta(AbstractOption.Meta):
verbose_name = "Task Priority Option"
verbose_name_plural = "Task Priority Options"
class TaskPrioritySelection(AbstractSelection):
"""Tracks which priority options each tenant has enabled."""
tenant_model = "tasks.Tenant"
option_model = "tasks.TaskPriorityOption"
class Meta(AbstractSelection.Meta):
verbose_name = "Task Priority Selection"
verbose_name_plural = "Task Priority Selections"
```
Key points:
- `"High"` and `"Low"` are **mandatory** -- every tenant's users will see them.
- `"Critical"` and `"Medium"` are **optional** -- each tenant chooses whether to enable them.
- Tenants can also create **custom** priorities (e.g., "Urgent", "Blocked").
- The `Meta` class inherits from `AbstractOption.Meta` / `AbstractSelection.Meta` to preserve database constraints.
## Step 4: Define Task Status models
Repeat the pattern for task statuses. Add to `tasks/models.py`:
```python
class TaskStatusOption(AbstractOption):
"""Available task status options."""
tenant_model = "tasks.Tenant"
selection_model = "tasks.TaskStatusSelection"
default_options = {
"New": {"option_type": OptionType.MANDATORY},
"In Progress": {"option_type": OptionType.OPTIONAL},
"Completed": {"option_type": OptionType.MANDATORY},
"Archived": {"option_type": OptionType.MANDATORY},
}
class Meta(AbstractOption.Meta):
verbose_name = "Task Status Option"
verbose_name_plural = "Task Status Options"
class TaskStatusSelection(AbstractSelection):
"""Tracks which status options each tenant has enabled."""
tenant_model = "tasks.Tenant"
option_model = "tasks.TaskStatusOption"
class Meta(AbstractSelection.Meta):
verbose_name = "Task Status Selection"
verbose_name_plural = "Task Status Selections"
```
You now have two independent sets of customizable options. The same pattern works for any kind of option your tenants might need.
## Step 5: Create the Task model
Add the `Task` model with ForeignKey fields to both option types:
```python
class Task(models.Model):
"""A task assigned to a user with customizable priority and status."""
title = models.CharField(max_length=100)
description = models.TextField(blank=True)
user = models.ForeignKey(
"tasks.User",
on_delete=models.CASCADE,
related_name="tasks",
)
priority = models.ForeignKey(
"tasks.TaskPriorityOption",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="tasks",
)
status = models.ForeignKey(
"tasks.TaskStatusOption",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="tasks",
)
def __str__(self):
return self.title
```
```{tip}
Using `on_delete=models.SET_NULL` with `null=True` means that if an option is ever hard-deleted, tasks won't be deleted along with it.
```
## Step 6: Run migrations and sync options
```bash
python manage.py makemigrations
python manage.py migrate
python manage.py syncoptions
```
The `syncoptions` command creates the default option records in your database. Verify with:
```bash
python manage.py listoptions
```
You should see the priority and status options listed with their types.
## Step 7: Create forms
Create `tasks/forms.py`:
```python
from django import forms
from django_tenant_options.forms import (
OptionCreateFormMixin,
OptionUpdateFormMixin,
SelectionsForm,
UserFacingFormMixin,
)
from .models import (
Task,
TaskPriorityOption,
TaskPrioritySelection,
TaskStatusOption,
TaskStatusSelection,
)
# End-user form for creating/editing tasks
class TaskForm(UserFacingFormMixin, forms.ModelForm):
class Meta:
model = Task
fields = ["title", "description", "priority", "status"]
# Forms for tenant admins to create custom options
class TaskPriorityCreateForm(OptionCreateFormMixin, forms.ModelForm):
class Meta:
model = TaskPriorityOption
fields = ["name", "option_type", "tenant", "deleted"]
class TaskStatusCreateForm(OptionCreateFormMixin, forms.ModelForm):
class Meta:
model = TaskStatusOption
fields = ["name", "option_type", "tenant", "deleted"]
# Forms for tenant admins to update/delete custom options
class TaskPriorityUpdateForm(OptionUpdateFormMixin, forms.ModelForm):
class Meta:
model = TaskPriorityOption
fields = "__all__"
class TaskStatusUpdateForm(OptionUpdateFormMixin, forms.ModelForm):
class Meta:
model = TaskStatusOption
fields = "__all__"
# Forms for tenant admins to manage which options are enabled
class TaskPrioritySelectionForm(SelectionsForm):
class Meta:
model = TaskPrioritySelection
class TaskStatusSelectionForm(SelectionsForm):
class Meta:
model = TaskStatusSelection
```
The form types and when to use them:
| Form | Mixin | Used by | Purpose |
|------|-------|---------|---------|
| `TaskForm` | `UserFacingFormMixin` | End users | Create/edit tasks with filtered option choices |
| `TaskPriorityCreateForm` | `OptionCreateFormMixin` | Tenant admins | Create new custom priority options |
| `TaskPriorityUpdateForm` | `OptionUpdateFormMixin` | Tenant admins | Update or soft-delete custom options |
| `TaskPrioritySelectionForm` | `SelectionsForm` | Tenant admins | Enable/disable optional priorities |
## Step 8: Build views
Create `tasks/views.py`. Every form that uses a `django-tenant-options` mixin must receive a `tenant` argument:
```python
from django.shortcuts import get_object_or_404, redirect, render
from .forms import (
TaskForm,
TaskPriorityCreateForm,
TaskPrioritySelectionForm,
TaskStatusCreateForm,
TaskStatusSelectionForm,
)
from .models import (
Task,
TaskPriorityOption,
TaskPrioritySelection,
TaskStatusOption,
TaskStatusSelection,
)
def task_list(request):
tasks = Task.objects.filter(user=request.user)
return render(request, "tasks/task_list.html", {"tasks": tasks})
def task_create(request):
if request.method == "POST":
form = TaskForm(request.POST, tenant=request.user.tenant)
if form.is_valid():
task = form.save(commit=False)
task.user = request.user
task.save()
return redirect("task_list")
else:
form = TaskForm(tenant=request.user.tenant)
return render(request, "tasks/form.html", {"form": form, "title": "Create Task"})
def task_update(request, task_id):
task = get_object_or_404(Task, id=task_id)
if request.method == "POST":
form = TaskForm(request.POST, instance=task, tenant=request.user.tenant)
if form.is_valid():
form.save()
return redirect("task_list")
else:
form = TaskForm(instance=task, tenant=request.user.tenant)
return render(request, "tasks/form.html", {"form": form, "title": "Edit Task"})
def priority_list(request):
options = TaskPriorityOption.objects.options_for_tenant(request.user.tenant)
selections = TaskPrioritySelection.objects.selected_options_for_tenant(
tenant=request.user.tenant
)
return render(request, "tasks/option_list.html", {
"options": options,
"selections": selections,
"title": "Task Priorities",
"create_url": "priority_create",
"selections_url": "priority_selections",
})
def priority_create(request):
if request.method == "POST":
form = TaskPriorityCreateForm(request.POST, tenant=request.user.tenant)
if form.is_valid():
form.save()
return redirect("priority_list")
else:
form = TaskPriorityCreateForm(tenant=request.user.tenant)
return render(request, "tasks/form.html", {"form": form, "title": "Create Priority"})
def priority_selections(request):
if request.method == "POST":
form = TaskPrioritySelectionForm(request.POST, tenant=request.user.tenant)
if form.is_valid():
form.save()
return redirect("priority_list")
else:
form = TaskPrioritySelectionForm(tenant=request.user.tenant)
return render(request, "tasks/form.html", {"form": form, "title": "Manage Priority Selections"})
```
The pattern is the same for status options -- create analogous views for `status_list`, `status_create`, and `status_selections`.
## Step 9: Create templates
Create a base template and a few page templates.
**`tasks/templates/tasks/form.html`:**
```html
{{ title }}
Skip to main content
{{ title }}
{% if form.errors %}
Please correct the following:
{% for field in form %}
{% for error in field.errors %}
- {{ field.label }}: {{ error }}
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}- {{ error }}
{% endfor %}
{% endif %}
```
> **Accessibility note:** `{{ form.as_p }}` does not link each input to its error
> text for screen readers. For production forms, pick one approach: either mix in
> `django_tenant_options.forms.AccessibleFormMixin` (which sets `aria-invalid` and an
> `aria-describedby` pointing at `id_{{ field.html_name }}_errors`, so you hand-render that
> error container yourself), or use `django-crispy-forms`, which wires the associations with
> its own ids. Use one or the other, not both - combining them leaves a dangling
> `aria-describedby`.
**`tasks/templates/tasks/task_list.html`:**
```html
Tasks
Skip to main content
Tasks
Create Task
Your tasks
| Title |
Priority |
Status |
Actions |
{% for task in tasks %}
| {{ task.title }} |
{{ task.priority }} |
{{ task.status }} |
Edit {{ task.title }} |
{% endfor %}
```
**`tasks/templates/tasks/option_list.html`:**
```html
{{ title }}
Skip to main content
{{ title }}
Create Custom Option |
Manage Selections
Available Options
{% for option in options %}
-
{{ option.name }}
{# The text label below carries the meaning; color is only supplementary. #}
{# Never remove the text label - color alone fails WCAG 1.4.1. #}
{% if option.option_type == "dm" %}
(Mandatory)
{% elif option.option_type == "do" %}
(Optional)
{% else %}
(Custom)
{% endif %}
{% endfor %}
Currently Selected
{% for selection in selections %}
- {{ selection.name }}
{% endfor %}
```
> **Accessibility note:** The option type is shown with a text label (`(Mandatory)`,
> `(Optional)`, `(Custom)`) so it does not rely on color (WCAG 1.4.1). If you add color
> via CSS, verify each color/background pair meets the 4.5:1 contrast minimum (WCAG 1.4.3) -
> for example, plain `orange` (#FFA500) text on white is only ~2.9:1 and fails. Define a
> `.skip-link` and `.visually-hidden` utility in your CSS:
>
> ```css
> .skip-link { position: absolute; left: -9999px; }
> .skip-link:focus { left: 0; }
> .visually-hidden {
> position: absolute; width: 1px; height: 1px;
> margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); border: 0;
> }
> ```
## Step 10: Wire up URLs
Create `tasks/urls.py`:
```python
from django.urls import path
from . import views
urlpatterns = [
path("", views.task_list, name="task_list"),
path("create/", views.task_create, name="task_create"),
path("/edit/", views.task_update, name="task_update"),
path("priorities/", views.priority_list, name="priority_list"),
path("priorities/create/", views.priority_create, name="priority_create"),
path("priorities/selections/", views.priority_selections, name="priority_selections"),
]
```
Include in your root `urls.py`:
```python
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("tasks/", include("tasks.urls")),
]
```
## Step 11: Test it out
1. Create a superuser:
```bash
python manage.py createsuperuser
```
2. Start the server:
```bash
python manage.py runserver
```
3. Log in via the admin at `http://127.0.0.1:8000/admin/`
4. Create a Tenant and assign your superuser to it (via the admin)
5. Visit `http://127.0.0.1:8000/tasks/priorities/` -- you should see the default priorities
6. Try managing selections -- enable/disable optional priorities
7. Create a custom priority option
8. Create a task -- the priority dropdown should show only the options your tenant has selected

## Step 12: Validate your configuration
Run the validation command to confirm everything is properly set up:
```bash
python manage.py validate_tenant_options
```
You should see "All validations passed!" If there are warnings about missing constraints, check that your Meta classes inherit from the abstract model's Meta.
## What you've built
You now have a working multi-tenant task manager where:
- **Mandatory** options ("High", "Low", "New", "Completed", "Archived") are always available to every tenant
- **Optional** options ("Critical", "Medium", "In Progress") can be enabled or disabled per tenant
- Tenants can create **custom** options visible only to their users
- The `UserFacingFormMixin` automatically filters form choices to respect tenant selections
- Soft deletes preserve data integrity -- deleted options don't break existing records
## Next steps
- [Models Guide](models.md) -- Custom managers, querysets, and advanced model configuration
- [Forms Guide](forms.md) -- Deep dive into every form mixin
- [Views and Templates](views-and-templates.md) -- Class-based views, admin integration, template patterns
- [Commands](commands.md) -- Database triggers for extra integrity protection
- [Configuration Reference](configuration.md) -- Every available setting
- [Options Cookbook](optionscookbook.md) -- Inspiration for option sets across industries