Data model¶
Every table this project owns, what it holds, how it links, and what the database refuses to store. The tables below are generated from the models, so this page cannot fall behind a migration.
Where the rest lives
Columns, types and help text are in src/data/models/, one module per area, and each model
carries a docstring explaining itself. The reasoning behind the shape is in the
ADRs. The request path that reads these tables is in
Architecture, and the payloads the app receives are in the
API reference.
Regenerate this page with make data-model-doc after any model change. A test fails the suite
if you forget.
How to read it¶
One app owns every table. They all live in src/data/, and nothing outside it defines a model.
Reads go through src/readers/, writes through src/actions/. Framework tables that Wagtail,
Django auth and django-taggit create are not listed here.
Publishable means Wagtail draft and live. A publishable model has a draft version and a live
version, and readers only ever see live. One exception, on purpose: a piece unpublished after a
reader saved it stays in that reader's bookmark list with is_available: false, so the save does
not look lost. They get the card, never the body. A journey chapter is not publishable on its own:
it is a child row captured inside the journey's revision, so publishing the journey publishes its
chapters with it.
A row that points at "a piece" carries one nullable foreign key per piece type, plus a check
constraint on how many may be set. Bookmarks and audio take exactly one. A reflection takes at most
one, because its link is cleared when the piece is deleted and the reflection stays. A single
content_type column would have been shorter but would lose the database-level integrity this
keeps (ADR-0008).
A journey chapter owns its passage. Passage.journey_only marks a passage made for a chapter.
A chapter accepts only those, and a unique constraint keeps each one with a single chapter. The
discovery queries filter on the flag. Writings and articles stay shareable
(ADR-0015).
Deleting follows three rules. A chapter PROTECTs the piece it uses, so an editor cannot
delete a passage that a journey is built on. Everything a reader owns CASCADEs from the reader,
so deleting an account takes the reader's data with it. A reflection's link to its piece is
SET_NULL, because what the reader wrote outlives the piece it answered.
Some absences are deliberate. There is no current_chapter column: the next chapter is derived
from what the reader has completed (ADR-0011). There
is no audio staleness flag: a row exists only for approved audio, and staleness is a hash
comparison at read time. There is no mid-chapter cursor: position inside a chapter stays on the
device.
Every publishable model also carries latest_revision, live_revision and locked_by from
Wagtail. They are the same on every such model, so they are left out of the tables below.
Join tables are drawn, not listed. A many-to-many field creates its own table, for example themes on a passage. The tables below list declared models only, so the link shows up as an edge in the diagram rather than as a row of its own.
Taxonomy¶
The vocabulary every other table points at. Themes are the worlds the app skins itself to and are loaded by sync_themes; tags are editor-made and grouped into categories.
| Table | Model | Kind | What it holds |
|---|---|---|---|
data_tag |
Tag |
Table | A value within a Tag Category. |
data_tagcategory |
TagCategory |
Table | An editor-created container for tags, e.g. 'Emotional Register', 'Depth'. |
data_theme |
Theme |
Table | A world a user enters (not a tag). |
erDiagram
data_tagcategory ||--o{ data_tag : "category"
What the database refuses to store
| Table | Rule |
|---|---|
data_tag |
unique on name, category |
data_tagcategory |
name is unique |
data_theme |
slug is unique |
data_theme |
name is unique |
Content¶
The four readable pieces. A Passage is a quote, a Writing is a letter, essay or book chapter, an Article is editorial by a named contributor, and a Book is the physical work a Writing came out of.
| Table | Model | Kind | What it holds |
|---|---|---|---|
data_article |
Article |
Publishable | Editorial (non-canon) writing by a named contributor — distinct from Lewis's corpus (Passage/Writing) and its source Works (Book). |
data_book |
Book |
Publishable | The Work: reference/purchase object — not readable in-app (discovery + buy handoff). |
data_passage |
Passage |
Publishable | The atom: a standalone, consumable Lewis passage. |
data_passage_related |
RelatedPassage |
Child of data_passage |
One editor-curated related quote on a Passage, in drag order. |
data_writing |
Writing |
Publishable | A full readable Lewis unit — letter, essay, or book chapter — discriminated by kind (ADR-0006: one model, not three). |
erDiagram
users |o--o{ data_article : "author"
data_theme |o--o{ data_article : "primary_theme"
data_theme }o--o{ data_article : "themes"
data_tag }o--o{ data_article : "tags"
data_theme }o--o{ data_book : "themes"
data_tag }o--o{ data_book : "tags"
data_book |o--o{ data_passage : "source"
data_writing |o--o{ data_passage : "source_writing"
data_theme |o--o{ data_passage : "primary_theme"
data_theme }o--o{ data_passage : "themes"
data_tag }o--o{ data_passage : "tags"
data_passage ||--o{ data_passage_related : "passage"
data_passage ||--o{ data_passage_related : "related"
data_book |o--o{ data_writing : "source"
data_theme |o--o{ data_writing : "primary_theme"
data_theme }o--o{ data_writing : "themes"
data_tag }o--o{ data_writing : "tags"
What the database refuses to store
| Table | Rule |
|---|---|
data_article |
slug is unique |
data_article |
source_url is unique |
data_book |
slug is unique |
data_passage |
slug is unique |
data_writing |
slug is unique |
Curation¶
What an editor arranges: journeys, their chapters, and the Home screen. Every row here points at content rather than holding any.
| Table | Model | Kind | What it holds |
|---|---|---|---|
data_home |
Home |
Singleton | Singleton settings record for the Home page — one row, one CMS screen. |
data_home_featured_book |
HomeFeaturedBook |
Child of data_home |
One Book featured on Home, in editor drag order. |
data_home_featured_journey |
HomeFeaturedJourney |
Child of data_home |
One Journey featured on Home, in editor drag order. |
data_home_featured_passage |
HomeFeaturedPassage |
Child of data_home |
One Passage in the Home hero queue, in editor drag order. |
data_journey |
Journey |
Publishable | A guided course through Lewis's work: an editor-curated, ordered set of chapters built around one central question, so a reader meets the pieces in a considered order instead of searching for them. |
data_journey_chapter |
JourneyChapter |
Child of data_journey |
One chapter of a Journey. |
data_related_journey |
RelatedJourney |
Child of data_journey |
One editor-curated 'try this next' journey, in drag order — surfaces on the finished-state landing only. |
erDiagram
data_theme |o--o{ data_home : "featured_theme"
data_home ||--o{ data_home_featured_book : "home"
data_book ||--o{ data_home_featured_book : "book"
data_home ||--o{ data_home_featured_journey : "home"
data_journey ||--o{ data_home_featured_journey : "journey"
data_home ||--o{ data_home_featured_passage : "home"
data_passage ||--o{ data_home_featured_passage : "passage"
data_theme |o--o{ data_journey : "primary_theme"
data_theme }o--o{ data_journey : "themes"
data_journey ||--o{ data_journey_chapter : "journey"
data_passage |o--o{ data_journey_chapter : "passage"
data_writing |o--o{ data_journey_chapter : "writing"
data_article |o--o{ data_journey_chapter : "article"
data_journey ||--o{ data_related_journey : "from_journey"
data_journey ||--o{ data_related_journey : "to_journey"
What the database refuses to store
| Table | Rule |
|---|---|
data_journey |
slug is unique |
data_journey_chapter |
slug is unique |
data_journey_chapter |
journey_chapter_exactly_one_piece: check |
data_journey_chapter |
journey_chapter_unique_passage: unique on passage |
data_related_journey |
related_journey_unique_pair: unique on from_journey, to_journey |
Narration¶
Audio for one field of one piece. AudioAsset is what the app plays; AudioTake and AudioSegment are studio-only candidates the reader never sees.
| Table | Model | Kind | What it holds |
|---|---|---|---|
data_audio_asset |
AudioAsset |
Table | The approved narration of one CMS field — the row the app serves. |
data_audio_segment |
AudioSegment |
Table | One chunk of a long-form take (CSL-367). |
data_audio_take |
AudioTake |
Table | A candidate recording in the Audio Studio. |
erDiagram
data_passage |o--o{ data_audio_asset : "passage"
data_writing |o--o{ data_audio_asset : "writing"
data_article |o--o{ data_audio_asset : "article"
data_journey_chapter |o--o{ data_audio_asset : "journey_chapter"
data_journey |o--o{ data_audio_asset : "journey"
users |o--o{ data_audio_asset : "approved_by"
data_audio_take ||--o{ data_audio_segment : "take"
data_passage |o--o{ data_audio_take : "passage"
data_writing |o--o{ data_audio_take : "writing"
data_article |o--o{ data_audio_take : "article"
data_journey_chapter |o--o{ data_audio_take : "journey_chapter"
data_journey |o--o{ data_audio_take : "journey"
users |o--o{ data_audio_take : "created_by"
What the database refuses to store
| Table | Rule |
|---|---|
data_audio_asset |
audio_asset_exactly_one_target: check |
data_audio_asset |
audio_asset_unique_passage_section: unique on passage, field, block_id, when passage is set |
data_audio_asset |
audio_asset_unique_writing_section: unique on writing, field, block_id, when writing is set |
data_audio_asset |
audio_asset_unique_article_section: unique on article, field, block_id, when article is set |
data_audio_asset |
audio_asset_unique_journey_chapter_section: unique on journey_chapter, field, block_id, when journey_chapter is set |
data_audio_asset |
audio_asset_unique_journey_section: unique on journey, field, block_id, when journey is set |
data_audio_segment |
audio_segment_unique_index: unique on take, index |
data_audio_take |
audio_take_exactly_one_target: check |
Reader data¶
Everything owned by a reader rather than an editor. None of it reaches the Wagtail CMS. Reflections, bookmarks, entitlements and the event log are visible read-only in the Django admin for support; users is the one table staff can change there. processor_deletions outlives its reader: it holds a deleted account's UID until RevenueCat confirms the delete.
| Table | Model | Kind | What it holds |
|---|---|---|---|
data_bookmark |
Bookmark |
Table | One piece, saved once by one reader. |
data_journey_chapter_completion |
ChapterCompletion |
Table | One chapter, finished once by one reader. |
data_journey_progress |
JourneyProgress |
Table | One reader's run at one journey. |
data_reader_free_journey |
ReaderFreeJourney |
Table | The journey a reader read first, which is theirs for good. |
data_reflection |
Reflection |
Table | One reader's answer to one piece, kept as written. |
processor_deletions |
ProcessorDeletion |
Table | ProcessorDeletion(id, processor, external_uid, attempts, last_error, created_at, updated_at) |
reader_entitlements |
ReaderEntitlement |
Table | One reader's grant of one RevenueCat entitlement, mirrored verbatim. |
subscription_charges |
SubscriptionCharge |
Table | One store charge, from a RevenueCat purchase or renewal webhook (CSL-383/425). |
subscription_events |
SubscriptionEvent |
Table | The RevenueCat webhook ledger: idempotency, audit, and support inspection (CSL-355 AC5). |
subscription_notices |
SubscriptionNotice |
Table | One compliance email to a subscriber, and the record that it went (CSL-383/425). |
users |
User |
Table | Email-keyed user for Firebase auth (ADR-0003). |
erDiagram
users ||--o{ data_bookmark : "user"
data_passage |o--o{ data_bookmark : "passage"
data_writing |o--o{ data_bookmark : "writing"
data_article |o--o{ data_bookmark : "article"
users ||--o{ data_journey_chapter_completion : "user"
data_journey_chapter ||--o{ data_journey_chapter_completion : "chapter"
users ||--o{ data_journey_progress : "user"
data_journey ||--o{ data_journey_progress : "journey"
users ||--o{ data_reader_free_journey : "user"
data_journey ||--o{ data_reader_free_journey : "journey"
users ||--o{ data_reflection : "user"
data_passage |o--o{ data_reflection : "passage"
data_writing |o--o{ data_reflection : "writing"
data_article |o--o{ data_reflection : "article"
users ||--o{ reader_entitlements : "user"
users |o--o{ subscription_charges : "user"
users |o--o{ subscription_notices : "user"
subscription_charges |o--o{ subscription_notices : "charge"
What the database refuses to store
| Table | Rule |
|---|---|
data_bookmark |
bookmark_exactly_one_target: check |
data_bookmark |
bookmark_unique_reader_passage: unique on user, passage, when passage is set |
data_bookmark |
bookmark_unique_reader_writing: unique on user, writing, when writing is set |
data_bookmark |
bookmark_unique_reader_article: unique on user, article, when article is set |
data_journey_chapter_completion |
chapter_completion_unique_reader: unique on user, chapter |
data_journey_progress |
journey_progress_unique_reader: unique on user, journey |
data_journey_progress |
journey_progress_claim_needs_completion: check: emblem_claimed_at is empty or completed_at is set |
data_reader_free_journey |
reader_free_journey_one_per_reader: unique on user |
data_reflection |
reflection_at_most_one_target: check |
data_reflection |
reflection_unique_reader_passage: unique on user, passage, when passage is set |
data_reflection |
reflection_unique_reader_writing: unique on user, writing, when writing is set |
data_reflection |
reflection_unique_reader_article: unique on user, article, when article is set |
processor_deletions |
unique_processor_deletion: unique on processor, external_uid |
reader_entitlements |
unique_reader_entitlement: unique on user, entitlement_key |
subscription_charges |
transaction_id is unique |
subscription_events |
rc_event_id is unique |
subscription_notices |
dedupe_key is unique |
users |
email is unique |
users |
external_uid is unique |
Support pages¶
Editorial copy for the standalone legal and support pages.
| Table | Model | Kind | What it holds |
|---|---|---|---|
data_supportinfosettings |
SupportInfoSettings |
Singleton | Editorial copy for the standalone Legal and Support pages (CSL-282, CSL-334): About, Privacy Policy, Terms of Service, and Support. |
Paywall¶
Editorial copy for the Journey Premium paywall — headline, benefit lines, and the renewal-reminder window, the one number this and the CSL-383 reminder job share.
| Table | Model | Kind | What it holds |
|---|---|---|---|
data_paywallsettings |
PaywallSettings |
Singleton | Editorial copy for the Journey Premium paywall. |