Skip to main content

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} (the public_route_prefix setting), 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 /{locale}/news/{slug} anonymous; one mount per non-default content locale
GET /api/news/articles?limit&offset&category&locale&translation_group&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}/translations 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.

Multilingual articles

An article is a page, so its language is the page's language — configured in pagebuilder (its content_locales setting), not here, because a language news offered that pagebuilder did not would be one no article could be written in. Off by default: with one content locale every article URL is exactly what it was.

Articles in the default language keep /news/{slug}; every other language is prefixed, /de/news/{slug}. ArticleRead.url carries the prefix, so the admin list, the feed block and the slug news claims from pagebuilder all agree on one address. /{default}/news/{slug} permanently redirects to the bare form.

?locale=de narrows a listing to one language — what a feed block on a German page passes, so a German list never shows an English card. The block reads the surrounding page's language rather than offering it as a field: a feed set to one language on a page written in another is a mistake nothing would catch. The admin list leaves it unset and shows every language, badged per row.

POST /articles/{id}/translations starts the same story in another language: one request, one transaction, creating both the translated page and the sidecar row. Category, byline, date, pin and feed membership are copied from the source rather than asked for again — they are facts about the story, not about the language it is told in. The translation starts as a draft.

?translation_group=… lists one article and its counterparts, which is what the editor's language switcher shows. It goes through the ordinary listing rather than a route of its own, so it inherits the same visibility rule: a reader without news.edit sees the published translations only.

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_integrations fails if the two vocabularies ever drift.
  • edit_url and 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

simple_module_news-0.0.7.tar.gz (118.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

simple_module_news-0.0.7-py3-none-any.whl (123.1 kB view details)

Uploaded Python 3

File details

Details for the file simple_module_news-0.0.7.tar.gz.

File metadata

  • Download URL: simple_module_news-0.0.7.tar.gz
  • Upload date:
  • Size: 118.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for simple_module_news-0.0.7.tar.gz
Algorithm Hash digest
SHA256 4aa3db52026f0f9f56c34d39fa985cd88086ef4c9ac8cd34c52ff544bf0beda9
MD5 db21c63abd6f747b596761068666b7e2
BLAKE2b-256 7db058640ffeff3c7e61127fab08f2f1164f7571c2a8d64ce0cc1c722eecb6c1

See more details on using hashes here.

Provenance

The following attestation bundles were made for simple_module_news-0.0.7.tar.gz:

Publisher: release.yml on antosubash/smpy_modules

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file simple_module_news-0.0.7-py3-none-any.whl.

File metadata

File hashes

Hashes for simple_module_news-0.0.7-py3-none-any.whl
Algorithm Hash digest
SHA256 e55184b2bece7105631fb16717ae882d22a37349028f763bbb4fd89464c1b713
MD5 b673ad554b858dd38094f832b91d69c0
BLAKE2b-256 f8cb7f858f4725873810ea70447077592010340d755b73725dd6a0757a7b3b5e

See more details on using hashes here.

Provenance

The following attestation bundles were made for simple_module_news-0.0.7-py3-none-any.whl:

Publisher: release.yml on antosubash/smpy_modules

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.0.7 This release

2 files

0.0.6

2 files

0.0.5

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page