Camomilla 6.4 → 6.5 — Release Update
Dev teams briefing · July 2026

Release update
6.4.0 → 6.5.0

Media renditions, Meta Models, typed links, the new page lifecycle with drafts & preview — and the one command that upgrades your project safely.

camomillacms / camomilla-core Django 4.2–5.2 · Python 3.10–3.14 camomillacms.github.io/camomilla-core

to navigate · Home/End jump · works when printed too

The two releases at a glance

Eight headlines, two themes:
faster media & a real editorial workflow

6.4.0April 22, 2026
  • Media sourceset renditionsresponsive srcset out of the box, zero runtime image work→ 4
  • Meta Modelsruntime-defined content types — no migrations, no deploy→ 6
  • MenuNodeLink revampvalidator-driven links — the groundwork for typed links→ 8
6.5.0June 17, 2026
  • Page lifecyclederived status, per-language drafts, scheduling, revisions→ 11
  • pages-router-previeweditor preview for headless frontends→ 14
  • camomilla.types.Permalinktyped, localized links in any template_data→ 8
  • camomilla_makemigrationssafe upgrade — data migrations auto-injected→ 15
  • Docs & AI toolingVitePress + llms.txt + repo skills→ 16
release April 22, 2026
6.4.0media & content types
  • Media sourceset renditionsEvery upload pre-generates WebP/AVIF variants; the API hands you a ready-made srcset.
  • Meta ModelsEditors define validated content types at runtime via django-structured-metaobjects.
6.4.0 media renditions · 1/2

Renditions: responsive images with zero runtime work

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.

settings.py — all knobs in one placepython
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
        },
    },
}
6.4.0 media renditions · 2/2

Consuming renditions: API, templates, regeneration

API — two new read-only fieldshttp
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, …"
  }
}
Server-rendered templatesdjango
{% 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" %}
After changing VARIANTSbash
# 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/
Gotchas
  • Generation is eager and synchronous in post_save — 9 encodes per upload by default. Trim VARIANTS if upload latency matters.
  • AVIF needs the extra: pip install "django-camomilla-cms[avif]" — without it AVIF variants are silently skipped.
  • Small images can end up with zero renditions (upscale + inflate guards) — always fall back to file; the shipped tags already degrade gracefully.
  • Changing config never touches existing files — run regenerate_renditions after.
6.4.0 meta models · 1/2

Meta Models: content types defined at runtime

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.

Create a content type — one POST, no migrationhttp
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"}
  ]
}
6.4.0 meta models · 2/2

Validated writes, typed reads, auto-generated forms

Instances are validated server-sidehttp
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/
Typed access from Pythonpython
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
Gotchas
  • Add "structured" and "structured_metaobjects" to INSTALLED_APPS and run migrate — the endpoints are always mounted, but without the apps their tables don't exist.
  • Both viewsets are staff-only by default (IsAdminUser) — public frontends fetch through your backend, or you register subclasses with relaxed permissions.
  • Validation runs in the serializer and 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.
Docs…/How to/Use Meta Models/note: the docs page lags the released package slightly — the field kinds on the previous slide are source-verified
6.4.0 + 6.5.0 typed links · 1/2

Permalink: links that survive renames and speak every language

Bare-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.

A typed CTA in your page's template_datapython
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")
6.4.0 + 6.5.0 typed links · 2/2

One stored reference, a localized URL per language

Same page, two languageshttp
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/" }
Menus use the same primitivedjango
{% load menus %}
{% for item in menu.nodes %}
  {% if item.link.url %}
    <a href="{{ item|node_url:request }}">{{ item.title }}</a>
  {% endif %}
{% endfor %}
Gotchas
  • Bind hrefs to url, never to staticurl 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.
  • Deleted target → url comes back null: guard with {% if item.link.url %}.
  • Migration-free: MenuNodeLink is now an alias of Permalink; stored JSON shape unchanged.
release June 17, 2026
6.5.0the page-lifecycle release
  • Derived statusNo more status column — publication state is computed from timestamps. It can't disagree with reality.
  • Per-language draftsStage edits without touching the live site; publish or schedule the swap.
  • Editor previewpages-router-preview: the headless frontend renders drafts with the same payload shape.
  • Safe upgradecamomilla_makemigrations auto-injects the data backfill for camomilla's models and yours.
6.5.0 page lifecycle · 1/3

Status is derived, not stored

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.

StateDerivationScope
PUBdeleted_at IS NULL and published_at <= now()per language
DRFdeleted_at IS NULL and published_at IS NULLper language
PLAdeleted_at IS NULL and published_at > now()per language
TRSdeleted_at IS NOT NULLglobal

A pending draft never changes the label: a published page with staged edits is still PUB — observe drafts via has_draft / draft_data.

The page object, day to daypython
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)
6.5.0 page lifecycle · 2/3

The editor workflow: stage → preview → publish (or schedule)

Pythonpython
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
REST — same flow (auth + model permissions)http
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)
Gotchas
  • Draft endpoints act on the active language — one Draft row per page per language; publishing a language applies only what its row carries.
  • 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.
  • Revisions need "reversion" in INSTALLED_APPS — everything else works without it.
6.5.0 page lifecycle · 3/3

Your existing queries keep working

Compat rewrite + the canonical helperspython
# 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.

6.5.0 editor preview

pages-router-preview: drafts in your headless frontend

6.5.0 finally gates the public pages-router on is_publicbefore, 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.

Same page, two routersbash
# 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…}}
Gotchas
  • Auth is IsAuthenticated, not staff-gated — any logged-in user can read unpublished content. Projects with non-staff accounts must gate preview at the frontend.
  • Never cache preview responses in your frontend — the public router is server-cached (ROUTER_CACHE, 900 s default), preview must be no-cache.
  • Both routers return canonical redirects as a 200 body: {"redirect": "/about/", "status": 301} — detect and follow them.
6.5.0 upgrading a project

One command upgrades you — don't run plain makemigrations

Exact steps, in order (back up the DB first)bash
pip 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
What gets auto-injected, per page modelpython
# 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).

Gotchas
  • Back up first — the data step is forward-only (reversing restores columns, not values).
  • Already ran plain makemigrations? Delete that migration and regenerate with camomilla_makemigrations.
go deeper

Resources for the team

Documentation
Tooling & hands-on
  • AI toolsdocs publish llms.txt; the repo ships AGENTS.md + two skills (camomilla-usage, camomilla-internal-architecture) — point Claude Code / Cursor at them
  • Demo playgrounduv 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
  • Headless frontendsAstro integration with built-in ?preview=true support
Questionsgrab the docs URL from any slide — every claim in this deck is source-verified against v6.4.0 / v6.5.0
camomilla 6.4 → 6.5 1 / 16