From 183f8a3aa8246d3db4bdc9ac30afc727bfa4a2f5 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 28 Aug 2026 18:08:36 +0900 Subject: [PATCH 1/2] Update django todo d1 example to use todo backend --- README.md | 2 +- django-todo-d1/README.md | 32 +- .../migrations/0001_create_todos.sql | 9 - django-todo-d1/migrations/0001_schema.sql | 6 + django-todo-d1/public/app.js | 216 ---------- django-todo-d1/public/index.html | 103 ----- django-todo-d1/public/style.css | 404 ------------------ django-todo-d1/pyproject.toml | 2 +- django-todo-d1/src/todo_project/settings.py | 2 +- django-todo-d1/src/todo_project/urls.py | 7 +- django-todo-d1/src/todos/middleware.py | 20 + django-todo-d1/src/todos/models.py | 11 +- django-todo-d1/src/todos/views.py | 136 ++---- django-todo-d1/wrangler.jsonc | 6 - tests/test_examples.py | 90 +--- 15 files changed, 111 insertions(+), 935 deletions(-) delete mode 100644 django-todo-d1/migrations/0001_create_todos.sql create mode 100644 django-todo-d1/migrations/0001_schema.sql delete mode 100644 django-todo-d1/public/app.js delete mode 100644 django-todo-d1/public/index.html delete mode 100644 django-todo-d1/public/style.css create mode 100644 django-todo-d1/src/todos/middleware.py diff --git a/README.md b/README.md index 5c67c4b..22fd133 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha - [**`sync-http-clients/`**](sync-http-clients) — demonstrates outbound HTTP with synchronous Python clients (`requests`, `urllib3`, and `httpx.Client`). - [**`dynamic-py-py/`**](dynamic-py-py) — shows how to load and run a Python Worker dynamically at runtime using a [Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) binding. - [**`django/`**](django) — runs a naive Django WSGI application directly on Python Workers. -- [**`django-todo-d1/`**](django-todo-d1) — uses Django with D1 for a basic TODO application. +- [**`django-todo-d1/`**](django-todo-d1) — implements the Todo-Backend API with Django and D1. diff --git a/django-todo-d1/README.md b/django-todo-d1/README.md index c1cb48b..c22e63e 100644 --- a/django-todo-d1/README.md +++ b/django-todo-d1/README.md @@ -1,8 +1,8 @@ -# Django TODO + D1 Example +# Django Todo Backend + D1 Example [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/python-workers-examples/tree/main/django-todo-d1) -This example uses Django with [django-cf](https://pypi.org/project/django-cf/) and a D1 database to serve a small TODO API under `/api/*`, plus a static frontend from `public/` served by [Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/). +This example uses Django with [django-cf](https://pypi.org/project/django-cf/) and a D1 database to implement the [Todo-Backend](https://todobackend.com) API. The Worker only serves the backend; use the existing Todo-Backend client as the frontend. ## How to Run @@ -21,15 +21,26 @@ Start the development server: uv run pywrangler dev ``` -Then open http://localhost:8787/ in your browser. +Then open the Todo-Backend client with the local API URL: + +``` +https://todobackend.com/client/index.html?http://localhost:8787/todos +``` + +You can also run the Todo-Backend specification against the Django implementation: + +``` +https://todobackend.com/specs/index.html?http://localhost:8787/todos +``` ## How to deploy Replace the `database_id` in `wrangler.jsonc` with your actual D1 database UUID. -Then run +Apply the migrations to the remote D1 database, then deploy the Worker: ```sh +uv run pywrangler d1 migrations apply django-todo-d1 --remote uv run pywrangler deploy ``` @@ -38,13 +49,14 @@ uv run pywrangler deploy | Endpoint | Description | |---|---| -| `GET /api/health/` | Health check | -| `GET /api/todos/` | List TODOs, newest first | -| `POST /api/todos/` | Create a TODO | -| `GET /api/todos//` | Fetch one TODO | -| `PATCH /api/todos//` | Partially update `title` and/or `completed` | -| `DELETE /api/todos//` | Delete a TODO | +| `GET /todos` | List all todos | +| `POST /todos` | Create a todo | +| `DELETE /todos` | Delete all todos | +| `GET /todos/` | Fetch one todo | +| `PATCH /todos/` | Partially update a todo | +| `DELETE /todos/` | Delete a todo | ## Notes - Schema changes are managed by hand-written Wrangler D1 SQL migrations, not Django `manage.py migrate`. +- Cross-origin requests are enabled so the hosted Todo-Backend client can call the Worker. diff --git a/django-todo-d1/migrations/0001_create_todos.sql b/django-todo-d1/migrations/0001_create_todos.sql deleted file mode 100644 index 04bf346..0000000 --- a/django-todo-d1/migrations/0001_create_todos.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS todos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title VARCHAR(200) NOT NULL, - completed BOOLEAN NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS todos_created_at_id_idx -ON todos (created_at DESC, id DESC); diff --git a/django-todo-d1/migrations/0001_schema.sql b/django-todo-d1/migrations/0001_schema.sql new file mode 100644 index 0000000..a5c9759 --- /dev/null +++ b/django-todo-d1/migrations/0001_schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE todos ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + completed INTEGER NOT NULL DEFAULT 0, + "order" INTEGER +); diff --git a/django-todo-d1/public/app.js b/django-todo-d1/public/app.js deleted file mode 100644 index 40260a2..0000000 --- a/django-todo-d1/public/app.js +++ /dev/null @@ -1,216 +0,0 @@ -const form = document.getElementById("todo-form"); -const titleInput = document.getElementById("title"); -const submitBtn = document.getElementById("submit-btn"); -const refreshButton = document.getElementById("refresh"); -const list = document.getElementById("todo-list"); -const status = document.getElementById("status"); -const template = document.getElementById("todo-template"); - -function setStatus(message, isError = false) { - status.textContent = message; - status.dataset.error = isError ? "true" : "false"; - if (message) { - status.classList.add("visible"); - } else { - status.classList.remove("visible"); - } -} - -async function api(path, options = {}) { - try { - const response = await fetch(path, { - ...options, - headers: { - ...(options.headers ?? {}), - "Content-Type": "application/json", - }, - }); - - if (response.status === 204) { - return null; - } - - const data = await response.json().catch(() => null); - - if (!response.ok) { - throw new Error( - data?.error ?? `Request failed with status ${response.status}`, - ); - } - - return data; - } catch (error) { - if (error instanceof TypeError) { - throw new Error("Network error. Please check your connection."); - } - throw error; - } -} - -function renderTodos(todos) { - list.innerHTML = ""; - - if (todos.length === 0) { - const empty = document.createElement("li"); - empty.className = "empty-state"; - empty.textContent = "No tasks yet. Add one above!"; - list.append(empty); - return; - } - - for (const todo of todos) { - const node = template.content.firstElementChild.cloneNode(true); - const checkbox = node.querySelector(".todo-completed"); - const title = node.querySelector(".todo-title"); - const meta = node.querySelector(".todo-meta"); - const renameButton = node.querySelector(".rename-button"); - const deleteButton = node.querySelector(".delete-button"); - const renameForm = node.querySelector(".todo-rename-form"); - const renameInput = node.querySelector(".todo-rename-input"); - const cancelRenameBtn = node.querySelector(".cancel-rename"); - const allButtons = node.querySelectorAll("button, input"); - - const setBusy = (isBusy) => { - node.classList.toggle("is-busy", isBusy); - allButtons.forEach((btn) => (btn.disabled = isBusy)); - }; - - checkbox.checked = todo.completed; - title.textContent = todo.title; - - const date = new Date(todo.created_at); - meta.textContent = `Created ${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; - - node.dataset.id = String(todo.id); - node.classList.toggle("is-completed", todo.completed); - - checkbox.addEventListener("change", async () => { - const originalState = !checkbox.checked; - setBusy(true); - try { - await api(`/api/todos/${todo.id}/`, { - method: "PATCH", - body: JSON.stringify({ completed: checkbox.checked }), - }); - setStatus(""); - node.classList.toggle("is-completed", checkbox.checked); - } catch (error) { - checkbox.checked = originalState; - setStatus(error.message, true); - } finally { - setBusy(false); - } - }); - - const toggleRename = (show) => { - node.classList.toggle("is-editing", show); - renameForm.hidden = !show; - if (show) { - renameInput.value = todo.title; - renameInput.focus(); - renameInput.select(); - } else { - renameButton.focus(); - } - }; - - renameButton.addEventListener("click", () => toggleRename(true)); - cancelRenameBtn.addEventListener("click", () => toggleRename(false)); - - renameForm.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - event.preventDefault(); - toggleRename(false); - } - }); - - renameForm.addEventListener("submit", async (e) => { - e.preventDefault(); - const nextTitle = renameInput.value.trim(); - if (!nextTitle || nextTitle === todo.title) { - toggleRename(false); - return; - } - - setBusy(true); - try { - await api(`/api/todos/${todo.id}/`, { - method: "PATCH", - body: JSON.stringify({ title: nextTitle }), - }); - todo.title = nextTitle; - title.textContent = nextTitle; - toggleRename(false); - setStatus(""); - } catch (error) { - setStatus(error.message, true); - } finally { - setBusy(false); - } - }); - - deleteButton.addEventListener("click", async () => { - setBusy(true); - try { - await api(`/api/todos/${todo.id}/`, { method: "DELETE" }); - node.remove(); - setStatus(""); - refreshButton.focus(); - if (list.children.length === 0) { - renderTodos([]); - } - } catch (error) { - setStatus(error.message, true); - setBusy(false); - } - }); - - list.append(node); - } -} - -async function loadTodos() { - refreshButton.disabled = true; - setStatus("Loading...", false); - try { - const data = await api("/api/todos/"); - renderTodos(data.todos); - setStatus(""); - } catch (error) { - setStatus(error.message, true); - } finally { - refreshButton.disabled = false; - } -} - -form.addEventListener("submit", async (event) => { - event.preventDefault(); - const title = titleInput.value.trim(); - if (!title) return; - - titleInput.disabled = true; - submitBtn.disabled = true; - setStatus("Adding...", false); - - try { - await api("/api/todos/", { - method: "POST", - body: JSON.stringify({ title }), - }); - titleInput.value = ""; - setStatus(""); - await loadTodos(); - } catch (error) { - setStatus(error.message, true); - } finally { - titleInput.disabled = false; - submitBtn.disabled = false; - titleInput.focus(); - } -}); - -refreshButton.addEventListener("click", () => { - void loadTodos(); -}); - -void loadTodos(); diff --git a/django-todo-d1/public/index.html b/django-todo-d1/public/index.html deleted file mode 100644 index 434fe82..0000000 --- a/django-todo-d1/public/index.html +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - Django TODO + D1 - - - -
-
-

