Content Editing — Decision Analysis¶
Decision: How practice managers edit website content, and the underlying data model Status: Locked — Aletheia custom views + Single model with JSONField Date: 2026-03-26
Context¶
Helios practice websites (Next.js) pull all content from Aletheia (Django). Practice managers — non-technical users — need to edit website content (hero text, service descriptions, FAQs, etc.).
Three related decisions were evaluated together:
- Rich text editor? No — structured content blocks with form fields and Markdown textareas. Prevents formatting disasters, enforces consistent branding, and is faster for non-tech users. (See "Rich Text Editor" section below.)
- Where does editing happen? Evaluated below (Options A–D).
- What data model? Evaluated below (three approaches).
- Which content blocks? Deferred — driven by visual design (next_steps.md step 3).
Decision 1 — No Rich Text Editor¶
Practice managers are non-technical. A rich text editor (CKEditor, Tiptap, etc.) would:
- Produce inconsistent formatting across practices (pasting from Word, random font sizes)
- Create HTML soup in the database, making redesigns painful
- Break responsive layouts and accessibility
- Give a false sense of control with worse results
Instead: structured content blocks with predefined form fields. Each block type has a specific form (title field, textarea, image upload, select dropdown). Text fields use plain text or limited Markdown — rendered by Next.js templates that enforce typography, spacing, and responsive behavior.
Evaluated options:
| Editor | License | Django integration | Verdict |
|---|---|---|---|
| django-ckeditor-5 | GPL 2+ (editor), BSD (wrapper) | Mature, maintained, batteries-included | GPL is a concern; unnecessary if we use structured blocks |
| Tiptap (django-tiptap) | MIT (editor), MIT (wrapper) | Abandoned (last release Dec 2021) | No viable Django package |
| Neither — structured blocks | N/A | Custom forms | Selected — best UX for non-tech users, cleanest data |
Decision 2 — Where Editing Happens¶
Option A — Django Admin (customized)¶
Practice managers log into Django admin with custom widgets for block editing.
Pros: Already exists, permissions built-in, audit trail via AuditModel. Cons: Django admin is reserved for super admins in Aletheia. Managers don't use it and shouldn't. Heavy admin customization fights the framework. Separate mental model from their daily tool.
Option B — Dedicated interface on the practice website (Next.js)¶
An /admin section on each Next.js practice site with inline editing.
Pros: Best WYSIWYG-in-context UX. Cons: Significant frontend work (15-20 days). Duplicates auth/permissions between Next.js and Django. Second frontend stack to maintain. New URL for managers to learn.
Option C — Standalone Django app (separate URL)¶
A new section at e.g. aletheia.groupe-suffren.com/website-editor/.
Pros: Purpose-built UX, leverages Aletheia auth. Cons: Yet another URL/destination for managers. Disconnected from their daily workflow.
Option D — Aletheia custom views (new top-level path) ← Selected¶
A new top-level path (e.g. /web/, /helios/, or similar) within the existing Aletheia application at aletheia.groupe-suffren.com. Same app, same auth, same navigation patterns — but its own URL namespace separate from /group/ (which is the main dashboard).
Pros:
- Familiar environment — managers already use Aletheia daily for statistics, dentist management, practice hours. Same login, same session.
- Existing auth & permissions — UserPracticeAccess model already handles practice-level role-based access. No new auth surface.
- Existing frontend stack — HTMX + Alpine.js + Bootstrap 5 + django-unfold patterns are established. No new technology.
- Data continuity — blocks that auto-pull from existing models (dentists, hours, equipment) are natural: "I updated my hours in Aletheia, the website updates too."
- No new infrastructure — same server, same deployment, same monitoring.
- Audit trail — AuditModel (created_by, updated_by) applies automatically.
Cons: - New views and templates to build (~8-12 days). - No true WYSIWYG-in-context (mitigated by preview button linking to Next.js preview URL).
Why Option D wins¶
| vs. | Why Option D is better |
|---|---|
| Django Admin (A) | Admin is for super admins. Managers already have their own interface. |
| Next.js (B) | 2x the effort, duplicated auth, second frontend stack. |
| Separate URL (C) | Option D is this, but integrated into the app managers already use daily. |
Decision 3 — Data Model¶
Approach 1 — Wagtail StreamField¶
Full CMS framework with built-in block editing UI.
Pros: Best-in-class block editing UI, drag-and-drop, preview, mature ecosystem. Cons: Adds ~20 Django apps. Brings its own admin, Page tree model, image system — all parallel to Aletheia's existing infrastructure. Wagtail's page-tree hierarchy conflicts with the multi-tenant practice → pages → blocks model. Massive overkill for structured blocks with bounded types.
Verdict: Not recommended. The integration tax far exceeds the UI benefit.
Approach 2 — django-content-blocks¶
Lightweight third-party package for block-based content.
Pros: Purpose-built for this use case, admin integration included. Cons: Small project (~50 stars), bus-factor risk, may conflict with django-unfold. The abstraction it provides is simple enough to build in 2-3 days.
Verdict: Unnecessary dependency for limited value.
Approach 3 — Single model + JSONField (DIY) ← Selected¶
One ContentBlock model with common fields (page, block_type, position, is_visible) and a JSONField for block-specific content, validated per block type.
Pros:
- Full control, zero external dependency.
- Fits naturally with existing model patterns (AuditModel, SoftDeleteModel).
- One table, one model, simple DRF serialization for the Next.js API.
- Per-block-type validation via Django forms or pydantic schemas.
- HTMX partial template per block type — clean editing flow matching existing /group/ patterns.
Cons:
- JSON is opaque to DB-level queries (acceptable — blocks are always fetched per-page, never queried by content fields).
- No drag-and-drop out of the box (solved with Alpine.js + Sortable.js or HTMX, same as any ordered list in /group/).
Rejected sub-variants¶
| Variant | Why rejected |
|---|---|
| django-polymorphic (subclass per block type) | More tables, migration headaches when adding types, polymorphic queries add complexity. Justified only with many block types with very different schemas — not our case. |
| Proxy models + JSONField | Awkward pattern, no real benefit over single model. |
Implementation Sketch¶
URL structure¶
aletheia.groupe-suffren.com/
├── group/ ← existing dashboard (stats, reports)
├── web/ ← NEW: website content management (extensible for Google reviews, etc.)
│ ├── pages/ (list pages for this practice)
│ ├── pages/<id>/ (edit page — block list, reorder, edit)
│ ├── pages/<id>/publish/ (publish page — triggers revalidateTag() webhook)
│ ├── blocks/<id>/edit/ (HTMX partial — inline block edit form)
│ ├── blocks/<id>/save/ (HTMX partial — save as draft, swap back)
│ ├── blocks/reorder/ (HTMX — receive sorted block IDs)
│ ├── testimonials/ (manage testimonials)
│ └── preview/<page_id>/ (redirect to Next.js preview URL)
Editing flow (HTMX)¶
[Page Edit View — block list]
│
├── Block 1: Hero ──── [Edit] → hx-get="/<path>/blocks/42/edit/"
│ │ → swaps in hero_form.html partial
│ └── [Save] → hx-post → validates → swaps back to hero_display.html
│
├── Block 2: Text ──── [Edit] → same pattern, text_form.html
│
├── Block 3: Team ──── (auto-pull from Dentist model, toggle visibility only)
│
├── [↕ drag to reorder] → hx-post="/<path>/blocks/reorder/"
│
└── [Preview] → opens Next.js preview URL in new tab
Model sketch¶
class Page(AuditModel, SoftDeleteModel):
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE, null=True, blank=True) # NULL = default page (shared content library)
template = models.CharField(max_length=30, choices=PageTemplate.choices)
slug = models.SlugField()
title = models.CharField(max_length=200)
status = models.CharField(max_length=10, choices=[("draft", "Draft"), ("review", "Review"), ("published", "Published")], default="draft")
published_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ["practice", "slug"]
class ContentBlock(AuditModel, SoftDeleteModel):
page = models.ForeignKey("Page", on_delete=models.CASCADE, related_name="blocks")
block_type = models.CharField(max_length=30, choices=BlockType.choices)
position = models.PositiveIntegerField(default=0)
content = models.JSONField(default=dict)
is_visible = models.BooleanField(default=True)
class Meta:
ordering = ["position"]
Block types and their JSON schemas will be defined after visual design (next_steps.md step 3).
Validation¶
Each block type has a corresponding Django Form or pydantic model that validates the content JSONField. Validation runs on save in the view, not at the model level, to keep the model simple and allow schema evolution.
Content Blocks — Deferred¶
The list of predefined block types is deferred until visual design (next_steps.md step 3) establishes the page layouts and components. Visual design drives content blocks, not the other way around.
Open Questions¶
- ~~URL path name~~: Locked:
/web/. Extensible namespace for future web-related features (Google reviews, etc.). - ~~Draft/publish workflow~~: Locked: Draft/review/publish from v1. Three model-level statuses (draft, review, published). Local staff submits for review; central team publishes. Explicit publish action triggers revalidation. Prevents accidental live changes.
- Revalidation: On content publish, trigger Next.js
revalidateTag()via webhook for affected practice/page tags (already planned in next_steps.md A5).