diff --git a/README.md b/README.md index 8f19c5f..e0c4626 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha - [**`image-redraw/`**](image-redraw) — an example that combines [FastAPI](https://fastapi.tiangolo.com/), [R2](https://developers.cloudflare.com/r2/), [Queues](https://developers.cloudflare.com/queues/), [Workflows](https://developers.cloudflare.com/workflows/) and [Workers AI](https://developers.cloudflare.com/workers-ai/) to redraw uploaded images. - [**`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-markdown-r2/`**](django-markdown-r2) — a server-rendered Django blog using Durable Object SQLite and R2 for media storage. - [**`fastapi-todo/`**](fastapi-todo) — implements the [Todo-Backend](https://todobackend.com) spec with FastAPI (ASGI) and D1. - [**`flask-todo/`**](flask-todo) — implements the same [Todo-Backend](https://todobackend.com) API with [Flask](https://flask.palletsprojects.com/) (WSGI) and D1. diff --git a/django-markdown-r2/README.md b/django-markdown-r2/README.md new file mode 100644 index 0000000..d115a8c --- /dev/null +++ b/django-markdown-r2/README.md @@ -0,0 +1,24 @@ +# Django Markdown Blog + Durable Objects + R2 + +[![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-markdown-r2) + +A blog built with Django on Cloudflare Python Workers, using Durable Objects for database storage and R2 for image storage. + +## Local setup + +Install [uv](https://docs.astral.sh/uv/getting-started/installation/#standalone-installer), then install the project dependencies and start the Worker: + +```sh +uv sync +uv run pywrangler dev +``` + +Open http://localhost:8787/. Wrangler provisions the configured Durable Object locally and simulates the `IMAGES` R2 binding. + +## Remote setup and deployment + +Create an R2 bucket, update the `IMAGES` bucket name in `wrangler.jsonc`, and deploy: + +```sh +uv run pywrangler deploy +``` diff --git a/django-markdown-r2/package.json b/django-markdown-r2/package.json new file mode 100644 index 0000000..0b530ce --- /dev/null +++ b/django-markdown-r2/package.json @@ -0,0 +1,13 @@ +{ + "name": "django-markdown-r2-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/django-markdown-r2/pyproject.toml b/django-markdown-r2/pyproject.toml new file mode 100644 index 0000000..04abac5 --- /dev/null +++ b/django-markdown-r2/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "django-markdown-r2-worker" +version = "0.1.0" +description = "Server-rendered Django blog backed by Durable Object SQLite and R2" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "django", + "django-cf>=0.2.16", + "markdown-it-py==4.2.0", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk", +] diff --git a/django-markdown-r2/src/articles/__init__.py b/django-markdown-r2/src/articles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-markdown-r2/src/articles/apps.py b/django-markdown-r2/src/articles/apps.py new file mode 100644 index 0000000..9baf7c9 --- /dev/null +++ b/django-markdown-r2/src/articles/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ArticlesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "articles" diff --git a/django-markdown-r2/src/articles/forms.py b/django-markdown-r2/src/articles/forms.py new file mode 100644 index 0000000..67e28cf --- /dev/null +++ b/django-markdown-r2/src/articles/forms.py @@ -0,0 +1,21 @@ +from django import forms + +from .models import Article + + +class ArticleForm(forms.ModelForm): + class Meta: + model = Article + fields = ["title", "body", "image"] + widgets = { + "body": forms.Textarea(attrs={"rows": 16}), + "image": forms.ClearableFileInput( + attrs={"accept": "image/gif,image/jpeg,image/png,image/webp"} + ), + } + help_texts = {"image": "PNG, JPEG, GIF, or WebP."} + + +class ArticleEditForm(ArticleForm): + class Meta(ArticleForm.Meta): + fields = ["title", "body"] diff --git a/django-markdown-r2/src/articles/models.py b/django-markdown-r2/src/articles/models.py new file mode 100644 index 0000000..52fb6cb --- /dev/null +++ b/django-markdown-r2/src/articles/models.py @@ -0,0 +1,39 @@ +import uuid + +from django.core.validators import FileExtensionValidator +from django.db import models +from django.utils.text import slugify + + +def generate_article_id() -> str: + return str(uuid.uuid4()) + + +class Article(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=generate_article_id) + title = models.CharField(max_length=200) + slug = models.SlugField(max_length=100, unique=True) + body = models.TextField(max_length=20_000) + image = models.FileField( + upload_to="articles", + blank=True, + validators=[FileExtensionValidator(["gif", "jpeg", "jpg", "png", "webp"])], + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "articles" + ordering = ["-created_at"] + + def save(self, *args, **kwargs): + if not self.slug: + base = (slugify(self.title) or "article")[:100].rstrip("-") + candidate = base + suffix_number = 2 + while type(self).objects.filter(slug=candidate).exists(): + suffix = f"-{suffix_number}" + candidate = f"{base[: 100 - len(suffix)].rstrip('-')}{suffix}" + suffix_number += 1 + self.slug = candidate + return super().save(*args, **kwargs) diff --git a/django-markdown-r2/src/articles/templates/articles/article_detail.html b/django-markdown-r2/src/articles/templates/articles/article_detail.html new file mode 100644 index 0000000..9f6f76e --- /dev/null +++ b/django-markdown-r2/src/articles/templates/articles/article_detail.html @@ -0,0 +1,33 @@ +{% extends "articles/base.html" %} + +{% block title %}{{ article.title }} | Knowledge base{% endblock %} + +{% block content %} +
+
+
+

{{ article.title }}

+

+ + Published + · Updated + +

+
+ +
+ {% if article.image %} +
+ Illustration for {{ article.title }} +
+ {% endif %} +
+ {{ rendered_body }} +
+
+{% endblock %} diff --git a/django-markdown-r2/src/articles/templates/articles/article_form.html b/django-markdown-r2/src/articles/templates/articles/article_form.html new file mode 100644 index 0000000..7af1278 --- /dev/null +++ b/django-markdown-r2/src/articles/templates/articles/article_form.html @@ -0,0 +1,44 @@ +{% extends "articles/base.html" %} + +{% block title %}{% if is_edit %}Edit {{ article.title }}{% else %}New article{% endif %} | Knowledge base{% endblock %} + +{% block content %} +
+
+

{% if is_edit %}Edit article{% else %}New article{% endif %}

+

{% if is_edit %}Refine this entry and save the updated reference for readers.{% else %}Add a clear, useful entry to the knowledge base.{% endif %}

+
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} +
+ Article details + {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% if field.errors %} +
{{ field.errors }}
+ {% endif %} + {% else %} + + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if field.errors %} +
{{ field.errors }}
+ {% endif %} + {% endif %} + {% endfor %} +
+ +
+
+{% endblock %} diff --git a/django-markdown-r2/src/articles/templates/articles/article_list.html b/django-markdown-r2/src/articles/templates/articles/article_list.html new file mode 100644 index 0000000..f4557d4 --- /dev/null +++ b/django-markdown-r2/src/articles/templates/articles/article_list.html @@ -0,0 +1,35 @@ +{% extends "articles/base.html" %} + +{% block title %}Articles | Knowledge base{% endblock %} + +{% block content %} +
+
+
+

