sardine-cms-admin 0.1.0__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.
- sardine_cms_admin-0.1.0/.gitignore +37 -0
- sardine_cms_admin-0.1.0/PKG-INFO +69 -0
- sardine_cms_admin-0.1.0/README.md +52 -0
- sardine_cms_admin-0.1.0/pyproject.toml +25 -0
- sardine_cms_admin-0.1.0/src/cms_admin/__init__.py +6 -0
- sardine_cms_admin-0.1.0/src/cms_admin/app.py +102 -0
- sardine_cms_admin-0.1.0/src/cms_admin/articles.py +393 -0
- sardine_cms_admin-0.1.0/src/cms_admin/auth.py +186 -0
- sardine_cms_admin-0.1.0/src/cms_admin/dashboard.py +95 -0
- sardine_cms_admin-0.1.0/src/cms_admin/db.py +34 -0
- sardine_cms_admin-0.1.0/src/cms_admin/demo_export.py +167 -0
- sardine_cms_admin-0.1.0/src/cms_admin/media.py +320 -0
- sardine_cms_admin-0.1.0/src/cms_admin/pages.py +565 -0
- sardine_cms_admin-0.1.0/src/cms_admin/publishing.py +167 -0
- sardine_cms_admin-0.1.0/src/cms_admin/py.typed +0 -0
- sardine_cms_admin-0.1.0/src/cms_admin/security.py +33 -0
- sardine_cms_admin-0.1.0/src/cms_admin/settings.py +52 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/admin.css +93 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/OFL.txt +92 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/inter-normal-latin.woff2 +0 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/inter-normal-latinext.woff2 +0 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/newsreader-italic-latin.woff2 +0 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/newsreader-italic-latinext.woff2 +0 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/newsreader-normal-latin.woff2 +0 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/newsreader-normal-latinext.woff2 +0 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/vendor/adminlte/ADMINLTE-LICENSE.txt +20 -0
- sardine_cms_admin-0.1.0/src/cms_admin/static/vendor/adminlte/adminlte.min.css +7 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/_fields.html.j2 +50 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/article_edit.html.j2 +68 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/article_new.html.j2 +26 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/article_translation.html.j2 +42 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/articles_list.html.j2 +44 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/base.html.j2 +18 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/dashboard.html.j2 +104 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/login.html.j2 +31 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/media_edit.html.j2 +62 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/media_list.html.j2 +50 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/media_new.html.j2 +27 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/page_edit.html.j2 +122 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/page_new.html.j2 +26 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/page_translation.html.j2 +39 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/pages_list.html.j2 +47 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/publishing.html.j2 +74 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/section_edit.html.j2 +61 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/section_translation.html.j2 +51 -0
- sardine_cms_admin-0.1.0/src/cms_admin/templates/shell.html.j2 +75 -0
- sardine_cms_admin-0.1.0/src/cms_admin/workflow.py +69 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
.mypy_cache/
|
|
8
|
+
.ruff_cache/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
|
|
15
|
+
# Node (admin panel UI, if applicable)
|
|
16
|
+
node_modules/
|
|
17
|
+
*.tsbuildinfo
|
|
18
|
+
|
|
19
|
+
# Environment and secrets
|
|
20
|
+
.env
|
|
21
|
+
.env.*
|
|
22
|
+
!.env.example
|
|
23
|
+
|
|
24
|
+
# Local databases
|
|
25
|
+
*.sqlite3
|
|
26
|
+
*.db
|
|
27
|
+
|
|
28
|
+
# Static build output
|
|
29
|
+
_site/
|
|
30
|
+
out/
|
|
31
|
+
export/
|
|
32
|
+
|
|
33
|
+
# OS / editors
|
|
34
|
+
.DS_Store
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
37
|
+
!.vscode/settings.json
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sardine-cms-admin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sardine CMS admin panel: FastAPI API + server-rendered UI
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: argon2-cffi>=23.1
|
|
8
|
+
Requires-Dist: fastapi>=0.115
|
|
9
|
+
Requires-Dist: jinja2>=3.1
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
11
|
+
Requires-Dist: sardine-cms-build
|
|
12
|
+
Requires-Dist: sardine-cms-cli
|
|
13
|
+
Requires-Dist: sardine-cms-core
|
|
14
|
+
Requires-Dist: sardine-cms-validation
|
|
15
|
+
Requires-Dist: uvicorn>=0.30
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# cms-admin
|
|
19
|
+
|
|
20
|
+
The Sardine CMS admin panel: an authenticated FastAPI application serving both
|
|
21
|
+
the admin API and the server-rendered UI (ADR-0013). It drives `cms-core`,
|
|
22
|
+
`cms-validation` and `cms-build` through their public APIs — storage comes
|
|
23
|
+
from `create_storage(url)`, so every supported engine works unchanged.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e apps/admin
|
|
27
|
+
SARDINE_STORAGE_URL="sqlite:///content.db" uvicorn --factory cms_admin.app:create_app
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
There are no default credentials — create the first account with the CLI,
|
|
31
|
+
then sign in at `/login`:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cms admin create-user editor-in-chief -p my-project --role admin
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The UI is built on AdminLTE 4 (MIT, vendored verbatim with its license —
|
|
38
|
+
ADR-0017), used CSS-only: the admin ships zero JavaScript and serves
|
|
39
|
+
everything itself, no CDN. A small overlay adds the brand (local
|
|
40
|
+
Inter/Newsreader) and accessibility fixes. The dashboard shows
|
|
41
|
+
content by workflow status, the translation coverage matrix and a live
|
|
42
|
+
validation report. Articles and pages are edited in the browser: EN source
|
|
43
|
+
plus a side-by-side editor per translation, with state indicators from the
|
|
44
|
+
checksum model and a preview rendered by the builder's own Markdown
|
|
45
|
+
renderer (raw HTML disabled). The media library uploads with server-side
|
|
46
|
+
validation (MIME sniffed from bytes, size limit, parsed image dimensions),
|
|
47
|
+
manages mandatory EN + translatable alt text, and refuses to delete assets
|
|
48
|
+
that articles or sections still reference. The workflow moves content through
|
|
49
|
+
draft → review → published → archived with each transition owned by a rung
|
|
50
|
+
of the role ladder; publishing runs the validation gate. The Publishing
|
|
51
|
+
panel previews into `/preview/` and builds/exports the project output with
|
|
52
|
+
target extras, with every run recorded on the dashboard.
|
|
53
|
+
|
|
54
|
+
Configuration is environment-only (no config files with secrets):
|
|
55
|
+
|
|
56
|
+
| Variable | Meaning | Default |
|
|
57
|
+
| ------------------------------- | ------------------------------------------- | ---------------------- |
|
|
58
|
+
| `SARDINE_STORAGE_URL` | Storage URL for `create_storage` | `sqlite:///content.db` |
|
|
59
|
+
| `SARDINE_ADMIN_SESSION_HOURS` | Session lifetime in hours | `12` |
|
|
60
|
+
| `SARDINE_ADMIN_COOKIE_SECURE` | Set `0` only for plain-http local dev | `1` |
|
|
61
|
+
| `SARDINE_MEDIA_DIR` | Media upload directory (the project's) | `media` |
|
|
62
|
+
| `SARDINE_ADMIN_UPLOAD_MAX_MB` | Upload size limit in MB | `10` |
|
|
63
|
+
| `SARDINE_PROJECT_DIR` | Project directory (`sardine.toml`) | `.` |
|
|
64
|
+
| `SARDINE_ADMIN_PUBLISH_GATE` | Set `0` to publish despite validation errors | `1` |
|
|
65
|
+
|
|
66
|
+
The full admin guide — configuration, roles, workflow, publishing,
|
|
67
|
+
security model — is [docs/ADMIN_GUIDE.md](../../docs/ADMIN_GUIDE.md)
|
|
68
|
+
(anti-drift-checked). Milestone 3 status and the phased plan live in
|
|
69
|
+
[docs/PLAN.md](../../docs/PLAN.md).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# cms-admin
|
|
2
|
+
|
|
3
|
+
The Sardine CMS admin panel: an authenticated FastAPI application serving both
|
|
4
|
+
the admin API and the server-rendered UI (ADR-0013). It drives `cms-core`,
|
|
5
|
+
`cms-validation` and `cms-build` through their public APIs — storage comes
|
|
6
|
+
from `create_storage(url)`, so every supported engine works unchanged.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install -e apps/admin
|
|
10
|
+
SARDINE_STORAGE_URL="sqlite:///content.db" uvicorn --factory cms_admin.app:create_app
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
There are no default credentials — create the first account with the CLI,
|
|
14
|
+
then sign in at `/login`:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cms admin create-user editor-in-chief -p my-project --role admin
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The UI is built on AdminLTE 4 (MIT, vendored verbatim with its license —
|
|
21
|
+
ADR-0017), used CSS-only: the admin ships zero JavaScript and serves
|
|
22
|
+
everything itself, no CDN. A small overlay adds the brand (local
|
|
23
|
+
Inter/Newsreader) and accessibility fixes. The dashboard shows
|
|
24
|
+
content by workflow status, the translation coverage matrix and a live
|
|
25
|
+
validation report. Articles and pages are edited in the browser: EN source
|
|
26
|
+
plus a side-by-side editor per translation, with state indicators from the
|
|
27
|
+
checksum model and a preview rendered by the builder's own Markdown
|
|
28
|
+
renderer (raw HTML disabled). The media library uploads with server-side
|
|
29
|
+
validation (MIME sniffed from bytes, size limit, parsed image dimensions),
|
|
30
|
+
manages mandatory EN + translatable alt text, and refuses to delete assets
|
|
31
|
+
that articles or sections still reference. The workflow moves content through
|
|
32
|
+
draft → review → published → archived with each transition owned by a rung
|
|
33
|
+
of the role ladder; publishing runs the validation gate. The Publishing
|
|
34
|
+
panel previews into `/preview/` and builds/exports the project output with
|
|
35
|
+
target extras, with every run recorded on the dashboard.
|
|
36
|
+
|
|
37
|
+
Configuration is environment-only (no config files with secrets):
|
|
38
|
+
|
|
39
|
+
| Variable | Meaning | Default |
|
|
40
|
+
| ------------------------------- | ------------------------------------------- | ---------------------- |
|
|
41
|
+
| `SARDINE_STORAGE_URL` | Storage URL for `create_storage` | `sqlite:///content.db` |
|
|
42
|
+
| `SARDINE_ADMIN_SESSION_HOURS` | Session lifetime in hours | `12` |
|
|
43
|
+
| `SARDINE_ADMIN_COOKIE_SECURE` | Set `0` only for plain-http local dev | `1` |
|
|
44
|
+
| `SARDINE_MEDIA_DIR` | Media upload directory (the project's) | `media` |
|
|
45
|
+
| `SARDINE_ADMIN_UPLOAD_MAX_MB` | Upload size limit in MB | `10` |
|
|
46
|
+
| `SARDINE_PROJECT_DIR` | Project directory (`sardine.toml`) | `.` |
|
|
47
|
+
| `SARDINE_ADMIN_PUBLISH_GATE` | Set `0` to publish despite validation errors | `1` |
|
|
48
|
+
|
|
49
|
+
The full admin guide — configuration, roles, workflow, publishing,
|
|
50
|
+
security model — is [docs/ADMIN_GUIDE.md](../../docs/ADMIN_GUIDE.md)
|
|
51
|
+
(anti-drift-checked). Milestone 3 status and the phased plan live in
|
|
52
|
+
[docs/PLAN.md](../../docs/PLAN.md).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sardine-cms-admin"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sardine CMS admin panel: FastAPI API + server-rendered UI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"sardine-cms-core",
|
|
10
|
+
"sardine-cms-validation",
|
|
11
|
+
"sardine-cms-build",
|
|
12
|
+
"sardine-cms-cli",
|
|
13
|
+
"fastapi>=0.115",
|
|
14
|
+
"jinja2>=3.1",
|
|
15
|
+
"uvicorn>=0.30",
|
|
16
|
+
"argon2-cffi>=23.1",
|
|
17
|
+
"python-multipart>=0.0.9",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/cms_admin"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Application factory.
|
|
2
|
+
|
|
3
|
+
One FastAPI process serves the API and the server-rendered UI (ADR-0013).
|
|
4
|
+
Storage is opened once per process (any supported engine, via the
|
|
5
|
+
thread-affine ``StorageExecutor``) and closed on shutdown; handlers reach it
|
|
6
|
+
through ``get_db``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, Request
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
from fastapi.templating import Jinja2Templates
|
|
17
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
18
|
+
|
|
19
|
+
from cms_admin.articles import router as articles_router
|
|
20
|
+
from cms_admin.auth import get_db
|
|
21
|
+
from cms_admin.auth import router as auth_router
|
|
22
|
+
from cms_admin.dashboard import router as dashboard_router
|
|
23
|
+
from cms_admin.db import StorageExecutor
|
|
24
|
+
from cms_admin.media import router as media_router
|
|
25
|
+
from cms_admin.pages import router as pages_router
|
|
26
|
+
from cms_admin.publishing import router as publishing_router
|
|
27
|
+
from cms_admin.settings import AdminSettings
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@asynccontextmanager
|
|
31
|
+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
32
|
+
settings: AdminSettings = app.state.settings
|
|
33
|
+
app.state.db = StorageExecutor(settings.storage_url)
|
|
34
|
+
try:
|
|
35
|
+
yield
|
|
36
|
+
finally:
|
|
37
|
+
app.state.db.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Hardening (SECURITY_STRATEGY, M3 phase 9): the admin ships zero JavaScript,
|
|
41
|
+
# so the CSP allows no script source at all; styles, fonts and images come
|
|
42
|
+
# only from the app itself; nothing may frame it.
|
|
43
|
+
SECURITY_HEADERS = {
|
|
44
|
+
"Content-Security-Policy": (
|
|
45
|
+
"default-src 'none'; style-src 'self'; font-src 'self'; "
|
|
46
|
+
"img-src 'self' data:; form-action 'self'; base-uri 'none'; "
|
|
47
|
+
"frame-ancestors 'none'"
|
|
48
|
+
),
|
|
49
|
+
"X-Content-Type-Options": "nosniff",
|
|
50
|
+
"X-Frame-Options": "DENY",
|
|
51
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
52
|
+
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_app(settings: AdminSettings | None = None) -> FastAPI:
|
|
57
|
+
from cms_admin.auth import LoginRateLimiter
|
|
58
|
+
|
|
59
|
+
app = FastAPI(title="Sardine CMS admin", lifespan=_lifespan, docs_url=None, redoc_url=None)
|
|
60
|
+
|
|
61
|
+
@app.middleware("http")
|
|
62
|
+
async def _security_headers(request: Request, call_next): # type: ignore[no-untyped-def]
|
|
63
|
+
response = await call_next(request)
|
|
64
|
+
for name, value in SECURITY_HEADERS.items():
|
|
65
|
+
response.headers.setdefault(name, value)
|
|
66
|
+
return response
|
|
67
|
+
|
|
68
|
+
app.state.settings = settings if settings is not None else AdminSettings.from_env()
|
|
69
|
+
# autoescape must be forced on: the stock select_autoescape does not
|
|
70
|
+
# recognize the .html.j2 extension and would render templates unescaped.
|
|
71
|
+
app.state.templates = Jinja2Templates(
|
|
72
|
+
env=Environment(
|
|
73
|
+
loader=FileSystemLoader(Path(__file__).parent / "templates"),
|
|
74
|
+
autoescape=select_autoescape(default=True, default_for_string=True),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
app.state.login_limiter = LoginRateLimiter()
|
|
78
|
+
app.include_router(auth_router)
|
|
79
|
+
app.include_router(dashboard_router)
|
|
80
|
+
app.include_router(articles_router)
|
|
81
|
+
app.include_router(pages_router)
|
|
82
|
+
app.include_router(media_router)
|
|
83
|
+
app.include_router(publishing_router)
|
|
84
|
+
app.state.preview_dir = tempfile.mkdtemp(prefix="sardine-preview-")
|
|
85
|
+
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
|
86
|
+
app.mount(
|
|
87
|
+
"/preview",
|
|
88
|
+
StaticFiles(directory=app.state.preview_dir, html=True, check_dir=False),
|
|
89
|
+
name="preview",
|
|
90
|
+
)
|
|
91
|
+
app.mount(
|
|
92
|
+
"/media-files",
|
|
93
|
+
StaticFiles(directory=app.state.settings.media_dir, check_dir=False),
|
|
94
|
+
name="media-files",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@app.get("/healthz")
|
|
98
|
+
async def healthz(request: Request) -> dict[str, str | int]:
|
|
99
|
+
version = await get_db(request).run(lambda storage: storage.schema_version())
|
|
100
|
+
return {"status": "ok", "schema_version": version}
|
|
101
|
+
|
|
102
|
+
return app
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Articles: list, create, and the side-by-side translation editor.
|
|
2
|
+
|
|
3
|
+
The EN source is edited on the article page; each translation is edited next
|
|
4
|
+
to a read-only view of the source it translates (the checksum model marks it
|
|
5
|
+
outdated automatically when the source changes afterwards). The preview uses
|
|
6
|
+
the builder's own Markdown renderer, so what an editor sees is exactly what
|
|
7
|
+
the published site will render — raw HTML stays disabled.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
from cms_build import render_markdown
|
|
13
|
+
from cms_core import (
|
|
14
|
+
AdminSession,
|
|
15
|
+
Article,
|
|
16
|
+
ArticleContent,
|
|
17
|
+
ContentStatus,
|
|
18
|
+
Language,
|
|
19
|
+
Role,
|
|
20
|
+
User,
|
|
21
|
+
new_article,
|
|
22
|
+
)
|
|
23
|
+
from cms_core.languages import SOURCE_LANGUAGE, TARGET_LANGUAGES
|
|
24
|
+
from cms_validation import SiteContent
|
|
25
|
+
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
|
26
|
+
from fastapi.responses import RedirectResponse
|
|
27
|
+
from pydantic import ValidationError
|
|
28
|
+
|
|
29
|
+
from cms_admin.auth import current_session, enforce_csrf, get_db
|
|
30
|
+
from cms_admin.workflow import (
|
|
31
|
+
allowed,
|
|
32
|
+
available_transitions,
|
|
33
|
+
publish_blockers,
|
|
34
|
+
transition_minimum,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
router = APIRouter(prefix="/articles")
|
|
38
|
+
|
|
39
|
+
HTTP_422 = status.HTTP_422_UNPROCESSABLE_CONTENT
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def form_errors(error: ValidationError) -> list[str]:
|
|
43
|
+
return [
|
|
44
|
+
f"{'.'.join(str(part) for part in item['loc']) or 'form'}: {item['msg']}"
|
|
45
|
+
for item in error.errors()
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_tags(raw: str) -> tuple[str, ...]:
|
|
50
|
+
return tuple(part.strip() for part in raw.split(",") if part.strip())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def content_form(content: ArticleContent | None) -> dict[str, str]:
|
|
54
|
+
if content is None:
|
|
55
|
+
return {"title": "", "summary": "", "body_markdown": "", "slug": ""}
|
|
56
|
+
return {
|
|
57
|
+
"title": content.title,
|
|
58
|
+
"summary": content.summary,
|
|
59
|
+
"body_markdown": content.body_markdown,
|
|
60
|
+
"slug": content.slug or "",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _target_language(code: str) -> Language:
|
|
65
|
+
try:
|
|
66
|
+
language = Language(code)
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=status.HTTP_404_NOT_FOUND, detail="unknown language"
|
|
70
|
+
) from None
|
|
71
|
+
if language is SOURCE_LANGUAGE:
|
|
72
|
+
raise HTTPException(
|
|
73
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
74
|
+
detail="the source language is edited on the article page",
|
|
75
|
+
)
|
|
76
|
+
return language
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _load_article(request: Request, article_id: str) -> Article:
|
|
80
|
+
article = await get_db(request).run(lambda storage: storage.load_article(article_id))
|
|
81
|
+
if article is None:
|
|
82
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown article")
|
|
83
|
+
return article
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _page(
|
|
87
|
+
request: Request, template: str, context: dict[str, object], status_code: int = 200
|
|
88
|
+
) -> object:
|
|
89
|
+
return request.app.state.templates.TemplateResponse(
|
|
90
|
+
request, template, {"active_section": "articles", **context}, status_code=status_code
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.get("")
|
|
95
|
+
async def articles_list(
|
|
96
|
+
request: Request,
|
|
97
|
+
user_session: tuple[User, AdminSession] = Depends(current_session),
|
|
98
|
+
) -> object:
|
|
99
|
+
user, session = user_session
|
|
100
|
+
articles = await get_db(request).run(lambda storage: storage.load_all_articles())
|
|
101
|
+
return _page(
|
|
102
|
+
request,
|
|
103
|
+
"articles_list.html.j2",
|
|
104
|
+
{
|
|
105
|
+
"user": user,
|
|
106
|
+
"csrf_token": session.csrf_token,
|
|
107
|
+
"articles": articles,
|
|
108
|
+
"target_languages": TARGET_LANGUAGES,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.get("/new")
|
|
114
|
+
async def article_new_form(
|
|
115
|
+
request: Request,
|
|
116
|
+
user_session: tuple[User, AdminSession] = Depends(current_session),
|
|
117
|
+
) -> object:
|
|
118
|
+
user, session = user_session
|
|
119
|
+
form = {"id": "", "category": "", "tags": "", "cover": "", **content_form(None)}
|
|
120
|
+
return _page(
|
|
121
|
+
request,
|
|
122
|
+
"article_new.html.j2",
|
|
123
|
+
{"user": user, "csrf_token": session.csrf_token, "errors": [], "form": form},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _validated_article(base: Article, form: dict[str, str]) -> Article:
|
|
128
|
+
"""Rebuild the article from the form and run every model validator once."""
|
|
129
|
+
payload = base.model_dump()
|
|
130
|
+
payload.update(
|
|
131
|
+
source=ArticleContent(
|
|
132
|
+
title=form["title"],
|
|
133
|
+
summary=form["summary"],
|
|
134
|
+
body_markdown=form["body_markdown"],
|
|
135
|
+
slug=form["slug"] or None,
|
|
136
|
+
).model_dump(),
|
|
137
|
+
category=form["category"] or None,
|
|
138
|
+
cover=form["cover"] or None,
|
|
139
|
+
tags=parse_tags(form["tags"]),
|
|
140
|
+
updated_at=datetime.now(UTC),
|
|
141
|
+
)
|
|
142
|
+
return Article.model_validate(payload)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.post("")
|
|
146
|
+
async def article_create(
|
|
147
|
+
request: Request,
|
|
148
|
+
user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
|
|
149
|
+
article_id: str = Form(alias="id"),
|
|
150
|
+
title: str = Form(""),
|
|
151
|
+
summary: str = Form(""),
|
|
152
|
+
body_markdown: str = Form(""),
|
|
153
|
+
slug: str = Form(""),
|
|
154
|
+
category: str = Form(""),
|
|
155
|
+
tags: str = Form(""),
|
|
156
|
+
cover: str = Form(""),
|
|
157
|
+
) -> object:
|
|
158
|
+
user, session = user_session
|
|
159
|
+
db = get_db(request)
|
|
160
|
+
form = {
|
|
161
|
+
"id": article_id,
|
|
162
|
+
"title": title,
|
|
163
|
+
"summary": summary,
|
|
164
|
+
"body_markdown": body_markdown,
|
|
165
|
+
"slug": slug,
|
|
166
|
+
"category": category,
|
|
167
|
+
"tags": tags,
|
|
168
|
+
"cover": cover,
|
|
169
|
+
}
|
|
170
|
+
try:
|
|
171
|
+
base = new_article(article_id, ArticleContent(title=title or "-"))
|
|
172
|
+
article = _validated_article(base, form)
|
|
173
|
+
except ValidationError as error:
|
|
174
|
+
errors = form_errors(error)
|
|
175
|
+
else:
|
|
176
|
+
existing = await db.run(lambda storage: storage.load_article(article_id))
|
|
177
|
+
if existing is None:
|
|
178
|
+
await db.run(lambda storage: storage.save_article(article))
|
|
179
|
+
return RedirectResponse(
|
|
180
|
+
f"/articles/{article.id}", status_code=status.HTTP_303_SEE_OTHER
|
|
181
|
+
)
|
|
182
|
+
errors = [f"id: an article with id {article_id!r} already exists"]
|
|
183
|
+
return _page(
|
|
184
|
+
request,
|
|
185
|
+
"article_new.html.j2",
|
|
186
|
+
{"user": user, "csrf_token": session.csrf_token, "errors": errors, "form": form},
|
|
187
|
+
status_code=HTTP_422,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _editor_context(
|
|
192
|
+
article: Article, form: dict[str, str] | None = None, role: Role | None = None
|
|
193
|
+
) -> dict[str, object]:
|
|
194
|
+
return {
|
|
195
|
+
"article": article,
|
|
196
|
+
"transitions": available_transitions(article.status, role) if role else [],
|
|
197
|
+
"states": article.translation_states(),
|
|
198
|
+
"target_languages": TARGET_LANGUAGES,
|
|
199
|
+
"preview_html": render_markdown(article.source.body_markdown),
|
|
200
|
+
"form": form
|
|
201
|
+
or {
|
|
202
|
+
**content_form(article.source),
|
|
203
|
+
"category": article.category or "",
|
|
204
|
+
"tags": ", ".join(article.tags),
|
|
205
|
+
"cover": article.cover or "",
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@router.get("/{article_id}")
|
|
211
|
+
async def article_edit_form(
|
|
212
|
+
request: Request,
|
|
213
|
+
article_id: str,
|
|
214
|
+
user_session: tuple[User, AdminSession] = Depends(current_session),
|
|
215
|
+
) -> object:
|
|
216
|
+
user, session = user_session
|
|
217
|
+
article = await _load_article(request, article_id)
|
|
218
|
+
return _page(
|
|
219
|
+
request,
|
|
220
|
+
"article_edit.html.j2",
|
|
221
|
+
{
|
|
222
|
+
"user": user,
|
|
223
|
+
"csrf_token": session.csrf_token,
|
|
224
|
+
"errors": [],
|
|
225
|
+
**_editor_context(article, role=user.role),
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@router.post("/{article_id}")
|
|
231
|
+
async def article_edit_save(
|
|
232
|
+
request: Request,
|
|
233
|
+
article_id: str,
|
|
234
|
+
user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
|
|
235
|
+
title: str = Form(""),
|
|
236
|
+
summary: str = Form(""),
|
|
237
|
+
body_markdown: str = Form(""),
|
|
238
|
+
slug: str = Form(""),
|
|
239
|
+
category: str = Form(""),
|
|
240
|
+
tags: str = Form(""),
|
|
241
|
+
cover: str = Form(""),
|
|
242
|
+
) -> object:
|
|
243
|
+
user, session = user_session
|
|
244
|
+
article = await _load_article(request, article_id)
|
|
245
|
+
form = {
|
|
246
|
+
"title": title,
|
|
247
|
+
"summary": summary,
|
|
248
|
+
"body_markdown": body_markdown,
|
|
249
|
+
"slug": slug,
|
|
250
|
+
"category": category,
|
|
251
|
+
"tags": tags,
|
|
252
|
+
"cover": cover,
|
|
253
|
+
}
|
|
254
|
+
try:
|
|
255
|
+
article = _validated_article(article, form)
|
|
256
|
+
except ValidationError as error:
|
|
257
|
+
return _page(
|
|
258
|
+
request,
|
|
259
|
+
"article_edit.html.j2",
|
|
260
|
+
{
|
|
261
|
+
"user": user,
|
|
262
|
+
"csrf_token": session.csrf_token,
|
|
263
|
+
"errors": form_errors(error),
|
|
264
|
+
**_editor_context(article, form, role=user.role),
|
|
265
|
+
},
|
|
266
|
+
status_code=HTTP_422,
|
|
267
|
+
)
|
|
268
|
+
await get_db(request).run(lambda storage: storage.save_article(article))
|
|
269
|
+
return RedirectResponse(f"/articles/{article.id}", status_code=status.HTTP_303_SEE_OTHER)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _translation_context(
|
|
273
|
+
article: Article, language: Language, form: dict[str, str] | None = None
|
|
274
|
+
) -> dict[str, object]:
|
|
275
|
+
translation = article.translations.get(language)
|
|
276
|
+
content = translation.content if translation else None
|
|
277
|
+
return {
|
|
278
|
+
"article": article,
|
|
279
|
+
"language": language,
|
|
280
|
+
"state": article.translation_state(language),
|
|
281
|
+
"source_preview_html": render_markdown(article.source.body_markdown),
|
|
282
|
+
"preview_html": render_markdown(content.body_markdown) if content else "",
|
|
283
|
+
"form": form or content_form(content),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@router.get("/{article_id}/translations/{language_code}")
|
|
288
|
+
async def translation_form(
|
|
289
|
+
request: Request,
|
|
290
|
+
article_id: str,
|
|
291
|
+
language_code: str,
|
|
292
|
+
user_session: tuple[User, AdminSession] = Depends(current_session),
|
|
293
|
+
) -> object:
|
|
294
|
+
user, session = user_session
|
|
295
|
+
language = _target_language(language_code)
|
|
296
|
+
article = await _load_article(request, article_id)
|
|
297
|
+
return _page(
|
|
298
|
+
request,
|
|
299
|
+
"article_translation.html.j2",
|
|
300
|
+
{
|
|
301
|
+
"user": user,
|
|
302
|
+
"csrf_token": session.csrf_token,
|
|
303
|
+
"errors": [],
|
|
304
|
+
**_translation_context(article, language),
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.post("/{article_id}/translations/{language_code}")
|
|
310
|
+
async def translation_save(
|
|
311
|
+
request: Request,
|
|
312
|
+
article_id: str,
|
|
313
|
+
language_code: str,
|
|
314
|
+
user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
|
|
315
|
+
title: str = Form(""),
|
|
316
|
+
summary: str = Form(""),
|
|
317
|
+
body_markdown: str = Form(""),
|
|
318
|
+
slug: str = Form(""),
|
|
319
|
+
) -> object:
|
|
320
|
+
user, session = user_session
|
|
321
|
+
language = _target_language(language_code)
|
|
322
|
+
article = await _load_article(request, article_id)
|
|
323
|
+
form = {"title": title, "summary": summary, "body_markdown": body_markdown, "slug": slug}
|
|
324
|
+
try:
|
|
325
|
+
content = ArticleContent(
|
|
326
|
+
title=title, summary=summary, body_markdown=body_markdown, slug=slug or None
|
|
327
|
+
)
|
|
328
|
+
except ValidationError as error:
|
|
329
|
+
return _page(
|
|
330
|
+
request,
|
|
331
|
+
"article_translation.html.j2",
|
|
332
|
+
{
|
|
333
|
+
"user": user,
|
|
334
|
+
"csrf_token": session.csrf_token,
|
|
335
|
+
"errors": form_errors(error),
|
|
336
|
+
**_translation_context(article, language, form),
|
|
337
|
+
},
|
|
338
|
+
status_code=HTTP_422,
|
|
339
|
+
)
|
|
340
|
+
article.set_translation(language, content)
|
|
341
|
+
article.updated_at = datetime.now(UTC)
|
|
342
|
+
await get_db(request).run(lambda storage: storage.save_article(article))
|
|
343
|
+
return RedirectResponse(
|
|
344
|
+
f"/articles/{article.id}/translations/{language.value}",
|
|
345
|
+
status_code=status.HTTP_303_SEE_OTHER,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@router.post("/{article_id}/status")
|
|
350
|
+
async def article_status(
|
|
351
|
+
request: Request,
|
|
352
|
+
article_id: str,
|
|
353
|
+
user_session: tuple[User, AdminSession] = Depends(enforce_csrf),
|
|
354
|
+
to: str = Form(...),
|
|
355
|
+
) -> object:
|
|
356
|
+
user, session = user_session
|
|
357
|
+
article = await _load_article(request, article_id)
|
|
358
|
+
try:
|
|
359
|
+
target = ContentStatus(to)
|
|
360
|
+
except ValueError:
|
|
361
|
+
raise HTTPException(status_code=400, detail="unknown status") from None
|
|
362
|
+
minimum = transition_minimum(article.status, target)
|
|
363
|
+
if minimum is None:
|
|
364
|
+
raise HTTPException(status_code=400, detail="invalid transition")
|
|
365
|
+
if not allowed(user.role, minimum):
|
|
366
|
+
raise HTTPException(
|
|
367
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
368
|
+
detail=f"requires the {minimum.value} role",
|
|
369
|
+
)
|
|
370
|
+
if target is ContentStatus.PUBLISHED and request.app.state.settings.publish_gate:
|
|
371
|
+
db = get_db(request)
|
|
372
|
+
content = SiteContent(
|
|
373
|
+
articles=await db.run(lambda storage: storage.load_all_articles()),
|
|
374
|
+
pages=await db.run(lambda storage: storage.load_all_pages()),
|
|
375
|
+
media=await db.run(lambda storage: storage.load_all_media_assets()),
|
|
376
|
+
)
|
|
377
|
+
blockers = publish_blockers(article, content)
|
|
378
|
+
if blockers:
|
|
379
|
+
return _page(
|
|
380
|
+
request,
|
|
381
|
+
"article_edit.html.j2",
|
|
382
|
+
{
|
|
383
|
+
"user": user,
|
|
384
|
+
"csrf_token": session.csrf_token,
|
|
385
|
+
"errors": blockers,
|
|
386
|
+
**_editor_context(article, role=user.role),
|
|
387
|
+
},
|
|
388
|
+
status_code=HTTP_422,
|
|
389
|
+
)
|
|
390
|
+
article.status = target
|
|
391
|
+
article.updated_at = datetime.now(UTC)
|
|
392
|
+
await get_db(request).run(lambda storage: storage.save_article(article))
|
|
393
|
+
return RedirectResponse(f"/articles/{article.id}", status_code=status.HTTP_303_SEE_OTHER)
|