diff --git a/backend/app/models.py b/backend/app/models.py index 0b7c832..061fba3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,8 +1,8 @@ """SQLAlchemy ORM models.""" -from datetime import datetime, timezone +from datetime import date, datetime, timezone -from sqlalchemy import Boolean, DateTime, Integer, String, Text +from sqlalchemy import Boolean, Date, DateTime, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.database import Base @@ -17,6 +17,13 @@ class Todo(Base): title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + priority: Mapped[str] = mapped_column( + String(16), + default="medium", + server_default="medium", + nullable=False, + ) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) archived_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, diff --git a/backend/app/routes/todos.py b/backend/app/routes/todos.py index 50ab643..b6beeba 100644 --- a/backend/app/routes/todos.py +++ b/backend/app/routes/todos.py @@ -42,6 +42,8 @@ async def create_todo( todo = Todo( title=todo_data.title, description=todo_data.description, + priority=todo_data.priority, + due_date=todo_data.due_date, ) db.add(todo) await db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4f9c188..86a194b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,15 +1,20 @@ """Pydantic schemas for request/response validation.""" -from datetime import datetime +from datetime import date, datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, Field +Priority = Literal["low", "medium", "high"] + class TodoCreate(BaseModel): """Schema for creating a new todo.""" title: str = Field(..., min_length=1, max_length=255) description: str | None = None + priority: Priority = "medium" + due_date: date | None = None class TodoUpdate(BaseModel): @@ -18,6 +23,8 @@ class TodoUpdate(BaseModel): title: str | None = Field(None, min_length=1, max_length=255) description: str | None = None completed: bool | None = None + priority: Priority | None = None + due_date: date | None = None class TodoResponse(BaseModel): @@ -29,6 +36,8 @@ class TodoResponse(BaseModel): title: str description: str | None completed: bool + priority: Priority + due_date: date | None archived_at: datetime | None created_at: datetime updated_at: datetime diff --git a/backend/migrations/20261009_add_priority_and_due_date.sql b/backend/migrations/20261009_add_priority_and_due_date.sql new file mode 100644 index 0000000..19d2297 --- /dev/null +++ b/backend/migrations/20261009_add_priority_and_due_date.sql @@ -0,0 +1,18 @@ +-- Adds a priority and an optional due date to the todos table. +-- +-- New databases get these columns from the models. Apply this script once to an +-- existing SQLite database before deploying the matching backend version: +-- +-- sqlite3 todos.db < backend/migrations/20261009_add_priority_and_due_date.sql +-- +-- Existing rows get the default priority "medium" and no due date. + +BEGIN; + +ALTER TABLE todos ADD COLUMN priority VARCHAR(16) NOT NULL DEFAULT 'medium'; +ALTER TABLE todos ADD COLUMN due_date DATE; + +-- Speeds up sorting and filtering by due date. +CREATE INDEX IF NOT EXISTS ix_todos_due_date ON todos (due_date); + +COMMIT; diff --git a/backend/tests/test_priority_due_date.py b/backend/tests/test_priority_due_date.py new file mode 100644 index 0000000..f0cdaf7 --- /dev/null +++ b/backend/tests/test_priority_due_date.py @@ -0,0 +1,86 @@ +"""Tests for todo priority and due date, including the SQL migration.""" + +import sqlite3 +from pathlib import Path + +import pytest +from httpx import AsyncClient + +MIGRATION = Path(__file__).parent.parent / "migrations" / "20261009_add_priority_and_due_date.sql" + + +@pytest.mark.asyncio +async def test_new_todos_default_to_medium_priority(client: AsyncClient): + response = await client.post("/api/todos", json={"title": "Plan sprint"}) + assert response.status_code == 201 + data = response.json() + assert data["priority"] == "medium" + assert data["due_date"] is None + + +@pytest.mark.asyncio +async def test_create_todo_with_priority_and_due_date(client: AsyncClient): + response = await client.post( + "/api/todos", + json={"title": "Ship release", "priority": "high", "due_date": "2026-11-01"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["priority"] == "high" + assert data["due_date"] == "2026-11-01" + + +@pytest.mark.asyncio +async def test_update_priority_and_due_date(client: AsyncClient): + created = await client.post("/api/todos", json={"title": "Review PRs"}) + todo_id = created.json()["id"] + + response = await client.patch( + f"/api/todos/{todo_id}", + json={"priority": "low", "due_date": "2026-12-24"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["priority"] == "low" + assert data["due_date"] == "2026-12-24" + assert data["title"] == "Review PRs" + + +@pytest.mark.asyncio +async def test_rejects_unknown_priority(client: AsyncClient): + response = await client.post("/api/todos", json={"title": "Bad", "priority": "urgent"}) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_rejects_malformed_due_date(client: AsyncClient): + response = await client.post("/api/todos", json={"title": "Bad", "due_date": "next week"}) + assert response.status_code == 422 + + +def test_migration_upgrades_an_existing_database(tmp_path: Path): + db_path = tmp_path / "legacy.db" + connection = sqlite3.connect(db_path) + connection.executescript( + """ + CREATE TABLE todos ( + id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + completed BOOLEAN NOT NULL, + archived_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + INSERT INTO todos (title, completed, created_at, updated_at) + VALUES ('Existing todo', 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00'); + """ + ) + + connection.executescript(MIGRATION.read_text()) + + columns = {row[1] for row in connection.execute("PRAGMA table_info(todos)")} + assert {"priority", "due_date"} <= columns + row = connection.execute("SELECT priority, due_date FROM todos").fetchone() + assert row == ("medium", None) + connection.close() diff --git a/docs/deployment.md b/docs/deployment.md index c9b5826..e2654e0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -36,6 +36,15 @@ Save this as `docker-compose.override.yml` next to `docker-compose.yml`, and Com If you serve the frontend from your own domain, add it to `CORS_ORIGINS` on the backend, for example `CORS_ORIGINS=["https://todos.example.com"]`. See [configuration](configuration.md) for all settings. +## Upgrade an existing database + +The backend creates missing tables on startup but never alters existing ones. When a release adds columns, apply the matching script from `backend/migrations/` once before you deploy it. Take a copy of the database file first. + +```bash +cp todos.db todos.db.bak +sqlite3 todos.db < backend/migrations/20261009_add_priority_and_due_date.sql +``` + ## Updating ```bash