Media renditions, Meta Models, typed links, the new page lifecycle with drafts & preview — and the one command that upgrades your project safely.
← → to navigate · Home/End jump · works when printed too
camomilla.types.Permalinktyped, localized links in any template_data→ 8camomilla_makemigrationssafe upgrade — data migrations auto-injected→ 15llms.txt + repo skills→ 16srcset.django-structured-metaobjects.Before: one optimized original (max 1980×1400) plus a 50×50 thumbnail — every device downloaded the full-size image.
Now: each uploaded image is encoded once, at upload time, into width-based variants (default: 400/800/1600 px × WebP/AVIF/original = 9 files). Their metadata — url, width, height, format, size — is denormalized into a JSONField on the Media row.
Serving a page needs no Pillow work and no extra queries.
The serializer pre-formats the srcset string per format — drop it straight into a <picture> source.
Per-media override: Media.renditions_config beats the global config when set.
CAMOMILLA = {
"MEDIA": {
"RENDITIONS": {
"ENABLE": True, # default: on
"FOLDER": "renditions",
"VARIANTS": [
{"name": "sm-webp", "width": 400, "format": "webp"},
{"name": "md-webp", "width": 800, "format": "webp"},
{"name": "lg-webp", "width": 1600, "format": "webp"},
{"name": "lg-avif", "width": 1600, "format": "avif"},
{"name": "lg-orig", "width": 1600, "format": "original"},
],
"WEBP_QUALITY": 82,
"AVIF_QUALITY": 60,
"PREVENT_INFLATE": True, # skip outputs bigger than source
},
},
}
GET /api/camomilla/media/6/
{
"file": "https://mysite.it/media/hero.jpg",
"renditions": {
"sm-webp": {"url": "…/renditions/hero/sm-webp.webp",
"width": 400, "height": 267,
"format": "webp", "size": 18432},
"md-webp": {…}
},
"srcset": {
"webp": "…/sm-webp.webp 400w, …/md-webp.webp 800w",
"avif": "…/sm-avif.avif 400w, …"
}
}
{% load media_extras %}
<img src="{{ media.file.url }}"
srcset="{{ media|srcset:'webp' }}"
sizes="(min-width: 1024px) 1600px, 100vw">
{% media_picture media alt="Hero" sizes="100vw" loading="lazy" %}
# all image media — management command, 6.5.0+
python manage.py regenerate_renditions
# a single media, via the API
POST /api/camomilla/media/6/regenerate-renditions/
post_save — 9 encodes per upload by default. Trim VARIANTS if upload latency matters.pip install "django-camomilla-cms[avif]" — without it AVIF variants are silently skipped.file; the shipped tags already degrade gracefully.regenerate_renditions after.Small structured content — FAQs, testimonials, team members — normally costs a Django model, a migration and a release for every schema tweak. Meta Models turn the schema into data.
A MetaType declares typed fields; every MetaInstance is validated against a Pydantic model compiled on the fly. Saving a type invalidates the cache — no restart.
Field kinds: string, html, number, boolean, date, datetime, select, ref, queryset, group, list — plus per-field translation.
ref/queryset point at any installed Django model (pages, media, …).
Lives in the external django-structured-metaobjects package (hard dependency since 6.4.0); camomilla mounts its two viewsets on the API router.
POST /api/camomilla/meta-types/
{
"name": "FAQ",
"schema": [
{"name": "question", "kind": "string",
"required": true, "translated": true},
{"name": "answer", "kind": "html",
"required": true, "translated": true},
{"name": "weight", "kind": "number",
"integer": true, "minimum": 0},
{"name": "related_page", "kind": "ref",
"target_model": "camomilla.Page"}
]
}
POST /api/camomilla/meta-instances/
{
"meta_type": 1,
"data": {
"question": {"en": "What is camomilla?",
"it": "Cos'è camomilla?"},
"answer": {"en": "<p>A headless CMS.</p>",
"it": "<p>Un CMS headless.</p>"},
"weight": 10,
"related_page": 42
}
}
# wrong types / missing required → 400 with details
# JSON Schema for auto-rendering the editing form:
GET /api/camomilla/meta-types/1/schema/
from structured_metaobjects.models import MetaInstance
faq = (MetaInstance.objects
.select_related("meta_type").get(pk=1))
obj = faq.obj # cached runtime Pydantic instance
obj.question["en"] # translated → per-language dict
obj.weight # int (number + integer=True)
obj.related_page.title # ref → a real camomilla Page
"structured" and "structured_metaobjects" to INSTALLED_APPS and run migrate — the endpoints are always mounted, but without the apps their tables don't exist.IsAdminUser) — public frontends fetch through your backend, or you register subclasses with relaxed permissions.clean() — plain objects.create(...) skips it unless you call full_clean().meta_type is on_delete=PROTECT — you can't delete a type that still has instances.Permalink: links that survive renames and speak every languageBare-string URL fields break silently: rename or translate a page and every stored "/about" is stale.
A relational Permalink stores the UrlNode reference — the row, not the URL.
A read-only url field is computed at serialization time in the active language: /about/ on EN, /it/about/ on IT — honoring i18n_patterns and APPEND_SLASH.
Two kinds: relational (a page) and static (external URL) — validated conditionally, derived fields hidden from editors.
6.4.0 rebuilt MenuNodeLink this way; 6.5.0 promoted it to camomilla.types.Permalink so any template_data schema can use it.
from typing import Optional
from camomilla.models import AbstractPage
from camomilla.types import Permalink, LinkTypes
from structured.fields import StructuredJSONField
from structured.pydantic.models import BaseModel
class HeroBlock(BaseModel):
headline: str = ""
cta_label: str = ""
cta: Optional[Permalink] = None # the typed link
class HomePageData(BaseModel):
hero: HeroBlock = HeroBlock()
def _default(): return HomePageData()
class HomePage(AbstractPage):
template_data = StructuredJSONField(
schema=HomePageData, default=_default)
# building links in code / fixtures:
Permalink(link_type=LinkTypes.relational,
url_node=about_page.url_node)
Permalink(link_type=LinkTypes.static,
static="https://example.com")
GET /api/camomilla/pages-router/it/
{
"template_data": {
"hero": {
"cta_label": "Esplora la documentazione",
"cta": {
"link_type": "RE",
"url_node": {"id": 36, "permalink": "/about", …},
"url": "/it/about/"
}
}
}
}
# default-language path → same link, localized:
GET /api/camomilla/pages-router/
# … "cta": { …, "url": "/about/" }
{% load menus %}
{% for item in menu.nodes %}
{% if item.link.url %}
<a href="{{ item|node_url:request }}">{{ item.title }}</a>
{% endif %}
{% endfor %}
url, never to static — url handles both kinds; relational links omit static entirely.url is always root-relative. For absolute URLs (sitemaps, emails) call link.get_url(request=request) (6.5.0) or node_url:request.url comes back null: guard with {% if item.link.url %}.MenuNodeLink is now an alias of Permalink; stored JSON shape unchanged.status column — publication state is computed from timestamps. It can't disagree with reality.pages-router-preview: the headless frontend renders drafts with the same payload shape.camomilla_makemigrations auto-injects the data backfill for camomilla's models and yours.Two timestamps + a separate Draft table replace the old status/publication_date columns. State and visibility can never disagree, and draft metadata stops leaking into every page subclass schema.
| State | Derivation | Scope |
|---|---|---|
| PUB | deleted_at IS NULL and published_at <= now() | per language |
| DRF | deleted_at IS NULL and published_at IS NULL | per language |
| PLA | deleted_at IS NULL and published_at > now() | per language |
| TRS | deleted_at IS NOT NULL | global |
A pending draft never changes the label: a published page with staged edits is still PUB — observe drafts via has_draft / draft_data.
page.status # "PUB" / "DRF" / "PLA" / "TRS"
# (for the active language)
page.is_public # True only when status == "PUB"
page.has_draft # pending Draft, active language
page.draft_data # the staged payload (dict), or {}
# One Draft row per page per language:
# UNIQUE(content_type, object_id, language)
from datetime import timedelta
from django.utils import timezone
from camomilla.models import Page
page = Page.objects.get(permalink="/about")
# stage a draft for the active language
page.save_draft({"translations":
{"en": {"title": "New title"}}})
page.publish(comment="Editor approved")
# → applies the draft + published_at = now()
# …or stage a new draft and schedule the swap —
# live content stays up until the date:
page.save_draft({"translations":
{"en": {"title": "Summer sale"}}})
page.schedule(timezone.now() + timedelta(days=7))
page.trash() # global soft-delete → TRS everywhere
page.restore() # back to what the timestamps say
PATCH /api/camomilla/pages/12/draft/
{"translations": {"en": {"title": "New hero title"}}}
GET /api/camomilla/pages/12/preview/
# live page + draft overlay (JSON)
POST /api/camomilla/pages/12/publish/
{"comment": "Editor approved"}
POST /api/camomilla/pages/12/schedule/
{"publish_at": "2026-08-01T09:00:00Z"}
GET /api/camomilla/pages/12/revisions/
POST /api/camomilla/pages/12/revert/34/
# django-reversion snapshots (501 without it)
schedule() on an already-public language attaches to the existing draft — save it first via /draft/, or the call is a no-op with a warning."reversion" in INSTALLED_APPS — everything else works without it.# Old-style lookups still work — the manager rewrites
# them into timestamp SQL:
Page.objects.filter(status="PUB")
Page.objects.exclude(status="TRS")
Page.objects.filter(is_public=True)
# Canonical helpers (preferred in new code):
Page.objects.public() # live right now
Page.objects.draft() # has a pending Draft row
Page.objects.scheduled() # future swap or first publish
Page.objects.trashed() # deleted_at IS NOT NULL
# order_by / values need the explicit annotation:
Page.objects.with_lifecycle().order_by("computed_status")
The rewrite covers keyword lookups only: filter / exclude / get with status=, status__in=, is_public=.
Not covered: status inside a positional Q(), order_by("status"), values("status") — those need .with_lifecycle() and its computed_status annotation.
Scheduled swaps materialize lazily on the first public read — for pages nobody visits, run manage.py camomilla_publish_scheduled on cron.
A subclass that defines its own real status column is left alone — the rewrite only kicks in when the field doesn't exist.
pages-router-preview: drafts in your headless frontend6.5.0 finally gates the public pages-router on is_public — before, it served unpublished pages to anyone. The preview router is the sanctioned replacement for editors.
Authenticated mirror of the public router: same payload shape, same canonical redirects — your frontend reuses its rendering path, just adds auth.
Bypasses is_public: drafts, scheduled and trashed pages return 200, with the pending draft overlaid per active language.
Never calls publish_if_due — looking at a due scheduled draft doesn't consume it. Exactly the semantics a preview needs.
The Astro integration already uses it on ?preview=true, forwarding the editor's session cookies.
# Public router: a draft page 404s
curl -i https://cms.example.com/api/camomilla/pages-router/about/
# HTTP/1.1 404 Not Found
# Preview mirror: 200 for an authenticated editor
TOKEN=$(curl -s -X POST \
https://cms.example.com/api/camomilla/token-auth/ \
-d 'username=editor&password=secret' | jq -r .token)
curl -H "Authorization: Token $TOKEN" \
https://cms.example.com/api/camomilla/pages-router-preview/about/
# 200 {"status": "DRF", "has_draft": true,
# "translations": {…draft content…}}
IsAuthenticated, not staff-gated — any logged-in user can read unpublished content. Projects with non-staff accounts must gate preview at the frontend.ROUTER_CACHE, 900 s default), preview must be no-cache.{"redirect": "/about/", "status": 301} — detect and follow them.makemigrationspip install -U django-camomilla-cms
# settings.py: add "reversion" to INSTALLED_APPS
# preview (accepts the usual makemigrations flags)
python manage.py camomilla_makemigrations --dry-run
# run WITHOUT an app argument → covers YOUR
# AbstractPage subclasses too
python manage.py camomilla_makemigrations
python manage.py migrate
# myapp/migrations/00XX_… — generated for you
from camomilla.upgrades.migrations import (
MigrateStatusToLifecycle)
operations = [
migrations.AddField("customblog",
"published_at", …),
MigrateStatusToLifecycle("customblog"),
# ↑ the data backfill, placed between
# AddField and RemoveField
migrations.RemoveField("customblog",
"status"), …
]
Why it exists: 6.5 drops the status/publication_date columns. Plain makemigrations would generate a migration that silently wipes publication state — and camomilla can't ship migrations for your apps. The wrapper detects the breaking change per model and injects the ready-made backfill (PUB→published_at=now, PLA carries its date, TRS→deleted_at when every language was trashed).
makemigrations? Delete that migration and regenerate with camomilla_makemigrations.template_data + PermalinkAGENTS.md + two skills (camomilla-usage, camomilla-internal-architecture) — point Claude Code / Cursor at themuv run python manage.py migrate && uv run python manage.py seed_demo --reset then runserver — demo pages, drafts and menus with an admin/admin user?preview=true support