API Contract — Aletheia ↔ Next.js¶
Date: 2026-03-27 Status: Draft (finalize during A2) Depends on: visual_design.md, content_editing.md, data_audit_gap_analysis.md
Context¶
Helios is a multi-tenant website platform for 9 dental practices. Aletheia (Django 5.2, ~/coding/aletheia/aletheia_v2/) is the existing practice management backend — a new apps/websites/ module serves as the CMS. Next.js 16 (this repo's future frontend) consumes Aletheia's REST API over a private network (OVH vRack) to render practice websites.
This document defines the API boundary between the two codebases and the process for keeping them in sync.
Codebase References¶
| Aletheia (backend) | Helios (frontend) | |
|---|---|---|
| GitHub | baudry-suffren/aletheia_v2 | TBD (create during B1) |
| Local path | ~/coding/aletheia/aletheia_v2/ |
~/coding/helios/helios_test2/ |
| Language | Python 3.13, Django 5.2, DRF | TypeScript, Next.js 16, Tailwind |
| Deploy | OVH VPS #1 (54.36.99.184) |
Same VPS initially, optional VPS #2 later (see infra_contract.md) |
| Live URL | aletheia.groupe-suffren.com |
Per-practice domains (e.g., cabinet-dentaire-aubagne.fr) |
Key Files — Aletheia (what Helios depends on)¶
~/coding/aletheia/aletheia_v2/
├── apps/
│ ├── practices/models.py ← Practice, PracticeBusinessHour, PracticeEquipment, PracticeRoom
│ ├── dentists/models.py ← Dentist, DentistContract, DentistSkill, DentistTraining, DentistWorkSchedule
│ └── websites/ ← NEW (A1) — all website-specific models
│ ├── models.py ← SiteConfig, Page, ContentBlock, CaseStudy, Testimonial, MediaFile, ContactSubmission
│ ├── serializers.py ← DRF serializers (A2) — defines the JSON shapes in §3
│ ├── views.py ← DRF viewsets (A2) + /web/ editing views (A3)
│ ├── urls.py ← API routes (/api/v1/websites/...) + editing routes (/web/...)
│ ├── schemas/ ← JSON schemas for ContentBlock validation (per block_type)
│ └── signals.py ← Post-save signals → ISR revalidation webhook (A5)
├── config/
│ ├── settings/ ← Django settings (DB, Redis, Celery, Brevo)
│ └── urls.py ← Root URL config (includes /api/, /web/, /group/)
Key Files — Helios (what Aletheia feeds into)¶
helios/ ← Next.js platform, built + deployed to staging.
├── spec_helios.md ← Full functional spec
├── decisions/
│ ├── visual_design.md ← Design system, tokens, components, templates
│ ├── api_contract.md ← THIS FILE — API boundary
│ ├── infra_contract.md ← Server architecture, local dev, deploy, monitoring
│ ├── content_editing.md ← CMS architecture decisions
│ └── redirect_engine.md
├── data_audit_gap_analysis.md ← What Aletheia has vs what the website needs
├── backlog.md ← Spec-vs-implementation gaps
└── roadmap/ ← Roadmap: backlog/ (open work), done/, README.md
1. Process — How Spec Changes Flow¶
The Problem¶
Aletheia (Django) and Helios (Next.js) are developed in two separate codebases. Design decisions made in the Helios spec may hit Aletheia constraints during implementation. Without a sync mechanism, the specs drift silently.
The Rule¶
This document is the shared boundary. Both codebases reference it.
- Next.js cares about: endpoint URLs, JSON payload shapes, query parameters, media URL patterns.
- Aletheia is free to implement however it wants (model design, admin views, permissions) as long as the API contract holds.
- When Aletheia can't deliver what's specced: update this document first (with a dated note in §7 Changelog), then adapt the frontend.
Change Process¶
- Developer hits a constraint in Aletheia that affects what the API returns.
- Add a dated entry to §7 Changelog explaining: what changed, why, and what frontend impact.
- If the change affects visual_design.md or spec_helios.md, update those too with a cross-reference.
- Next time you context-switch to Next.js work, read the Changelog first.
Source of Truth¶
| Concern | Source of truth |
|---|---|
| What data exists and how it's stored | Aletheia codebase (apps/) |
| What the API returns | This document |
| What the frontend needs to display | visual_design.md + spec_helios.md |
| ContentBlock JSON schemas | Aletheia codebase (validated there), documented here |
2. API Overview¶
Base URL: Configured via ALETHEIA_API_URL environment variable.
- Local dev: http://localhost:8000/api/v1/websites/
- Server (Option A): http://aletheia-{env}-web:8000/api/v1/websites/ (Docker network)
- Server (Options B/C): http://10.0.0.1/api/v1/websites/ (vRack private network)
- See infra_contract.md §2-3 for full setup details.
Auth: None needed (private network, read-only for Next.js). Write endpoints (contact form) use CSRF or API key.
Format: JSON. All responses use consistent envelope:
meta.generated_at is the response serialization time (Aletheia renders fresh
per request; caching is downstream in Helios/ISR). Renamed from cached_at,
which mislabelled it as a cache timestamp.
Shared base + per-practice override (page resolution)¶
The website content model is a two-layer stack: a shared base authored
by the central team (rows with practice IS NULL in Page / ContentBlock)
plus optional per-practice overrides (rows with practice = <id> at the
same slug). The override mechanism propagates new shared content
automatically — practices receive every shared-base update for free unless
they have opted to override a specific page.
Resolution rules (enforced server-side; Helios does not reimplement):
GET /sites/{code}/pages/{slug}/looks up the per-practice row first, then falls back to the shared row when the practice has no override at that slug. Same slug → practice wins.GET /sites/{code}/pages/unions the practice's published pages with the shared library, deduplicates byslugwith practice rows ranked first, and returns the merged set.- Custom pages (template
custom, no shared counterpart) are returned only for the owning practice.
Propagation consequences:
- Adding a new page to the shared base appears on every practice site at the next request — no per-practice work required.
- Editing an existing shared page propagates the change to all practices that have not created an override at that slug. Practices that have overridden the page keep their version; the shared update does not merge into their copy (no field-level sync).
- Removing a shared page hides it from every practice that did not override it. Per-practice overrides survive shared deletion.
- Renaming a shared page would orphan all per-practice overrides at the
old slug. Slugs of canonical shared pages are therefore reserved and
cannot be renamed once seeded — enforced by
Page.clean()againstapps.websites.models.RESERVED_PAGE_SLUGS(the 9 Mandatory + 3 Optional protected slugs from the shared-base buildout decision matrix).
Per-practice page visibility (opt-out at the page level for Optional
shared pages, without forking the content) is a separate mechanism
landing later in the buildout — see
roadmap/done/websites-shared-base-buildout.md item N8.
Tenant key — {code}, not {domain} (Phase B, 2026-07-10)¶
The path segment {code} is the tenant's Practice.internal_code (case-insensitive;
e.g. cda), not its production domain. This is the Phase-B identity decoupling
(roadmap/backlog/helios-domain-onboarding.md): a site is identified, previewed, and
served by its stable code, and SiteConfig.domain is now an optional cutover-time
attribute (blank/null until the real .fr goes live), used only for the canonical URL.
Previously every endpoint keyed off {domain}; see the §7 changelog entry for the break
and coordinated-deploy order. During the transition Aletheia's resolver still accepts a
{domain} value as a fallback (dropped once Helios is fully on {code}).
Endpoints¶
| Method | Endpoint | Purpose | Consumer |
|---|---|---|---|
GET |
/sites/{code}/config/ |
Practice theme, branding, enabled features | Next.js proxy (Host resolution) |
GET |
/sites/{code}/pages/ |
Page list (slug, title, template, status) | Next.js sitemap, nav generation |
GET |
/sites/{code}/pages/{slug}/ |
Full page with ordered ContentBlocks | Next.js page rendering |
GET |
/sites/{code}/pages/{slug}/preview/?token=… |
Same payload, any page status, token-gated (§3.9) | Next.js Draft Mode (CMS preview pane) |
GET |
/sites/{code}/practice/ |
Practice structured data (address, hours, team, contact) | Next.js layout, footer, Schema.org |
GET |
/sites/{code}/team/ |
Practitioner list with skills, training, schedules | Next.js team page, homepage preview |
GET |
/sites/{code}/team/{slug}/ |
Single practitioner detail | Next.js practitioner page |
GET |
/sites/{code}/case-studies/ |
Before/after cases (filterable by treatment) | Next.js results page, service cross-links |
GET |
/sites/{code}/testimonials/ |
Patient testimonials (filterable by treatment) | Next.js testimonial sections |
GET |
/sites/{code}/nav/ |
Navigation tree (service categories, patient needs, static pages) | Next.js nav component |
GET |
/sites/{code}/jobs/ |
Published openings (careers list, static params) | Next.js careers detail generateStaticParams |
GET |
/sites/{code}/jobs/{slug}/ |
Single open job detail | Next.js /recrutement/[slug] route |
POST |
/sites/{code}/jobs/{slug}/apply/ |
Careers application (multipart: fields + CV) | Next.js careers apply form (via same-origin proxy) |
POST |
/sites/{code}/contact/ |
Contact form submission | Next.js contact form |
Direction note. Every row above is an Aletheia endpoint that Helios consumes. The ISR revalidation webhook is the lone exception and runs the other way:
POST /webhooks/revalidate/is a Helios-hosted route (Next.js route handler) that Aletheia calls on a content change. It is documented in §3.6 and is not part of Aletheia's/api/v1/websites/surface.
3. Payload Shapes¶
3.1 SiteConfig — GET /sites/{code}/config/¶
{
"data": {
"domain": "cabinet-dentaire-aubagne.fr",
"practice_name": "Cabinet Dentaire d'Aubagne",
"practice_short_name": "Cabinet d'Aubagne",
"umami_website_id": "2c6ff41e-2b5d-4163-939e-46523c23b066",
"logo_url": "/media/practices/aubagne/logo.svg",
"favicon_url": "/media/sites/favicons/aubagne.ico",
"theme": {
"primary_hue": 195,
"primary_chroma": 0.12,
"accent_hue": 70,
"accent_chroma": 0.14
},
"seo": {
"city_name": "Aubagne",
"meta_description_default": "..."
}
}
}
Key design decisions:
- The theme sends hue + chroma values. Next.js generates the full OKLCH palette client-side using the formulas in visual_design.md §3. This keeps SiteConfig simple (2 numbers per color) while the full token set is derived in CSS.
- Fonts are not sent — Helios hardcodes --font-dm-sans / --font-dm-serif. The former theme.heading_font / theme.body_font were dropped 2026-06-01 (see §7).
- domain is now string | null (Phase B, 2026-07-10) — the production canonical domain, null until cutover. Helios must fall back to the serving host for canonical / OG / sitemap / robots when it is null, and never index a null-domain site. It is no longer the tenant key (the endpoint keys off {code}); it is used only to build the canonical URL.
- practice_id / practice_code, enabled_locales, enabled_services, and seo.meta_title_template were dropped 2026-06-01 — none were read by Helios (it hardcodes i18n, derives services from the nav, and the server already applies the title template in the page seo). (Historical note: the "keys off domain" rationale is superseded by Phase B — Helios now keys off code.)
- favicon_url (absolute URL string or null, resolved from SiteConfig.favicon_media) was re-added 2026-06-08 (R2 — spec §8 per-tenant brand favicon). Helios wires it into the Next.js metadata.icons per tenant. It had been dropped 2026-06-01 on the assumption Helios hardcoded the favicon; the spec asks for a per-practice one.
- logo_url is the practice's SVG brand mark (absolute URL string or null), resolved from Practice.logo_media. Helios consumes it three ways: the site header, the Schema.org logo, and as the source for its own generated /api/og social card. Aletheia no longer rasterizes a card — og_image_url was dropped 2026-06-08 (see §7).
3.2 Page with ContentBlocks — GET /sites/{code}/pages/{slug}/¶
{
"data": {
"id": 42,
"slug": "implant-dentaire-aubagne",
"title": "Implantologie à Aubagne",
"template": "service_hub",
"status": "published",
"published_at": "2026-03-27T10:00:00Z",
"url": "/implant-dentaire-aubagne/",
"seo": {
"meta_title": "Implant dentaire Aubagne | Cabinet Dentaire",
"meta_description": "...",
"canonical_url": "/implant-dentaire-aubagne/"
},
"breadcrumbs": [
{ "label": "Accueil", "url": "/" },
{ "label": "Implantologie Aubagne", "url": "/implant-dentaire-aubagne/" }
],
"blocks": [
{
"id": 101,
"block_type": "hero",
"position": 0,
"is_visible": true,
"content": {
"heading": "Implantologie à Aubagne",
"tagline": "Des solutions modernes pour remplacer vos dents",
"image": {
"url": "/media/pages/42/hero.webp",
"alt": "Cabinet d'implantologie à Aubagne"
},
"video_url": null,
"cta_primary_text": "Prendre rendez-vous",
"cta_primary_url": "https://www.doctolib.fr/...",
"cta_secondary_text": "04 42 XX XX XX",
"cta_secondary_url": "tel:+33442XXXXXX",
"overlay_position": "left"
}
},
{
"id": 102,
"block_type": "text",
"position": 1,
"is_visible": true,
"content": {
"body": "<p>L'implantologie dentaire permet de remplacer...</p>"
}
// NOTE: body is HTML (not Markdown). Helios renders via sanitized dangerouslySetInnerHTML.
// Content is authored as Markdown in Aletheia /web/ editor, converted to HTML on save.
},
{
"id": 103,
"block_type": "cards_grid",
"position": 2,
"is_visible": true,
"content": {
"heading": "Nos traitements en implantologie",
"cards": [
{
"title": "Remplacer une dent",
"excerpt": "L'implant unitaire est la solution...",
"image": { "url": "...", "alt": "..." },
"url": "/implant-dentaire-aubagne/remplacer-une-dent/"
}
]
}
}
]
}
}
Key decisions:
- url is the canonical front-end path for the page, built server-side from template + slug (+ the practice's city_name for service pages, e.g. /implant-dentaire-aubagne/). Helios consumes it directly for the sitemap and JSON-LD; it matches seo.canonical_url for most templates. Always present (PageDetailSerializer.get_url → build_page_url), never null.
- Blocks are returned pre-ordered by position. Next.js renders them sequentially.
- Images are returned with resolved URLs (not file IDs). Aletheia handles the URL generation.
- Every image object is { url, alt } (decorative?: boolean on editor-supplied
block images — see below). Helios's next/image derives its own responsive
srcset and negotiates WebP/AVIF on the fly from the source URL, so Aletheia
ships no pre-baked srcset / blur_placeholder (see §7, 2026-06-01).
- related_pages was dropped from the page payload 2026-06-01 — Helios never read it (the Page.related_pages M2M still exists model-side; it just isn't serialized).
- featured_image (blog pages) is an image object { url, alt } or null — the same shape as every other image. It now resolves from a MediaFile library reference (Page.featured_image_media) via build_image_object. It was previously a bare ImageField that serialized to a string URL — a latent mismatch with Helios's ImageData | null type; converging it onto the FK fixed the shape (image-picker convergence, see §7, 2026-06-01).
ContentBlock content shapes by block_type¶
Each block_type has a specific JSON shape for its content field. Helios renders each block type using the appropriate component.
Cross-repo invariant —
block_typeis a shared vocabulary, and Helios'sBlockRendererswitch fails open: an unknownblock_typerenders nothing, silently, with no error or telemetry. A new block type must therefore ship with its paired HeliosBlockRenderercase in the same coordinated change — never add one here first and expect Helios to no-op gracefully.
hero — Hero banner with image, CTA buttons, and text overlay
{
"heading": "Bienvenue au cabinet",
"tagline": "Votre sourire, notre engagement",
"image": { "url": "/media/...", "alt": "..." },
"video_url": null,
"cta_primary_text": "Prendre rendez-vous",
"cta_primary_url": "https://www.doctolib.fr/...",
"cta_secondary_text": "04 42 XX XX XX",
"cta_secondary_url": "tel:+33...",
"overlay_position": "left"
}
overlay_position: "left", "right", or "center" — controls text alignment over the hero image
- image: optional { url, alt, decorative? }; null when no hero image is set. decorative: true (present only when set) marks the image as purely presentational — Helios renders it via <DecorativeImage> with alt="" + role="presentation". Emitted on editor-supplied block images (hero, text_media, gallery items, cards_grid cards).
text — Rich text content (HTML)
body is sanitized HTML, not Markdown. Render with a sanitizer like DOMPurify.
text_media — Text with an image alongside
{
"heading": "Notre plateau technique",
"body": "<p>HTML content...</p>",
"image": { "url": "/media/...", "alt": "..." },
"media_position": "right"
}
media_position: "left" or "right" — which side the image appears on
cards_grid — Grid of linked cards
{
"heading": "Nos specialites",
"cards": [
{ "title": "Implantologie", "excerpt": "Description...", "image": { "url": "...", "alt": "..." }, "url": "/implant-dentaire-aubagne/" }
]
}
cta — Call to action banner
{
"heading": "Besoin d'un rendez-vous ?",
"body": "Description text",
"primary_text": "Reserver en ligne",
"primary_url": "https://...",
"secondary_text": "Appeler le cabinet",
"secondary_url": "tel:+33..."
}
faq — Frequently asked questions (accordion)
{
"heading": "Questions frequentes",
"items": [
{ "question": "Est-ce douloureux ?", "answer": "Non, l'intervention se fait sous anesthesie..." }
]
}
stats — Key figures / statistics
{
"heading": "Le cabinet en chiffres",
"stats": [
{ "value": "20+", "label": "Annees d'experience", "icon": "bi-calendar" }
]
}
icon: Bootstrap Icons class name (e.g., bi-calendar, bi-people)
testimonials — Patient testimonials (data-bound)
{
"heading": "Ce que disent nos patients",
"max_display": 3,
"treatment_filter": "Implant dentaire",
"testimonials": [
{ "quote": "...", "author": "Marie D.", "rating": 5, "treatment_type": "Implant dentaire" }
]
}
heading, max_display, treatment_filter. The API resolves published Testimonial rows for the practice and appends testimonials: [...] — same shape GET /sites/{code}/testimonials/ returns. treatment_filter narrows by treatment_type (case-insensitive); max_display caps the list. The standalone endpoint still exists, but Helios reads the inline array.
before_after — Case study before/after gallery (data-bound)
{
"heading": "Nos resultats en images",
"max_display": 4,
"service_category_filter": "implant-dentaire",
"case_studies": [
{ "before_image": { "url": "...", "alt": "..." }, "after_image": { "url": "...", "alt": "..." }, "caption": "...", "treatment_url": "/implant-dentaire-aubagne/" }
]
}
heading, max_display, service_category_filter. The API resolves published CaseStudy rows for the practice and appends case_studies: [...] — same shape GET /sites/{code}/case-studies/ returns. service_category_filter is a service-page slug (matched against CaseStudy.service_page.slug, e.g. implant-dentaire, not a taxonomy slug); max_display caps the list. The per-case service_category_name field was dropped (Helios never read it). The standalone endpoint still exists, but Helios reads the inline array.
team_grid — Practitioner grid (data-bound)
{
"heading": "Notre equipe",
"show_all": false,
"max_display": 6,
"members": [
{ "slug": "andre-guigue", "title": "Dr", "first_name": "André", "last_name": "Guigue", "photo": { "url": "...", "alt": "..." }, "specialty": "...", "skills": [], "languages": [], "description": "...", "booking_url": "...", "is_bookable": true, "training": [], "visible": true }
]
}
heading, show_all, max_display. The API resolves the practice's visible DentistContract rows and appends members: [...] — same shape GET /sites/{code}/team/ returns. When show_all is false the list is capped at max_display (default 6). The standalone endpoint still exists (also feeds /equipe), but Helios reads the inline array.
gallery — Image gallery / carousel
{
"heading": "Visite du cabinet",
"images": [
{ "url": "/media/...", "alt": "Accueil", "caption": "Notre espace d'accueil" }
]
}
map — Practice location map with access info (data-bound)
{
"heading": "Comment nous trouver",
"show_access_info": true,
"show_transit": true,
"show_parking": true,
"practice": { "name": "...", "address": { "...": "..." }, "access": { "...": "..." }, "...": "..." }
}
practice — the full PracticeDataSerializer object (same shape GET /sites/{code}/practice/ and the page-level practice prop return). The flags control which sections Helios displays.
related_services — Links to related service pages (data-bound)
{
"heading": "Decouvrez aussi",
"page_slugs": ["implant-dentaire-aubagne", "orthodontie-aubagne"],
"max_display": 3,
"services": [
{ "slug": "implant-dentaire", "title": "Implant dentaire", "excerpt": "...", "url": "/implant-dentaire-aubagne/", "image": { "url": "...", "alt": "..." } }
]
}
heading, page_slugs, max_display. The API resolves the named Page rows (published, active, practice-scoped — per-practice override wins) and appends services: [...] in the authored slug order, capped at max_display. Each card is the same shape as a sub_pages_grid card (slug / title / excerpt / url / image); unknown slugs drop out.
video — Embedded video
{
"heading": "Decouvrez notre cabinet",
"url": "https://www.youtube.com/watch?v=...",
"poster": { "url": "/media/..." },
"caption": "Visite virtuelle",
"autoplay": false
}
poster: thumbnail image shown before playback
- autoplay: whether to auto-start (muted) on viewport enter
quote — Blockquote / testimonial highlight
{
"text": "Choisir de proteger ses dents...",
"author": "Dr Andre Guigue",
"role": "Fondateur du cabinet"
}
sub_pages_grid — Auto-derived links to sibling pages (data-bound)
{
"heading": "En savoir plus",
"subheading": "Découvrez nos engagements et notre plateau technique.",
"parent_template": "cabinet",
"max_display": 8,
"include_self": false,
"pages": [
{ "slug": "notre-philosophie", "title": "Notre philosophie", "excerpt": "...", "image": { "url": "...", "alt": "..." }, "url": "/cabinet/notre-philosophie/" },
{ "slug": "nos-technologies", "title": "Nos technologies", "excerpt": "...", "image": null, "url": "/cabinet/nos-technologies/" }
]
}
heading, subheading, one of parent_slug / parent_template, max_display, include_self. The API resolves matching Page rows at read time and appends pages: [...].
- Each card carries image — the child Page's featured_image_media resolved to the standard { url, alt } | null object (same shape as a cards_grid card). null when the child has no featured image; the practice-specific page row (and its featured image) wins via the slug-collapse, so no separate per-practice library swap applies.
- Two selection modes (mutually exclusive; parent_slug wins if both are present):
- children mode (parent_slug): lists the published, active children of that page via the Page.parent tree. include_self=true adds the parent page itself as a card.
- by-type mode (parent_template, or the host page's own template when unset): lists every page of that type — the sibling-directory case. The host page is excluded unless include_self=true.
- Per-practice override (slug match on the active practice) wins over the shared row. Hidden pages drop out once the per-practice PageVisibility flag (Pass 3 / N8) is wired in.
equipment_showcase — Practice equipment cards (data-bound)
{
"heading": "Notre plateau technique",
"subheading": "Les outils que nous utilisons au quotidien.",
"equipment_types": ["cbct", "intraoral_scanner", "laser"],
"group_by": "equipment_type",
"show_only_with_photo": false,
"max_display": 6,
"equipment": [
{
"equipment_type": "cbct",
"equipment_type_label": "CBCT",
"manufacturer": "Planmeca",
"model_name": "ProMax 3D",
"photo": { "url": "/media/...", "alt": "Planmeca ProMax 3D" }
}
]
}
PracticeEquipment rows for the active practice and appends equipment: [...].
- Filters: status=active, optional equipment_types whitelist, optional show_only_with_photo, optional max_display.
- Photo is the standard { url, alt } image object. When the equipment has no photo the photo key is null.
- Each item exposes its equipment_type_label (display string). When group_by="equipment_type" Helios groups cards under those labels; empty/omitted renders a flat grid.
cabinet_gallery — Practice room gallery (data-bound)
{
"heading": "Nos salles",
"subheading": "",
"group_by": "room_family",
"display_style": "grid",
"show_only_with_photo": true,
"max_display": 12,
"rooms": [
{
"name": "Salle de soins 1",
"room_type": "treatment_room",
"room_type_label": "Salle de soins",
"family_label": "Zones cliniques",
"description": "Salle équipée d'un fauteuil Sirona Intego.",
"photo": { "url": "/media/...", "alt": "Salle de soins 1" }
}
]
}
PracticeRoom rows for the active practice and appends rooms: [...].
- Filters: status=active, is_website_visible=true, optional show_only_with_photo (default true), optional max_display.
- Each room exposes its room_type_label (display string) and its family_label (one of Zones cliniques, Zones de support, Zones d'accueil des patients). When group_by="room_family" Helios groups rooms under the family labels.
- display_style (grid | masonry | carousel, default grid) is a layout hint for the Helios renderer: grid is a fixed 3-column grid, masonry a balanced multi-column flow, carousel a horizontal snap-scroll row. Grouping (group_by) composes with each style.
- Photo is the standard { url, alt } image object; null when the room has no photo (and show_only_with_photo=false).
contact_form — Contact form with optional map (data-bound)
{
"heading": "Contactez-nous",
"body": "Description text above the form",
"show_phone_field": true,
"show_map": true,
"success_message": "Votre message a bien ete envoye.",
"practice": { "name": "...", "address": { "...": "..." }, "phone": "...", "email": "...", "...": "..." }
}
body: optional descriptive text rendered above the form
- show_phone_field: whether to include the phone number input
- show_map: whether to render a map alongside the form (using practice coordinates)
- Data-bound block (Group 1 convergence, 2026-06-30): the API appends practice — the same full PracticeDataSerializer object as the map block (phone / email / address / coordinates for the optional map). Helios reads it inline instead of the page-level practice prop.
- Form submits to POST /sites/{code}/contact/
3.3 Practice Data — GET /sites/{code}/practice/¶
{
"data": {
"name": "Cabinet Dentaire d'Aubagne",
"address": {
"line1": "123 Avenue de la République",
"line2": null,
"postal_code": "13400",
"city": "Aubagne",
"country": "FR",
"latitude": 43.2927,
"longitude": 5.5668
},
"phone": "+33442XXXXXX",
"email": "contact@cabinet-aubagne.fr",
"whatsapp": "+33612345678",
"emergency": { "phone": "+33491XXXXXX", "type": "permanence téléphonique" },
"google_business_profile_url": "https://g.page/...",
"social": {
"facebook": "https://facebook.com/...",
"instagram": "https://instagram.com/...",
"linkedin": null,
"tiktok": null
},
"hours": {
"regular": [
{ "day": 1, "opens": "09:00", "closes": "19:00" },
{ "day": 2, "opens": "09:00", "closes": "19:00" },
{ "day": 6, "opens": "09:00", "closes": "12:00" }
],
"holidays": [
{ "date": "2026-12-25", "is_closed": true },
{ "date": "2026-07-14", "opens": "09:00", "closes": "12:00" }
]
},
"access": {
"parking_type": "public",
"parking_address": "Parking Centre-Ville",
"has_elevator": true,
"is_handicap_accessible": true,
"transit_stations": [{ "name": "Aubagne Gare", "lines": ["TER"] }],
"access_info": "2ème étage, ascenseur"
},
"payment": {
"accepts_carte_vitale": true,
"accepts_check": true,
"accepts_cash": true,
"accepts_credit_card": true,
"regulation_sector": "Établissement conventionné",
"third_party_payer": "national_and_additional",
"payment_facilities": "Facilités de paiement possibles pour les traitements importants (nous contacter)"
},
"booking": {
"is_bookable": true,
"doctolib_url": "https://www.doctolib.fr/..."
}
}
}
- The
paymentblock (accepts_*booleans,regulation_sector/third_party_payerstrings ornull) was re-added 2026-06-08 (R1 — spec §6.1 / §3). Helios maps it to theDentistJSON-LDpaymentAcceptedarray on practice pages. It had been dropped 2026-06-01; the cut left B3's structured-data work incomplete. payment_facilities(string ornull) was added 2026-06-08 (R1 follow-up) — a newPracticeCharField holding the per-practice "facilités de paiement" line. Shown by default: the field carries a default sentence (applied to existing and new rows via the column default, not a data migration), so every practice shows the line until an editor clears the field, at which point it serializes asnulland is hidden. Surfaced visibly via themapblock toggles (show_payment/show_regulation_sector/show_third_party_payer/show_facilities) and the tarifs shared page via{{practice.payment_methods}}/{{practice.payment_facilities}}tokens — same source fields as the JSON-LD, so visible content and structured data stay in sync.- The
emergencyblock (phone+type) was re-added 2026-06-08 (R3 — spec line 87 / footer §ASCII 399-400). The whole block isnullwhen noemergency_phoneis set on the Practice;typeisnullwhen only the phone is set. Surfaced as a pair (never as flatemergency_phone/emergency_contact_typetop-level keys) because the two are edited and displayed as a unit on Aletheia's own detail page. Helios uses it for the footer "Urgences" entry (rendered only whenphoneis set) and the ContactForm urgence callout, falling back to the regularphonewhen the block isnull. The underlying Practice fields were never dropped — serializer-only re-add. It had been dropped 2026-06-01.
3.4 Team — GET /sites/{code}/team/¶
Ordering: Manual-sorted members appear first (by display_order), then alphabetical members (by last name). This list endpoint returns only members with show_on_website=true (active, non-expired contracts); the per-member detail endpoint below also serves hidden/departed members — see the visible notes after the example.
{
"data": [
{
"slug": "dr-jean-dupont",
"title": "Dr",
"first_name": "Jean",
"last_name": "Dupont",
"photo": {
"url": "/media/dentists/dupont/portrait.webp",
"alt": "Dr Jean Dupont"
},
"specialty": "Chirurgien-dentiste",
"skills": [
{ "name": "Implantologie", "type": "subspeciality" },
{ "name": "Chirurgie guidée", "type": "procedure" }
],
"languages": ["fr", "en"],
"description": "Bio courte pour la grille...",
"booking_url": "https://www.doctolib.fr/...",
"is_bookable": true,
"training": [
{ "title": "DU Implantologie", "establishment": "Université Paris V", "year": 2015 }
],
"visible": true
}
]
}
visibleis always present and is the discriminant Helios narrows on (Practitioner | PractitionerHidden). On this list endpoint every returned member hasvisible: true(the queryset already filters toshow_on_website=true,active=true, and a non-pastend_date— seeTeamListView). The full profile fields (specialty,skills,languages,description,booking_url,is_bookable,training) accompanyvisible: true.GET /sites/{code}/team/{slug}/(member detail) is the only placevisible: falseappears. It returns any contract for the slug — including departed/hidden practitioners — so a fiche URL keeps resolving after departure. A non-visible member returns the minimal fallback shape only:Helios renders a departure page from it; the full profile fields are absent. (See{ "slug": "dr-jean-dupont", "title": "Dr", "first_name": "Jean", "last_name": "Dupont", "photo": { "url": "...", "alt": "..." }, "visible": false }TeamMemberSerializer/TeamDetailView.)- Ordering is applied server-side (manual members by
display_order, then alphabetical) — thedisplay_order/sort_modevalues and the heavywork_schedulearray were dropped from the payload 2026-06-01; Helios renders members in received order and never read any of them (see §7). The DentistContract fields are unchanged.
3.5 Navigation Tree — GET /sites/{code}/nav/¶
{
"data": {
"main": [
{ "label": "Le Cabinet", "url": "/cabinet/", "children": [
{ "label": "Notre philosophie", "url": "/cabinet/notre-philosophie/" },
{ "label": "Nos technologies", "url": "/cabinet/nos-technologies/" },
{ "label": "Notre charte qualité", "url": "/cabinet/charte-qualite/" },
{ "label": "Tarifs et remboursements", "url": "/cabinet/tarifs/" },
{ "label": "Accès et informations pratiques", "url": "/cabinet/acces-informations/" }
]},
{ "label": "L'Équipe", "url": "/equipe/" },
{ "label": "Votre Besoin", "url": "/votre-besoin/", "children": [
{ "label": "Embellir mon sourire", "url": "/votre-besoin/embellir-mon-sourire/" },
{ "label": "Remplacer des dents", "url": "/votre-besoin/remplacer-des-dents/" }
]},
{ "label": "Nos Soins", "url": "#", "children": [
{ "label": "Implantologie", "url": "/implant-dentaire-aubagne/", "children": [
{ "label": "Remplacer une dent", "url": "/implant-dentaire-aubagne/remplacer-une-dent/" }
]}
]},
{ "label": "Résultats", "url": "/resultats/" },
{ "label": "Contact", "url": "/contact/" }
],
"cta": {
"label": "Prendre RDV",
"url": "https://www.doctolib.fr/...",
"phone": "+33442XXXXXX"
}
}
}
Response structure:
| Key | Type | Description |
|---|---|---|
main |
NavItem[] |
Ordered top-level navigation. Each item has label, url, and optional children (recursive NavItem[]). |
cta |
object |
Primary call-to-action button (booking). label: button text, url: Doctolib booking link, phone: practice phone number. |
url: "#" — non-navigating menu grouping:
Top-level items whose url is "#" are pure menu groupings (currently only "Nos Soins"). They have children but no destination page of their own. Helios renders them as disclosure buttons (<button type="button" aria-haspopup="true">) that open the dropdown without navigating.
Dynamic hub dropdowns (Le Cabinet, Votre Besoin, Nos Soins):
Children for the Le Cabinet, Votre Besoin, and Nos Soins groupings are derived from the practice's visible Page rows (cabinet / votre_besoin / service_hub+service_detail templates) at API time — fully page-driven, no separate taxonomy. Per-practice overrides win over the shared library at matching slugs; pages a practice has hidden via PageVisibility are dropped. For Nos Soins, service_detail pages nest under their service_hub parent and each level is ordered by the frozen NOS_SOINS_NAV_SLUG_ORDER (slug-alphabetical tiebreak); labels use menu_label. The Le Cabinet / Votre Besoin hub links are always present — if no sub-pages are visible, the entry has no children key and renders as a flat link.
3.6 ISR Revalidation Webhook — POST /webhooks/revalidate/ (Aletheia → Helios)¶
This is the one endpoint hosted by Helios (a Next.js route handler at
src/app/webhooks/revalidate/route.ts) and called by Aletheia. Helios pages
are statically generated, so a CMS edit only appears in production once the
affected cache tags are busted. Aletheia's apps/websites/signals.py (model
saves) and apps/websites/views_cms.py (CMS image swaps) funnel through
apps/websites/revalidation.py → the websites.trigger_revalidation Celery task,
which POSTs here.
Auth — shared secret in the X-Revalidate-Secret request header, compared
in constant time (crypto.timingSafeEqual) against Helios's REVALIDATION_SECRET
env var (= Aletheia's HELIOS_REVALIDATION_SECRET; provisioned per-env via Aether).
The secret is not in the body.
Request
{
"practice_code": "cda",
"tags": ["page:implant-dentaire-aubagne", "nav:cda"],
"reason": "Page published: Implantologie à Aubagne"
}
practice_codeis inert — Helios reads onlytagsand busts by exact-string match. It carries the tenantinternal_codefor logging/observability. (Waspractice_domainbefore Phase B.)
Responses — 200 {"revalidated": true, "tags": [...]} (Helios called
revalidateTag() for each tag); 401 {"error": "Unauthorized"} (missing/wrong
secret — Aletheia does not retry); 400 (non-JSON body or missing/empty
tags). Transient 5xx / connection failures are retried by the Celery task
(max 3). The webhook is a silent no-op when HELIOS_REVALIDATION_URL is unset.
Tag vocabulary (must match the fetch tags in Helios src/lib/tags.ts):
| Tag | Busts | Emitted on |
|---|---|---|
page:<slug> |
one page (getPage) |
Page / ContentBlock save |
nav:<code> |
page list + nav tree (getPageList / getNavigation) |
Page save |
practice:<code> |
site config + practice data (getSiteConfig / getPracticeData) |
SiteConfig / Practice save |
blog:<code> |
blog index (getBlogPosts) |
blog_post Page publish |
team |
team grid + member pages (getTeam / getTeamMember) — global (dentists are shared across practices, no practice FK) |
Dentist / DentistContract save |
site:<code> |
every fetch for one tenant — the coarse lever for image swaps and inline data-bound blocks | image override/revert; shared library replace/reset (fan-out); CaseStudy / Testimonial save (their before_after / testimonials blocks are inlined into getPage — no standalone fetch to tag) |
Tenant-scoped tags are code-scoped (…:<code> where <code> is
internal_code lower-cased), not domain- or pk-scoped (Phase B, 2026-07-10) —
page:<slug> (slug-keyed) and team (global) are the exceptions. site:<code>
is carried by every Helios fetch so a single revalidateTag('site:<code>')
re-renders a whole tenant; it is used for image swaps, where the changed asset
may surface on any page and there is no key→referencing-pages map yet
(per-referencing-page targeting is a later refinement).
Code-only tags (step c landed 2026-07-11). Aletheia emits each tenant-scoped tag once —
…:<code>— keyed oninternal_code. The transitional…:<domain>dual-emit that carried a mid-deploy Helios still tagging by domain was dropped once Helios prod was verified on code (see §7).
3.7 Contact Form — POST /sites/{code}/contact/¶
{
"name": "Marie Martin",
"email": "marie@example.com",
"phone": "+33612345678",
"message": "Je souhaite prendre rendez-vous...",
"website": "",
"elapsed_ms": 8421
}
Response: 201 Created with { "success": true } or 400 with { "errors": { ... } }.
Bot protection (B19 — honeypot baseline). website and elapsed_ms are
write-only bot signals (accepted, never persisted, never returned):
website— honeypot. The Helios form renders an off-screen decoy input namedwebsitethat real users never see or fill. Send"". A non-empty value is a bot. Fails safe — absent or blank is never a bot.elapsed_ms— client-measured milliseconds between form mount and submit. A value present and below ~2s is treated as a bot. Fails safe — an absent value is not a bot (so a client that stops sending the field degrades to honeypot-only protection rather than silently dropping every lead). Helios should still send it for the timing check to function.
A submission flagged as a bot (honeypot filled or present-and-too-fast) is
silently dropped: not persisted, but answered with the same
201 { "success": true } as a genuine submission so bots can't detect the
filter. Drops are logged server-side at WARNING (apps.websites.views) with the
reason and measured timing — monitor the drop rate per domain (an all-drop
spike for one domain flags a misconfigured client losing real leads); it is not
a per-drop Sentry event. Escalation path if spam still lands: per-practice
Cloudflare Turnstile (adds a client token → backend siteverify).
3.8 Careers — job_listing block + GET /sites/{code}/jobs/{slug}/¶
Careers (HR ATS Phase 3) is practice-scoped and surfaces only published
ats.Job rows (talent-pool anchors and draft/archived jobs never appear). The
careers nav entry ({ "label": "Recrutement", "url": "/recrutement/" }) is
appended to nav.main only when the practice has ≥1 published job (the
optional-careers toggle — same conditional pattern as Blog).
Listing — data-bound job_listing block (on the shared recrutement
template Page; getPage("recrutement") → BlockRenderer). Stored config is
heading / subheading / department (a Department name, case-insensitive) /
order (recent default, or title) / max_display. The API resolves the
practice's published jobs and appends jobs: [...]:
{
"slug": "secretaire-medicale",
"title": "Secrétaire médicale (H/F)",
"url": "/recrutement/secretaire-medicale/",
"role": "Secrétaire médicale",
"location": "Marseille",
"department": "Assistant(e)s",
"employment_type": "fulltime_permanent",
"employment_type_label": "CDI",
"work_mode": "on_site",
"work_mode_label": "Sur site",
"published_at": "2026-06-01T09:00:00+00:00"
}
Detail — GET /sites/{code}/jobs/{slug}/. The "honest exception" to
no-new-Helios-route (getPage is exact-slug and can't serve an arbitrary job
slug): a thin Helios /recrutement/[slug] route + this dedicated endpoint,
mirroring /equipe/[slug] → getTeamMember. Practice-scoped, published-only;
404 when the slug doesn't resolve. GET /sites/{code}/jobs/ returns the
same shape as a list (feeds generateStaticParams). Detail adds the full
posting body + employment details to the card fields above: description,
missions, requirements, benefits (light HTML), experience_level(_label),
education_level(_label), min_hours, max_hours, salary_min / salary_max
(decimal string or null), salary_period(_label), salary_currency,
number_of_openings, requires_cv, requires_cover_letter.
Revalidation. Jobs are data-bound, so a publish/close in the ATS is not a
CMS edit — an ats.Job post_save/post_delete signal busts the whole tenant
via the existing site:<code> tag (no new tag namespace; §3.6 unchanged). The
listing, detail, and conditional nav all live under site:<code>.
Apply — POST /sites/{code}/jobs/{slug}/apply/ (HR ATS Phase 3, slice 3).
The platform's first public multipart/form-data endpoint (DRF is globally
JSON-only; this view adds MultiPartParser). Helios renders the form in its
design system and POSTs same-origin to src/app/api/careers/[code]/apply/,
which streams the multipart body to Aletheia server-side (the browser never
touches Aletheia directly, mirroring the contact proxy — Helios never stores
the CV or the candidate). Aletheia owns all validation + storage: it creates an
always-new Candidate (source=career_site, no email-merge) + Application
in the practice graph, stores the CV in the documents repository, and enqueues
text + headshot extraction.
Form fields (multipart): first_name, last_name (required); email
(optional), phone, linkedin_url, cover_letter (text); cv (file, PDF/DOCX,
≤10 MB, content sniffed — required when requires_cv); consent (must be
true); plus the bot baseline website (honeypot) + elapsed_ms. Responses:
201 {"success": true} on accept and on a spam verdict (honeypot / too-fast
are silently dropped, indistinguishable to a bot); 400 {"errors": {...}} on
validation failure; 404 for an unknown/unpublished/other-practice slug; 429
when the per-IP rate limit (10/hour) is exceeded. CV size/type guards and the
rate limit are enforced server-side (no global default protects this surface).
The browser never reaches Aletheia directly, so the throttle keys on the
applicant's IP only because the Helios proxy forwards it as the first
X-Forwarded-For entry; Aletheia's NUM_PROXIES (env DRF_NUM_PROXIES) must
match the deployed proxy depth, or the limit collapses onto a single proxy IP.
3.9 Page Preview — GET /sites/{code}/pages/{slug}/preview/?token=…¶
Draft-aware twin of §3.2 for the CMS visual-editing preview pane
(roadmap/in-progress/websites-cms-visual-editing.md, Phase 1). Payload shape
is identical to §3.2 (same serializer + envelope, so Helios's PageSchema
parses both); the differences are access and scope:
- Serves pages of any status (
draft/review/published) — the public §3.2 endpoint stays published-only. Everything else matches the public lookup: soft-deleted pages 404,PageVisibilityopt-outs apply, andis_visible=falseblocks stay filtered (preview shows what the page would render once published). - Gated by a signed preview token (
?token=):django.core.signingHMAC over Aletheia'sSECRET_KEY, scoped to one tenant domain, 4 h TTL (apps/websites/preview.py). Invalid / expired / cross-domain token → 403. Domain-scoped, not page-scoped, so Draft Mode navigation within the previewed site keeps working on one token. - Response carries
Cache-Control: no-store— draft content must never sit in a shared cache. Helios fetches it uncached (no ISR tags).
Trust model: Aletheia both mints and verifies the token; Helios only relays it. No shared secret is needed for preview (unlike the §3.6 webhook).
Helios side (Draft Mode entry): a GET /api/preview?token=…&path=… route
handler validates the token by probing this endpoint, enables Next.js Draft
Mode, stores the token in an HttpOnly cookie, and redirects to path. While
Draft Mode is on, getPage fetches the preview endpoint (uncached) instead of
the ISR-cached §3.2 one — one branch, same renderer. getPageList / nav /
blog fetches stay published-only: a draft page's body previews, its nav
entry appears only on publish.
Block click-to-edit (visual editing Phase 3, postMessage): while Draft
Mode is on, Helios tags each rendered block wrapper with data-block-id="{id}"
(the ContentBlock.id from this payload) and mounts an overlay that, on a
block click, posts { type: "aletheia:block-click", blockId: <int> } to the
framing CMS. The message targets the CMS origin explicitly
(ALETHEIA_CMS_ORIGIN, never *); the CMS accepts it only from the previewed
site's origin, then opens that block's editor. Production DOM stays clean — the
attribute and overlay exist only under Draft Mode. No new HTTP endpoint: this
is a browser-only window.postMessage contract between the two origins.
4. Media URL Patterns¶
All media served via Cloudflare CDN. Aletheia generates URLs, Next.js consumes them.
/media/
├── practices/{practice_id}/
│ ├── logo.svg
│ ├── favicon.ico
│ └── hero/
│ ├── hero-640.webp
│ ├── hero-1280.webp
│ └── hero-1920.webp
├── dentists/{dentist_id}/
│ ├── portrait-300.webp
│ └── portrait-600.webp
├── pages/{page_id}/
│ ├── {block_id}-{filename}-{size}.webp
│ └── ...
├── case-studies/{id}/
│ ├── before-400.webp
│ ├── before-800.webp
│ ├── after-400.webp
│ └── after-800.webp
└── videos/
├── {id}.mp4
└── {id}.webm
5. Model Structure Suggestion for apps/websites/¶
This is the recommended model structure for Aletheia, based on all analysis work (data audit, visual design, content editing decisions, spec). Aletheia implementation may deviate — update §7 Changelog if the API shape changes.
5.1 Core Models¶
# apps/websites/models.py
class SiteConfig(AuditModel, SoftDeleteModel):
"""Per-practice website configuration. One per practice."""
practice = models.OneToOneField("practices.Practice", on_delete=models.CASCADE,
related_name="site_config")
domain = models.CharField(max_length=253, unique=True) # e.g., "cabinet-dentaire-aubagne.fr"
is_active = models.BooleanField(default=False)
# Theme — stored as OKLCH hue + chroma; full palette derived in CSS
primary_hue = models.FloatField(default=195) # 0-360
primary_chroma = models.FloatField(default=0.12) # 0-0.4
accent_hue = models.FloatField(default=70)
accent_chroma = models.FloatField(default=0.14)
# Branding — favicon references the shared MediaFile library. The LOGO is
# NOT here: it lives on Practice.logo_media (§5.5), the SVG brand mark
# feeding logo_url (§3.1) — header, Schema.org logo, and Helios's /api/og card.
favicon_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → favicon_url (R2, §3.1)
# SEO
city_name = models.CharField(max_length=100) # "Aubagne" — for URL generation
meta_title_template = models.CharField(max_length=200,
default="{page_title} | {practice_name}")
meta_description_default = models.TextField(blank=True)
# Features
enabled_locales = models.JSONField(default=list) # ["fr"] or ["fr", "en"]
enabled_services = models.JSONField(default=list) # ["implantologie", "esthetique", ...]
class PageTemplate(models.TextChoices):
HOMEPAGE = "homepage"
SERVICE_HUB = "service_hub" # L1 — category hub
SERVICE_DETAIL = "service_detail" # L2 — individual service
PRACTITIONER = "practitioner"
TEAM = "team"
VOTRE_BESOIN = "votre_besoin" # patient-centric need page
CABINET = "cabinet" # static: philosophie, technologies, tarifs, acces
RESULTS = "results" # cas cliniques + testimonials
BLOG_LIST = "blog_list"
BLOG_POST = "blog_post"
CONTACT = "contact"
LEGAL = "legal" # mentions legales, confidentialite
CUSTOM = "custom" # freeform
class PageStatus(models.TextChoices):
DRAFT = "draft"
REVIEW = "review"
PUBLISHED = "published"
class Page(AuditModel, SoftDeleteModel):
"""A website page. practice=NULL means default (shared content library)."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE,
null=True, blank=True, related_name="website_pages")
template = models.CharField(max_length=30, choices=PageTemplate.choices)
slug = models.SlugField(max_length=200)
title = models.CharField(max_length=200)
status = models.CharField(max_length=10, choices=PageStatus.choices, default="draft")
published_at = models.DateTimeField(null=True, blank=True)
# Blog featured image — a reference into the shared MediaFile library (one
# picker UX, reusable rows). Serialized as featured_image: {url, alt} | null.
featured_image_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → featured_image
# SEO overrides (optional — defaults derived from title + SiteConfig template)
meta_title = models.CharField(max_length=200, blank=True)
meta_description = models.TextField(blank=True)
# Cross-linking
related_pages = models.ManyToManyField("self", symmetrical=False, blank=True,
related_name="referenced_by")
# Service taxonomy — a service page IS its own node (ServiceCategory was
# collapsed into this self-FK). service_hub has parent=None; service_detail
# points at its hub. Set on shared (practice IS NULL) pages only; a
# per-practice override inherits structure from its shared twin by slug.
parent = models.ForeignKey("self", on_delete=models.PROTECT,
null=True, blank=True, related_name="children")
# Token-free nav label for service pages (was ServiceCategory.name). Nav uses
# this when set, else resolve_tokens(title).
menu_label = models.CharField(max_length=100, blank=True, default="")
class Meta:
unique_together = ["practice", "slug"]
# Restores the global-slug guarantee the retired ServiceCategory.slug
# gave: unique_together does not bind shared rows (NULL practices are
# distinct in Postgres).
constraints = [UniqueConstraint(fields=["slug"], condition=Q(practice__isnull=True),
name="uniq_shared_page_slug")]
ordering = ["title"]
def save(self, *args, **kwargs):
# Shared service pages auto-derive hub/detail from parent presence, so
# the CMS exposes a single "Service" choice. loaddata (raw save) bypasses
# this; per-practice rows keep their inherited template.
...
class BlockType(models.TextChoices):
HERO = "hero"
TEXT = "text"
TEXT_MEDIA = "text_media"
CARDS_GRID = "cards_grid"
SUB_PAGES_GRID = "sub_pages_grid"
EQUIPMENT_SHOWCASE = "equipment_showcase"
CABINET_GALLERY = "cabinet_gallery"
CTA = "cta"
FAQ = "faq"
TESTIMONIALS = "testimonials"
BEFORE_AFTER = "before_after"
STATS = "stats"
TEAM_GRID = "team_grid"
GALLERY = "gallery"
MAP = "map"
RELATED_SERVICES = "related_services"
VIDEO = "video"
QUOTE = "quote"
CONTACT_FORM = "contact_form"
JOB_LISTING = "job_listing"
class ContentBlock(AuditModel, SoftDeleteModel):
"""Flexible content block. JSON content validated per block_type."""
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"]
5.2 Service Taxonomy¶
The service taxonomy is not a separate model — a service page is its own
taxonomy node via Page.parent (see §5.1). The retired ServiceCategory mapped
1:1 onto its Page, so the convergence collapsed it:
ServiceCategory.url_prefix→ the hub page's slug (e.g.implant-dentaire); URLs are unchanged.ServiceCategory.name→Page.menu_label(token-free nav label).ServiceCategory.display_order→ a frozen code constantNOS_SOINS_NAV_SLUG_ORDER(serializers.py), likeCABINET_NAV_SLUG_ORDER/VOTRE_BESOIN_NAV_SLUG_ORDER.ServiceCategory.icon→ dropped (never read end-to-end).- The taxonomy
slug(e.g.implantologie) → dropped; its only consumer (the case-study filter) moved onto a realCaseStudy.service_pageFK.
5.3 Case Studies & Testimonials¶
class CaseStudy(AuditModel, SoftDeleteModel):
"""Before/after case linked to treatment types. Cross-page: displayed on
/resultats/cas-cliniques/ AND individual service detail pages."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE)
title = models.CharField(max_length=200)
# Before/after — references into the shared MediaFile library (one picker UX,
# reusable rows). Serialized as before_image / after_image: {url, alt} | null.
before_image_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → before_image
after_image_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → after_image
caption = models.TextField(blank=True)
# The service page this case illustrates (was a slug match against the
# retired ServiceCategory; now a real FK).
service_page = models.ForeignKey("Page", on_delete=models.SET_NULL,
null=True, blank=True, related_name="case_studies")
is_published = models.BooleanField(default=False)
class Meta:
ordering = ["-created_at"]
class Testimonial(AuditModel, SoftDeleteModel):
"""Anonymized patient testimonial."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE)
quote = models.TextField()
author = models.CharField(max_length=100) # "Marie D." (anonymized)
rating = models.PositiveSmallIntegerField(default=5,
validators=[MinValueValidator(1), MaxValueValidator(5)])
treatment_type = models.CharField(max_length=100, blank=True) # "Implant dentaire"
is_published = models.BooleanField(default=False)
class Meta:
ordering = ["-created_at"]
5.4 Media & Contact¶
class MediaFile(AuditModel, SoftDeleteModel):
"""Uploaded media (images, video, documents) served to Helios."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE)
file = models.FileField(upload_to="media/")
media_type = models.CharField(max_length=10,
choices=[("image", "Image"), ("video", "Video"), ("document", "Document")])
alt_text = models.CharField(max_length=200, blank=True)
caption = models.CharField(max_length=300, blank=True)
# Images are served as { url, alt }; Helios's next/image derives its own
# responsive srcset on the fly (no pre-baked variants — see §7, 2026-06-01).
class Meta:
ordering = ["-created_at"]
class ContactSubmission(AuditModel):
"""Contact form submission. Not soft-deletable (legal record)."""
practice = models.ForeignKey("practices.Practice", on_delete=models.CASCADE)
name = models.CharField(max_length=100)
email = models.EmailField()
phone = models.CharField(max_length=20, blank=True)
message = models.TextField()
is_read = models.BooleanField(default=False)
# No soft delete — submissions are a legal/audit record
5.5 Aletheia Core Model Changes (from data audit)¶
These are small migrations on existing models, not new models:
# apps/practices/models.py — ADD:
# Branding/website images are MediaFile library references (image-picker
# convergence, 2026-06-01); the {{practice.X.url}} tokens resolving them are
# unchanged. logo_media + hero_image_media / cabinet_hero_image_media /
# reception_photo_media / exterior_photo_media are FKs into the library.
# logo_media is the canonical practice logo (SVG): it resolves the §3.1
# `logo_url` and is edited on the practice form (2026-06-08).
logo_media = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+")
# CharField (not PhoneNumberField — no phonenumbers dep); validate_whatsapp_number
# enforces full international format (leading + and country code) so the Helios
# wa.me link can't silently break on a national-only number.
whatsapp_number = models.CharField(max_length=20, blank=True,
validators=[validate_whatsapp_number])
google_business_profile_url = models.URLField(blank=True)
# Extend equipment_type choices: add laser, microscope, cerec, meopa, piezo, guided_surgery
# apps/people/models.py:
# Portrait is a MediaFile library reference (one picker UX, reusable rows;
# image-picker convergence, 2026-06-01) on the *human* (P8, 2026-07-15), not the
# practitioner role. Serialized as team[].photo: {url,alt} | null.
# A human spans practices, so a portrait is a shared-scope (practice=NULL) row.
photo = models.ForeignKey("websites.MediaFile", on_delete=models.SET_NULL,
null=True, blank=True, related_name="+") # → team[].photo
# apps/dentists/models.py — the serializer's accessor, a read-through property
# onto the anchor since P8 (no column). Filter/select_related via `person__photo`.
@property
def photo_media(self):
return self.person.photo if self.person_id else None
title = models.CharField(max_length=10, blank=True) # "Dr", "Prof"
slug = models.SlugField(max_length=100, blank=True) # auto-generated from name
# DentistContract — ADD:
display_order = models.PositiveIntegerField(default=0)
6. Caching & Performance Contract¶
All tenant-scoped tags are code-scoped (…:<code>, internal_code lower-cased;
Phase B, 2026-07-10), plus the per-tenant site:<code> carried by every fetch.
page:<slug> (slug-keyed) and team (global) are the exceptions. See §3.6 for the
full tag vocabulary. (The transitional …:<domain> dual-emit was dropped in step c,
2026-07-11 — tags are …:<code> only.)
| Data | Cache tag(s) | Revalidation |
|---|---|---|
| SiteConfig | practice:<code>, site:<code> |
On SiteConfig save → revalidate tag |
| Page + blocks | page:<slug>, site:<code> |
On publish / block save → revalidate tag |
| Practice data | practice:<code>, site:<code> |
On Practice save → revalidate tag |
| Team | team, site:<code> |
On Dentist/DentistContract save → revalidate |
| Nav tree | nav:<code>, site:<code> |
On Page change → revalidate |
| Blog index | blog:<code>, site:<code> |
On blog_post Page publish → revalidate |
| Case studies | site:<code> |
On CaseStudy save → revalidate (inlined into getPage; no standalone fetch) |
| Testimonials | site:<code> |
On Testimonial save → revalidate (inlined into getPage; no standalone fetch) |
| Image swap | site:<code> |
On per-practice override/revert (one tenant) or shared library replace/reset (fan-out per active tenant) |
All revalidation flows through the webhook (§3.6). Aletheia fires the webhook on model-save signals + CMS image-swap views (via the Celery task, to avoid blocking the request).
7. Changelog¶
Track spec-breaking changes here. Format: date, what changed, why, frontend impact.
2026-07-11 — Phase B step (c): drop the transitional {domain} fallback + dual-emit
Completes the Phase B coordinated deploy (entry 2026-07-10 below), now that
Helios prod is verified serving on {code} (Phase C/D landed). Retires the two
transitional shims:
- DomainResolveMixin no longer falls back to a domain match — /sites/{code}/…
resolves by Practice.internal_code (case-insensitive) ONLY; an unknown code
is a plain 404. Passing a production .fr as the segment no longer resolves.
- Revalidation emits code tags only (…:<code>); the …:<domain> half of
scoped_tags is gone (revalidation.py + signals.py; helpers active_site_keys /
domain_for_practice removed — active_code replaces them).
Why: both shims existed solely to bridge the a→b deploy window; Helios is fully
on code so they are dead weight (and the domain fallback would mask a mis-keyed
request as a domain hit).
Frontend impact: NONE for a compliant Helios (already keys on code since 2026-07-10
step b). BREAKING only for any caller still passing {domain} as the URL segment or
still tagging fetches …:<domain> — both now silently miss. Ops: fire
revalidate_all_active_sites once after deploy (safety net; prod has 0 SiteConfig
rows so it is a no-op there today). Aether: the blackbox-health API probe was
repointed sites/<domain>/config/ → sites/<code>/config/ in the same pass.
2026-07-10 — Retire `cases:` / `testimonials:` revalidation namespaces
The `before_after` and `testimonials` blocks are server-resolved inline into the
getPage payload (Group 1 convergence, 2026-06-30), so Helios has no standalone
case-studies / testimonials fetch to tag — the `cases:<code>` / `testimonials:<code>`
tags Aletheia emitted on CaseStudy/Testimonial save matched zero cache entries
(silent no-op → edits stayed stale on the live site). Both namespaces are removed
from the vocabulary (Aletheia TAG_NAMESPACES, Helios src/lib/tags.ts, §3.6). Those
saves now bust the whole tenant via the existing `site:<code>` tag, the same coarse
lever every other data-bound block (rooms/equipment/jobs) already uses.
Why: orphaned tags left over from the inline convergence; not a Phase B regression.
Frontend impact: none functional — Helios never fetched with these tags. The dead
`casesTag`/`testimonialsTag` builders + their conformance-test entries are removed.
The standalone `GET /sites/{code}/case-studies|testimonials/` endpoints are unaffected.
2026-07-10 — Tenant keyed by {code}, domain optional (Phase B — BREAKING)
Identity decoupling (roadmap/backlog/helios-domain-onboarding.md Phase B). The
API path segment {domain} becomes {code} = Practice.internal_code (case-
insensitive) on EVERY /sites/… endpoint; SiteConfig.domain becomes optional
(string | null, blank until cutover) and stops being the identity — it is used
only for the canonical URL. Revalidation tags re-key from …:<domain> to
…:<code> for the tenant-scoped namespaces (nav/practice/blog/site — cases/
testimonials since retired); page:<slug> and team are unchanged. Webhook body field
practice_domain → practice_code (inert either way). Preview token is now
code-scoped.
Why: onboarding/preview no longer needs a real .fr — a site is created,
previewed and served by its stable code, and the domain is set only at cutover.
Frontend impact: BREAKING — Helios must (a) key all fetches + the [practice]
route segment + proxy rewrite on code, (b) re-key its src/lib/tags.ts fetch
tags to <code>, (c) fall back to the serving host for canonical/OG/sitemap/
robots and gate shouldIndexSite on domain-set when config.domain is null.
Coordinated deploy order (a→b→c):
(a) Aletheia — resolver + tags on code, dual-emit …:<code> + …:<domain>,
AND accept both {code}/{domain} URL keys (transitional fallback). [DONE]
(b) Helios — code fetch-tags + sites/{code} + _sites/[practice] rewrite.
(c) Aletheia — drop the {domain} URL fallback + the …:<domain> dual-emit
half. [DONE 2026-07-11 — see entry below]
Fire revalidate_all_active_sites after (b) to catch content edited mid-swap.
2026-07-03 — Envelope meta.cached_at renamed to meta.generated_at
The field always held the serialization time (timezone.now()), never a cache
timestamp — Aletheia renders fresh per request; caching is downstream
(Helios/ISR). Renamed to stop it misleading. No known consumer reads it.
Frontend impact: NONE observed (Helios does not consume meta.*). If any code
reads meta.cached_at, switch it to meta.generated_at.
2026-07-03 — Block click-to-edit postMessage protocol (additive, browser-only, §3.9)
Under Draft Mode, Helios tags each block wrapper with data-block-id and posts
{ type: "aletheia:block-click", blockId } to the framing CMS (targeted at
ALETHEIA_CMS_ORIGIN); the CMS opens that block's editor. Visual-editing
Phase 3 (roadmap websites-cms-visual-editing.md).
Frontend impact: ADDITIVE — no HTTP surface change; production DOM unchanged
(data-block-id + overlay exist only in Draft Mode). Pure window.postMessage.
2026-07-03 — Page preview endpoint + Next.js Draft Mode (additive, §3.9)
New GET /sites/{domain}/pages/{slug}/preview/?token= — §3.2's payload for
any-status pages, gated by a signed domain-scoped token (Aletheia mints and
verifies; Helios relays). Feeds the CMS visual-editing preview pane
(roadmap websites-cms-visual-editing.md, Phase 1).
Frontend impact: ADDITIVE — public endpoints unchanged. Helios adds a
/api/preview Draft Mode route, a draft branch in getPage (uncached preview
fetch), and relaxes frame-ancestors for the Aletheia CMS origin so the
preview iframe can embed the site.
2026-06-30 — Group 1: six data-bound blocks now resolved server-side (additive on the wire)
team_grid, testimonials, before_after, map, contact_form, related_services were
"reference blocks": they shipped config-only JSON and Helios joined the domain
data itself (prefetchReferenceData / the page-level practice prop / client-side
slug resolve). The block-render convergence (roadmap
websites-helios-block-render-convergence.md, Group 1) moves that join into
Aletheia resolvers, so each block now arrives with its data inline:
- team_grid → members[] (TeamMemberSerializer, GET .../team/ shape)
- testimonials → testimonials[] (TestimonialSerializer, GET .../testimonials/)
- before_after → case_studies[] (CaseStudySerializer, GET .../case-studies/)
- map → practice (PracticeDataSerializer, GET .../practice/)
- contact_form → practice (same PracticeDataSerializer object)
- related_services→ services[] (sub_pages_grid card shape, authored order)
- job_listing → jobs[] (open ats.Job cards; HR ATS Phase 3, see §3.8)
These keys are config-only persistence: stored content carries config only, the
payload key is injected on read (and stripped on save) — same contract as
sub_pages_grid / equipment_showcase / cabinet_gallery.
Frontend impact: ADDITIVE on the Aletheia side — the standalone endpoints
(team/testimonials/case-studies/practice) all still exist, so Helios keeps
rendering off prefetchReferenceData until the paired Helios PR (Phase B) switches
each renderer to read block.content.<key>. After that cutover, prefetchReferenceData
+ getTestimonials + getCaseStudies are deleted and these blocks cost zero extra
round-trips. The `client_joined` provenance class is now empty: every block-path
payload binds server-side.
2026-03-27 — text.body is HTML, not Markdown
Content blocks with block_type "text" and "text_media" return body as HTML
(e.g., <p><strong>...</strong></p>), not raw Markdown. Content is authored
as Markdown in Aletheia /web/ editor, converted to HTML on save.
Frontend impact: Helios renders body with sanitized dangerouslySetInnerHTML,
not a Markdown parser.
2026-03-27 — stats block field rename: items → stats (done)
Stats block content uses { stats: [...] }, not { items: [...] }.
Frontend impact: none (fixed before first Helios consume).
2026-03-28 — theme.heading_font and theme.body_font added to SiteConfig
Two new string fields in the theme object: heading_font and body_font.
Values are Google Font family names (e.g., "DM Serif Display", "Inter").
Defaults: heading_font="DM Serif Display", body_font="DM Sans".
Frontend impact: Helios should load these via next/font/google and apply
to --font-heading / --font-body CSS variables.
2026-03-30 — All 16 block content shapes documented in §3.2
Added "ContentBlock content shapes by block_type" section with JSON
examples and field descriptions for all 16 block types. Identifies
reference blocks (testimonials, before_after, team_grid, map) that
require additional API calls to fetch data.
Frontend impact: Helios can now implement all block renderers from
this single document without inspecting Aletheia schemas.
2026-03-30 — Team endpoint: show_on_website filter + sort_mode ordering
GET /sites/{domain}/team/ now filters by show_on_website=true (new field
on DentistContract). Ordering: manual-sorted members first (by
display_order), then alphabetical (by last name). New field sort_mode
("manual" or "alpha") returned per team member.
Frontend impact: Helios should display team members in the order returned
by the API (no client-side sorting needed). The sort_mode field is
informational — no frontend logic change required.
2026-04-03 — practice_code and umami_website_id added to SiteConfig
Two new fields at the top level of GET /sites/{domain}/config/:
- practice_code (string): internal code from Practice model (e.g., "CDA", "VSM")
- umami_website_id (string): per-domain Umami tracking ID (empty if not set)
Frontend impact: Helios should read umami_website_id from config response
instead of NEXT_PUBLIC_UMAMI_WEBSITE_ID env var (fall back to env var if empty).
practice_code is informational, available for any frontend logic that needs it.
2026-04-15 — portal object added to nav response
GET /sites/{domain}/nav/ now returns a third key "portal" alongside "main"
and "cta". Shape: { label, url, enabled }. Currently enabled=false (hardcoded).
Frontend impact: Helios should render a "Mon Espace" element in the header
(near the CTA, not in main nav). While enabled=false, show as disabled/greyed
with "Bientot disponible" tooltip. No routing needed until portal is built.
2026-05-12 — build_website seeding + URL resolution changes
Three related changes from the shared-content-library work:
1. New page template "espace_patient" (URL prefix /espace-patient/).
Placeholder template for now — content will be fleshed out from the
content guidelines in a follow-up. Currently seeded as a shared page
and may be returned by GET /sites/{domain}/pages/ for any practice.
Frontend impact: Helios should treat it as a renderable CMS page
(do not add to EXCLUDED_TEMPLATES). Generic block rendering is fine
until a dedicated layout lands; expect the template to gain
bespoke blocks/sections later.
2. city_name is slugified (not lowercased) when used in URLs.
Service-hub URLs (build_page_url) and the "Nos Soins" nav children
now use slugify(site_config.city_name) instead of city_name.lower().
No-op for ASCII single-word cities ("Aubagne"); for cities with
accents or spaces ("Aix-en-Provence", "Saint-Étienne") the URL slug
now strips accents and collapses spaces to hyphens.
Frontend impact: none for existing practices. New practices: trust
the URL strings returned by the API; do not re-derive from city_name
client-side.
3. Service-hub slug lookup accepts the city-suffixed URL.
Hubs are stored at slug "implant-dentaire" but served at
"/implant-dentaire-{city}/". GET /sites/{domain}/pages/{slug}/ now
strips the trailing -{city_slug} and retries for the service_hub
template, matching the URL pattern Helios's catch-all generates.
Frontend impact: none — Helios's existing "last URL segment as slug"
extraction continues to work.
Also: page title and excerpt are now token-resolved server-side in
PageList / PageDetail responses (same {{practice.X}} / {{site.X}}
tokens already applied to block content). Behavior-compatible — Helios
receives resolved strings either way.
2026-05-21 — nav response: portal removed; charte-qualité added; "Le Cabinet" now 5-child
Three nav reconciliation changes from the shared-base buildout
(roadmap/done/websites-shared-base-buildout.md, decisions N4/C2/N6):
1. Drop the "portal" / "Mon Espace" placeholder.
GET /sites/{domain}/nav/ no longer returns a "portal" key. The
/mon-espace/ surface is covered by /espace-patient/ (Optional
protected shared page); the placeholder was a divergent header
element with no consumer route.
Frontend impact: Helios drops PortalButton / MobilePortalButton.
Navigation type loses the `portal` field. The 2026-04-15 changelog
entry above is superseded.
2. Restore "Notre charte qualité" to the "Le Cabinet" dropdown.
"Le Cabinet" children now have 5 entries (was 4): Notre philosophie,
Nos technologies, Notre charte qualité, Tarifs, Accès & informations.
The charte page was already seeded by build_website; only the menu
was missing it.
Frontend impact: none — Helios renders the dropdown from the API
payload directly.
3. Helios divergent fallbacks dropped.
Helios no longer ships hardcoded fallback navs (DEFAULT_NAV in
Nav.tsx, the inline array in Footer.tsx, MOCK_NAV in lib/mock-data.ts).
The `navigation` prop is now required on both Nav and Footer; the
layout passes the live API payload to both. The mock-data file is
deleted.
Frontend impact: any new consumer must obtain `navigation` from
`getNavigation(domain)`; there is no static fallback.
2026-05-21 — shared base + per-practice override semantics documented
No payload change. §2 of this contract now documents the page-resolution
rules that PageListView / PageDetailView have always followed: per-practice
row wins at a given slug, shared row otherwise; new shared pages propagate
automatically; overrides survive shared deletion; canonical slugs cannot
be renamed (enforced by Page.clean against RESERVED_PAGE_SLUGS).
Frontend impact: none — describes existing server behavior.
2026-05-21 — Two new BlockTypes: sub_pages_grid + equipment_showcase
Shared-base buildout decisions C9 + C3. Both are data-bound blocks: the
CMS stores config-only JSON, and the API injects the joined payload at
read time. Seed wires them into the cabinet hub and /cabinet/nos-technologies/
respectively, replacing hand-authored cards_grid blocks that had to be
re-edited on every shared-base change.
- sub_pages_grid: serializer queries sibling Page rows by template,
appends `pages: [{slug,title,excerpt,url}, ...]`. Per-practice override
wins on slug; PageVisibility (N8, Pass 3) will tighten the filter once
it ships.
- equipment_showcase: serializer joins PracticeEquipment for the active
practice, appends `equipment: [{equipment_type,equipment_type_label,
manufacturer,model_name,photo}, ...]`. Filters: status=active,
equipment_types whitelist, show_only_with_photo, max_display.
Frontend impact: Helios needs two new renderers (SubPagesGrid,
EquipmentShowcase). Until the components ship, falling back to a generic
cards-style renderer that consumes `pages` / `equipment` works — both
payloads carry titles, URLs/photos, and excerpts/labels.
2026-05-21 — Helios sub_pages_grid + equipment_showcase renderers shipped
No payload change. Closes the frontend half of the 2026-05-21 entry above:
the dedicated renderers landed in Helios (commit f670a81) under C11, so the
generic cards-style fallback is no longer in use.
2026-05-21 — nav response: Le Cabinet + Votre Besoin dropdowns now dynamic
per practice (N1 + N2).
GET /sites/{domain}/nav/ no longer hardcodes the "Le Cabinet" children.
The dropdown is built from visible `cabinet`-template Page rows
(per-practice override + shared library), filtered by PageVisibility
(Optional pages a practice has opted out of are dropped). The hub link
is always emitted; `children` is only present when at least one
sub-page is visible — when a practice hides every Optional cabinet
sub-page, "Le Cabinet" renders as a flat link to /cabinet/. Same rule
applies to "Votre Besoin" (visible `votre_besoin` Page rows, ordered
by the VOTRE_BESOIN_NAV_SLUG_ORDER constant).
Frontend impact: none — Helios already renders dropdowns directly
from the API payload. Labels match the practice's Page.title (so a
per-practice override that renames "Notre philosophie" propagates
to the menu).
2026-05-21 — New BlockType cabinet_gallery + /cabinet/visitez-le-cabinet/
page + Helios renderer (C1).
Shared-base buildout decision C1. cabinet_gallery is a data-bound block:
the CMS stores config-only JSON, the API injects the joined payload at
read time. Seed wires it into the new /cabinet/visitez-le-cabinet/ shared
page; new `PracticeRoom.photo` and `PracticeRoom.is_website_visible` model
fields drive the joined list.
- cabinet_gallery: serializer joins PracticeRoom rows for the active
practice where is_website_visible=true and status=active, appends
`rooms: [{name, room_type, room_type_label, family_label, description,
photo}, ...]`. Filters: optional `show_only_with_photo` (default true),
optional `max_display`. `family_label` is one of "Zones cliniques",
"Zones de support", "Zones d'accueil des patients" — Helios can group
on this when `group_by="room_family"`.
- /cabinet/visitez-le-cabinet/: new Optional shared page (template=cabinet).
Replaces the three retired stand-alone room pages from the original
plan (folded into a single gallery per the DROP list in
websites-shared-base-buildout.md).
Frontend impact: Helios renderer for `cabinet_gallery` shipped alongside
the API change (BlockRenderer.tsx). Practices fill rooms (with photos)
in Aletheia and they surface here automatically — zero per-page CMS
authoring.
2026-05-28 — PatientNeed model retired (see
roadmap/done/websites-retire-patient-need.md).
The PatientNeed model is dropped (DeleteModel migration). Its two original
responsibilities had both already been revoked: the nav-derivation rule was
inverted to gate on visible Page rows (2026-05-21/05-25), and the "Soins
associés" cross-links on votre-besoin pages were always hand-authored
cards_grid blocks, never resolved from the PatientNeed.service_pages M2M.
The model's last live consumer was the votre-besoin dropdown sort key, now
a frozen slug tuple (VOTRE_BESOIN_NAV_SLUG_ORDER) — the same nav-ordering
pattern as CABINET_NAV_SLUG_ORDER.
Frontend impact: none. The /sites/{domain}/nav/ payload is unchanged — the
"Votre Besoin" dropdown still emits the same 5 entries ({label, url}) in the
same canonical order, derived from visible votre_besoin Page rows.
2026-05-29 — ServiceCategory collapsed into the Page tree (see
roadmap/done/websites-service-category-page-tree-convergence.md).
The ServiceCategory model is dropped. A service page IS its own taxonomy node:
Page gains a `parent` self-FK (service_hub → no parent; service_detail → its
hub) + an optional token-free `menu_label`. `url_prefix` already equalled the
hub slug, so URLs are byte-identical; `display_order` became the frozen
`NOS_SOINS_NAV_SLUG_ORDER` code constant; `icon` and the `implantologie`-style
taxonomy slug were dropped. "Nos Soins" nav is now built from service Pages
(page-driven, like Le Cabinet / Votre Besoin), so an orphan node can no longer
surface a 404 menu link.
Spec-breaking, but no site is live → coordinated cutover, no transition window.
Verified against the Helios `develop` checkout — **no Helios code change is
required**:
- **Case-study payload**: the per-case `service_category_name` is dropped, with
no replacement field. Helios never read it (`getCaseStudies`'s return type
reads only `before_image` / `after_image` / `caption` / `treatment_url`), and
cases are already returned service-filtered via `?service_category=`, so a
per-row service identifier is redundant. No-op for the frontend.
- **before_after `service_category_filter`**: same block key, still passed to
`GET /case-studies/?service_category=<value>` (both the block key and the
query-param name are unchanged). `BlockRenderer` reads the block value and
forwards it verbatim, so the value shift to a service-page slug
(`implant-dentaire`, never the old `implantologie`) flows through with no
code change; no old taxonomy slug is hardcoded anywhere in Helios.
- **Nav / page URLs**: unchanged by construction (hub slug = old url_prefix;
detail = `/{hub-slug}-{city}/{detail-slug}/`). Helios switches on the
`service_hub` / `service_detail` template values (both kept) and consumes nav
`url` fields; it never composes service URLs from `url_prefix`. No
`service_category` field ever appeared in nav or page payloads.
- **enabled_services**: values are now hub-page slugs; Helios only declares the
field as a type, never branches on its values — no impact.
Frontend impact: none (verified). The only required action is operational:
deploy + reseed each environment from the regenerated fixture
(`restore_website_seed --clean-full`) and re-verify a service hub/detail page,
the "Nos Soins" dropdown, and a before_after gallery on staging.
2026-06-01 — image objects trimmed to { url, alt } (variant pipeline retired)
(see roadmap/done/websites-retire-image-variant-pipeline.md).
Every image object across the API — page/block images, team photos,
case-study before/after, equipment + room photos — now returns only
`{ url, alt }`. The pre-baked `srcset` map and base64 `blur_placeholder`
(LQIP) are gone, along with the `variants` / `source_hash` columns and the
Celery/signal machinery that produced them.
Why: Helios is self-hosted Next.js standalone with image optimization on, so
`next/image` generates its own responsive `srcset` and negotiates WebP/AVIF
on the fly from the source URL. Verified field-by-field against the Helios
`develop` checkout (`9908fe4`): no component ever read `srcset` or
`blur_placeholder` (declared in `src/lib/types.ts` but unused), so the
pre-baked payload was dead weight.
Spec-breaking but harmless; no site is live → coordinated cutover, no
transition window. Frontend impact: none (verified). Helios's `ImageData`
drops the now-unused `srcset?` / `blur_placeholder?` fields; `decorative?`
stays. If a `placeholder="blur"` consumer ever appears, a single base64
string per image can be re-added cheaply (no columns/signals/Celery).
2026-06-01 — payloads trimmed to what Helios consumes; treatment_url + decorative
(see roadmap/done/websites-trim-api-payloads.md).
Re-verified field-by-field against the Helios `develop` checkout (`0695431`).
Each dropped field had 0 grep hits in `src/app`+`src/components`+`src/lib`, or
appeared only as a `src/lib/types.ts` type declaration that no component reads.
Dropped (over-fetch — Helios never read them):
- SiteConfig: `practice_id`, `practice_code` (keys off `domain`), `favicon_url`
(favicon hardcoded), `enabled_locales` / `enabled_services` (no i18n; services
nav is page-driven), `theme.heading_font` / `theme.body_font` (fonts hardcoded
to `--font-dm-sans` / `-serif`), `seo.meta_title_template` (server already
applies it in the page `seo`).
- Page: `related_pages` (+ the `RelatedPageSerializer`). The `Page.related_pages`
M2M is unchanged model-side; it is simply no longer serialized.
- Practice: the `payment` block (`accepts_*`, `regulation_sector`,
`third_party_payer`) and the emergency contact (`emergency_phone` +
`emergency_contact_type`).
- Team: `work_schedule` (a heavy per-practitioner nested array shipped on every
team-page load), `sort_mode`, `display_order` — ordering is still applied
server-side; only the redundant payload is gone.
- CaseStudy: `id`, `title`, `is_published` (the queryset already filters
`is_published=True`, so it was always `true`).
- Testimonial: `id`.
Added:
- CaseStudy `treatment_url`: the public URL of the case's linked `service_page`
(the treatment page it illustrates), or `null` when unlinked. Lets Helios
deep-link a before/after case to its service page. Migration-free — resolves
the existing `CaseStudy.service_page` FK; the `/case-studies/` view now passes
`site_config` to the serializer so service-hub URLs are city-suffixed.
- `decorative?: boolean` on editor-supplied block images (`hero`, `text_media`,
`gallery` items, `cards_grid` cards). Present only when set; marks an image as
purely presentational so Helios renders it via `<DecorativeImage>` (`alt=""` +
`role="presentation"`) per SEO-AUDIT-2026-05 Helios-010. As part of this,
`cards_grid` card images were reshaped from a legacy flat URL string into the
`{ url, alt, decorative? }` object the schema + Helios `ServiceCard` expect.
Spec-breaking but harmless (removing fields a typed client never reads + adding
two). No site is live → coordinated cutover, no transition window. Helios change
(separate commit): `src/lib/types.ts` drops the now-unsent fields, and the
before/after renderer consumes `treatment_url`. Operational action: deploy +
reseed each environment and re-verify a team page, a before_after gallery (incl.
the treatment link), and a config-driven page on staging.
2026-06-01 — SiteConfig branding now references the MediaFile library (internal)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 first slice).
`SiteConfig.logo` / `favicon` ImageFields became `logo_media` / `favicon_media`
FKs into the shared `MediaFile` library, so the one library picker drives them
and a logo is a reusable library row (eventually a `practice=NULL` curated
default) instead of a per-config upload.
API impact: none. `config.logo_url` is unchanged — still an absolute URL string
or `null`, now resolved from `logo_media.file.url`. `favicon_url` was already
dropped (favicon hardcoded in Helios), so the favicon FK is CMS-only. Verified
against the Helios `develop` checkout: the only `logo_url` reader
(`generatePracticeSchema` → JSON-LD `image`) sees an identical value.
Migration carries any existing `logo` / `favicon` upload into a `MediaFile` row
in place (no on-disk copy). No live tenant → apply the migration + reseed; no
transition window.
2026-06-01 — Practice branding/website images now reference the MediaFile library (internal)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 Practice slice).
`Practice.logo` + the four website-content ImageFields (`hero_image`,
`cabinet_hero_image`, `reception_photo`, `exterior_photo`) became `<name>_media`
FKs into the shared `MediaFile` library, so the one library picker drives them
and each becomes a reusable library row (eventually a `practice=NULL` curated
default) instead of a per-practice upload.
API impact: none. These fields are never sent on the wire — they are surfaced
only through shared-content `{{practice.X.url}}` tokens and the OG-card render.
The token names are unchanged (`{{practice.hero_image.url}}` etc.); the
resolver now reads `<name>_media.file.url`, so seeded content and the fixture
need no edit. Verified against the Helios `develop` checkout.
Migration carries any existing upload into a `MediaFile` row in place (no
on-disk copy). No live tenant → apply the migration + reseed; no transition
window.
2026-06-01 — Page.featured_image now references the MediaFile library (+ shape fix)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 Page slice).
`Page.featured_image` (the blog featured image) became `featured_image_media`,
an FK into the shared `MediaFile` library, so the one library picker drives it
and a featured image becomes a reusable library row (eventually a `practice=NULL`
curated default) instead of a per-page upload.
API impact — shape FIX (no transition window needed; no live tenant). On the
wire `featured_image` is now an image object `{ url, alt }` or `null`, resolved
via `build_image_object` from `featured_image_media.file` — matching Helios's
documented `ImageData | null` type for `page.featured_image` (consumed by the
blog post header and `BlogPostCard`). It was previously a bare `ImageField` that
DRF serialized to a *string* URL — a latent mismatch with what Helios reads
(`page.featured_image.url` / `.alt`); the FK conversion is the moment it is
corrected. Verified against the Helios `develop` checkout.
Seed fixture regenerated (`shared_content.yaml` holds Page rows): every shared
page's `featured_image: ''` became `featured_image_media: null` (no shared page
carries a featured image). Migration carries any existing upload into a
`MediaFile` row in place (no on-disk copy).
2026-06-01 — CaseStudy before/after now reference the MediaFile library (internal)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 CaseStudy slice).
`CaseStudy.before_image` / `after_image` ImageFields became `before_image_media`
/ `after_image_media` FKs into the shared `MediaFile` library, so the one library
picker drives them and a before/after photo is a reusable library row (eventually
a `practice=NULL` curated default) instead of a per-case upload. The pair stays
required at the form level (`CaseStudyForm`); the DB FK is nullable (`SET_NULL`).
API impact: none. On the wire `before_image` / `after_image` are unchanged —
still a `{ url, alt }` image object or `null` (they were already
`SerializerMethodField`s resolving via `build_image_object`); only the source
moved from `<field>.file` to `<field>_media.file`. Verified against the Helios
`develop` checkout: `getCaseStudies` reads `before_image` / `after_image` /
`caption` / `treatment_url` and sees identical shapes.
CaseStudy is not in `shared_content.yaml`, so no fixture regen. Migration carries
any existing upload into a `MediaFile` row in place (tag `treatment`; no on-disk
copy). No live tenant → apply the migration + reseed; no transition window.
2026-06-01 — Dentist portrait now references the MediaFile library (internal)
(see roadmap/done/websites-image-picker-convergence.md, Phase P1 Dentist slice —
the last P1 field; the dentists app is the first adopter of the websites
`MediaLibrarySelect` widget).
`Dentist.photo` ImageField became `photo_media`, an FK into the shared `MediaFile`
library, so the one library picker drives it and a portrait is a reusable library
row (eventually a `practice=NULL` curated default) instead of a per-dentist upload.
A dentist has no practice FK (it works at practices through DentistContract,
possibly several), so a portrait is a person-level, *shared-scope* asset: the
migration files existing uploads as `practice=NULL` library rows (tag `team`) and
the CMS picker opens on the shared library.
API impact: none. On the wire `team[].photo` is unchanged — still a `{ url, alt }`
image object or `null` (`TeamMemberSerializer` already resolved it via
`build_image_object`); only the source moved from `dentist.photo` to
`dentist.photo_media.file`. Verified against the Helios `develop` checkout: the
`/equipe/` pages and `TeamCard` read `member.photo.url` / `member.photo.alt` and
see identical shapes.
Dentist is not in `shared_content.yaml`, so no fixture regen. Migration carries any
existing upload into a `MediaFile` row in place (no on-disk copy). No live tenant →
apply the migration + reseed; no transition window.
2026-06-02 — block images may store a library `ref` / `media_id` (internal, no wire change)
(see roadmap/in-progress/websites-platform-image-library.md, C10 step 1 — the
curated platform-image-library reference mechanism.)
The `hero` / `text_media` / `cards_grid` image object may now carry, alongside the
legacy `url`, one of two optional authoring keys: `ref` (a `MediaFile.library_key` —
a curated platform-library slot) or `media_id` (a direct `MediaFile` pk). A new
practice-aware resolver in `ContentBlockSerializer` turns either into the standard
`{ url, alt }` at read time: `ref` prefers the practice's own override row over the
shared `practice=NULL` default (per-practice swap without a page fork); `media_id`
resolves that exact row; an unresolved/not-yet-seeded slot becomes `null` (Helios's
existing graceful fallback — plain text / gradient).
API impact: none. `ref` / `media_id` are server-side authoring keys — Helios never
sees them; on the wire every block image stays `{ url, alt } | null` (`decorative?`
preserved). No new endpoint, no shape change. The new `MediaFile.library_key` column
is internal. No live tenant → migrate + reseed; no transition window.
2026-06-02 — empty `hero` blocks resolve a fallback image (internal, no wire change)
(see roadmap/in-progress/websites-platform-image-library.md, C10 — generic hero
fallbacks.)
A `hero` block whose image resolves to nothing (no `ref` / `media_id` / `url`, or a
not-yet-seeded `ref`) now falls back server-side instead of emitting `null`: first to
the page's **parent hero** (a `service_detail` borrows its hub's banner — a different
image from its own body illustration, so no on-page repeat), then — for a parentless
page (the utility pages) — to a **generic library fallback** picked by `Page.template`
(the 3 seeded `ai_hero_*_fallback` images). Resolution stays practice-aware (an
inherited hub hero honours a per-practice override) and is read-only — nothing is
stamped onto the page.
API impact: none. A hero image that used to come back `null` (gradient) may now come
back `{ url, alt }`; the wire shape is unchanged and Helios keeps its gradient when
even the fallback is unseeded. No endpoint or schema change.
2026-06-02 — platform-library images served at content-addressed URLs (internal, no wire change)
(see roadmap/in-progress/websites-platform-image-library.md, C10 step 9a — cache
propagation.)
Curated platform-library files (`practice=NULL` `MediaFile` rows seeded from
`apps/websites/content/library/`) are now stored and served at
`/media/library/<library_key>.<hash8>.<ext>`, where `<hash8>` is the first 8 hex of the
bytes' SHA-256 — instead of the former stable `/media/library/<library_key>.<ext>`. The
URL is stable while the bytes are stable and changes the instant they change, so a
curated re-render or CMS shared-replace busts the browser / `next/image` optimizer /
`/api/media` proxy caches that key on the URL (the proxy's `immutable, max-age=1y` is now
correct rather than a trap). The `library_key` and the repo / `manifest.generated.json`
filenames stay **hashless** — only the served path carries the digest.
API impact: none. Every image object stays `{ url, alt } | null`; `url` was always an
opaque absolute string and Helios treats it as one (its `/api/media` proxy + `next/image`
handle any path). The one consumer-side caveat: nothing may assume the hashless
`library/<key>.<ext>` name — in particular a future build-time `public/media` snapshot
must key on the full served path. NB: this fixes only the *image-bytes* cache; making a
change appear on a **statically-rendered prod** page still needs on-demand revalidation
(the half-built `POST /webhooks/revalidate/`, C10 step 9b — not yet wired in Helios).
2026-06-03 — ISR revalidation webhook finished + reframed as a Helios receiver
(see roadmap/in-progress/websites-helios-revalidation-webhook.md, C10 step 9b).
Completes the half-built Aletheia→Helios on-demand revalidation pipeline so a CMS
content change re-renders the affected pages on a statically-generated prod site
without a rebuild — the page-HTML half of the 9a content-addressing fix.
- Reframed (§2 + §3.6): `POST /webhooks/revalidate/` is a **Helios-hosted** route
handler (`src/app/webhooks/revalidate/route.ts`) that **Aletheia calls** — it was
mis-filed under Aletheia's endpoint table. Not part of `/api/v1/websites/`.
- Auth moved to a header: shared secret in `X-Revalidate-Secret`, constant-time
compared against Helios's `REVALIDATION_SECRET` (= Aletheia's
`HELIOS_REVALIDATION_SECRET`). Dropped from the JSON body. 401 (no retry) on
bad/missing secret, 400 on bad payload, 200 on success.
- Tag namespace reconciled to **domain-scoped** on the Aletheia side (`signals.py`):
`nav` / `practice` / `cases` / `testimonials` now emit `…:<domain>` to match
Helios's fetch tags (were `…:<pk>`, which matched nothing); `page:<slug>` + `team`
already aligned; `blog:<domain>` added on `blog_post` publish.
- New `site:<domain>` tag carried by **every** Helios fetch (`src/lib/api.ts`), so a
single `revalidateTag('site:<domain>')` re-renders a whole tenant. Used for image
swaps (per-practice override/revert → that one domain; shared library replace/reset
→ fan-out per active domain), which previously fired nothing. Coarse-first;
per-referencing-page targeting is a later refinement.
Frontend impact: new route handler + one extra tag per fetch (additive — existing
tags unchanged). Operational: Aether provisions `HELIOS_REVALIDATION_URL`
(→ `https://<helios-host>/webhooks/revalidate/`) + a real shared secret on both sides
per env. No live tenant → clean cutover, no transition window.
2026-06-03 — documented `Page.url` (§3.2) + Team `visible` (§3.4) — already emitted, no wire change
Two fields the API has always sent are now in the spec examples, closing a
Helios-audit gap where they were consumed but undocumented:
- `url` (top-level on the page payload) — canonical front-end path from
`PageDetailSerializer.get_url` → `build_page_url`; always present. Helios
uses it for sitemap + JSON-LD.
- `visible` (boolean) on every team member — Helios's `Practitioner |
PractitionerHidden` discriminant. `true` on the `/team/` list (pre-filtered
to active + `show_on_website`); `false` only on the `/team/{slug}/` detail
endpoint, which serves departed/hidden members with a minimal fallback shape
(now documented).
Also added a cross-repo note in §3.2: a new `block_type` must ship with its
paired Helios `BlockRenderer` case — Helios fails open (renders nothing) on
unknown types.
Frontend impact: none — clarification only; the wire payload is unchanged.
2026-06-04 — sub_pages_grid cards now carry an `image` (additive)
Each card in the data-bound `sub_pages_grid` `pages[]` array gains an
`image` key — the child Page's `featured_image_media` resolved to the
standard `{ url, alt } | null` object (same shape as a `cards_grid` card),
so the auto child-grid can render thumbnails instead of text-only cards.
`null` when the child has no featured image. Practice-awareness is handled
by the existing slug-collapse (a practice's own page row, with its own
featured image, wins over the shared row) — no separate library swap path.
Frontend impact: additive only — the SubPagesGrid renderer may now show a
thumbnail when `card.image` is non-null; existing text-only rendering still
works (image is optional).
2026-06-04 — sub_pages_grid `parent_slug` now honored (config only, additive)
The `parent_slug` config key — declared since the block shipped but never read
by the resolver — now selects "children-of-a-named-page" mode: it lists the
published, active children of that page via the `Page.parent` tree, taking
precedence over `parent_template`. `parent_template` keeps the by-type
(sibling-directory) behavior. The two are mutually exclusive in CMS-authored
content (one unified picker emits one or the other). The resolved `pages[]`
output shape is unchanged.
Frontend impact: none — wire payload (the `pages[]` cards) is identical; only
which pages populate the list can differ, driven by CMS config.
2026-06-07 — contact form: hcaptcha_token dropped → honeypot baseline (B19, breaking field swap)
POST /sites/{domain}/contact/ no longer accepts/expects `hcaptcha_token`.
Replaced by two write-only bot signals (accepted, never persisted, never
returned), see §3.7:
- `website` (string) — honeypot decoy; send "". Non-empty ⇒ bot. Fails safe.
- `elapsed_ms` (int) — ms from form mount to submit; Helios should send it.
Present-and-below-~2000ms ⇒ bot. Fails SAFE: absent ⇒ NOT a bot (a client
that stops sending it degrades to honeypot-only, never silent lead loss).
A bot-flagged submission (honeypot filled OR present-and-too-fast) is silently
dropped: not persisted, but answered with the same 201 {"success": true} so
bots can't detect the filter. Drops are logged server-side at WARNING (logger
apps.websites.views) with reason + measured timing; monitor the drop RATE per
domain (it is not a per-drop Sentry event — bot volume would flood it).
hCaptcha is dropped entirely — no sitekey/secret, and the CSP hCaptcha origins
were removed on the Helios side. Escalation if spam lands: per-practice
Cloudflare Turnstile (re-adds a client-token → siteverify shape; not built yet).
Frontend impact: Helios contact form already updated — stops sending
`hcaptcha_token`, now sends `website` + `elapsed_ms`. An old client that sends
only the four base fields now PERSISTS normally (absent elapsed_ms fails safe);
it just loses the timing check. No live tenant yet, so clean cutover.
2026-06-08 — logo_url now resolves from Practice.logo_media (internal)
`SiteConfig.logo_media` is dropped; the practice logo lives solely on
`Practice.logo_media`. Before this, two independently-nullable columns held a
"logo": the OG card (`og_image_url`) rendered from `Practice.logo_media` while
`logo_url` resolved from `SiteConfig.logo_media` — so the two SEO surfaces
could disagree (or one be blank) with no sync. Both now read one field.
Rationale: a logo is practice-brand identity, not website appearance, and is
the durable home for future non-web uses (PDF letterheads, reports). It is now
edited on the practice form alongside the other branding images, not on the
site-config form.
API impact: none. `logo_url` keeps its shape — absolute URL string or `null`,
now `practice.logo_media.file.url`. The sole Helios reader
(`generatePracticeSchema` → JSON-LD `image`, page.tsx) is unchanged; the Nav
"logo" was already practice-name text, never an image. `favicon_media` stays
on SiteConfig (CMS-only). No live tenant → apply the migration + reseed; no
transition window.
2026-06-08 — og_image_url dropped; logo is now SVG; Helios owns the OG card
`SiteConfig.og_image_url` is removed from the config payload (§3.1), and
Aletheia's server-side OG renderer (`apps/websites/og_image.py`, PIL) is
deleted along with its signals (`invalidate_og_card`) and tests. The brand
logo is now an **SVG** vector on `Practice.logo_media` (help text updated;
no schema change beyond help_text).
Why: Helios generates its own per-tenant 1200×630 social card at `/api/og`
(Satori/resvg), composed from `logo_url` + `practice_short_name` + city +
theme — the brand design system lives on the frontend, not in a backend
rasterizer. Helios had already cut over (its `SiteConfig` type no longer
carries `og_image_url`), so Aletheia was emitting a dead field and rendering
an unused PNG.
API impact: `og_image_url` is gone — it was no longer read. `logo_url` is
unchanged on the wire (absolute URL or `null`) but should now point at an
SVG; Helios consumes it for the header, the Schema.org `logo`, and as the
source for its `/api/og` card. The Schema.org `image` is that generated card
(`${siteUrl}/api/og`), not a practice photo. The hero (`hero_image_media`)
feeds only the homepage `og:image`/`twitter:image`; no JSON-LD role, so no
crop requirement on Aletheia's side. No live tenant → clean cutover.
2026-06-08 — R1 + R2: reopen two API-trim regressions (re-serialize pending fields)
Two fields the 2026-06-01 trim (`4ec1c13`) cut on a "0 grep in Helios = dead"
rule were in fact pending, not dead — `spec_helios.md` still asks for both.
Re-added (serializer-only; the Practice / SiteConfig columns were never
dropped, so no migration):
- R2 — `config.favicon_url` (§3.1): absolute URL string or `null`, resolved
from `SiteConfig.favicon_media.file` (the library FK that replaced the old
`favicon` ImageField). Spec §8 ("Brand: …favicon") wants a per-tenant
favicon; the trim had assumed Helios hardcoded it.
- R1 — `practice.payment` block (§3.3): `accepts_carte_vitale` / `accepts_check`
/ `accepts_cash` / `accepts_credit_card` (booleans) + `regulation_sector` /
`third_party_payer` (strings or `null`). Spec §6.1 puts `paymentAccepted` in
the `Dentist` JSON-LD and §3 lists payment methods as core practice info; the
trim left B3's structured data incomplete.
Still dropped: the emergency contact — `emergency_phone` *and*
`emergency_contact_type` (R3, separate decision — regular `phone` is a
passable fallback; reopen later if the spec'd distinct urgence field is
wanted). Reopen both fields together (the type is the human label for the
number), not the phone alone.
Frontend impact (Helios, separate commit): `SiteConfigSchema` adds
`favicon_url` and wires `metadata.icons` per tenant; `PracticeDataSchema` adds
the `payment` block and the practice-page `Dentist` JSON-LD emits
`paymentAccepted`. No live tenant → coordinated cutover, no transition window.
2026-06-08 — payment_facilities field + visible payment surfacing (R1 follow-up)
New `Practice.payment_facilities` CharField (migration 0020). Shown by default:
the field's non-blank `default` is applied to existing and new rows via the
column default (no data migration), so every practice shows the line until an
editor clears the field — then it serializes `null` (§3.3 `payment` block) and
is hidden. R1 had only wired the JSON-LD; this surfaces payment info visibly
while keeping it in sync with the structured data:
- `map` block gains `show_payment` / `show_regulation_sector` /
`show_third_party_payer` / `show_facilities` toggles (schemas/map.json),
rendering the same `practice.payment` fields the JSON-LD reads.
- Shared tarifs page now uses `{{practice.payment_methods}}` (HTML <ul>,
omits unaccepted methods) + `{{practice.payment_facilities}}` (HTML <p>,
self-wrapping so a cleared field leaves no orphan markup) tokens instead of
hardcoded text — labels match the JSON-LD (Carte Vitale /
Carte bancaire / Chèque / Espèces). `third_party_payer` enum codes
(national / national_and_additional) map to patient-facing FR labels;
unknown/custom values pass through.
Gating differs by surface **by design**: the `map` block is opt-in per `show_*`
toggle (default off, like its access toggles), while the dedicated tarifs page
renders methods + the shown-by-default facilities line unconditionally. Same
`practice.payment` source either way, so the two surfaces never disagree.
Frontend impact (Helios): `PracticeData.payment` adds `payment_facilities`;
`MapContentSchema` adds the four `show_*` toggles; the map block renders the
payment column; label logic centralised in `src/lib/payment.ts` (must stay
aligned with `apps/websites/tokens.py`).
2026-06-08 — R3: reopen the emergency contact (re-serialize as a paired block)
The last of the three 2026-06-01 trim regressions. `emergency_phone` +
`emergency_contact_type` were cut on the "0 grep in Helios = dead" rule, but
spec line 87 lists the emergency number as a *distinct* contact field and the
footer §ASCII (399-400) has a dedicated "Urgences" entry. Re-added
serializer-only (the Practice columns were never dropped, no migration):
- `practice.emergency` (§3.3): `{ phone, type }`, or the whole block is `null`
when no `emergency_phone` is set; `type` is `null` when only the phone is.
Surfaced as a **pair** (not flat `emergency_phone` / `emergency_contact_type`
keys) — the two are edited and displayed as a unit on the detail page.
Frontend impact (Helios, separate commit): `PracticeDataSchema` adds the
`emergency` block; the footer renders an "Urgences" entry **only when
`emergency.phone` is set** (opt-in per practice), with `type` as the line
descriptor when present; the ContactForm urgence callout prefers
`emergency.phone`, falling back to the regular `phone`. No
live tenant → coordinated cutover, no transition window. Completes the trim
reopen series (R1 payment / R2 favicon / R3 emergency).
2026-07-15 — team portrait now resolves from Person.photo (internal, no wire change)
(see roadmap/done/people-master-data.md, P8 — the last duplicate identity
field on `Dentist`.)
`Dentist.photo_media` is gone as a column; the canonical portrait is
`people.Person.photo`, the same FK into the shared `MediaFile` library, on the
human rather than the practitioner role. One face per human, shared by every
role they hold (dentist / user / holder) instead of one per role row. The 2026-06-01
entry above moved this field's *storage* to the library; this moves its *owner*.
API impact: none — the second time this field has moved with no wire change, and
for the same reason. `team[].photo` is still a `{ url, alt }` image object or
`null`: `TeamMemberSerializer` resolves it via `build_image_object`, and
`dentist.photo_media` survives as a read-through property onto the anchor, so the
serializer line is byte-identical. Helios never knew where the file came from.
Verified live rather than by inspection: with the photo written **only** to
`Person.photo` (no dentist column exists to write), `GET /api/v1/websites/sites/
{code}/team/` returned `"photo": {"url": "…", "alt": "Dr SIMON DAVID"}` for the
practitioner and `"photo": null` for the photoless ones — the documented shape at
§"team[]" above.
Internal-only follow-ups, no Helios action: querysets can no longer filter or
`select_related` `photo_media` (a property has no column) — `TeamListView` /
`TeamDetailView` now `select_related("dentist__person__photo")` and
`websites.completeness` reads `person.photo_id`. The picker moved from
`DentistForm` to `PersonForm` (gate `people_manage`).
Dentist is not in `shared_content.yaml`, so no fixture regen. Migrations
(`people.0008`, `dentists.0038`+`0039`) carry every existing portrait onto the
anchor in place — the `MediaFile` rows themselves are untouched, so no on-disk
copy and no URL churn. No live tenant → apply + reseed; no transition window.