Django TODO + D1

-

- A minimal same-origin frontend backed by Django, django-cf, and - Cloudflare D1. -

-
- -
-

Create a TODO

-
- -
- - -
-
-
- -
-
-

Tasks

- -
-
-
    -
    -
    - - - - - - diff --git a/django-todo-d1/public/style.css b/django-todo-d1/public/style.css deleted file mode 100644 index bb04ac3..0000000 --- a/django-todo-d1/public/style.css +++ /dev/null @@ -1,404 +0,0 @@ -:root { - /* Colors */ - --color-bg: #f9f7f5; - --color-surface: #ffffff; - --color-ink: #1d1d1d; - --color-ink-muted: #595959; - --color-accent: #f6821f; /* Cloudflare orange */ - --color-accent-hover: #e07010; - --color-border: #e5e5e5; - --color-error: #d92d20; - --color-error-bg: #fef3f2; - --color-focus: rgba(246, 130, 31, 0.4); - --color-accent-border: rgba(246, 130, 31, 0.55); - --color-surface-editing: #fffcf7; - - /* Spacing */ - --space-1: 0.25rem; - --space-2: 0.5rem; - --space-3: 0.75rem; - --space-4: 1rem; - --space-5: 1.5rem; - --space-6: 2rem; - - /* Typography */ - --font-sans: - system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: 1.25rem; - --text-xl: 1.5rem; - - /* Control heights */ - --control-height: 2.75rem; - --control-height-sm: 2.25rem; - - /* Radii */ - --radius-sm: 0.25rem; - --radius-md: 0.5rem; - --radius-lg: 0.75rem; - - /* Shadows */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); - --shadow-md: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--color-bg); - color: var(--color-ink); - font-family: var(--font-sans); - line-height: 1.5; - -webkit-font-smoothing: antialiased; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.layout { - max-width: 42rem; - margin: 0 auto; - padding: var(--space-6) var(--space-4); -} - -.hero { - margin-bottom: var(--space-6); - text-align: center; -} - -.hero h1 { - margin: 0 0 var(--space-2); - font-size: var(--text-xl); - font-weight: 700; - letter-spacing: -0.025em; -} - -.hero p { - margin: 0; - color: var(--color-ink-muted); -} - -.panel { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--space-5); - box-shadow: var(--shadow-sm); - margin-bottom: var(--space-5); -} - -.panel h2 { - margin: 0 0 var(--space-4); - font-size: var(--text-lg); - font-weight: 600; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--space-4); -} - -.section-header h2 { - margin: 0; -} - -/* Forms & Inputs */ -.input-group { - display: flex; - align-items: center; - gap: var(--space-3); -} - -.input-group .btn { - flex-shrink: 0; -} - -input[type="text"] { - width: 100%; - min-width: 0; - height: var(--control-height); - padding: 0 var(--space-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - font: inherit; - font-size: var(--text-base); - transition: - border-color 0.2s, - box-shadow 0.2s; -} - -input[type="text"].input-sm { - height: var(--control-height-sm); - padding: 0 var(--space-3); - font-size: var(--text-sm); -} - -input[type="text"]:focus-visible { - outline: none; - border-color: var(--color-accent); - box-shadow: 0 0 0 3px var(--color-focus); -} - -input[type="text"]:disabled { - background: var(--color-bg); - color: var(--color-ink-muted); - cursor: not-allowed; -} - -/* Buttons */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: var(--control-height); - padding: 0 var(--space-4); - border: 1px solid transparent; - border-radius: var(--radius-md); - font: inherit; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.btn:focus-visible { - outline: none; - box-shadow: 0 0 0 3px var(--color-focus); -} - -.btn-primary { - background: var(--color-accent); - color: white; -} - -.btn-primary:hover:not(:disabled) { - background: var(--color-accent-hover); -} - -.btn-secondary { - background: var(--color-surface); - border-color: var(--color-border); - color: var(--color-ink); -} - -.btn-secondary:hover:not(:disabled) { - background: var(--color-bg); -} - -.btn-danger { - background: var(--color-surface); - border-color: var(--color-border); - color: var(--color-error); -} - -.btn-danger:hover:not(:disabled) { - background: var(--color-error-bg); - border-color: var(--color-error); -} - -.btn-sm { - min-height: var(--control-height-sm); - padding: 0 var(--space-3); - font-size: var(--text-sm); -} - -/* Status */ -.status-message { - min-height: 1.5rem; - margin-bottom: var(--space-4); - font-size: var(--text-sm); - color: var(--color-ink-muted); - display: none; -} - -.status-message.visible { - display: block; -} - -.status-message[data-error="true"] { - color: var(--color-error); - background: var(--color-error-bg); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - border: 1px solid rgba(217, 45, 32, 0.2); -} - -/* Todo List */ -.todo-list { - list-style: none; - padding: 0; - margin: 0; - display: grid; - gap: var(--space-3); -} - -.todo-item { - display: flex; - gap: var(--space-4); - padding: var(--space-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: var(--color-surface); - align-items: flex-start; - transition: opacity 0.2s; -} - -.todo-item.is-busy { - opacity: 0.6; - pointer-events: none; -} - -.todo-item.is-editing { - border-color: var(--color-accent-border); - background: var(--color-surface-editing); -} - -.todo-body { - flex: 1; - min-width: 0; /* Prevent flex blowout */ - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.todo-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-4); - min-height: var(--control-height-sm); -} - -.todo-item.is-editing .todo-row { - display: none; -} - -.todo-toggle { - display: flex; - align-items: center; - min-height: var(--control-height-sm); - cursor: pointer; -} - -.todo-completed { - width: 1.25rem; - height: 1.25rem; - margin: 0; - cursor: pointer; - accent-color: var(--color-accent); -} - -.todo-title { - flex: 1; - min-width: 0; - align-self: center; - font-weight: 500; - word-break: break-word; -} - -.is-completed .todo-title { - text-decoration: line-through; - color: var(--color-ink-muted); -} - -.todo-meta { - font-size: var(--text-sm); - color: var(--color-ink-muted); -} - -.todo-actions { - display: flex; - gap: var(--space-2); - flex-shrink: 0; -} - -.todo-rename-form { - display: flex; - align-items: center; - gap: var(--space-2); - min-height: var(--control-height-sm); -} - -.todo-rename-form[hidden] { - display: none; -} - -.empty-state { - padding: var(--space-6); - text-align: center; - color: var(--color-ink-muted); - background: var(--color-bg); - border: 1px dashed var(--color-border); - border-radius: var(--radius-md); -} - -/* Responsive */ -@media (max-width: 640px) { - .input-group, - .todo-row, - .todo-rename-form { - flex-direction: column; - align-items: stretch; - gap: var(--space-3); - } - - .todo-title { - align-self: stretch; - } - - .todo-toggle { - min-height: 1.5rem; - } - - .todo-item.is-editing .todo-toggle { - min-height: var(--control-height); - } - - input[type="text"].input-sm { - height: var(--control-height); - font-size: var(--text-base); - } - - .todo-actions { - display: grid; - grid-template-columns: 1fr 1fr; - padding-top: var(--space-3); - border-top: 1px solid var(--color-border); - } - - .btn-sm { - min-height: var(--control-height); - } -} - -/* Reduced Motion */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} diff --git a/django-todo-d1/pyproject.toml b/django-todo-d1/pyproject.toml index 90be6a0..2f4d627 100644 --- a/django-todo-d1/pyproject.toml +++ b/django-todo-d1/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "django-todo-d1-worker" version = "0.1.0" -description = "Django TODO example backed by D1 via django-cf" +description = "Django Todo-Backend implementation backed by D1 via django-cf" readme = "README.md" requires-python = ">=3.13" dependencies = [ diff --git a/django-todo-d1/src/todo_project/settings.py b/django-todo-d1/src/todo_project/settings.py index 6e2b839..438d8c7 100644 --- a/django-todo-d1/src/todo_project/settings.py +++ b/django-todo-d1/src/todo_project/settings.py @@ -7,7 +7,7 @@ INSTALLED_APPS = [ "todos", ] -MIDDLEWARE = [] +MIDDLEWARE = ["todos.middleware.CorsMiddleware"] DATABASES = { "default": { "ENGINE": "django_cf.db.backends.d1", diff --git a/django-todo-d1/src/todo_project/urls.py b/django-todo-d1/src/todo_project/urls.py index 12b0a0b..787d016 100644 --- a/django-todo-d1/src/todo_project/urls.py +++ b/django-todo-d1/src/todo_project/urls.py @@ -1,8 +1,7 @@ from django.urls import path -from todos.views import health_view, todo_detail_view, todo_list_view +from todos.views import todo_detail_view, todo_list_view urlpatterns = [ - path("api/health/", health_view), - path("api/todos/", todo_list_view), - path("api/todos//", todo_detail_view), + path("todos", todo_list_view), + path("todos/", todo_detail_view), ] diff --git a/django-todo-d1/src/todos/middleware.py b/django-todo-d1/src/todos/middleware.py new file mode 100644 index 0000000..9eb788b --- /dev/null +++ b/django-todo-d1/src/todos/middleware.py @@ -0,0 +1,20 @@ +from django.http import HttpResponse + + +class CorsMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.method == "OPTIONS": + response = HttpResponse() + else: + response = self.get_response(request) + + response["Access-Control-Allow-Origin"] = "*" + response["Access-Control-Allow-Methods"] = ( + "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT" + ) + response["Access-Control-Allow-Headers"] = "*" + response["Access-Control-Expose-Headers"] = "*" + return response diff --git a/django-todo-d1/src/todos/models.py b/django-todo-d1/src/todos/models.py index 410a65a..2ff7725 100644 --- a/django-todo-d1/src/todos/models.py +++ b/django-todo-d1/src/todos/models.py @@ -1,10 +1,17 @@ +import uuid + from django.db import models +def generate_todo_id(): + return str(uuid.uuid4()) + + class Todo(models.Model): - title = models.CharField(max_length=200) + id = models.CharField(primary_key=True, max_length=36, default=generate_todo_id) + title = models.TextField(default="") completed = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) + order = models.IntegerField(null=True, blank=True) class Meta: db_table = "todos" diff --git a/django-todo-d1/src/todos/views.py b/django-todo-d1/src/todos/views.py index e89ee3e..5f365d3 100644 --- a/django-todo-d1/src/todos/views.py +++ b/django-todo-d1/src/todos/views.py @@ -1,20 +1,18 @@ import json -from django.http import HttpResponse, JsonResponse +from django.http import JsonResponse from .models import Todo -def json_error(message, status): - return JsonResponse({"error": message}, status=status) - - -def serialize_todo(todo): +def serialize_todo(todo, request): + collection_url = request.build_absolute_uri("/todos").rstrip("/") return { - "id": todo.id, + "id": str(todo.id), "title": todo.title, "completed": todo.completed, - "created_at": todo.created_at.isoformat(), + "order": todo.order, + "url": f"{collection_url}/{todo.id}", } @@ -22,96 +20,45 @@ def parse_json_body(request): try: payload = json.loads(request.body.decode("utf-8") or "{}") except (UnicodeDecodeError, json.JSONDecodeError): - return None, json_error("Invalid JSON body.", status=400) + return None, JsonResponse({"error": "Invalid JSON body."}, status=400) if not isinstance(payload, dict): - return None, json_error("JSON body must be an object.", status=400) + return None, JsonResponse( + {"error": "JSON body must be an object."}, status=400 + ) return payload, None -def validate_title(value): - if not isinstance(value, str): - return None, "The 'title' field must be a string." - - normalized = value.strip() - if not normalized: - return None, "The 'title' field cannot be blank." - - if len(normalized) > 200: - return None, "The 'title' field must be 200 characters or fewer." - - return normalized, None - - -def validate_todo_payload(payload, *, partial): - allowed_fields = {"title", "completed"} - unknown_fields = sorted(set(payload) - allowed_fields) - if unknown_fields: - return None, f"Unsupported field(s): {', '.join(unknown_fields)}." - - if partial and not payload: - return None, "Provide at least one field to update." - - cleaned = {} - - if not partial and "title" not in payload: - return None, "The 'title' field is required." - - if "title" in payload: - title, error = validate_title(payload["title"]) - if error: - return None, error - cleaned["title"] = title - - if "completed" in payload: - if not isinstance(payload["completed"], bool): - return None, "The 'completed' field must be a boolean." - cleaned["completed"] = payload["completed"] - - return cleaned, None - - def get_todo(todo_id): return Todo.objects.filter(pk=todo_id).first() -def health_view(request): - match request.method: - case "GET": - return JsonResponse({"status": "ok"}) - - case _: - return json_error("Method not allowed.", status=405) - - def todo_list_view(request): match request.method: case "GET": - todos = [ - serialize_todo(todo) - for todo in Todo.objects.order_by("-created_at", "-id")[:100] - ] - return JsonResponse({"todos": todos}) + todos = [serialize_todo(todo, request) for todo in Todo.objects.all()] + return JsonResponse(todos, safe=False) case "POST": payload, error_response = parse_json_body(request) if error_response is not None: return error_response - - cleaned, error = validate_todo_payload(payload, partial=False) - if error: - return json_error(error, status=400) - assert cleaned is not None + assert payload is not None todo = Todo.objects.create( - title=cleaned["title"], - completed=cleaned.get("completed", False), + title=payload.get("title", ""), + completed=bool(payload.get("completed", False)), + order=payload.get("order"), ) - return JsonResponse({"todo": serialize_todo(todo)}, status=201) + return JsonResponse(serialize_todo(todo, request)) + + case "DELETE": + Todo.objects.all().delete() + return JsonResponse([], safe=False) case _: - return json_error("Method not allowed.", status=405) + return JsonResponse({"error": "Method not allowed."}, status=405) def todo_detail_view(request, todo_id): @@ -119,36 +66,37 @@ def todo_detail_view(request, todo_id): case "GET": todo = get_todo(todo_id) if todo is None: - return json_error("TODO not found.", status=404) - return JsonResponse({"todo": serialize_todo(todo)}) + return JsonResponse({"error": "not found"}) + return JsonResponse(serialize_todo(todo, request)) case "PATCH": todo = get_todo(todo_id) if todo is None: - return json_error("TODO not found.", status=404) + return JsonResponse({"error": "not found"}) payload, error_response = parse_json_body(request) if error_response is not None: return error_response - - cleaned, error = validate_todo_payload(payload, partial=True) - if error: - return json_error(error, status=400) - assert cleaned is not None + assert payload is not None update_fields = [] - for field_name, value in cleaned.items(): - setattr(todo, field_name, value) - update_fields.append(field_name) - - todo.save(update_fields=update_fields) - return JsonResponse({"todo": serialize_todo(todo)}) + if "title" in payload: + todo.title = payload["title"] + update_fields.append("title") + if "completed" in payload: + todo.completed = bool(payload["completed"]) + update_fields.append("completed") + if "order" in payload: + todo.order = payload["order"] + update_fields.append("order") + + if update_fields: + todo.save(update_fields=update_fields) + return JsonResponse(serialize_todo(todo, request)) case "DELETE": - deleted_count, _ = Todo.objects.filter(pk=todo_id).delete() - if deleted_count == 0: - return json_error("TODO not found.", status=404) - return HttpResponse(status=204) + Todo.objects.filter(pk=todo_id).delete() + return JsonResponse([], safe=False) case _: - return json_error("Method not allowed.", status=405) + return JsonResponse({"error": "Method not allowed."}, status=405) diff --git a/django-todo-d1/wrangler.jsonc b/django-todo-d1/wrangler.jsonc index 272c541..f09e690 100644 --- a/django-todo-d1/wrangler.jsonc +++ b/django-todo-d1/wrangler.jsonc @@ -6,12 +6,6 @@ "compatibility_flags": [ "python_workers" ], - "assets": { - "directory": "./public", - "run_worker_first": [ - "/api/*" - ] - }, "d1_databases": [ { "binding": "DB", diff --git a/tests/test_examples.py b/tests/test_examples.py index af93dc2..fc2e47d 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,5 +1,4 @@ import subprocess -from datetime import UTC, datetime import pytest import requests @@ -192,8 +191,7 @@ def init_fastapi_todo_db(): ) -def test_fastapi_todo(init_fastapi_todo_db, dev_server): - port = dev_server +def assert_todo_backend(port): base = f"http://localhost:{port}/todos" # DELETE all todos @@ -254,6 +252,10 @@ def test_fastapi_todo(init_fastapi_todo_db, dev_server): assert response.json() == [] +def test_fastapi_todo(init_fastapi_todo_db, dev_server): + assert_todo_backend(dev_server) + + def test_django(dev_server): port = dev_server response = requests.get(f"http://localhost:{port}") @@ -281,85 +283,5 @@ def init_django_todo_d1_db(): def test_django_todo_d1(init_django_todo_d1_db, dev_server): - port = dev_server - response = requests.get(f"http://localhost:{port}/api/health/") - assert response.status_code == 200 - assert response.json() == {"status": "ok"} - assert response.headers["content-type"] == "application/json" - - index_response = requests.get(f"http://localhost:{port}/") - assert index_response.status_code == 200 - assert index_response.headers["content-type"].startswith("text/html") - assert "Django TODO + D1" in index_response.text - - script_response = requests.get(f"http://localhost:{port}/app.js") - assert script_response.status_code == 200 - assert "javascript" in script_response.headers["content-type"] - - style_response = requests.get(f"http://localhost:{port}/style.css") - assert style_response.status_code == 200 - assert "text/css" in style_response.headers["content-type"] + assert_todo_backend(dev_server) - create_response = requests.post( - f"http://localhost:{port}/api/todos/", - json={"title": "Write Django TODO example"}, - ) - assert create_response.status_code == 201 - created_todo = create_response.json()["todo"] - assert created_todo["title"] == "Write Django TODO example" - assert created_todo["completed"] is False - assert isinstance(created_todo["id"], int) - assert created_todo["created_at"].endswith("+00:00") - created_at = datetime.fromisoformat(created_todo["created_at"]) - assert created_at.tzinfo is not None - assert created_at.astimezone(UTC).tzinfo == UTC - - list_response = requests.get(f"http://localhost:{port}/api/todos/") - assert list_response.status_code == 200 - todos = list_response.json()["todos"] - assert created_todo["id"] in {todo["id"] for todo in todos} - - detail_response = requests.get( - f"http://localhost:{port}/api/todos/{created_todo['id']}/" - ) - assert detail_response.status_code == 200 - assert detail_response.json()["todo"]["title"] == "Write Django TODO example" - - blank_title_response = requests.post( - f"http://localhost:{port}/api/todos/", - json={"title": " "}, - ) - assert blank_title_response.status_code == 400 - assert blank_title_response.json() == { - "error": "The 'title' field cannot be blank." - } - - wrong_completed_response = requests.patch( - f"http://localhost:{port}/api/todos/{created_todo['id']}/", - json={"completed": "yes"}, - ) - assert wrong_completed_response.status_code == 400 - assert wrong_completed_response.json() == { - "error": "The 'completed' field must be a boolean." - } - - patch_response = requests.patch( - f"http://localhost:{port}/api/todos/{created_todo['id']}/", - json={"title": "Ship Django TODO example", "completed": True}, - ) - assert patch_response.status_code == 200 - patched_todo = patch_response.json()["todo"] - assert patched_todo["title"] == "Ship Django TODO example" - assert patched_todo["completed"] is True - - delete_response = requests.delete( - f"http://localhost:{port}/api/todos/{created_todo['id']}/" - ) - assert delete_response.status_code == 204 - assert delete_response.text == "" - - missing_response = requests.get( - f"http://localhost:{port}/api/todos/{created_todo['id']}/" - ) - assert missing_response.status_code == 404 - assert missing_response.json() == {"error": "TODO not found."} From beff265b98bf3c991aa66f15fc1c3c94b13218e9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:10:12 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- django-todo-d1/src/todos/views.py | 4 +--- tests/test_examples.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/django-todo-d1/src/todos/views.py b/django-todo-d1/src/todos/views.py index 5f365d3..eee7b04 100644 --- a/django-todo-d1/src/todos/views.py +++ b/django-todo-d1/src/todos/views.py @@ -23,9 +23,7 @@ def parse_json_body(request): return None, JsonResponse({"error": "Invalid JSON body."}, status=400) if not isinstance(payload, dict): - return None, JsonResponse( - {"error": "JSON body must be an object."}, status=400 - ) + return None, JsonResponse({"error": "JSON body must be an object."}, status=400) return payload, None diff --git a/tests/test_examples.py b/tests/test_examples.py index fc2e47d..7ab5377 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -284,4 +284,3 @@ def init_django_todo_d1_db(): def test_django_todo_d1(init_django_todo_d1_db, dev_server): assert_todo_backend(dev_server) -