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.
- simple_module_news-0.0.2/.gitignore +14 -0
- simple_module_news-0.0.2/PKG-INFO +114 -0
- simple_module_news-0.0.2/README.md +93 -0
- simple_module_news-0.0.2/news/__init__.py +0 -0
- simple_module_news-0.0.2/news/components/ArticleRow.tsx +78 -0
- simple_module_news-0.0.2/news/components/NewsFeed.tsx +96 -0
- simple_module_news-0.0.2/news/constants.py +40 -0
- simple_module_news-0.0.2/news/contracts/__init__.py +0 -0
- simple_module_news-0.0.2/news/contracts/schemas.py +56 -0
- simple_module_news-0.0.2/news/endpoints/__init__.py +0 -0
- simple_module_news-0.0.2/news/endpoints/api.py +158 -0
- simple_module_news-0.0.2/news/endpoints/views.py +28 -0
- simple_module_news-0.0.2/news/models.py +70 -0
- simple_module_news-0.0.2/news/module.py +130 -0
- simple_module_news-0.0.2/news/pages/.gitkeep +1 -0
- simple_module_news-0.0.2/news/pages/NewsList.tsx +121 -0
- simple_module_news-0.0.2/news/puck-blocks.ts +19 -0
- simple_module_news-0.0.2/news/service.py +213 -0
- simple_module_news-0.0.2/news/utils/api.ts +161 -0
- simple_module_news-0.0.2/package.json +16 -0
- simple_module_news-0.0.2/pyproject.toml +53 -0
- simple_module_news-0.0.2/tests/conftest.py +184 -0
- simple_module_news-0.0.2/tests/test_api.py +246 -0
- simple_module_news-0.0.2/tests/test_models.py +51 -0
- simple_module_news-0.0.2/tests/test_module.py +97 -0
- simple_module_news-0.0.2/tests/test_service.py +233 -0
- simple_module_news-0.0.2/tsconfig.json +10 -0
|
@@ -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)
|