Flaxon 0.3.0 and Teloce-Py 0.3.0 are available on PyPI. Release instructions are maintained on this site; source links identify the framework snapshot. Check version and upgrade guidance.
13 | CMS help and sample data
Goal
Publish sanitized help articles and show them through read-only public endpoints.
What you are building
CMS uses staff authentication. Help articles have title, summary, body and draft/published status. Public endpoints whitelist fields and expose only published items. Rich text is sanitized before the article component displays it with v-html; ordinary customer text remains escaped. seed is a project custom command exposed by flaxon_cli.py. It creates a sample project for the first registered customer and a published help article. Stop the server before seeding, then restart it so in-process CMS state reloads. Publishing permission remains distinct from drafting permission.
Build this chapter
Stop the server. Work in project_manager. Follow each CREATE, whole-file replacement, or marked-block edit below in order.
Step 1: CREATE - make parent folders, then create this file - flaxon_cli.py
This file owns the feature named by its module or component. Read the route/form flow before continuing.
Create the parent directories, then save this complete file.
"""Expose commands owned by feature modules to the Flaxon CLI.
Run these commands from the project directory. Add another module's
install_cli_commands(globals()) call here when you add its commands.
"""
from modules.welcome.module import welcome
welcome.install_cli_commands(globals())
from flaxon.cli.base import Command
def seed_command(args, console):
import asyncio
from seed import seed
asyncio.run(seed())
return 0
seed = Command("seed", seed_command, help_text="Create sample projects and published help content")
Say: "This file owns the feature named by its module or component. Read the route/form flow before continuing."
Type: Follow the action above in flaxon_cli.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 2: CREATE - make parent folders, then create this file - modules/content/init.py
This file owns the feature named by its module or component. Read the route/form flow before continuing.
Create the parent directories, then save this complete file.
"""Module-owned APIs and interface files."""
Say: "This file owns the feature named by its module or component. Read the route/form flow before continuing."
Type: Follow the action above in modules/content/__init__.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 3: CREATE - make parent folders, then create this file - modules/content/module.py
Create the public content API. Return only published CMS help articles. Staff draft and publishing operations remain in Admin.
Create the parent directories, then save this complete file.
"""Public read-only content endpoints; never expose the authenticated CMS API to readers."""
from pathlib import Path
from flaxon.modules import FlaxonModule
from flaxon.exceptions import NotFound
content = FlaxonModule(
"content",
ui_dir=Path(__file__).parent / "ui",
ui_routes={"Help.js": "/help", "Article/[slug].js": "/help/:slug"},
)
async def published_articles(request):
cms = request.app.cms
await cms._load_database()
articles = cms.content_types["help_article"]
return [
item for item in articles.items.values() if item.get("status") == "published"
]
@content.get("/articles")
async def list_articles(request):
articles = await published_articles(request)
return {
"data": [
{key: item.get(key) for key in ("id", "title", "slug", "summary")}
for item in articles
]
}
@content.get("/articles/<slug>")
async def get_article(request, slug):
for article in await published_articles(request):
if article["slug"] == slug:
return {
"data": {
key: article.get(key)
for key in ("id", "title", "slug", "summary", "body")
}
}
raise NotFound("Article not found.")
Say: "Create the public content API. Return only published CMS help articles. Staff draft and publishing operations remain in Admin."
Type: Follow the action above in modules/content/module.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 4: CREATE - make parent folders, then create this file - modules/content/ui/pages/Help.html
This file owns the feature named by its module or component. Read the route/form flow before continuing.
Create the parent directories, then save this complete file.
<template>
<section>
<h1>Help centre</h1>
<p v-if="loading" role="status">Loading articles…</p>
<p v-if="message" role="alert">{{ message }}</p>
<p v-if="!loading && articles.length === 0">No published articles yet.</p>
<div class="cards">
<a
v-for="article in articles"
:key="article.id"
:href="'/help/' + article.slug"
data-teloce-link
class="card"
><h2>{{ article.title }}</h2>
<p>{{ article.summary }}</p></a
>
</div>
</section>
</template>
<script lang="ts">
import { api } from "../../../../ui/api.ts";
export default {
data() {
return { articles: [], loading: true, message: "" };
},
async mounted() {
try {
this.articles = await api("/api/content/articles");
} catch (error) {
this.message = error.message;
} finally {
this.loading = false;
}
},
};
</script>
<style scoped>
* {
box-sizing: border-box;
}
a {
color: #1d4ed8;
}
h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
line-height: 1.15;
}
h2 {
font-size: 1.2rem;
}
p {
line-height: 1.6;
}
[role="alert"] {
color: #a21c2b;
padding: 0.8rem;
border-left: 4px solid;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
.card {
display: block;
background: white;
border: 1px solid #d8e0ed;
border-radius: 12px;
padding: 1.2rem;
margin: 1rem 0;
color: inherit;
text-decoration: none;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 1rem;
}
</style>
Say: "This file owns the feature named by its module or component. Read the route/form flow before continuing."
Type: Follow the action above in modules/content/ui/pages/Help.html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 5: CREATE - make parent folders, then create this file - modules/content/ui/pages/Article/[slug].html
This file owns the feature named by its module or component. Read the route/form flow before continuing.
Create the parent directories, then save this complete file.
<template>
<article>
<a href="/help" data-teloce-link>← Help centre</a>
<p v-if="loading" role="status">Loading article…</p>
<p v-if="message" role="alert">{{ message }}</p>
<div v-if="article">
<h1>{{ article.title }}</h1>
<p>{{ article.summary }}</p>
<div class="article-body" v-html="article.body"></div>
</div>
</article>
</template>
<script lang="ts">
import { api } from "../../../../../ui/api.ts";
export default {
props: ["slug"],
data() {
return { article: null, loading: true, message: "" };
},
async mounted() {
try {
this.article = await api(
"/api/content/articles/" + encodeURIComponent(this.slug),
);
} catch (error) {
this.message = error.message;
} finally {
this.loading = false;
}
},
};
</script>
<style scoped>
* {
box-sizing: border-box;
}
a {
color: #1d4ed8;
}
h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
line-height: 1.15;
}
h2 {
font-size: 1.2rem;
}
p {
line-height: 1.6;
}
[role="alert"] {
color: #a21c2b;
padding: 0.8rem;
border-left: 4px solid;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
.article-body {
line-height: 1.6;
overflow-wrap: anywhere;
}
</style>
Say: "This file owns the feature named by its module or component. Read the route/form flow before continuing."
Type: Follow the action above in modules/content/ui/pages/Article/[slug].html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 6: CREATE - make parent folders, then create this file - seed.py
Populate sample data explicitly after a customer exists; do not seed on every server start.
Create the parent directories, then save this complete file.
"""Idempotent sample content. Register your own user before running this command."""
from app import create_app
from models import User, Project, Task
async def seed():
app = create_app()
async with app.db:
user = await User.all().order_by("id").first()
if user is None:
raise ValueError("Register an account before seeding sample projects.")
if not await Project.filter(owner_id=user.id).exists():
project = await Project.create(
owner_id=user.id,
name="Launch my portfolio",
description="A small project to practise planning.",
)
for title, status in [
("Choose a design", "done"),
("Build the homepage", "doing"),
("Deploy the website", "todo"),
]:
await Task.create(project_id=project.id, title=title, status=status)
articles = app.cms.content_types["help_article"]
if not articles.items:
articles.create(
{
"title": "Getting started",
"summary": "Create a project, add tasks, and track progress.",
"body": "<p>Open Projects, create a project, and add your first task. Move tasks from To do to Doing to Done.</p>",
"status": "published",
}
)
app.cms._save(articles)
print("Sample projects, tasks, and help content are ready.")
Say: "Populate sample data explicitly after a customer exists; do not seed on every server start."
Type: Follow the action above in seed.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 7: EDIT - replace the marked blocks - ui/app.html
This is the SPA shell. Its internal anchors and router view cooperate with each module's page mappings.
Edit 1: find this exact block
Open ui/app.html. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.
<a href="/" data-teloce-link class="brand">Project Manager</a>
<nav aria-label="Main navigation">
<a href="/projects" data-teloce-link>Projects</a>
<a href="/login" data-teloce-link>Account</a>
</nav>
</header>
Replace that block with
<a href="/" data-teloce-link class="brand">Project Manager</a>
<nav aria-label="Main navigation">
<a href="/projects" data-teloce-link>Projects</a>
<a href="/help" data-teloce-link>Help</a>
<a href="/login" data-teloce-link>Account</a>
</nav>
</header>
Leave the rest of the file unchanged.
Say: "This is the SPA shell. Its internal anchors and router view cooperate with each module's page mappings."
Type: Follow the action above in ui/app.html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 8: EDIT - replace the marked blocks - app.py
The application factory mounts only the features already introduced. Follow the action label: a marked-block edit keeps the surrounding factory intact.
Edit 1: find this exact block
Open app.py. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.
from modules.auth.module import auth
from modules.projects.module import projects
from modules.tasks.module import tasks
from modules.welcome.module import welcome
from settings import (
ROOT,
Replace that block with
from modules.auth.module import auth
from modules.projects.module import projects
from modules.tasks.module import tasks
from modules.content.module import content
from modules.welcome.module import welcome
from settings import (
ROOT,
Leave the rest of the file unchanged.
Edit 2: find this exact block
Open app.py. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.
(auth, "/api/auth"),
(projects, "/api/projects"),
(tasks, "/api/tasks"),
(welcome, "/api/welcome"),
]:
app.mount_module(module, prefix=prefix)
Replace that block with
(auth, "/api/auth"),
(projects, "/api/projects"),
(tasks, "/api/tasks"),
(content, "/api/content"),
(welcome, "/api/welcome"),
]:
app.mount_module(module, prefix=prefix)
Leave the rest of the file unchanged.
Edit 3: find this exact block
Open app.py. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.
@app.get("/login")
@app.get("/projects")
@app.get("/projects/<int:project_id>")
async def spa(request: Request, project_id=None, slug=None):
return await request.compile("app.html", {})
Replace that block with
@app.get("/login")
@app.get("/projects")
@app.get("/projects/<int:project_id>")
@app.get("/help")
@app.get("/help/<slug>")
async def spa(request: Request, project_id=None, slug=None):
return await request.compile("app.html", {})
Leave the rest of the file unchanged.
Say: "The application factory mounts only the features already introduced. Follow the action label: a marked-block edit keeps the surrounding factory intact."
Type: Follow the action above in app.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Run and check
python management.py seed
# Stop/restart the running server after seeding:
python management.py runserver
# Open /help and /admin/cms/.
Expected result
Run seed after registering a customer. /help lists the sample published article and the article page survives a refresh. Drafts remain absent from public responses.
Common errors
Do not call authenticated CMS editing endpoints from public reader pages. Do not display arbitrary HTML using v-html. A draft editor also needs publishing permission to modify live content.
Short exercise
Create a draft in staff CMS, confirm it is hidden publicly, then publish it with a permitted staff account.
What to say and show
Say: "Publish sanitized help articles and show them through read-only public endpoints. The server remains responsible for persistence and authorization; the browser presents the result."
Show the expected result above, then point to the file responsible for it. Run the chapter check before the next lesson.
Save your checkpoint
git add .
git commit -m "Complete chapter 13: CMS help and sample data"