ModelForms

Picture writing a plain forms.Form for creating a TaskFlow task. You'd need a CharField for title, a CharField for description, a DateField for due_date, a ChoiceField for priority — every one of those already exists, spelled out, on the Task model itself. Retyping them in a second class is pure duplication, and the two definitions can quietly drift out of sync as the model changes. A ModelForm solves this directly: it's a form class generated automatically from a model's fields, so you describe the model once and let Django build the form from it.

A ModelForm, Built From Task

A ModelForm looks like an ordinary form class, except it has no field definitions of its own — instead, an inner Meta class points at a model and lists which of that model's fields to expose:

# taskflow/forms.py
from django import forms
from .models import Task

class TaskForm(forms.ModelForm):
    class Meta:
        model = Task
        fields = ['title', 'description', 'due_date', 'priority']

From those four lines, Django inspects the Task model and builds a CharField for title, a Textarea-backed field for description, a date-picker-friendly field for due_date, and a ChoiceField for priority — matching each model field's type and constraints automatically. Notice project, tags, and completed aren't listed here: they're deliberately left off this particular form, since a brand-new task doesn't need to set its completion status or tags at creation time.

fields vs exclude

Meta.fields, as used above, explicitly lists which model fields should appear on the form — everything not listed is simply left out. The alternative, Meta.exclude, does the opposite: it lists which fields to leave off, and everything else on the model is included automatically.

# Equivalent in effect to the fields=[...] version above,
# but only as long as Task's fields don't change
class TaskForm(forms.ModelForm):
    class Meta:
        model = Task
        exclude = ['project', 'tags', 'completed']

exclude looks like less typing today, but it has a real security cost: if someone later adds a new field to Task — say, an internal_notes field meant to stay admin-only — exclude exposes it on this form automatically, with no code change required to do so. fields is the safer default, since a new model field simply doesn't appear on the form until you deliberately add it to that list.

Saving a ModelForm: Creating a New Task

A ModelForm's biggest convenience is its .save() method. Where a plain form only gives you cleaned_data and leaves object creation to you, form.save() builds the model instance and writes it to the database in one call:

# taskflow/views.py
from django.shortcuts import render, redirect
from .forms import TaskForm

def task_create(request):
    if request.method == 'POST':
        form = TaskForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('task-list')
    else:
        form = TaskForm()

    return render(request, 'taskflow/task_form.html', {'form': form})

That single form.save() call replaces what would otherwise be several manual lines — reading each value out of cleaned_data, constructing a Task(...) instance field by field, and calling .save() on it yourself. Because TaskForm already knows which model it belongs to, it handles all of that internally.

commit=False: Setting Extra Fields Before Saving

Sometimes a value needs to be attached to the new object before it's written to the database, but that value isn't something the visitor should be filling in on the form itself — the clearest TaskFlow example is stamping a new task's project with whichever project the current logged-in user actually owns. Calling form.save(commit=False) builds the model instance in memory without saving it yet, giving you a chance to set additional fields first:

def task_create(request, project_id):
    if request.method == 'POST':
        form = TaskForm(request.POST)
        if form.is_valid():
            task = form.save(commit=False)
            task.project_id = project_id
            task.save()
            return redirect('task-list')
    else:
        form = TaskForm()

    return render(request, 'taskflow/task_form.html', {'form': form})

task here is a real Task instance, already populated from the form's cleaned data — it just hasn't hit the database yet. Setting task.project_id and then calling task.save() ourselves finishes the job with the extra field in place. This exact pattern is how a future view will attach the current logged-in user via request.user, once User Authentication introduces that concept properly.

Editing an Existing Task

Creating and editing use the same TaskForm, with one difference: passing an existing model instance as instance= pre-fills every field with that object's current values, and points .save() at updating that same row instead of inserting a new one.

from django.shortcuts import render, redirect, get_object_or_404
from .models import Task
from .forms import TaskForm

def task_edit(request, task_id):
    task = get_object_or_404(Task, pk=task_id)

    if request.method == 'POST':
        form = TaskForm(request.POST, instance=task)
        if form.is_valid():
            form.save()
            return redirect('task-list')
    else:
        form = TaskForm(instance=task)

    return render(request, 'taskflow/task_form.html', {'form': form})

On GET, TaskForm(instance=task) renders with task's current title, description, due date, and priority already filled in. On POST, TaskForm(request.POST, instance=task) validates the submitted data and, on .save(), updates that exact task row rather than creating a second one — the same template and the same view function handle both creating and editing, just with instance as the difference.

Next up: Filling in one task at a time works, but adding several tasks to a project in one page load needs a different tool — Formsets, up next.