# Public content CMS and API contract — approved for implementation **Superseded for CMS implementation on 2026-09-27.** The owner chose [direct-save content editing](bundle1-simplification-plan.md) and rejected the draft/publish/revision and custom admin workflow below. This file records the earlier approval only; use the course correction plan and current [checklist](todo.md) for new work. Date: 2026-09-24; revised 2026-09-25; approved by the owner through `bundle0-review.md` on 2026-09-26. Status: **Bundle 0 contract approved for implementation; no public API or schema exists yet**. Inputs: `content-inventory.md`, `product-review.md`, frontend `PRODUCT.md` and `DESIGN.md`, existing Django Ninja routing in `guilan-ace-backend/config/api.py`. ## Ownership boundary The CMS owns association identity, approved public navigation/footer/contact entries, and page introductions/SEO. It owns ordered, visible sections of `home` and `about` from the finite registry below. It does **not** own event/blog records, registration success markdown already stored on each event, auth/form/admin text, payment outcomes, or free-form layouts. Stable slugs initially: `home`, `about`, `events`, `blog`. ## Proposed persistent records | Record | Fields and constraints | | --- | --- | | `SiteIdentity` singleton | Stable site key and nullable `published_revision_id`. Singleton enforced in service/admin and database. | | `SiteRevision` | Immutable snapshot of `official_name` (required, max 180), `short_name` (required, max 80), `faculty_subline` (max 140), `description` (max 500), logo media FK and required alt when logo exists; approved contacts/social entries and navigation entries with fixed IDs, label, sort order and visibility; footer copy; default SEO. Tracks editor, publisher and timestamps like page revisions. | | `EditorialPage` | Unique immutable `slug` from an allowed registry; `page_kind` (`sections` or `listing_intro`); current published revision FK nullable. Slug never derived from editable title. | | `PageRevision` | Page FK, monotonic revision number, `draft` or `published` snapshot, title, intro, SEO title/description/OG media, creator, publisher, timestamps, source seed key/version and immutable content checksum. Store each publish as a new immutable snapshot; do not mutate the public revision in place. | | `PageSection` within revision | Stable section key, approved type, position integer, visible boolean, bounded type-specific text fields, optional media FK and alt text. Unique `(revision, key)` and `(revision, position)`; type/slug registry restricts allowed combinations. | | `MediaAsset` | Uploaded file, MIME signature, bytes, dimensions, alt, license/source and approval status. Logo and OG media use the same managed-media path; no frontend build URL becomes permanent content. | | `PublicationAudit` | Actor, site or page target, from/to revision, operation (`create_draft`, `update_draft`, `publish`, `restore`), timestamp. Append only. | Proposed section registry aligned to the owner's fixed Home order. Section records supply editorial text; they do not duplicate event/post records or select a card layout, colors, or button styling: | Page | Section key/type | Required | Editable content | Order/visibility | | --- | --- | --- | --- | --- | | `home` | `hero` | Yes | Heading (max 120), lead (max 500), primary/secondary CTA labels (max 40 each), each CTA target selected from fixed `about`, `events`, `blog` route IDs; optional approved image and alt | First and visible. Site name/subline comes from `SiteRevision`, not a second copy. | | `home` | `featured_events` | Yes | Section heading (max 90), introduction (max 300), all-events link label (max 40) | Second and visible. Event cards, posters, dates, prices and status read the existing events API, not this section. | | `home` | `featured_posts` | Yes | Section heading (max 90), introduction (max 300), all-posts link label (max 40) | Third and visible. Post cards, titles, categories, images and links read the existing blog API, not this section. | | `home` | `participation` | Yes | Heading (max 90), body (max 600), up to 2 CTA labels (max 40 each) with fixed route IDs; optional approved image and alt | Fourth and visible, before the shared footer. | | `about` | `identity` | Yes | Heading (max 120), introduction (max 600); official name/mark from `SiteRevision` | First and visible. | | `about` | `mission`, `activities`, `values`, `committees`, `affiliations`, `faq` | No | Heading (max 90), body (max 1200) or typed entries (max 20, each title/question max 120 and text/answer max 600) | Reorderable after identity or hidden. Unmapped statistics and affiliation claims are omitted from the initial seed. | | `about` | `contact` | Yes | Heading (max 90); approved visible contact entries from `SiteRevision` | Last and visible; contacts have one source of truth. | `events` and `blog` use bounded title/intro/SEO fields and no sections. Every allowed section key is unique within its page revision; publishing rejects missing required sections or an invalid order. The Home order is fixed by the owner's design requirement; About optional sections remain reorderable/hideable. Section text is plain text. Navigation destination IDs and route paths are code-owned; editors can edit display labels/order/visibility on the approved destination list, not supply arbitrary in-app hrefs. The initial public contact set is the owner's About-page selection. This registry and field limits were accepted for T06 implementation on 2026-09-26. The Home featured-event source needs no new CMS event fields. The first release selects the next three published future events by start time; if none exist, it shows recent completed events with an archive label. Blog uses published `is_featured` posts, then recent published posts as a fallback. Empty results show a short honest message and a link to the full listing. This selection policy was accepted on 2026-09-26; it never changes registration, payment, or publication rules. ## Edit, preview, publish and restore | Actor | Draft | Preview | Publish | Restore | Public read | | --- | --- | --- | --- | --- | --- | | Anonymous | No | No | No | No | Published only | | Authenticated nonstaff | No | No | No | No | Published only | | `content_editor` staff group | Yes, on allowed pages | Own/authorized drafts | No | No | Published only | | `content_publisher` staff group | Yes | Yes | Yes | Yes | Published only | | Superuser | Yes | Yes | Yes | Yes | Published only | Use explicit Django permissions (`change_content_draft`, `preview_content_draft`, `publish_content`, `restore_content`) and group assignment; `is_staff` alone does not grant publishing. A transaction checks permission and locks the page row, writes an immutable revision and swaps `published_revision_id` atomically. Restore creates a new revision from the chosen prior snapshot, preserving history. Publisher and editor identities remain visible in admin audit. Preview requires authentication, authorization and a draft revision; no public route accepts a draft ID. ## Proposed API v1 Mounted under the existing `/api/` Ninja root in `config/urls.py`: - `GET /api/v1/content/site`: only published identity. `200` with `revision`, `published_at`, names, logo URL/alt, approved contacts, navigation route IDs/labels/order/visibility, footer and SEO defaults. `404` when there is no published identity. - `GET /api/v1/content/pages/{slug}`: only published page. `200` with `slug`, `revision`, `published_at`, `title`, `intro`, `seo`, and ordered visible `sections`; `404` for unknown or unpublished-only slugs. Hidden sections never leave the response. - `GET /api/v1/content/preview/pages/{slug}?revision=`: authenticated authorized staff only; exact draft revision; `403` for unauthorized staff and `404` for missing draft. The server must not expose draft existence to anonymous callers. Set `Cache-Control: private, no-store`, `Vary: Cookie, Authorization`, and `X-Robots-Tag: noindex, nofollow`. - `GET /api/v1/content/preview/site?revision=`: the same staff permission and no-store rules for a site-identity draft, including logo, navigation, contacts, footer and SEO defaults. Public site data remains at its published revision during preview. The [backend API schema draft](../../guilan-ace-backend/docs/content-api-schema-draft.md) contains complete site, Home and About payload examples. Use canonical path from the fixed slug map, not an arbitrary editor URL. Proposed public cache: `Cache-Control: public, s-maxage=60, must-revalidate` plus `ETag` from the published revision and response checksum. The frontend CMS adapter performs uncached server reads so a second page cache does not extend that 60-second bound. Publish/restore invalidates application cache and requests frontend revalidation for `/`, `/about`, `/events`, `/blog`, and shared shell; revalidation failure is logged while the shared API cache still expires within 60 seconds. The deployment proxy already routes `PathPrefix(/api)` to the Django service; the release rehearsal must verify the deployed configuration. `404` is explicit for empty site/page; the frontend flag remains on legacy rendering until approved content exists. Draft preview uses no shared cache at any layer. Unknown slugs and unpublished-only pages have the same public `404` shape. ## Media, validation and seed rules - Reject unsafe markup; text fields are plain text or tightly validated Markdown only where specified. Reject non-HTTP(S) links except explicitly supported `mailto:` and `tel:` contact types. Verify media by content signature, dimensions and byte limit; require meaningful alt for informative images. - Public images use backed-up Django media URLs. Optional image absent means the section remains legible with text alone. The source logo checksum and license/source are recorded before import. - The T12 manifest records stable keys, source path, exact legacy value, checksum where relevant, destination, approval actor/status, and exclusion reason. T13 dry run emits create/skip/conflict and field diffs, never overwrites an editor-modified row by default, and creates drafts only. ## Paper walkthrough and later release gates 1. Editor changes `home.hero` in a draft. Public `GET` stays at published revision 3. Authorized preview of draft 4 returns new copy with `no-store`; anonymous preview returns no draft content. 2. Publisher reviews preview at 320px and desktop, publishes. Transaction swaps revision pointer to 4 and logs publisher. Public cache invalidates or expires within agreed TTL. Event and payment records are untouched. 3. Publisher restores revision 3. Service creates revision 5 with revision 3 content, logs restore, swaps published pointer. Readers see revision 5; revision 4 stays in history. 4. At 320px, long Persian identity wraps, navigation remains reachable without covering content, optional image leaves no blank frame, and hidden optional About sections do not leave layout gaps. At desktop, Home section order matches the owner's fixed sequence and the approved visual direction. The earlier concepts were rejected. Frontend `DESIGN.md` records the approved logo-led Home/About direction. **T05 and Checkpoint B were accepted on 2026-09-26:** fixed Home order, featured-item selection policy, section registry and field limits above, `content_editor`/`content_publisher` permission policy, exact preview behavior, fixed-route payloads, and the 60-second API cache rule. **Later release gates:** assigning actual staff accounts, importing licensed media, deployment-proxy rehearsal, and mapping disputed content claims before seed publication.