simple-module-news 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
news/__init__.py ADDED
File without changes
@@ -0,0 +1,78 @@
1
+ import { Button } from '@simple-module-py/ui/components/ui/button';
2
+ import { Input } from '@simple-module-py/ui/components/ui/input';
3
+ import { useState } from 'react';
4
+
5
+ import type { ArticleRead } from '../utils/api';
6
+
7
+ /** `2026-02-01T00:00:00` -> `2026-02-01`, which is what <input type=date> wants. */
8
+ function toDateInput(iso: string | null): string {
9
+ return iso ? iso.slice(0, 10) : '';
10
+ }
11
+
12
+ export function ArticleRow({
13
+ article,
14
+ busy,
15
+ onSave,
16
+ onDetach,
17
+ }: {
18
+ article: ArticleRead;
19
+ busy: boolean;
20
+ onSave: (id: number, category: string, publishedAt: string | null) => void;
21
+ onDetach: (id: number) => void;
22
+ }) {
23
+ const [category, setCategory] = useState(article.category);
24
+ const [date, setDate] = useState(toDateInput(article.published_at));
25
+
26
+ const dirty = category !== article.category || date !== toDateInput(article.published_at);
27
+
28
+ return (
29
+ <tr className="border-b last:border-b-0">
30
+ <td className="py-2 pr-4">
31
+ <a href={`/pagebuilder/${article.page_id}`} className="font-medium hover:underline">
32
+ {article.title}
33
+ </a>
34
+ <div className="text-xs text-muted-foreground">{article.url}</div>
35
+ </td>
36
+ <td className="py-2 pr-4">
37
+ <Input
38
+ aria-label={`Category for ${article.title}`}
39
+ value={category}
40
+ disabled={busy}
41
+ onChange={(e) => setCategory(e.target.value)}
42
+ className="w-40"
43
+ />
44
+ </td>
45
+ <td className="py-2 pr-4">
46
+ <Input
47
+ type="date"
48
+ aria-label={`Date for ${article.title}`}
49
+ value={date}
50
+ disabled={busy}
51
+ onChange={(e) => setDate(e.target.value)}
52
+ className="w-40"
53
+ />
54
+ </td>
55
+ <td className="py-2 text-right whitespace-nowrap">
56
+ <Button
57
+ type="button"
58
+ size="sm"
59
+ // Enabled only when something changed, so the row never fires a
60
+ // no-op PUT that bumps updated_at for nothing.
61
+ disabled={busy || !dirty}
62
+ onClick={() => onSave(article.id, category, date ? `${date}T00:00:00Z` : null)}
63
+ >
64
+ Save
65
+ </Button>
66
+ <Button
67
+ type="button"
68
+ size="sm"
69
+ variant="ghost"
70
+ disabled={busy}
71
+ onClick={() => onDetach(article.id)}
72
+ >
73
+ Detach
74
+ </Button>
75
+ </td>
76
+ </tr>
77
+ );
78
+ }
@@ -0,0 +1,96 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import {
3
+ type ArticleCardItem,
4
+ ArticleCardsGrid,
5
+ } from '@simple-module-py/pagebuilder/pagebuilder/components/widgets/article-cards-render';
6
+ import { useEffect, useState } from 'react';
7
+
8
+ import { type ArticleRead, formatArticleDate, listArticles } from '../utils/api';
9
+
10
+ export interface NewsFeedProps {
11
+ title: string;
12
+ category: string;
13
+ limit: number;
14
+ columns: '2' | '3' | '4';
15
+ viewAllLabel: string;
16
+ viewAllHref: string;
17
+ }
18
+
19
+ function toCard(article: ArticleRead): ArticleCardItem {
20
+ return {
21
+ imageUrl: article.cover_image_url,
22
+ imageAlt: '',
23
+ eyebrow: article.category,
24
+ date: formatArticleDate(article.published_at),
25
+ title: article.title,
26
+ body: article.excerpt,
27
+ // An article *is* a page, so this is the page's own public URL.
28
+ href: article.url,
29
+ };
30
+ }
31
+
32
+ export function NewsFeedRender({
33
+ title,
34
+ category,
35
+ limit,
36
+ columns,
37
+ viewAllLabel,
38
+ viewAllHref,
39
+ }: NewsFeedProps) {
40
+ const [items, setItems] = useState<ArticleCardItem[] | null>(null);
41
+
42
+ useEffect(() => {
43
+ const controller = new AbortController();
44
+ listArticles({ limit, category, signal: controller.signal })
45
+ .then((response) => setItems(response.items.map(toCard)))
46
+ // An empty feed and a failed fetch look the same to a visitor on
47
+ // purpose: a broken API must not put an error box on a public page.
48
+ .catch(() => setItems([]));
49
+ return () => controller.abort();
50
+ }, [limit, category]);
51
+
52
+ // Render nothing at all until the first response, and nothing when there
53
+ // are no articles: a heading with an empty grid under it reads as a broken
54
+ // section. An empty fragment rather than null because Puck's render must
55
+ // return an Element.
56
+ if (items === null || items.length === 0) return <></>;
57
+
58
+ return (
59
+ <ArticleCardsGrid
60
+ title={title}
61
+ viewAllLabel={viewAllLabel}
62
+ viewAllHref={viewAllHref}
63
+ columns={columns}
64
+ items={items}
65
+ />
66
+ );
67
+ }
68
+
69
+ export const NewsFeedBlock: ComponentConfig<NewsFeedProps> = {
70
+ label: 'News feed (live articles)',
71
+ fields: {
72
+ title: { type: 'text', label: 'Heading' },
73
+ category: { type: 'text', label: 'Category filter (blank = all)' },
74
+ limit: { type: 'number', label: 'How many', min: 1, max: 12 },
75
+ columns: {
76
+ type: 'select',
77
+ label: 'Columns (desktop)',
78
+ options: [
79
+ { label: '2', value: '2' },
80
+ { label: '3', value: '3' },
81
+ { label: '4', value: '4' },
82
+ ],
83
+ },
84
+ viewAllLabel: { type: 'text', label: 'View-all link label' },
85
+ viewAllHref: { type: 'text', label: 'View-all link URL' },
86
+ },
87
+ defaultProps: {
88
+ title: 'Latest news',
89
+ category: '',
90
+ limit: 4,
91
+ columns: '4',
92
+ viewAllLabel: '',
93
+ viewAllHref: '',
94
+ },
95
+ render: NewsFeedRender,
96
+ };
news/constants.py ADDED
@@ -0,0 +1,40 @@
1
+ """News module constants.
2
+
3
+ Kept out of the call sites so no module name, route or permission appears as a
4
+ bare literal — see scripts/check_hardcoded_strings.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Final
10
+
11
+ PACKAGE: Final = "news"
12
+
13
+ ROUTE_PREFIX_API: Final = "/api/news"
14
+ VIEW_PREFIX: Final = "/news"
15
+ # Trailing slash: the list route is registered at "/" under VIEW_PREFIX, so
16
+ # linking to the bare prefix costs a 307 round trip on every navigation.
17
+ MENU_URL: Final = f"{VIEW_PREFIX}/"
18
+ MENU_GROUP: Final = "Content"
19
+ MENU_ICON: Final = "newspaper"
20
+
21
+ # Modules this one depends on.
22
+ _MODULE_PAGEBUILDER: Final = "PageBuilder"
23
+
24
+ # Inertia page identifier, rendered as a literal at the view so the SM003/SM004
25
+ # static-AST diagnostics can pair it with pages/NewsList.tsx.
26
+ _PAGE_LIST: Final = "News/NewsList"
27
+
28
+ PERM_VIEW: Final = "news.view"
29
+ PERM_EDIT: Final = "news.edit"
30
+
31
+ # Anonymous reads: the feed block runs on public pages, so listing has to work
32
+ # without a session. Writes stay behind news.edit.
33
+ PUBLIC_READ_PREFIXES: Final = (
34
+ f"{ROUTE_PREFIX_API}/articles",
35
+ f"{ROUTE_PREFIX_API}/categories",
36
+ )
37
+
38
+ MAX_CATEGORY_LEN: Final = 80
39
+ DEFAULT_LIMIT: Final = 20
40
+ MAX_LIMIT: Final = 100
File without changes
@@ -0,0 +1,56 @@
1
+ """DTOs for the News module — the public surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from news.constants import MAX_CATEGORY_LEN
10
+
11
+
12
+ class ArticleRead(BaseModel):
13
+ """An article, flattened from its sidecar row plus the page it belongs to."""
14
+
15
+ id: int
16
+ page_id: int
17
+ slug: str
18
+ title: str
19
+ excerpt: str = ""
20
+ cover_image_url: str = ""
21
+ category: str = ""
22
+ published_at: datetime | None = None
23
+ url: str
24
+ """Where the article actually serves.
25
+
26
+ An article *is* a page, so this is the page's own public URL rather than a
27
+ route this module owns — duplicating the public viewer would mean
28
+ duplicating its ETag, cache, CSP, SEO and site-layout handling.
29
+ """
30
+
31
+
32
+ class ArticleListResponse(BaseModel):
33
+ items: list[ArticleRead]
34
+ total: int
35
+
36
+
37
+ class CategoryCount(BaseModel):
38
+ category: str
39
+ count: int
40
+
41
+
42
+ class CategoryListResponse(BaseModel):
43
+ items: list[CategoryCount]
44
+
45
+
46
+ class ArticleCreate(BaseModel):
47
+ """Attach article metadata to an existing page."""
48
+
49
+ page_id: int
50
+ category: str = Field(default="", max_length=MAX_CATEGORY_LEN)
51
+ published_at: datetime | None = None
52
+
53
+
54
+ class ArticleUpdate(BaseModel):
55
+ category: str | None = Field(default=None, max_length=MAX_CATEGORY_LEN)
56
+ published_at: datetime | None = None
File without changes
news/endpoints/api.py ADDED
@@ -0,0 +1,158 @@
1
+ """REST API for News.
2
+
3
+ Reads are anonymous: the feed block runs on public pages, so a visitor with no
4
+ session has to be able to list articles. Writes require ``news.edit``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
10
+ from simple_module_db import get_db
11
+ from simple_module_hosting.permissions import (
12
+ WILDCARD,
13
+ RequiresPermission,
14
+ resolve_permissions,
15
+ )
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from news import service
19
+ from news.constants import DEFAULT_LIMIT, MAX_LIMIT, PERM_EDIT
20
+ from news.contracts.schemas import (
21
+ ArticleCreate,
22
+ ArticleListResponse,
23
+ ArticleRead,
24
+ ArticleUpdate,
25
+ CategoryListResponse,
26
+ )
27
+
28
+ router = APIRouter()
29
+
30
+ require_edit = Depends(RequiresPermission(PERM_EDIT))
31
+
32
+
33
+ def _may_see_drafts(request: Request) -> bool:
34
+ """Only an editor sees articles whose page is still a draft.
35
+
36
+ Everyone else gets the published site, which is what the public feed block
37
+ must show.
38
+
39
+ Reads ``request.state.resolved_permissions`` — where the hosting middleware
40
+ and ``RequiresPermission`` both put the resolved set — rather than a
41
+ ``permissions`` attribute on the user. ``UserContext`` has no such
42
+ attribute, so testing it always yielded an empty list and no editor ever
43
+ saw a draft.
44
+
45
+ The fallback mirrors ``RequiresPermission.__call__``: these listings are
46
+ registered as public routes, so on an anonymous-readable GET nothing has
47
+ populated ``resolved_permissions`` by the time we are asked. ``WILDCARD`` is
48
+ checked because an administrator resolves to it rather than to a literal
49
+ ``news.edit``.
50
+ """
51
+ resolved = getattr(request.state, "resolved_permissions", None)
52
+ if resolved is None:
53
+ user = getattr(request.state, "user", None)
54
+ if user is None:
55
+ return False
56
+ sm = getattr(getattr(request.app, "state", None), "sm", None)
57
+ registry = getattr(sm, "permissions", None) if sm is not None else None
58
+ resolved = resolve_permissions(
59
+ getattr(user, "roles", None) or [],
60
+ role_map=registry.role_map if registry is not None else None,
61
+ )
62
+ return WILDCARD in resolved or PERM_EDIT in resolved
63
+
64
+
65
+ async def _read_one_by_page(db: AsyncSession, page_id: int) -> ArticleRead:
66
+ """Re-read through the listing join so every response has one shape.
67
+
68
+ Drafts are included: an editor has just written this row and must see it
69
+ back whatever state its page is in.
70
+ """
71
+ article = await service.get_read_by_page(db, page_id, include_drafts=True)
72
+ if article is None:
73
+ # The page is the only source of slug and title, so without it there is
74
+ # nothing to return.
75
+ raise HTTPException(status_code=404, detail="Article's page not found.")
76
+ return article
77
+
78
+
79
+ @router.get("/articles", response_model=ArticleListResponse)
80
+ async def list_articles(
81
+ request: Request,
82
+ limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
83
+ offset: int = Query(0, ge=0),
84
+ category: str | None = Query(None),
85
+ db: AsyncSession = Depends(get_db),
86
+ ) -> ArticleListResponse:
87
+ items, total = await service.list_articles(
88
+ db,
89
+ limit=limit,
90
+ offset=offset,
91
+ category=category,
92
+ include_drafts=_may_see_drafts(request),
93
+ )
94
+ return ArticleListResponse(items=items, total=total)
95
+
96
+
97
+ @router.get("/categories", response_model=CategoryListResponse)
98
+ async def list_categories(
99
+ request: Request, db: AsyncSession = Depends(get_db)
100
+ ) -> CategoryListResponse:
101
+ items = await service.list_categories(db, include_drafts=_may_see_drafts(request))
102
+ return CategoryListResponse(items=items)
103
+
104
+
105
+ @router.post(
106
+ "/articles",
107
+ response_model=ArticleRead,
108
+ status_code=201,
109
+ dependencies=[require_edit],
110
+ )
111
+ async def attach_article(
112
+ body: ArticleCreate, db: AsyncSession = Depends(get_db)
113
+ ) -> ArticleRead:
114
+ """Make an existing page an article."""
115
+ if not await service.page_exists(db, body.page_id):
116
+ raise HTTPException(status_code=404, detail=f"Page {body.page_id} does not exist.")
117
+ if await service.get_by_page(db, body.page_id) is not None:
118
+ raise HTTPException(
119
+ status_code=409, detail=f"Page {body.page_id} is already an article."
120
+ )
121
+ await service.create(
122
+ db, page_id=body.page_id, category=body.category, published_at=body.published_at
123
+ )
124
+ return await _read_one_by_page(db, body.page_id)
125
+
126
+
127
+ @router.put(
128
+ "/articles/{article_id}", response_model=ArticleRead, dependencies=[require_edit]
129
+ )
130
+ async def update_article(
131
+ article_id: int, body: ArticleUpdate, db: AsyncSession = Depends(get_db)
132
+ ) -> ArticleRead:
133
+ article = await service.get(db, article_id)
134
+ if article is None:
135
+ raise HTTPException(status_code=404, detail="Article not found.")
136
+ # A partial update: an omitted `published_at` leaves the date alone, while
137
+ # an explicit null undates the article. Only `model_fields_set` can tell
138
+ # those apart, and the distinction is the endpoint's to make.
139
+ await service.update(
140
+ db,
141
+ article,
142
+ category=body.category,
143
+ published_at=(
144
+ body.published_at
145
+ if "published_at" in body.model_fields_set
146
+ else service.UNSET
147
+ ),
148
+ )
149
+ return await _read_one_by_page(db, article.page_id)
150
+
151
+
152
+ @router.delete("/articles/{article_id}", status_code=204, dependencies=[require_edit])
153
+ async def detach_article(article_id: int, db: AsyncSession = Depends(get_db)) -> None:
154
+ """Detach the metadata. The page, and its body, stays."""
155
+ article = await service.get(db, article_id)
156
+ if article is None:
157
+ raise HTTPException(status_code=404, detail="Article not found.")
158
+ await service.delete(db, article)
@@ -0,0 +1,28 @@
1
+ """Inertia view endpoints for News."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from inertia import InertiaResponse
7
+ from simple_module_hosting.inertia_deps import InertiaDep
8
+ from simple_module_hosting.permissions import RequiresPermission
9
+
10
+ from news import constants
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.get(
16
+ "/",
17
+ response_model=None,
18
+ dependencies=[Depends(RequiresPermission(constants.PERM_VIEW))],
19
+ )
20
+ async def article_list(inertia: InertiaDep) -> InertiaResponse:
21
+ # The list is fetched client-side from /api/news/articles so an inline edit
22
+ # can refresh one row without a full Inertia round trip.
23
+ #
24
+ # The page name comes from the constant, matching pagebuilder and this
25
+ # repo's check_hardcoded_strings. The framework's own modules inline the
26
+ # literal instead, for the SM003/SM004 static-AST pairing — the two
27
+ # conventions disagree, and an in-repo module follows the in-repo linter.
28
+ return await inertia.render(constants._PAGE_LIST)
news/models.py ADDED
@@ -0,0 +1,70 @@
1
+ """SQLModel table for the news module.
2
+
3
+ An article *is* a pagebuilder page — title, slug, body, approval workflow,
4
+ revisions and SEO all live there, and it serves at ``/p/{slug}`` with the
5
+ existing ETag, cache and CSP handling. This table adds only what a page has no
6
+ concept of: the category it belongs to and the date it should be listed under.
7
+
8
+ Keeping it a sidecar is what lets pagebuilder stay a generic CMS that knows
9
+ nothing about news. The cost is that every listing is a join, and that
10
+ ``page_id`` carries no database foreign key — see the field for why.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import datetime
16
+
17
+ from simple_module_db.base import create_module_base
18
+ from simple_module_db.mixins import AuditMixin
19
+ from sqlalchemy import Column, DateTime, Integer
20
+ from sqlmodel import Field
21
+
22
+ Base = create_module_base("news")
23
+
24
+ PAGE_TABLE = "pagebuilder_pages"
25
+
26
+
27
+ class NewsArticle(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
28
+ """Article metadata attached to a pagebuilder page."""
29
+
30
+ __tablename__ = "news_articles"
31
+
32
+ id: int | None = Field(default=None, primary_key=True)
33
+
34
+ page_id: int = Field(
35
+ sa_column=Column(Integer, nullable=False, unique=True, index=True)
36
+ )
37
+ """The pagebuilder page holding the body, slug and workflow.
38
+
39
+ Deliberately *not* a database foreign key. ``create_module_base`` gives
40
+ every module its own ``MetaData``, so a cross-module ``ForeignKey`` cannot
41
+ resolve its target table and SQLAlchemy raises ``NoReferencedTableError``
42
+ when the mapper configures. The framework offers no cross-module FK and
43
+ pagebuilder publishes no page-deleted event, so there is nothing to hang a
44
+ cascade on either.
45
+
46
+ Two things make that safe. Every listing inner-joins the page, so an article
47
+ whose page was deleted stops appearing immediately rather than surfacing a
48
+ broken link. And an orphan is removed rather than left invisible — by the
49
+ ``PageDeleted`` subscription first, and by the startup sweep
50
+ (``service.reconcile_orphans``) when that event is missed.
51
+
52
+ The sweep is not belt-and-braces. An invisible orphan does not stay
53
+ invisible: SQLite reuses a deleted row's id, so the row re-attaches to
54
+ whatever page is created next and lists one article's category and date
55
+ against another article's page.
56
+ """
57
+
58
+ category: str = Field(default="", max_length=80, index=True)
59
+ """Free-text grouping. Empty means uncategorised, which still lists."""
60
+
61
+ published_at: datetime | None = Field(
62
+ default=None,
63
+ sa_column=Column(DateTime(timezone=True), nullable=True, index=True),
64
+ )
65
+ """The article's display date.
66
+
67
+ Deliberately not ``Page.publish_at``, which is the single-shot scheduling
68
+ field and is cleared on every flip — it would be empty for every article
69
+ published immediately.
70
+ """
news/module.py ADDED
@@ -0,0 +1,130 @@
1
+ """News — articles backed by page-builder pages.
2
+
3
+ An article *is* a page: the body, slug, approval workflow, revisions and public
4
+ URL all belong to ``pagebuilder``. This module adds only the metadata a page
5
+ has no concept of — category and display date — plus the listing API and the
6
+ feed block that renders it.
7
+
8
+ There is therefore no public route here. An article serves at ``/p/{slug}``
9
+ with the existing ETag, cache, CSP, SEO and site-layout handling; a second
10
+ viewer would mean duplicating all of it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.metadata
16
+ import logging
17
+
18
+ from fastapi import APIRouter, FastAPI
19
+ from pagebuilder.contracts.events import PageDeleted
20
+ from simple_module_core import ModuleBase, ModuleMeta
21
+ from simple_module_core.events import EventBus
22
+ from simple_module_core.menu import MenuItem, MenuRegistry
23
+ from simple_module_core.permissions import PermissionRegistry
24
+ from simple_module_core.public_routes import PublicRouteRegistry
25
+
26
+ from news import constants
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _VERSION = importlib.metadata.version("simple_module_news")
31
+
32
+
33
+ class NewsModule(ModuleBase):
34
+ meta = ModuleMeta(
35
+ name="News",
36
+ route_prefix=constants.ROUTE_PREFIX_API,
37
+ view_prefix=constants.VIEW_PREFIX,
38
+ depends_on=[constants._MODULE_PAGEBUILDER],
39
+ version=_VERSION,
40
+ # The framework API version (1.0.0) is decoupled from the framework
41
+ # package version (0.0.x) — this range is correct as written.
42
+ requires_framework=">=1.0,<2.0",
43
+ )
44
+
45
+ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
46
+ from news.endpoints.api import router as api
47
+ from news.endpoints.views import router as views
48
+
49
+ api_router.include_router(api)
50
+ view_router.include_router(views)
51
+
52
+ def register_permissions(self, registry: PermissionRegistry) -> None:
53
+ registry.add_group(
54
+ NewsModule.meta.name, [constants.PERM_VIEW, constants.PERM_EDIT]
55
+ )
56
+
57
+ def register_menu_items(self, registry: MenuRegistry) -> None:
58
+ # Roles stay empty: filtering is a plain intersection with no admin
59
+ # bypass, so a non-empty list hides the entry from everyone outside it.
60
+ registry.add(
61
+ MenuItem(
62
+ label="News",
63
+ url=constants.MENU_URL,
64
+ icon=constants.MENU_ICON,
65
+ group=constants.MENU_GROUP,
66
+ )
67
+ )
68
+
69
+ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
70
+ """Drop an article when its page is deleted.
71
+
72
+ This is not tidiness. There is no cross-module foreign key to cascade
73
+ from, and a leftover row does not merely dangle: SQLite reuses the
74
+ deleted page's id, so the article silently re-attaches to whatever page
75
+ is created next and the listing shows one article's title under
76
+ another's metadata.
77
+
78
+ This is the fast path, not a guarantee — the bus logs a handler failure
79
+ rather than raising it, and pagebuilder has already committed the page
80
+ deletion by the time we run. ``on_startup``'s sweep is what makes the
81
+ outcome eventual rather than merely likely.
82
+ """
83
+ if app is None:
84
+ return
85
+
86
+ async def _drop_article(event: PageDeleted) -> None:
87
+ from news import service
88
+
89
+ # Outside a request, so `get_db` is not managing this session and
90
+ # the commit is ours to make.
91
+ async with app.state.sm.db.session_factory() as db:
92
+ article = await service.get_by_page(db, event.page_id)
93
+ if article is not None:
94
+ await service.delete(db, article)
95
+ await db.commit()
96
+
97
+ bus.subscribe(PageDeleted, _drop_article)
98
+
99
+ async def on_startup(self, app: FastAPI) -> None:
100
+ """Sweep away articles whose page no longer exists.
101
+
102
+ Closes the window the ``PageDeleted`` subscription cannot: if that
103
+ handler ever fails, the row survives with nothing to retry it, and the
104
+ only trace is a log line. A restart is a cheap, natural boundary at
105
+ which to reconcile, and the sweep costs one indexed anti-join over a
106
+ table holding one row per article.
107
+
108
+ A non-zero count means an event was lost, so it is logged at warning —
109
+ the repair should be visible, not silent.
110
+ """
111
+ from news import service
112
+
113
+ async with app.state.sm.db.session_factory() as db:
114
+ dropped = await service.reconcile_orphans(db)
115
+ await db.commit()
116
+ if dropped:
117
+ logger.warning(
118
+ "Dropped %d orphaned news article(s) whose page no longer exists; "
119
+ "a PageDeleted event was missed.",
120
+ dropped,
121
+ )
122
+
123
+ def register_public_routes(self, registry: PublicRouteRegistry) -> None:
124
+ """Let the feed block list articles for an anonymous visitor.
125
+
126
+ Reads only, and by exact prefix: the same paths carry POST/PUT/DELETE,
127
+ which must stay behind ``news.edit``.
128
+ """
129
+ for prefix in constants.PUBLIC_READ_PREFIXES:
130
+ registry.add_prefix(prefix, methods={"GET"})
news/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@simple-module-py/news",
3
+ "version": "0.0.2",
4
+ "private": true,
5
+ "description": "Frontend assets for the News module",
6
+ "peerDependencies": {
7
+ "react": "^19.0.0",
8
+ "react-dom": "^19.0.0",
9
+ "@inertiajs/react": "^2.0.0",
10
+ "@simple-module-py/ui": "*"
11
+ },
12
+ "devDependencies": {
13
+ "@simple-module-py/tsconfig": "*"
14
+ },
15
+ "dependencies": {}
16
+ }
news/pages/.gitkeep ADDED
@@ -0,0 +1 @@
1
+ # Keep this directory; TSX pages go here (e.g. Browse.tsx, Detail.tsx).