Todos¶
A multi-user todo list: persistence, sign-in, a service that is also a REST API, and strict per-user isolation.
todos/
├── app.py # composition root
├── settings.py # DATABASE_URL
├── models/ # database.py, todo.py (Todo, TodoOut, NewTodo), account.py (SignIn)
├── services/ # todos.py (TodoService)
└── views/
├── components/ # todo_item.py (TodoItem)
├── pages/ # todos.py (Todos), login.py (Login)
└── styles/
1. Settings and the database¶
"""Settings, read once from the environment."""
import os
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parent
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite+aiosqlite:///{(PROJECT_DIR / 'todos.db').as_posix()}")
"""One engine for the app, and the declarative base for its tables."""
from sqlalchemy.orm import DeclarativeBase
from rational.backend import Database
import settings
class Base(DeclarativeBase):
"""Any declarative base works; ``rational.backend.Model`` is a ready-made one."""
database = Database(settings.DATABASE_URL)
async def create_tables() -> None:
"""Development convenience: use Alembic migrations in production."""
await database.create_all(Base)
2. The service owns the rules¶
"""Every database operation lives here, scoped to the signed-in user.
The ``@remote`` methods are also a JSON API under ``/api/todo/...``.
"""
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from rational import NotFound, RemoteError
from rational.backend import Service, remote
from models import Todo, TodoOut
class TodoService(Service):
"""The signed-in user's todos."""
@remote(method="GET", auth="required")
async def list_todos(self) -> list[TodoOut]:
"""List your todos, oldest first."""
async with self.db.session() as session:
rows = await session.scalars(select(Todo).where(Todo.owner == self.user).order_by(Todo.id))
return [TodoOut.model_validate(t) for t in rows]
@remote(auth="required")
async def add(self, title: str) -> TodoOut:
"""Create a todo."""
title = title.strip()
if not title:
raise RemoteError("A todo needs a title", code="invalid")
async with self.db.transaction() as session:
todo = Todo(owner=self.user, title=title[:200])
session.add(todo)
await session.flush()
return TodoOut.model_validate(todo)
@remote(auth="required")
async def set_done(self, todo_id: int, done: bool) -> TodoOut:
"""Mark a todo as done or not done."""
async with self.db.transaction() as session:
todo = await self._owned(session, todo_id)
todo.done = done
return TodoOut.model_validate(todo)
@remote(auth="required")
async def remove(self, todo_id: int) -> None:
"""Delete a todo."""
async with self.db.transaction() as session:
await session.delete(await self._owned(session, todo_id))
@remote(auth="required")
async def clear_done(self) -> int:
"""Delete every completed todo; returns how many were removed."""
async with self.db.transaction() as session:
result = await session.execute(delete(Todo).where(Todo.owner == self.user, Todo.done.is_(True)))
return int(result.rowcount or 0)
async def _owned(self, session: AsyncSession, todo_id: int) -> Todo:
todo = await session.get(Todo, todo_id)
if todo is None or todo.owner != self.user: # never reveal that other users' rows exist
raise NotFound("No such todo")
return todo
Every query filters by self.user. _owned answers “not found” for other users’ todos,
so the API never even confirms they exist. Each @remote method becomes a typed endpoint:
GET /api/todo/list-todos, POST /api/todo/add and so on, all in Swagger UI at /api/docs.
3. A component with a callback prop¶
class TodoItem(Component):
"""One row. ``changed`` is a callback prop: the list reloads after a change."""
todo: TodoOut
changed: Callable[[], Awaitable[None]]
async def toggle(self, done: bool):
await self.use(TodoService).set_done(self.todo.id, done)
await self.changed()
async def remove(self):
await self.use(TodoService).remove(self.todo.id)
await self.changed()
def render(self):
return h(
"li",
Checkbox(
self.todo.title,
checked=self.todo.done,
on_change=self.toggle,
style=Style(grow=1) | (DONE if self.todo.done else None),
),
Button(
"Delete",
variant="ghost",
size="sm",
aria_label=f"Delete {self.todo.title}",
on_click=[js.confirm(f"Delete “{self.todo.title}”?"), self.remove], # asks in the browser first
),
style=TODO_ROW,
)
changed is a prop holding the page’s reload method: the item tells its parent that
something changed. on_click=[js.confirm(...), self.remove] asks in the browser first; if the
user cancels, no request is sent.
4. The page¶
@router.page("/", auth="required")
class Todos(Page):
title = "Todos · Rational Web"
todos = State(factory=list)
filter = State("all")
form_version = State(0)
async def mount(self):
await self.reload()
async def reload(self):
self.todos = await self.use(TodoService).list_todos()
async def add(self, form: NewTodo):
await self.use(TodoService).add(form.title)
self.form_version += 1 # a form with a new key is a fresh form: the input is cleared
await self.reload()
self.run_js(js.focus("#new-todo [name=title]"))
async def clear_done(self):
await self.use(TodoService).clear_done()
await self.reload()
def sign_out(self):
self.context.session.logout()
self.navigate("/login")
def show(self, name: str):
return lambda: setattr(self, "filter", name)
def render(self):
visible = [t for t in self.todos if FILTERS[self.filter](t)]
remaining = sum(not t.done for t in self.todos)
filters = Row(
*[
Button(
name.capitalize(),
size="sm",
variant="secondary" if self.filter == name else "ghost",
aria_pressed="true" if self.filter == name else "false",
on_click=self.show(name),
)
for name in FILTERS
],
gap="xs",
role="group",
aria_label="Filter",
)
return Container(
Header(
Heading("Todos", level=1),
Spacer(),
Text(f"Signed in as {self.context.user}", muted=True, size="sm"),
Button("Sign out", variant="outline", size="sm", on_click=self.sign_out),
style=TOOLBAR,
),
Card(
Form(
Input("New todo", name="title", placeholder="What needs doing?", hide_label=True, required=True),
Button("Add", type="submit"),
on_submit=self.add,
id="new-todo",
key=f"form-{self.form_version}",
style=NEW_TODO_FORM,
),
filters,
h("ul", *[TodoItem(todo=t, changed=self.reload, key=t.id) for t in visible], style=TODO_LIST),
Text("Nothing here yet.", muted=True) if not visible else None,
Row(
Text(f"{remaining} item{'s' if remaining != 1 else ''} left", muted=True, size="sm"),
Spacer(),
Button(
"Clear completed",
variant="ghost",
size="sm",
on_click=self.clear_done,
disabled=remaining == len(self.todos),
),
),
),
tag="main",
max_width=640,
style=PAGE,
)
auth="required"sends anonymous visitors to/login?next=/.key=f"form-{self.form_version}"clears the form after adding.self.run_js(js.focus(...))puts the cursor back in the input once the page has updated.
5. Composition¶
"""Todos: the composition root.
Run: pip install "rational[db]"
python app.py --dev # hot reload, readable errors
python app.py --prod # production server
"""
from rational import App
from models import create_tables, database
from services import TodoService
from views.pages import router
from views.styles import THEME
app = App(title="Todos", theme=THEME, database=database, login_path="/login")
app.include(router) # the pages
app.service(TodoService) # also expose the service as a JSON API: /api/todo/... (docs at /api/docs)
app.on_startup(create_tables)
if __name__ == "__main__":
app.run()