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 +0 -0
- news/components/ArticleRow.tsx +78 -0
- news/components/NewsFeed.tsx +96 -0
- news/constants.py +40 -0
- news/contracts/__init__.py +0 -0
- news/contracts/schemas.py +56 -0
- news/endpoints/__init__.py +0 -0
- news/endpoints/api.py +158 -0
- news/endpoints/views.py +28 -0
- news/models.py +70 -0
- news/module.py +130 -0
- news/package.json +16 -0
- news/pages/.gitkeep +1 -0
- news/pages/NewsList.tsx +121 -0
- news/puck-blocks.ts +19 -0
- news/service.py +213 -0
- news/utils/api.ts +161 -0
- simple_module_news-0.0.2.dist-info/METADATA +114 -0
- simple_module_news-0.0.2.dist-info/RECORD +21 -0
- simple_module_news-0.0.2.dist-info/WHEEL +4 -0
- simple_module_news-0.0.2.dist-info/entry_points.txt +2 -0
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)
|
news/endpoints/views.py
ADDED
|
@@ -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).
|