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.
Files changed (47) hide show
  1. sardine_cms_admin-0.1.0/.gitignore +37 -0
  2. sardine_cms_admin-0.1.0/PKG-INFO +69 -0
  3. sardine_cms_admin-0.1.0/README.md +52 -0
  4. sardine_cms_admin-0.1.0/pyproject.toml +25 -0
  5. sardine_cms_admin-0.1.0/src/cms_admin/__init__.py +6 -0
  6. sardine_cms_admin-0.1.0/src/cms_admin/app.py +102 -0
  7. sardine_cms_admin-0.1.0/src/cms_admin/articles.py +393 -0
  8. sardine_cms_admin-0.1.0/src/cms_admin/auth.py +186 -0
  9. sardine_cms_admin-0.1.0/src/cms_admin/dashboard.py +95 -0
  10. sardine_cms_admin-0.1.0/src/cms_admin/db.py +34 -0
  11. sardine_cms_admin-0.1.0/src/cms_admin/demo_export.py +167 -0
  12. sardine_cms_admin-0.1.0/src/cms_admin/media.py +320 -0
  13. sardine_cms_admin-0.1.0/src/cms_admin/pages.py +565 -0
  14. sardine_cms_admin-0.1.0/src/cms_admin/publishing.py +167 -0
  15. sardine_cms_admin-0.1.0/src/cms_admin/py.typed +0 -0
  16. sardine_cms_admin-0.1.0/src/cms_admin/security.py +33 -0
  17. sardine_cms_admin-0.1.0/src/cms_admin/settings.py +52 -0
  18. sardine_cms_admin-0.1.0/src/cms_admin/static/admin.css +93 -0
  19. sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/OFL.txt +92 -0
  20. sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/inter-normal-latin.woff2 +0 -0
  21. sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/inter-normal-latinext.woff2 +0 -0
  22. sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/newsreader-italic-latin.woff2 +0 -0
  23. sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/newsreader-italic-latinext.woff2 +0 -0
  24. sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/newsreader-normal-latin.woff2 +0 -0
  25. sardine_cms_admin-0.1.0/src/cms_admin/static/fonts/newsreader-normal-latinext.woff2 +0 -0
  26. sardine_cms_admin-0.1.0/src/cms_admin/static/vendor/adminlte/ADMINLTE-LICENSE.txt +20 -0
  27. sardine_cms_admin-0.1.0/src/cms_admin/static/vendor/adminlte/adminlte.min.css +7 -0
  28. sardine_cms_admin-0.1.0/src/cms_admin/templates/_fields.html.j2 +50 -0
  29. sardine_cms_admin-0.1.0/src/cms_admin/templates/article_edit.html.j2 +68 -0
  30. sardine_cms_admin-0.1.0/src/cms_admin/templates/article_new.html.j2 +26 -0
  31. sardine_cms_admin-0.1.0/src/cms_admin/templates/article_translation.html.j2 +42 -0
  32. sardine_cms_admin-0.1.0/src/cms_admin/templates/articles_list.html.j2 +44 -0
  33. sardine_cms_admin-0.1.0/src/cms_admin/templates/base.html.j2 +18 -0
  34. sardine_cms_admin-0.1.0/src/cms_admin/templates/dashboard.html.j2 +104 -0
  35. sardine_cms_admin-0.1.0/src/cms_admin/templates/login.html.j2 +31 -0
  36. sardine_cms_admin-0.1.0/src/cms_admin/templates/media_edit.html.j2 +62 -0
  37. sardine_cms_admin-0.1.0/src/cms_admin/templates/media_list.html.j2 +50 -0
  38. sardine_cms_admin-0.1.0/src/cms_admin/templates/media_new.html.j2 +27 -0
  39. sardine_cms_admin-0.1.0/src/cms_admin/templates/page_edit.html.j2 +122 -0
  40. sardine_cms_admin-0.1.0/src/cms_admin/templates/page_new.html.j2 +26 -0
  41. sardine_cms_admin-0.1.0/src/cms_admin/templates/page_translation.html.j2 +39 -0
  42. sardine_cms_admin-0.1.0/src/cms_admin/templates/pages_list.html.j2 +47 -0
  43. sardine_cms_admin-0.1.0/src/cms_admin/templates/publishing.html.j2 +74 -0
  44. sardine_cms_admin-0.1.0/src/cms_admin/templates/section_edit.html.j2 +61 -0
  45. sardine_cms_admin-0.1.0/src/cms_admin/templates/section_translation.html.j2 +51 -0
  46. sardine_cms_admin-0.1.0/src/cms_admin/templates/shell.html.j2 +75 -0
  47. 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,6 @@
1
+ """Sardine CMS admin panel (ADR-0013): FastAPI API + server-rendered UI."""
2
+
3
+ from cms_admin.app import create_app
4
+ from cms_admin.settings import AdminSettings
5
+
6
+ __all__ = ["AdminSettings", "create_app"]
@@ -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)