Skip to content

Adding the Task Model

Each project needs a list of tasks with their own state (planned, in progress, done), priority, and an optional due date.

Let's review how to use enum-typed columns and how to build relationships between tables.

Status Enums

We want to restrict status and priority to a fixed set of allowed values.

By using Python enums, we get:

  • Database enforcement
  • The API rejecting bad input / unkown values
  • IDE autocompletes for allowed values

StrEnum is the best fit. Each member compares equal to its string value, which makes serialization to and from JSON easy.

src/release_tracker/models.py
from enum import StrEnum


class TaskStatus(StrEnum):
    planned = "planned"
    in_progress = "in_progress"
    blocked = "blocked"
    done = "done"


class TaskPriority(StrEnum):
    low = "low"
    medium = "medium"
    high = "high"
    urgent = "urgent"

When SQLModel sees a StrEnum annotation on a column, it stores the value as a string and adds a database-level constraint that rejects anything outside the listed values.

TaskBase and the Task Table

The same three-schema split we used for Project (ProjectBase, Project, plus ProjectCreate/ProjectUpdate/ProjectRead) carries over here. Shared fields go into TaskBase; the table model adds the database-only fields.

src/release_tracker/models.py
from datetime import date
from typing import Annotated

from pydantic import StringConstraints
from sqlmodel import Field, Relationship, SQLModel

TaskTitle = Annotated[
    str,
    StringConstraints(strip_whitespace=True, min_length=2),
]


class TaskBase(SQLModel):
    title: TaskTitle
    details: str | None = None
    status: TaskStatus = TaskStatus.planned
    priority: TaskPriority = TaskPriority.medium
    due_date: date | None = None


class Task(TaskBase, table=True):
    __tablename__ = "tasks"

    id: int | None = Field(default=None, primary_key=True)
    project_id: int = Field(foreign_key="projects.id", index=True)

    project: Project = Relationship(back_populates="tasks")

details uses str | None rather than str so a task can be created without a body.

status and priority default at the Python level (TaskStatus.planned, TaskPriority.medium), so a new task without an explicit status ends up on the planned column.

due_date uses datetime.date rather than datetime. There's no time-of-day component to a deadline, with the assumption that everyone is working in the same timezone.

The table-only fields are id and project_id.

project_id carries the foreign key constraint that links every task to a project. index=True builds a database index on that column, which makes "find every task for a given project" a fast lookup once the API starts filtering.

The Project ↔ Task Relationship

A foreign key constraint connects rows on disk; a SQLModel Relationship connects the two Python objects in memory. With both sides wired up, task.project and project.tasks are navigable from a loaded record without writing any join queries by hand.

Task declares its half of the relationship above. The matching declaration on Project is one line:

src/release_tracker/models.py
class Project(ProjectBase, table=True):
    __tablename__ = "projects"

    id: int | None = Field(default=None, primary_key=True)
    slug: str = Field(unique=True)

    tasks: list[Task] = Relationship(back_populates="project")

    created_at: datetime = Field(
        default_factory=utc_now,
        sa_column=Column(DateTime(timezone=True), nullable=False),
    )

back_populates="project" tells SQLModel that Project.tasks and Task.project are the two sides of the same relationship. Updating one side keeps the other side consistent automatically.

Forward references between models

Task is defined after Project in this file, but Project.tasks: list[Task] references it before it exists. Python 3.14 evaluates annotations lazily, so the forward reference resolves once the module finishes loading.

TaskCreate, TaskUpdate, TaskRead

Three schema variants, each with the same role they had on Project:

src/release_tracker/models.py
class TaskCreate(TaskBase):
    pass


class TaskUpdate(SQLModel):
    title: TaskTitle | None = None
    details: str | None = None
    status: TaskStatus | None = None
    priority: TaskPriority | None = None
    due_date: date | None = None


class TaskRead(TaskBase):
    id: int
    project_id: int

TaskCreate accepts the exact TaskBase shape. TaskUpdate makes every field optional so a PATCH request can send a single field and leave the rest untouched. TaskRead adds the server-generated fields (id, project_id) the API returns to clients.

The model layer is in place, but the database doesn't know about it yet. Schema evolution is what alembic does.