Articles

+

A reference library for practical notes and lasting answers.

+
+

Create article

+
+ {% if articles %} + {% for article in articles %} +
+
+

{{ article.title }}

+

+ + Updated + +

+
+
+ {{ article.rendered_body|truncatewords_html:45 }} +
+ +
+ {% endfor %} + {% endif %} +
+{% endblock %} diff --git a/django-markdown-r2/src/articles/templates/articles/base.html b/django-markdown-r2/src/articles/templates/articles/base.html new file mode 100644 index 0000000..bb20123 --- /dev/null +++ b/django-markdown-r2/src/articles/templates/articles/base.html @@ -0,0 +1,28 @@ + + + + + + {% block title %}Knowledge base{% endblock %} + + + +
+ +
+
+ {% block content %}{% endblock %} +
+ + + diff --git a/django-markdown-r2/src/articles/views.py b/django-markdown-r2/src/articles/views.py new file mode 100644 index 0000000..037a7e8 --- /dev/null +++ b/django-markdown-r2/src/articles/views.py @@ -0,0 +1,87 @@ +from datetime import datetime +from pathlib import PurePosixPath + +from django.core.files.storage import default_storage +from django.http import Http404, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.safestring import SafeString, mark_safe +from markdown_it import MarkdownIt + +from .forms import ArticleEditForm, ArticleForm +from .models import Article + +IMAGE_CONTENT_TYPES: dict[str, str] = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +} + + +def render_markdown(markdown: str) -> SafeString: + return mark_safe(MarkdownIt("js-default").render(markdown)) + + +def format_date(value: datetime) -> str: + return f"{value:%B} {value.day}, {value.year}" + + +def article_list(request): + articles = list(Article.objects.all()) + for article in articles: + article.rendered_body = render_markdown(article.body) + article.updated_date = format_date(article.updated_at) + article.updated_iso = article.updated_at.isoformat() + return render(request, "articles/article_list.html", {"articles": articles}) + + +def article_detail(request, slug): + article = get_object_or_404(Article, slug=slug) + return render( + request, + "articles/article_detail.html", + { + "article": article, + "created_date": format_date(article.created_at), + "created_iso": article.created_at.isoformat(), + "rendered_body": render_markdown(article.body), + "updated_date": format_date(article.updated_at), + "updated_iso": article.updated_at.isoformat(), + }, + ) + + +def article_create(request): + form = ArticleForm(request.POST or None, request.FILES or None) + if request.method == "POST" and form.is_valid(): + article = form.save() + return redirect("article-detail", slug=article.slug) + return render( + request, "articles/article_form.html", {"form": form, "is_edit": False} + ) + + +def article_edit(request, slug): + article = get_object_or_404(Article, slug=slug) + form = ArticleEditForm(request.POST or None, instance=article) + if request.method == "POST" and form.is_valid(): + article = form.save() + return redirect("article-detail", slug=article.slug) + return render( + request, + "articles/article_form.html", + {"article": article, "form": form, "is_edit": True}, + ) + + +def media_image(_request, name: str) -> HttpResponse: + path = PurePosixPath(name) + content_type = IMAGE_CONTENT_TYPES.get(path.suffix.lower()) + if content_type is None or not default_storage.exists(name): + raise Http404 + with default_storage.open(name, "rb") as image_file: + response = HttpResponse(image_file.read(), content_type=content_type) + response["Content-Disposition"] = "inline" + response["X-Content-Type-Options"] = "nosniff" + return response diff --git a/django-markdown-r2/src/entry.py b/django-markdown-r2/src/entry.py new file mode 100644 index 0000000..448ef1d --- /dev/null +++ b/django-markdown-r2/src/entry.py @@ -0,0 +1,40 @@ +import os + +from django_cf import DjangoCFDurableObject +from workers import DurableObject, Request, Response, WorkerEntrypoint + +KNOWLEDGE_BASE_NAME: str = "blog" + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "markdown_project.settings") +from markdown_project.wsgi import application + + +class KnowledgeBase(DjangoCFDurableObject, DurableObject): + def __init__(self, ctx, env): + super().__init__(ctx, env) + self.ctx.storage.sql.exec( + """ + CREATE TABLE IF NOT EXISTS articles ( + id TEXT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + body TEXT NOT NULL, + image VARCHAR(100) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ) + """ + ) + self.ctx.storage.sql.exec( + "CREATE INDEX IF NOT EXISTS articles_created_at_idx ON articles (created_at DESC)" + ) + + def get_app(self): + return application + + +class Default(WorkerEntrypoint): + async def fetch(self, request: Request) -> Response: + do_id = self.env.DO_STORAGE.idFromName(KNOWLEDGE_BASE_NAME) + stub = self.env.DO_STORAGE.get(do_id) + return await stub.fetch(request) diff --git a/django-markdown-r2/src/markdown_project/__init__.py b/django-markdown-r2/src/markdown_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-markdown-r2/src/markdown_project/settings.py b/django-markdown-r2/src/markdown_project/settings.py new file mode 100644 index 0000000..9a9a940 --- /dev/null +++ b/django-markdown-r2/src/markdown_project/settings.py @@ -0,0 +1,40 @@ +SECRET_KEY = "django-insecure-development-placeholder" +DEBUG = False +ALLOWED_HOSTS = ["*"] +ROOT_URLCONF = "markdown_project.urls" +WSGI_APPLICATION = "markdown_project.wsgi.application" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +INSTALLED_APPS = ["articles"] +MIDDLEWARE = [ + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", +] +DATABASES = { + "default": { + "ENGINE": "django_cf.db.backends.do", + } +} +STORAGES = { + "default": { + "BACKEND": "django_cf.storage.R2Storage", + "OPTIONS": { + "binding": "IMAGES", + "location": "images", + "allow_overwrite": False, + }, + } +} +MEDIA_URL = "/media/" +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + ], + }, + } +] +TIME_ZONE = "UTC" +USE_TZ = False diff --git a/django-markdown-r2/src/markdown_project/urls.py b/django-markdown-r2/src/markdown_project/urls.py new file mode 100644 index 0000000..98e1dc4 --- /dev/null +++ b/django-markdown-r2/src/markdown_project/urls.py @@ -0,0 +1,10 @@ +from articles import views +from django.urls import path + +urlpatterns = [ + path("", views.article_list, name="article-list"), + path("articles/new/", views.article_create, name="article-create"), + path("articles//", views.article_detail, name="article-detail"), + path("articles//edit/", views.article_edit, name="article-edit"), + path("media/images/", views.media_image, name="media-image"), +] diff --git a/django-markdown-r2/src/markdown_project/wsgi.py b/django-markdown-r2/src/markdown_project/wsgi.py new file mode 100644 index 0000000..e15c994 --- /dev/null +++ b/django-markdown-r2/src/markdown_project/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "markdown_project.settings") + +application = get_wsgi_application() diff --git a/django-markdown-r2/wrangler.jsonc b/django-markdown-r2/wrangler.jsonc new file mode 100644 index 0000000..e1e46f4 --- /dev/null +++ b/django-markdown-r2/wrangler.jsonc @@ -0,0 +1,32 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "django-markdown-r2-worker", + "main": "src/entry.py", + "compatibility_date": "2026-08-21", + "compatibility_flags": [ + "python_workers" + ], + "durable_objects": { + "bindings": [ + { + "name": "DO_STORAGE", + "class_name": "KnowledgeBase" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["KnowledgeBase"] + } + ], + "r2_buckets": [ + { + "binding": "IMAGES", + "bucket_name": "django-markdown-r2-images" + } + ], + "observability": { + "enabled": true + } +} diff --git a/tests/test_examples.py b/tests/test_examples.py index 2d6e80f..52cc136 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,4 +1,6 @@ +import re import subprocess +import uuid import pytest import requests @@ -386,3 +388,27 @@ def init_django_todo_d1_db(): def test_django_todo_d1(init_django_todo_d1_db, dev_server): assert_todo_backend(dev_server) + + +def csrf_token(session, base_url, path): + response = session.get(f"{base_url}{path}") + assert response.status_code == 200 + match = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', response.text) + assert match is not None + return match.group(1) + + +def test_django_markdown_r2(dev_server): + base_url = f"http://localhost:{dev_server}" + session = requests.Session() + article_id = uuid.uuid4().hex + title = f"Safe Markdown {article_id}" + slug = f"safe-markdown-{article_id}" + + response = session.get(base_url) + assert response.status_code == 200 + assert '

Articles

' in response.text + assert ( + session.get(f"{base_url}/articles/missing-{uuid.uuid4().hex}/").status_code + == 404 + )