From 3364c6a86489d1dab23034963b4975de0d685154 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 18 Aug 2026 15:01:57 +0900 Subject: [PATCH 1/3] Add examples using django --- 18-django/README.md | 34 ++ 18-django/package.json | 13 + 18-django/pyproject.toml | 15 + 18-django/src/entry.py | 12 + 18-django/src/hello_django/__init__.py | 0 18-django/src/hello_django/settings.py | 8 + 18-django/src/hello_django/urls.py | 11 + 18-django/src/hello_django/wsgi.py | 7 + 18-django/wrangler.jsonc | 12 + 19-django-todo-d1/README.md | 39 ++ .../migrations/0001_create_todos.sql | 9 + 19-django-todo-d1/package.json | 13 + 19-django-todo-d1/public/app.js | 216 ++++++++++ 19-django-todo-d1/public/index.html | 103 +++++ 19-django-todo-d1/public/style.css | 404 ++++++++++++++++++ 19-django-todo-d1/pyproject.toml | 16 + 19-django-todo-d1/src/entry.py | 13 + .../src/todo_project/__init__.py | 0 .../src/todo_project/settings.py | 17 + 19-django-todo-d1/src/todo_project/urls.py | 8 + 19-django-todo-d1/src/todo_project/wsgi.py | 7 + 19-django-todo-d1/src/todos/__init__.py | 0 19-django-todo-d1/src/todos/apps.py | 6 + 19-django-todo-d1/src/todos/models.py | 10 + 19-django-todo-d1/src/todos/views.py | 154 +++++++ 19-django-todo-d1/wrangler.jsonc | 26 ++ README.md | 2 + tests/test_examples.py | 112 +++++ 28 files changed, 1267 insertions(+) create mode 100644 18-django/README.md create mode 100644 18-django/package.json create mode 100644 18-django/pyproject.toml create mode 100644 18-django/src/entry.py create mode 100644 18-django/src/hello_django/__init__.py create mode 100644 18-django/src/hello_django/settings.py create mode 100644 18-django/src/hello_django/urls.py create mode 100644 18-django/src/hello_django/wsgi.py create mode 100644 18-django/wrangler.jsonc create mode 100644 19-django-todo-d1/README.md create mode 100644 19-django-todo-d1/migrations/0001_create_todos.sql create mode 100644 19-django-todo-d1/package.json create mode 100644 19-django-todo-d1/public/app.js create mode 100644 19-django-todo-d1/public/index.html create mode 100644 19-django-todo-d1/public/style.css create mode 100644 19-django-todo-d1/pyproject.toml create mode 100644 19-django-todo-d1/src/entry.py create mode 100644 19-django-todo-d1/src/todo_project/__init__.py create mode 100644 19-django-todo-d1/src/todo_project/settings.py create mode 100644 19-django-todo-d1/src/todo_project/urls.py create mode 100644 19-django-todo-d1/src/todo_project/wsgi.py create mode 100644 19-django-todo-d1/src/todos/__init__.py create mode 100644 19-django-todo-d1/src/todos/apps.py create mode 100644 19-django-todo-d1/src/todos/models.py create mode 100644 19-django-todo-d1/src/todos/views.py create mode 100644 19-django-todo-d1/wrangler.jsonc diff --git a/18-django/README.md b/18-django/README.md new file mode 100644 index 0000000..5733730 --- /dev/null +++ b/18-django/README.md @@ -0,0 +1,34 @@ +# Django WSGI 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/18-django) + +This example runs a small, conventional Django project directly on Cloudflare Workers using Django's WSGI application. + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Run: + +```sh +uv run pywrangler dev +``` + +Then try: + +```sh +curl http://localhost:8787/ +``` + +You should receive: + +```json +{"message": "Hello from Django on Cloudflare Workers!"} +``` + +You can also deploy with: + +```sh +uv run pywrangler deploy +``` diff --git a/18-django/package.json b/18-django/package.json new file mode 100644 index 0000000..63f47ef --- /dev/null +++ b/18-django/package.json @@ -0,0 +1,13 @@ +{ + "name": "django-worker", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.114.0" + } +} diff --git a/18-django/pyproject.toml b/18-django/pyproject.toml new file mode 100644 index 0000000..3fc0f9f --- /dev/null +++ b/18-django/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "django-worker" +version = "0.1.0" +description = "Naive Django example for Python Workers" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "django", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk", +] diff --git a/18-django/src/entry.py b/18-django/src/entry.py new file mode 100644 index 0000000..7659ba8 --- /dev/null +++ b/18-django/src/entry.py @@ -0,0 +1,12 @@ +import os + +from workers import WorkerEntrypoint, wsgi + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hello_django.settings") + +from hello_django.wsgi import application + + +class Default(WorkerEntrypoint): + async def fetch(self, request): + return await wsgi.fetch(application, request, self.env) diff --git a/18-django/src/hello_django/__init__.py b/18-django/src/hello_django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/18-django/src/hello_django/settings.py b/18-django/src/hello_django/settings.py new file mode 100644 index 0000000..bcacd11 --- /dev/null +++ b/18-django/src/hello_django/settings.py @@ -0,0 +1,8 @@ +SECRET_KEY = "django-insecure-development-placeholder" +DEBUG = False +ALLOWED_HOSTS = ["*"] +ROOT_URLCONF = "hello_django.urls" +MIDDLEWARE = [] +INSTALLED_APPS = [] +WSGI_APPLICATION = "hello_django.wsgi.application" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/18-django/src/hello_django/urls.py b/18-django/src/hello_django/urls.py new file mode 100644 index 0000000..477fbe5 --- /dev/null +++ b/18-django/src/hello_django/urls.py @@ -0,0 +1,11 @@ +from django.http import JsonResponse +from django.urls import path + + +def root(_request): + return JsonResponse({"message": "Hello from Django on Cloudflare Workers!"}) + + +urlpatterns = [ + path("", root), +] diff --git a/18-django/src/hello_django/wsgi.py b/18-django/src/hello_django/wsgi.py new file mode 100644 index 0000000..7386076 --- /dev/null +++ b/18-django/src/hello_django/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hello_django.settings") + +application = get_wsgi_application() diff --git a/18-django/wrangler.jsonc b/18-django/wrangler.jsonc new file mode 100644 index 0000000..4ff4219 --- /dev/null +++ b/18-django/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "django-worker", + "main": "src/entry.py", + "compatibility_date": "2026-07-29", + "compatibility_flags": [ + "python_workers" + ], + "observability": { + "enabled": true + } +} diff --git a/19-django-todo-d1/README.md b/19-django-todo-d1/README.md new file mode 100644 index 0000000..d7771d6 --- /dev/null +++ b/19-django-todo-d1/README.md @@ -0,0 +1,39 @@ +# Django TODO + 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/19-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/). + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Apply local migrations before development: + +```sh +uv run pywrangler d1 migrations apply django-todo-d1 --local +``` + +Start the development server: + +```sh +uv run pywrangler dev +``` + +Then open http://localhost:8787/ in your browser. + +## Endpoints + +| 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 | + +## Notes + +- Schema changes are managed by hand-written Wrangler D1 SQL migrations, not Django `manage.py migrate`. diff --git a/19-django-todo-d1/migrations/0001_create_todos.sql b/19-django-todo-d1/migrations/0001_create_todos.sql new file mode 100644 index 0000000..04bf346 --- /dev/null +++ b/19-django-todo-d1/migrations/0001_create_todos.sql @@ -0,0 +1,9 @@ +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/19-django-todo-d1/package.json b/19-django-todo-d1/package.json new file mode 100644 index 0000000..c727a49 --- /dev/null +++ b/19-django-todo-d1/package.json @@ -0,0 +1,13 @@ +{ + "name": "django-todo-d1-worker", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.114.0" + } +} diff --git a/19-django-todo-d1/public/app.js b/19-django-todo-d1/public/app.js new file mode 100644 index 0000000..40260a2 --- /dev/null +++ b/19-django-todo-d1/public/app.js @@ -0,0 +1,216 @@ +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/19-django-todo-d1/public/index.html b/19-django-todo-d1/public/index.html new file mode 100644 index 0000000..434fe82 --- /dev/null +++ b/19-django-todo-d1/public/index.html @@ -0,0 +1,103 @@ + + + + + + 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/19-django-todo-d1/public/style.css b/19-django-todo-d1/public/style.css new file mode 100644 index 0000000..bb04ac3 --- /dev/null +++ b/19-django-todo-d1/public/style.css @@ -0,0 +1,404 @@ +: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/19-django-todo-d1/pyproject.toml b/19-django-todo-d1/pyproject.toml new file mode 100644 index 0000000..90be6a0 --- /dev/null +++ b/19-django-todo-d1/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "django-todo-d1-worker" +version = "0.1.0" +description = "Django TODO example backed by D1 via django-cf" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "django", + "django-cf>=0.2.14", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk", +] diff --git a/19-django-todo-d1/src/entry.py b/19-django-todo-d1/src/entry.py new file mode 100644 index 0000000..b00cafb --- /dev/null +++ b/19-django-todo-d1/src/entry.py @@ -0,0 +1,13 @@ +import os + +from django_cf import DjangoCF +from workers import WorkerEntrypoint + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "todo_project.settings") + + +class Default(DjangoCF, WorkerEntrypoint): + def get_app(self): + from todo_project.wsgi import application + + return application diff --git a/19-django-todo-d1/src/todo_project/__init__.py b/19-django-todo-d1/src/todo_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/19-django-todo-d1/src/todo_project/settings.py b/19-django-todo-d1/src/todo_project/settings.py new file mode 100644 index 0000000..6e2b839 --- /dev/null +++ b/19-django-todo-d1/src/todo_project/settings.py @@ -0,0 +1,17 @@ +SECRET_KEY = "django-insecure-development-placeholder" +DEBUG = False +ALLOWED_HOSTS = ["*"] +ROOT_URLCONF = "todo_project.urls" +WSGI_APPLICATION = "todo_project.wsgi.application" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +INSTALLED_APPS = [ + "todos", +] +MIDDLEWARE = [] +DATABASES = { + "default": { + "ENGINE": "django_cf.db.backends.d1", + "CLOUDFLARE_BINDING": "DB", + } +} +TIME_ZONE = "UTC" diff --git a/19-django-todo-d1/src/todo_project/urls.py b/19-django-todo-d1/src/todo_project/urls.py new file mode 100644 index 0000000..12b0a0b --- /dev/null +++ b/19-django-todo-d1/src/todo_project/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from todos.views import health_view, todo_detail_view, todo_list_view + +urlpatterns = [ + path("api/health/", health_view), + path("api/todos/", todo_list_view), + path("api/todos//", todo_detail_view), +] diff --git a/19-django-todo-d1/src/todo_project/wsgi.py b/19-django-todo-d1/src/todo_project/wsgi.py new file mode 100644 index 0000000..87b60e7 --- /dev/null +++ b/19-django-todo-d1/src/todo_project/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "todo_project.settings") + +application = get_wsgi_application() diff --git a/19-django-todo-d1/src/todos/__init__.py b/19-django-todo-d1/src/todos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/19-django-todo-d1/src/todos/apps.py b/19-django-todo-d1/src/todos/apps.py new file mode 100644 index 0000000..ff084f9 --- /dev/null +++ b/19-django-todo-d1/src/todos/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TodosConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "todos" diff --git a/19-django-todo-d1/src/todos/models.py b/19-django-todo-d1/src/todos/models.py new file mode 100644 index 0000000..410a65a --- /dev/null +++ b/19-django-todo-d1/src/todos/models.py @@ -0,0 +1,10 @@ +from django.db import models + + +class Todo(models.Model): + title = models.CharField(max_length=200) + completed = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "todos" diff --git a/19-django-todo-d1/src/todos/views.py b/19-django-todo-d1/src/todos/views.py new file mode 100644 index 0000000..e89ee3e --- /dev/null +++ b/19-django-todo-d1/src/todos/views.py @@ -0,0 +1,154 @@ +import json + +from django.http import HttpResponse, JsonResponse + +from .models import Todo + + +def json_error(message, status): + return JsonResponse({"error": message}, status=status) + + +def serialize_todo(todo): + return { + "id": todo.id, + "title": todo.title, + "completed": todo.completed, + "created_at": todo.created_at.isoformat(), + } + + +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) + + if not isinstance(payload, dict): + return None, json_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}) + + 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 + + todo = Todo.objects.create( + title=cleaned["title"], + completed=cleaned.get("completed", False), + ) + return JsonResponse({"todo": serialize_todo(todo)}, status=201) + + case _: + return json_error("Method not allowed.", status=405) + + +def todo_detail_view(request, todo_id): + match request.method: + 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)}) + + case "PATCH": + todo = get_todo(todo_id) + if todo is None: + return json_error("TODO not found.", status=404) + + 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 + + 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)}) + + 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) + + case _: + return json_error("Method not allowed.", status=405) diff --git a/19-django-todo-d1/wrangler.jsonc b/19-django-todo-d1/wrangler.jsonc new file mode 100644 index 0000000..272c541 --- /dev/null +++ b/19-django-todo-d1/wrangler.jsonc @@ -0,0 +1,26 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "django-todo-d1-worker", + "main": "src/entry.py", + "compatibility_date": "2026-07-29", + "compatibility_flags": [ + "python_workers" + ], + "assets": { + "directory": "./public", + "run_worker_first": [ + "/api/*" + ] + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "django-todo-d1", + "database_id": "00000000-0000-0000-0000-000000000000", // Replace with your D1 database UUID. + "migrations_dir": "./migrations" + } + ], + "observability": { + "enabled": true + } +} diff --git a/README.md b/README.md index 6e2a89e..af04d58 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha - [**`15-chatroom/`**](15-chatroom) - A real-time chatroom using WebSocket. - [**`16-sync-http-clients/`**](16-sync-http-clients) — demonstrates outbound HTTP with synchronous Python clients (`requests`, `urllib3`, and `httpx.Client`). - [**`17-dynamic-py-py/`**](17-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. +- [**`18-django/`**](18-django) — runs a naive Django WSGI application directly on Python Workers. +- [**`19-django-todo-d1/`**](19-django-todo-d1) — uses Django with D1 for a basic TODO application. diff --git a/tests/test_examples.py b/tests/test_examples.py index b008f20..aa23ba1 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,4 +1,5 @@ import subprocess +from datetime import UTC, datetime import pytest import requests @@ -170,3 +171,114 @@ def test_10_workflows(dev_server): # Check that response is JSON status = response.json() assert isinstance(status, dict) + + +def test_18_django(dev_server): + port = dev_server + response = requests.get(f"http://localhost:{port}") + assert response.status_code == 200 + assert response.text == '{"message": "Hello from Django on Cloudflare Workers!"}' + assert response.headers["content-type"] == "application/json" + + +@pytest.fixture +def init_19_django_todo_d1_db(): + subprocess.run( + [ + "uv", + "run", + "pywrangler", + "d1", + "migrations", + "apply", + "django-todo-d1", + "--local", + ], + cwd=REPO_ROOT / "19-django-todo-d1", + check=True, + ) + + +def test_19_django_todo_d1(init_19_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"] + + 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 c04431d687318d07e33613efc77464e6cea0fe04 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 21 Aug 2026 10:10:09 +0900 Subject: [PATCH 2/3] Update 18-django/pyproject.toml Co-authored-by: Dominik Picheta --- 18-django/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/18-django/pyproject.toml b/18-django/pyproject.toml index 3fc0f9f..39c0c17 100644 --- a/18-django/pyproject.toml +++ b/18-django/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "django-worker" version = "0.1.0" -description = "Naive Django example for Python Workers" +description = "Simple Django example for Python Workers" readme = "README.md" requires-python = ">=3.13" dependencies = [ From c16db38ccf0048ce6d152d32733c5d5be600a9ad Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 21 Aug 2026 10:13:31 +0900 Subject: [PATCH 3/3] chore: update readme --- 19-django-todo-d1/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/19-django-todo-d1/README.md b/19-django-todo-d1/README.md index d7771d6..ecfc61c 100644 --- a/19-django-todo-d1/README.md +++ b/19-django-todo-d1/README.md @@ -23,6 +23,17 @@ uv run pywrangler dev Then open http://localhost:8787/ in your browser. +## How to deploy + +Replace the `database_id` in `wrangler.jsonc` with your actual D1 database UUID. + +Then run + +```sh +uv run pywrangler deploy +``` + + ## Endpoints | Endpoint | Description |