simple_module_news
News articles for SimpleModule hosts, backed by page-builder pages.
An article is a page. Its title, slug, body, approval workflow and
revisions all belong to simple_module_pagebuilder; this module adds only the
things a page has no concept of — the category it belongs to, the date it
should be listed under — plus the listing API and a feed block.
The one thing it does own is the article's public address. Articles serve
at /news/{slug} (SM_NEWS_PUBLIC_ROUTE_PREFIX), not at pagebuilder's generic
/p/{slug}, so an article is distinguishable from a contact page in a URL, a
log line and an analytics report. The rendering is still pagebuilder's: news
resolves the slug and hands off to that module's viewer, keeping the ETag,
cache, CSP, canonical and site-layout handling in one place.
Claiming an address means giving it up elsewhere. /p/{slug} 404s for an
article, and the sitemap advertises the news URL — see
pagebuilder.public_claims, the generic hook that makes this possible without
pagebuilder learning anything about news.
The admin console is at /admin/news, not /news: one prefix cannot be both
a reader-facing URL and a permission-gated console.
Installation
pip install simple_module_news
The host must also install simple_module_pagebuilder. For an in-repo
checkout, resolve it from the workspace:
dependencies = ["simple_module_news"]
[tool.uv.sources.simple_module_news]
workspace = true
Then run make migrate — the module's first revision is labelled news, so it
can be removed on its own with alembic downgrade news@base.
Usage
Go to News in the sidebar. "New article" creates a page, attaches the article metadata to it, and drops you into the page-builder editor to write the body. Category and date are edited inline in the list.
To show articles on a page, add the News feed block in the page builder and set its category filter and item count.
API
| Route | Access |
|---|---|
GET /news/{slug} |
anonymous; published articles only |
GET /api/news/articles?limit&offset&category&undated_first |
anonymous; published only |
GET /api/news/categories |
anonymous; published only |
POST /api/news/articles |
news.edit |
POST /api/news/articles/with-page |
news.edit + pagebuilder.edit |
POST /api/news/articles/{id}/publish |
news.edit + pagebuilder.publish |
PUT /api/news/articles/{id} |
news.edit |
DELETE /api/news/articles/{id} |
news.edit |
Reads are anonymous because the feed block runs on public pages. Listing is
ordered newest first with undated articles last; undated_first=true flips
the undated to the front, which is what the admin list uses so work in
progress is not buried on the last page. An editor additionally sees
articles whose page is still a draft. Each item carries page_status —
the workflow state of the page behind it, which the admin list renders as a
badge; without news.edit it is always published.
POST /articles attaches metadata to a page that already exists.
POST /articles/with-page is the "New article" flow: it creates the page and
attaches the article in one transaction, so a failure leaves neither behind.
Omit slug and the server derives one from the title and takes the first free
variant — my-title, then my-title-2; send one and it is used verbatim, with
a collision reported as a 409 rather than silently renamed.
POST /articles/{id}/publish publishes the page behind an article, which is
what the list's row menu calls.
Both of those write a page, so both require pagebuilder's own permission on
top of news.edit. That module separates editor from publisher deliberately —
so a host can run an editor → publisher workflow without granting every editor
publish rights — and these routes replaced browser calls that went through
pagebuilder's endpoints and met that gate. Requiring only news.edit would
have handed every article author a way straight past it. A role that creates
and publishes articles therefore needs news.edit, pagebuilder.edit and
pagebuilder.publish; attaching metadata to a page somebody else made touches
nothing of pagebuilder's and still needs only news.edit.
published_at is a display date, not a timestamp. Whatever instant you
send, the day is taken as you wrote it and stored as midnight UTC — sending
2026-02-01T23:00:00-06:00 records the 1st, not the 2nd. The column is a
timestamp only because that is what the date is carried in.
PUT is a partial update: a field you omit is left alone. Sending
published_at as an explicit null is different from omitting it — that
undates the article, which is a real state rather than an error.
DELETE detaches the metadata; the page and its body stay.
Anonymous listings carry Cache-Control: public, max-age=60, because the feed
block runs on every public page that holds one. An editor's listing includes
drafts and so is private, no-store.
Design note: one seam onto pagebuilder
An article is a page, so depending on simple_module_pagebuilder is the
design rather than an accident. What is avoidable is that dependency being
spread, and it was: eight Python modules here imported it directly, the DTO
re-exported its PageStatus as part of news' own public contract, and the
frontend hardcoded its CSRF cookie name, its page API route, its editor URL and
its media library path.
It now arrives through news/integrations/pagebuilder.py, the only module here
that imports that package — asserted by a test, because a single convenient
import is exactly the kind of thing that quietly undoes a rule like this.
Concretely:
- news names its own
ArticleStatus. Same values, so the wire format is unchanged;test_integrationsfails if the two vocabularies ever drift. edit_urland the search screen's "see all" links are served, so no TSX spells out how the neighbouring module routes its own screens.- pages are created and published through pagebuilder's service, inside this request's transaction, instead of through its HTTP API with a borrowed CSRF token. That is what removed the throwaway priming GET, and what makes "new article" atomic.
Three frontend imports remain on purpose: the Puck block registry, which is how
a block is contributed at all, and ArticleCardsGrid and ConfirmDialog,
which are shared render kit a second copy of would only make inconsistent.
Design note: no foreign key
news_articles.page_id carries no database foreign key. The framework gives
every module its own MetaData, so a cross-module ForeignKey cannot resolve
its target table, and pagebuilder publishes no page-deleted event to hang a
cascade on either.
Two things make that safe. Every listing inner-joins the page, so an article
whose page was deleted stops appearing immediately rather than rendering a card
that links nowhere. And the orphan is then removed rather than merely hidden —
by a PageDeleted subscription first, and by a sweep at application startup
when that event is missed.
The sweep is not belt-and-braces. The event bus logs a handler failure instead of raising it, and pagebuilder has already committed the page deletion by the time the handler runs, so a dropped event leaves the row behind with nothing to retry it. An invisible orphan does not stay invisible either: SQLite reuses a deleted row's id, so the row would re-attach to whatever page is created next and list one article's category and date against another article's page.
Development
uv sync --extra dev
uv run pytest
API-version contract
ModuleMeta.requires_framework declares which simple_module_core versions
this module supports. Update the spec on each framework major bump after
verifying compatibility.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file simple_module_news-0.0.6.tar.gz.
File metadata
- Download URL: simple_module_news-0.0.6.tar.gz
- Upload date:
- Size: 102.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9f46cd80f1cb65ecf480d43751206cc3166b7f00413ac479dd93647d5cbe52a7
|
|
| MD5 |
0d5a15eb69de18c4da7ac7f571132395
|
|
| BLAKE2b-256 |
216d0020f9e3442511c2fb9aadb8b5369187af93eb1abbb78a635c9590148e5e
|
Provenance
The following attestation bundles were made for simple_module_news-0.0.6.tar.gz:
Publisher:
release.yml on antosubash/smpy_modules
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
simple_module_news-0.0.6.tar.gz -
Subject digest:
9f46cd80f1cb65ecf480d43751206cc3166b7f00413ac479dd93647d5cbe52a7 - Sigstore transparency entry: 2583462004
- Sigstore integration time:
-
Permalink:
antosubash/smpy_modules@bf5c8bfe5f38039b209febce9d43fa5a1d6717ab -
Branch / Tag:
refs/heads/main - Owner: https://github.com/antosubash
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@bf5c8bfe5f38039b209febce9d43fa5a1d6717ab -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file simple_module_news-0.0.6-py3-none-any.whl.
File metadata
- Download URL: simple_module_news-0.0.6-py3-none-any.whl
- Upload date:
- Size: 106.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
96ff45861be431d4573cb068fecf039f536ef8d12b2aac5e2cf616fbc9032da5
|
|
| MD5 |
649b3069e092d587e4c076b9420bfce6
|
|
| BLAKE2b-256 |
fb934352315ccfcf4caea321f4b489f10a7da6d6794c393487fedc763216f7c4
|
Provenance
The following attestation bundles were made for simple_module_news-0.0.6-py3-none-any.whl:
Publisher:
release.yml on antosubash/smpy_modules
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
simple_module_news-0.0.6-py3-none-any.whl -
Subject digest:
96ff45861be431d4573cb068fecf039f536ef8d12b2aac5e2cf616fbc9032da5 - Sigstore transparency entry: 2583462017
- Sigstore integration time:
-
Permalink:
antosubash/smpy_modules@bf5c8bfe5f38039b209febce9d43fa5a1d6717ab -
Branch / Tag:
refs/heads/main - Owner: https://github.com/antosubash
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@bf5c8bfe5f38039b209febce9d43fa5a1d6717ab -
Trigger Event:
workflow_dispatch
-
Statement type: