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.
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.
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:
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:
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.