Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 24 additions & 0 deletions django-markdown-r2/README.md
Original file line number Diff line number Diff line change
@@ -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
```
13 changes: 13 additions & 0 deletions django-markdown-r2/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
17 changes: 17 additions & 0 deletions django-markdown-r2/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"markdown-it-py==4.2.0",
]

[dependency-groups]
dev = [
"workers-py",
"workers-runtime-sdk",
]
Empty file.
6 changes: 6 additions & 0 deletions django-markdown-r2/src/articles/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class ArticlesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "articles"
21 changes: 21 additions & 0 deletions django-markdown-r2/src/articles/forms.py
Original file line number Diff line number Diff line change
@@ -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"]
39 changes: 39 additions & 0 deletions django-markdown-r2/src/articles/models.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% extends "articles/base.html" %}

{% block title %}{{ article.title }} | Knowledge base{% endblock %}

{% block content %}
<article aria-labelledby="article-title">
<header>
<hgroup>
<h1 id="article-title">{{ article.title }}</h1>
<p>
<small>
Published <time datetime="{{ created_iso }}">{{ created_date }}</time>
&middot; Updated <time datetime="{{ updated_iso }}">{{ updated_date }}</time>
</small>
</p>
</hgroup>
<nav aria-label="Article actions">
<ul>
<li><a href="{% url 'article-list' %}">Back to articles</a></li>
<li><a href="{% url 'article-edit' slug=article.slug %}">Edit article</a></li>
</ul>
</nav>
</header>
{% if article.image %}
<figure>
<img src="{{ article.image.url }}" alt="Illustration for {{ article.title }}">
</figure>
{% endif %}
<section aria-label="Article content">
{{ rendered_body }}
</section>
</article>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -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 %}
<section aria-labelledby="article-form-heading">
<header>
<h1 id="article-form-heading">{% if is_edit %}Edit article{% else %}New article{% endif %}</h1>
<p>{% if is_edit %}Refine this entry and save the updated reference for readers.{% else %}Add a clear, useful entry to the knowledge base.{% endif %}</p>
</header>
<form method="post"{% if not is_edit %} enctype="multipart/form-data"{% endif %}>
{% csrf_token %}
{% if form.non_field_errors %}
<div role="alert">
{{ form.non_field_errors }}
</div>
{% endif %}
<fieldset>
<legend>Article details</legend>
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% if field.errors %}
<div role="alert">{{ field.errors }}</div>
{% endif %}
{% else %}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<small>{{ field.help_text }}</small>
{% endif %}
{% if field.errors %}
<div role="alert">{{ field.errors }}</div>
{% endif %}
{% endif %}
{% endfor %}
</fieldset>
<footer>
<button type="submit">{% if is_edit %}Save changes{% else %}Create article{% endif %}</button>
<a href="{% if is_edit %}{% url 'article-detail' slug=article.slug %}{% else %}{% url 'article-list' %}{% endif %}">Cancel</a>
</footer>
</form>
</section>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{% extends "articles/base.html" %}

{% block title %}Articles | Knowledge base{% endblock %}

{% block content %}
<section aria-labelledby="articles-heading">
<header>
<hgroup>
<h1 id="articles-heading">Articles</h1>
<p>A reference library for practical notes and lasting answers.</p>
</hgroup>
<p><a href="{% url 'article-create' %}" role="button">Create article</a></p>
</header>
{% if articles %}
{% for article in articles %}
<article>
<header>
<h2><a href="{% url 'article-detail' slug=article.slug %}">{{ article.title }}</a></h2>
<p>
<small>
Updated <time datetime="{{ article.updated_iso }}">{{ article.updated_date }}</time>
</small>
</p>
</header>
<section aria-label="Article preview">
{{ article.rendered_body|truncatewords_html:45 }}
</section>
<footer>
<a href="{% url 'article-detail' slug=article.slug %}">Read article</a>
</footer>
</article>
{% endfor %}
{% endif %}
</section>
{% endblock %}
28 changes: 28 additions & 0 deletions django-markdown-r2/src/articles/templates/articles/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Knowledge base{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.classless.min.css">
</head>
<body>
<header>
<nav aria-label="Primary navigation">
<ul>
<li><strong><a href="{% url 'article-list' %}">Knowledge base</a></strong></li>
</ul>
<ul>
<li><a href="{% url 'article-list' %}">Articles</a></li>
<li><a href="{% url 'article-create' %}">Create article</a></li>
</ul>
</nav>
</header>
<main id="main-content">
{% block content %}{% endblock %}
</main>
<footer>
<small>Knowledge base</small>
</footer>
</body>
</html>
87 changes: 87 additions & 0 deletions django-markdown-r2/src/articles/views.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading