simple-module-news 0.0.2__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.db
8
+ *.sqlite3
9
+ .env
10
+
11
+ # Built frontend assets — shipped inside the wheel via force-include but
12
+ # NOT source-controlled. Re-run `npm run build` (or `vite build`) before
13
+ # `uv build` to repopulate.
14
+ */static/dist/
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_news
3
+ Version: 0.0.2
4
+ Summary: News articles backed by page-builder pages
5
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python_modules
6
+ Author-email: Anto Subash <antosubash@live.com>
7
+ License-Expression: MIT
8
+ Keywords: articles,cms,news,simple-module
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: pydantic-settings>=2.0
11
+ Requires-Dist: simple-module-core<0.1,>=0.0.25
12
+ Requires-Dist: simple-module-db<0.1,>=0.0.25
13
+ Requires-Dist: simple-module-hosting<0.1,>=0.0.25
14
+ Requires-Dist: simple-module-pagebuilder<0.1,>=0.0.1
15
+ Requires-Dist: sqlalchemy>=2.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Requires-Dist: simple-module-test<0.1,>=0.0.25; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # simple_module_news
23
+
24
+ News articles for SimpleModule hosts, backed by page-builder pages.
25
+
26
+ An article **is** a page. Its title, slug, body, approval workflow, revisions
27
+ and public URL all belong to `simple_module_pagebuilder`; this module adds only
28
+ the two things a page has no concept of — the category it belongs to and the
29
+ date it should be listed under — plus the listing API and a feed block.
30
+
31
+ That is why there is no public route here. An article serves at `/p/{slug}`
32
+ with the existing ETag, cache, CSP, SEO and site-layout handling; a second
33
+ viewer would mean duplicating all of it.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install simple_module_news
39
+ ```
40
+
41
+ The host must also install `simple_module_pagebuilder`. For an in-repo
42
+ checkout, resolve it from the workspace:
43
+
44
+ ```toml
45
+ dependencies = ["simple_module_news"]
46
+
47
+ [tool.uv.sources.simple_module_news]
48
+ workspace = true
49
+ ```
50
+
51
+ Then run `make migrate` — the module's first revision is labelled `news`, so it
52
+ can be removed on its own with `alembic downgrade news@base`.
53
+
54
+ ## Usage
55
+
56
+ Go to **News** in the sidebar. "New article" creates a page, attaches the
57
+ article metadata to it, and drops you into the page-builder editor to write the
58
+ body. Category and date are edited inline in the list.
59
+
60
+ To show articles on a page, add the **News feed** block in the page builder and
61
+ set its category filter and item count.
62
+
63
+ ### API
64
+
65
+ | Route | Access |
66
+ |---|---|
67
+ | `GET /api/news/articles?limit&offset&category` | anonymous; published only |
68
+ | `GET /api/news/categories` | anonymous; published only |
69
+ | `POST /api/news/articles` | `news.edit` |
70
+ | `PUT /api/news/articles/{id}` | `news.edit` |
71
+ | `DELETE /api/news/articles/{id}` | `news.edit` |
72
+
73
+ Reads are anonymous because the feed block runs on public pages. Listing is
74
+ ordered newest first with undated articles last, and an editor additionally
75
+ sees articles whose page is still a draft.
76
+
77
+ `PUT` is a **partial** update: a field you omit is left alone. Sending
78
+ `published_at` as an explicit `null` is different from omitting it — that
79
+ undates the article, which is a real state rather than an error.
80
+
81
+ `DELETE` detaches the metadata; the page and its body stay.
82
+
83
+ ## Design note: no foreign key
84
+
85
+ `news_articles.page_id` carries no database foreign key. The framework gives
86
+ every module its own `MetaData`, so a cross-module `ForeignKey` cannot resolve
87
+ its target table, and pagebuilder publishes no page-deleted event to hang a
88
+ cascade on either.
89
+
90
+ Two things make that safe. Every listing inner-joins the page, so an article
91
+ whose page was deleted stops appearing immediately rather than rendering a card
92
+ that links nowhere. And the orphan is then removed rather than merely hidden —
93
+ by a `PageDeleted` subscription first, and by a sweep at application startup
94
+ when that event is missed.
95
+
96
+ The sweep is not belt-and-braces. The event bus logs a handler failure instead
97
+ of raising it, and pagebuilder has already committed the page deletion by the
98
+ time the handler runs, so a dropped event leaves the row behind with nothing to
99
+ retry it. An invisible orphan does not stay invisible either: SQLite reuses a
100
+ deleted row's id, so the row would re-attach to whatever page is created next
101
+ and list one article's category and date against another article's page.
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ uv sync --extra dev
107
+ uv run pytest
108
+ ```
109
+
110
+ ## API-version contract
111
+
112
+ `ModuleMeta.requires_framework` declares which `simple_module_core` versions
113
+ this module supports. Update the spec on each framework major bump after
114
+ verifying compatibility.
@@ -0,0 +1,93 @@
1
+ # simple_module_news
2
+
3
+ News articles for SimpleModule hosts, backed by page-builder pages.
4
+
5
+ An article **is** a page. Its title, slug, body, approval workflow, revisions
6
+ and public URL all belong to `simple_module_pagebuilder`; this module adds only
7
+ the two things a page has no concept of — the category it belongs to and the
8
+ date it should be listed under — plus the listing API and a feed block.
9
+
10
+ That is why there is no public route here. An article serves at `/p/{slug}`
11
+ with the existing ETag, cache, CSP, SEO and site-layout handling; a second
12
+ viewer would mean duplicating all of it.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install simple_module_news
18
+ ```
19
+
20
+ The host must also install `simple_module_pagebuilder`. For an in-repo
21
+ checkout, resolve it from the workspace:
22
+
23
+ ```toml
24
+ dependencies = ["simple_module_news"]
25
+
26
+ [tool.uv.sources.simple_module_news]
27
+ workspace = true
28
+ ```
29
+
30
+ Then run `make migrate` — the module's first revision is labelled `news`, so it
31
+ can be removed on its own with `alembic downgrade news@base`.
32
+
33
+ ## Usage
34
+
35
+ Go to **News** in the sidebar. "New article" creates a page, attaches the
36
+ article metadata to it, and drops you into the page-builder editor to write the
37
+ body. Category and date are edited inline in the list.
38
+
39
+ To show articles on a page, add the **News feed** block in the page builder and
40
+ set its category filter and item count.
41
+
42
+ ### API
43
+
44
+ | Route | Access |
45
+ |---|---|
46
+ | `GET /api/news/articles?limit&offset&category` | anonymous; published only |
47
+ | `GET /api/news/categories` | anonymous; published only |
48
+ | `POST /api/news/articles` | `news.edit` |
49
+ | `PUT /api/news/articles/{id}` | `news.edit` |
50
+ | `DELETE /api/news/articles/{id}` | `news.edit` |
51
+
52
+ Reads are anonymous because the feed block runs on public pages. Listing is
53
+ ordered newest first with undated articles last, and an editor additionally
54
+ sees articles whose page is still a draft.
55
+
56
+ `PUT` is a **partial** update: a field you omit is left alone. Sending
57
+ `published_at` as an explicit `null` is different from omitting it — that
58
+ undates the article, which is a real state rather than an error.
59
+
60
+ `DELETE` detaches the metadata; the page and its body stay.
61
+
62
+ ## Design note: no foreign key
63
+
64
+ `news_articles.page_id` carries no database foreign key. The framework gives
65
+ every module its own `MetaData`, so a cross-module `ForeignKey` cannot resolve
66
+ its target table, and pagebuilder publishes no page-deleted event to hang a
67
+ cascade on either.
68
+
69
+ Two things make that safe. Every listing inner-joins the page, so an article
70
+ whose page was deleted stops appearing immediately rather than rendering a card
71
+ that links nowhere. And the orphan is then removed rather than merely hidden —
72
+ by a `PageDeleted` subscription first, and by a sweep at application startup
73
+ when that event is missed.
74
+
75
+ The sweep is not belt-and-braces. The event bus logs a handler failure instead
76
+ of raising it, and pagebuilder has already committed the page deletion by the
77
+ time the handler runs, so a dropped event leaves the row behind with nothing to
78
+ retry it. An invisible orphan does not stay invisible either: SQLite reuses a
79
+ deleted row's id, so the row would re-attach to whatever page is created next
80
+ and list one article's category and date against another article's page.
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ uv sync --extra dev
86
+ uv run pytest
87
+ ```
88
+
89
+ ## API-version contract
90
+
91
+ `ModuleMeta.requires_framework` declares which `simple_module_core` versions
92
+ this module supports. Update the spec on each framework major bump after
93
+ verifying compatibility.
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
+ };
@@ -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
@@ -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)