sardine-cms-core 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_core-0.1.0/.gitignore +37 -0
- sardine_cms_core-0.1.0/PKG-INFO +20 -0
- sardine_cms_core-0.1.0/README.md +5 -0
- sardine_cms_core-0.1.0/pyproject.toml +20 -0
- sardine_cms_core-0.1.0/src/cms_core/__init__.py +49 -0
- sardine_cms_core-0.1.0/src/cms_core/accounts.py +48 -0
- sardine_cms_core-0.1.0/src/cms_core/export.py +142 -0
- sardine_cms_core-0.1.0/src/cms_core/languages.py +17 -0
- sardine_cms_core-0.1.0/src/cms_core/media.py +38 -0
- sardine_cms_core-0.1.0/src/cms_core/models.py +51 -0
- sardine_cms_core-0.1.0/src/cms_core/pages.py +65 -0
- sardine_cms_core-0.1.0/src/cms_core/py.typed +0 -0
- sardine_cms_core-0.1.0/src/cms_core/states.py +16 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/__init__.py +23 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/base.py +126 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/dbapi.py +478 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/factory.py +77 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/migrations.py +108 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/mssql.py +81 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/mysql.py +83 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/postgres.py +417 -0
- sardine_cms_core-0.1.0/src/cms_core/storage/sqlite.py +423 -0
- sardine_cms_core-0.1.0/src/cms_core/translatable.py +79 -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,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sardine-cms-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Content model, versioned schemas and translation states for Sardine CMS
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: pydantic>=2.9
|
|
8
|
+
Provides-Extra: mssql
|
|
9
|
+
Requires-Dist: pymssql>=2.3; extra == 'mssql'
|
|
10
|
+
Provides-Extra: mysql
|
|
11
|
+
Requires-Dist: pymysql>=1.1; extra == 'mysql'
|
|
12
|
+
Provides-Extra: postgres
|
|
13
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# cms-core
|
|
17
|
+
|
|
18
|
+
Content model, versioned schemas and translation states (EN source;
|
|
19
|
+
`missing / outdated / complete`) for Sardine CMS. See the repository
|
|
20
|
+
root README and docs for the full architecture.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sardine-cms-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Content model, versioned schemas and translation states for Sardine CMS"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
dependencies = ["pydantic>=2.9"]
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
postgres = ["psycopg[binary]>=3.2"]
|
|
12
|
+
mysql = ["PyMySQL>=1.1"]
|
|
13
|
+
mssql = ["pymssql>=2.3"]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/cms_core"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Content model, versioned schemas and translation states."""
|
|
2
|
+
|
|
3
|
+
from cms_core.accounts import AdminSession, Role, User
|
|
4
|
+
from cms_core.languages import SOURCE_LANGUAGE, TARGET_LANGUAGES, Language
|
|
5
|
+
from cms_core.media import MediaAsset
|
|
6
|
+
from cms_core.models import (
|
|
7
|
+
SCHEMA_VERSION,
|
|
8
|
+
Article,
|
|
9
|
+
ArticleContent,
|
|
10
|
+
new_article,
|
|
11
|
+
)
|
|
12
|
+
from cms_core.pages import Page, PageContent, Section, SectionContent, new_page
|
|
13
|
+
from cms_core.states import ContentStatus, TranslationState
|
|
14
|
+
from cms_core.storage import StorageBackend, create_storage
|
|
15
|
+
from cms_core.translatable import (
|
|
16
|
+
ChecksummedContent,
|
|
17
|
+
TranslatableModel,
|
|
18
|
+
Translation,
|
|
19
|
+
worst_state,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SCHEMA_VERSION",
|
|
26
|
+
"SOURCE_LANGUAGE",
|
|
27
|
+
"TARGET_LANGUAGES",
|
|
28
|
+
"AdminSession",
|
|
29
|
+
"Article",
|
|
30
|
+
"ArticleContent",
|
|
31
|
+
"ChecksummedContent",
|
|
32
|
+
"ContentStatus",
|
|
33
|
+
"Language",
|
|
34
|
+
"MediaAsset",
|
|
35
|
+
"Page",
|
|
36
|
+
"PageContent",
|
|
37
|
+
"Role",
|
|
38
|
+
"Section",
|
|
39
|
+
"SectionContent",
|
|
40
|
+
"StorageBackend",
|
|
41
|
+
"TranslatableModel",
|
|
42
|
+
"Translation",
|
|
43
|
+
"TranslationState",
|
|
44
|
+
"User",
|
|
45
|
+
"create_storage",
|
|
46
|
+
"new_article",
|
|
47
|
+
"new_page",
|
|
48
|
+
"worst_state",
|
|
49
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Admin accounts and sessions (Milestone 3).
|
|
2
|
+
|
|
3
|
+
Accounts are an operational concern of the admin panel: they live in the
|
|
4
|
+
storage database (shared migration history) but are **never** part of the
|
|
5
|
+
JSON/Markdown export — the portable source of truth stays content-only.
|
|
6
|
+
The domain layer only defines the shapes; hashing and session issuance are
|
|
7
|
+
the admin application's job.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
USERNAME_PATTERN = r"^[a-z0-9][a-z0-9._-]{0,63}$"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Role(StrEnum):
|
|
19
|
+
"""Least-privilege ladder; each step includes the previous one's intent.
|
|
20
|
+
|
|
21
|
+
editor: create and edit drafts · reviewer: move drafts through review ·
|
|
22
|
+
publisher: publish and archive · admin: manage accounts and settings.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
EDITOR = "editor"
|
|
26
|
+
REVIEWER = "reviewer"
|
|
27
|
+
PUBLISHER = "publisher"
|
|
28
|
+
ADMIN = "admin"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class User(BaseModel):
|
|
32
|
+
model_config = ConfigDict(frozen=True)
|
|
33
|
+
|
|
34
|
+
username: str = Field(pattern=USERNAME_PATTERN)
|
|
35
|
+
password_hash: str = Field(min_length=1)
|
|
36
|
+
role: Role
|
|
37
|
+
created_at: datetime
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AdminSession(BaseModel):
|
|
41
|
+
"""A server-side session row. Only the token's hash is stored."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(frozen=True)
|
|
44
|
+
|
|
45
|
+
token_hash: str = Field(min_length=1)
|
|
46
|
+
username: str = Field(pattern=USERNAME_PATTERN)
|
|
47
|
+
csrf_token: str = Field(min_length=1)
|
|
48
|
+
expires_at: datetime
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Deterministic portable export: a JSON index plus per-language Markdown files.
|
|
2
|
+
|
|
3
|
+
Same input always produces byte-identical output — articles are sorted by id,
|
|
4
|
+
JSON keys are sorted, and no timestamps are generated at export time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
from cms_core.languages import SOURCE_LANGUAGE, Language
|
|
11
|
+
from cms_core.media import MediaAsset
|
|
12
|
+
from cms_core.models import SCHEMA_VERSION, Article, ArticleContent
|
|
13
|
+
from cms_core.pages import Page, Section
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def article_to_portable(article: Article) -> dict[str, object]:
|
|
17
|
+
languages: dict[str, dict[str, str]] = {
|
|
18
|
+
SOURCE_LANGUAGE.value: {
|
|
19
|
+
"state": "complete",
|
|
20
|
+
"title": article.source.title,
|
|
21
|
+
"summary": article.source.summary,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for language, translation in sorted(
|
|
25
|
+
article.translations.items(), key=lambda item: item[0].value
|
|
26
|
+
):
|
|
27
|
+
languages[language.value] = {
|
|
28
|
+
"state": article.translation_state(language).value,
|
|
29
|
+
"title": translation.content.title,
|
|
30
|
+
"summary": translation.content.summary,
|
|
31
|
+
"source_checksum": translation.source_checksum,
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
"id": article.id,
|
|
35
|
+
"status": article.status.value,
|
|
36
|
+
"created_at": article.created_at.isoformat(),
|
|
37
|
+
"updated_at": article.updated_at.isoformat(),
|
|
38
|
+
"languages": languages,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def section_to_portable(section: Section) -> dict[str, object]:
|
|
43
|
+
languages: dict[str, dict[str, object]] = {
|
|
44
|
+
SOURCE_LANGUAGE.value: {
|
|
45
|
+
"state": "complete",
|
|
46
|
+
"fields": dict(sorted(section.source.fields.items())),
|
|
47
|
+
"media": list(section.source.media),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for language, translation in sorted(
|
|
51
|
+
section.translations.items(), key=lambda item: item[0].value
|
|
52
|
+
):
|
|
53
|
+
languages[language.value] = {
|
|
54
|
+
"state": section.translation_state(language).value,
|
|
55
|
+
"fields": dict(sorted(translation.content.fields.items())),
|
|
56
|
+
"media": list(translation.content.media),
|
|
57
|
+
"source_checksum": translation.source_checksum,
|
|
58
|
+
}
|
|
59
|
+
return {"key": section.key, "kind": section.kind, "languages": languages}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def page_to_portable(page: Page) -> dict[str, object]:
|
|
63
|
+
languages: dict[str, dict[str, str]] = {
|
|
64
|
+
SOURCE_LANGUAGE.value: {
|
|
65
|
+
"state": "complete",
|
|
66
|
+
"title": page.source.title,
|
|
67
|
+
"description": page.source.description,
|
|
68
|
+
"slug": page.source.slug,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for language, translation in sorted(page.translations.items(), key=lambda item: item[0].value):
|
|
72
|
+
languages[language.value] = {
|
|
73
|
+
"state": page.translation_state(language).value,
|
|
74
|
+
"title": translation.content.title,
|
|
75
|
+
"description": translation.content.description,
|
|
76
|
+
"slug": translation.content.slug,
|
|
77
|
+
"source_checksum": translation.source_checksum,
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
"id": page.id,
|
|
81
|
+
"status": page.status.value,
|
|
82
|
+
"created_at": page.created_at.isoformat(),
|
|
83
|
+
"updated_at": page.updated_at.isoformat(),
|
|
84
|
+
"languages": languages,
|
|
85
|
+
"sections": [section_to_portable(section) for section in page.sections],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def media_to_portable(asset: MediaAsset) -> dict[str, object]:
|
|
90
|
+
return {
|
|
91
|
+
"id": asset.id,
|
|
92
|
+
"path": asset.path,
|
|
93
|
+
"mime_type": asset.mime_type,
|
|
94
|
+
"width": asset.width,
|
|
95
|
+
"height": asset.height,
|
|
96
|
+
"alt": {
|
|
97
|
+
language.value: text
|
|
98
|
+
for language, text in sorted(asset.alt.items(), key=lambda item: item[0].value)
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def export_content_json(
|
|
104
|
+
articles: Iterable[Article],
|
|
105
|
+
pages: Iterable[Page] = (),
|
|
106
|
+
media: Iterable[MediaAsset] = (),
|
|
107
|
+
) -> str:
|
|
108
|
+
payload = {
|
|
109
|
+
"schema_version": SCHEMA_VERSION,
|
|
110
|
+
"articles": [
|
|
111
|
+
article_to_portable(article)
|
|
112
|
+
for article in sorted(articles, key=lambda article: article.id)
|
|
113
|
+
],
|
|
114
|
+
"pages": [page_to_portable(page) for page in sorted(pages, key=lambda page: page.id)],
|
|
115
|
+
"media": [media_to_portable(asset) for asset in sorted(media, key=lambda asset: asset.id)],
|
|
116
|
+
}
|
|
117
|
+
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def export_markdown_files(articles: Iterable[Article]) -> dict[str, str]:
|
|
121
|
+
"""Map of relative file path -> Markdown content, one file per language."""
|
|
122
|
+
files: dict[str, str] = {}
|
|
123
|
+
for article in sorted(articles, key=lambda article: article.id):
|
|
124
|
+
files[f"{article.id}/{SOURCE_LANGUAGE.value}.md"] = _render_markdown(article.source)
|
|
125
|
+
for language, translation in sorted(
|
|
126
|
+
article.translations.items(), key=lambda item: item[0].value
|
|
127
|
+
):
|
|
128
|
+
files[f"{article.id}/{language.value}.md"] = _render_markdown(translation.content)
|
|
129
|
+
return files
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _render_markdown(content: ArticleContent) -> str:
|
|
133
|
+
lines = [f"# {content.title}", ""]
|
|
134
|
+
if content.summary:
|
|
135
|
+
lines.extend([f"> {content.summary}", ""])
|
|
136
|
+
lines.append(content.body_markdown)
|
|
137
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def languages_in_export(files: dict[str, str]) -> set[Language]:
|
|
141
|
+
"""Languages present in a Markdown export (used by parity checks)."""
|
|
142
|
+
return {Language(path.rsplit("/", 1)[1].removesuffix(".md")) for path in files}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Supported languages. English is the canonical source language."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Language(StrEnum):
|
|
7
|
+
EN = "en"
|
|
8
|
+
PT_PT = "pt-pt"
|
|
9
|
+
ES = "es"
|
|
10
|
+
FR = "fr"
|
|
11
|
+
DE = "de"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SOURCE_LANGUAGE = Language.EN
|
|
15
|
+
TARGET_LANGUAGES: tuple[Language, ...] = tuple(
|
|
16
|
+
language for language in Language if language is not SOURCE_LANGUAGE
|
|
17
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Media assets with mandatory, translatable alt text.
|
|
2
|
+
|
|
3
|
+
Editorial rules from the brief are enforced at the model level: images must
|
|
4
|
+
declare their dimensions, and alt text in the source language is mandatory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from cms_core.languages import SOURCE_LANGUAGE, TARGET_LANGUAGES, Language
|
|
10
|
+
from cms_core.models import SLUG_PATTERN
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MediaAsset(BaseModel):
|
|
14
|
+
id: str = Field(pattern=SLUG_PATTERN)
|
|
15
|
+
path: str = Field(min_length=1)
|
|
16
|
+
mime_type: str = Field(min_length=1)
|
|
17
|
+
width: int | None = Field(default=None, gt=0)
|
|
18
|
+
height: int | None = Field(default=None, gt=0)
|
|
19
|
+
alt: dict[Language, str]
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_image(self) -> bool:
|
|
23
|
+
return self.mime_type.startswith("image/")
|
|
24
|
+
|
|
25
|
+
@model_validator(mode="after")
|
|
26
|
+
def _enforce_editorial_rules(self) -> "MediaAsset":
|
|
27
|
+
if self.path.startswith("/") or ".." in self.path.split("/"):
|
|
28
|
+
raise ValueError("media path must be relative and must not traverse upwards")
|
|
29
|
+
if self.is_image and (self.width is None or self.height is None):
|
|
30
|
+
raise ValueError("images must declare width and height")
|
|
31
|
+
if not self.alt.get(SOURCE_LANGUAGE, "").strip():
|
|
32
|
+
raise ValueError(f"alt text in the source language ({SOURCE_LANGUAGE}) is mandatory")
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def missing_alt_languages(self) -> tuple[Language, ...]:
|
|
36
|
+
return tuple(
|
|
37
|
+
language for language in TARGET_LANGUAGES if not self.alt.get(language, "").strip()
|
|
38
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Article content model with per-language translations.
|
|
2
|
+
|
|
3
|
+
Translation freshness is tracked by checksum (see :mod:`cms_core.translatable`):
|
|
4
|
+
every translation records the checksum of the source (EN) content it was
|
|
5
|
+
translated from, so a source edit automatically marks translations outdated.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
|
|
11
|
+
from pydantic import Field, field_validator
|
|
12
|
+
|
|
13
|
+
from cms_core.states import ContentStatus
|
|
14
|
+
from cms_core.translatable import ChecksummedContent, TranslatableModel
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = 2
|
|
17
|
+
|
|
18
|
+
SLUG_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ArticleContent(ChecksummedContent):
|
|
22
|
+
title: str = Field(min_length=1)
|
|
23
|
+
summary: str = ""
|
|
24
|
+
body_markdown: str = ""
|
|
25
|
+
slug: str | None = Field(default=None, pattern=SLUG_PATTERN)
|
|
26
|
+
|
|
27
|
+
def checksum_payload(self) -> tuple[str, ...]:
|
|
28
|
+
return (self.title, self.summary, self.body_markdown, self.slug or "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Article(TranslatableModel[ArticleContent]):
|
|
32
|
+
id: str = Field(pattern=SLUG_PATTERN)
|
|
33
|
+
status: ContentStatus = ContentStatus.DRAFT
|
|
34
|
+
created_at: datetime
|
|
35
|
+
updated_at: datetime
|
|
36
|
+
category: str | None = Field(default=None, pattern=SLUG_PATTERN)
|
|
37
|
+
cover: str | None = Field(default=None, pattern=SLUG_PATTERN)
|
|
38
|
+
tags: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
@field_validator("tags")
|
|
41
|
+
@classmethod
|
|
42
|
+
def _tags_are_slugs(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
43
|
+
for tag in value:
|
|
44
|
+
if not re.fullmatch(SLUG_PATTERN, tag):
|
|
45
|
+
raise ValueError(f"tag {tag!r} is not a valid slug")
|
|
46
|
+
return tuple(sorted(set(value)))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def new_article(article_id: str, source: ArticleContent, *, now: datetime | None = None) -> Article:
|
|
50
|
+
timestamp = now or datetime.now(tz=UTC)
|
|
51
|
+
return Article(id=article_id, created_at=timestamp, updated_at=timestamp, source=source)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Pages composed of typed sections; both are independently translatable.
|
|
2
|
+
|
|
3
|
+
A section's ``kind`` names the theme template that renders it — cms-build
|
|
4
|
+
never hardcodes HTML, per the extensibility contracts in docs/PLAN.md. A
|
|
5
|
+
page's translation state aggregates its own content and every section: the
|
|
6
|
+
least-complete state wins, so one missing section translation blocks the
|
|
7
|
+
whole page from publishing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from cms_core.languages import Language
|
|
15
|
+
from cms_core.models import SLUG_PATTERN
|
|
16
|
+
from cms_core.states import ContentStatus, TranslationState
|
|
17
|
+
from cms_core.translatable import ChecksummedContent, TranslatableModel, worst_state
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PageContent(ChecksummedContent):
|
|
21
|
+
title: str = Field(min_length=1)
|
|
22
|
+
description: str = ""
|
|
23
|
+
slug: str = Field(pattern=SLUG_PATTERN)
|
|
24
|
+
|
|
25
|
+
def checksum_payload(self) -> tuple[str, ...]:
|
|
26
|
+
return (self.title, self.description, self.slug)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SectionContent(ChecksummedContent):
|
|
30
|
+
fields: dict[str, str] = Field(default_factory=dict)
|
|
31
|
+
media: list[str] = Field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def checksum_payload(self) -> tuple[str, ...]:
|
|
34
|
+
field_parts = tuple(f"{name}\x1e{self.fields[name]}" for name in sorted(self.fields))
|
|
35
|
+
return (*field_parts, "\x1d", *self.media)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Section(TranslatableModel[SectionContent]):
|
|
39
|
+
key: str = Field(pattern=SLUG_PATTERN)
|
|
40
|
+
kind: str = Field(pattern=SLUG_PATTERN)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Page(TranslatableModel[PageContent]):
|
|
44
|
+
id: str = Field(pattern=SLUG_PATTERN)
|
|
45
|
+
status: ContentStatus = ContentStatus.DRAFT
|
|
46
|
+
created_at: datetime
|
|
47
|
+
updated_at: datetime
|
|
48
|
+
sections: list[Section] = Field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
def translation_state(self, language: Language) -> TranslationState:
|
|
51
|
+
own = super().translation_state(language)
|
|
52
|
+
return worst_state(
|
|
53
|
+
(own, *(section.translation_state(language) for section in self.sections))
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def section(self, key: str) -> Section | None:
|
|
57
|
+
for section in self.sections:
|
|
58
|
+
if section.key == key:
|
|
59
|
+
return section
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def new_page(page_id: str, source: PageContent, *, now: datetime | None = None) -> Page:
|
|
64
|
+
timestamp = now or datetime.now(tz=UTC)
|
|
65
|
+
return Page(id=page_id, created_at=timestamp, updated_at=timestamp, source=source)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Translation states and the editorial content workflow."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TranslationState(StrEnum):
|
|
7
|
+
MISSING = "missing"
|
|
8
|
+
OUTDATED = "outdated"
|
|
9
|
+
COMPLETE = "complete"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ContentStatus(StrEnum):
|
|
13
|
+
DRAFT = "draft"
|
|
14
|
+
REVIEW = "review"
|
|
15
|
+
PUBLISHED = "published"
|
|
16
|
+
ARCHIVED = "archived"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Multi-backend persistence behind one interface.
|
|
2
|
+
|
|
3
|
+
Backends register per URL scheme (``create_storage("sqlite:///content.db")``).
|
|
4
|
+
SQLite ships built in; PostgreSQL, SQL Server and MySQL/MariaDB are planned
|
|
5
|
+
and third parties can register custom engines via :func:`register_backend`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from cms_core.storage.base import StorageBackend
|
|
9
|
+
from cms_core.storage.factory import (
|
|
10
|
+
available_schemes,
|
|
11
|
+
create_storage,
|
|
12
|
+
register_backend,
|
|
13
|
+
)
|
|
14
|
+
from cms_core.storage.sqlite import MIGRATIONS, SQLiteBackend
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MIGRATIONS",
|
|
18
|
+
"SQLiteBackend",
|
|
19
|
+
"StorageBackend",
|
|
20
|
+
"available_schemes",
|
|
21
|
+
"create_storage",
|
|
22
|
+
"register_backend",
|
|
23
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Backend-agnostic storage interface.
|
|
2
|
+
|
|
3
|
+
Every storage engine (SQLite today; PostgreSQL and SQL Server planned)
|
|
4
|
+
implements this one interface, so nothing above the storage layer knows which
|
|
5
|
+
database is in use. The database is always a working store — the portable
|
|
6
|
+
source of truth is the JSON/Markdown export (:mod:`cms_core.export`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
|
|
13
|
+
from cms_core.accounts import AdminSession, User
|
|
14
|
+
from cms_core.media import MediaAsset
|
|
15
|
+
from cms_core.models import Article
|
|
16
|
+
from cms_core.pages import Page
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StorageBackend(ABC):
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def schema_version(self) -> int: ...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def migrate(self) -> int: ...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def close(self) -> None: ...
|
|
28
|
+
|
|
29
|
+
# Articles
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def save_article(self, article: Article) -> None: ...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def load_article(self, article_id: str) -> Article | None: ...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def delete_article(self, article_id: str) -> bool: ...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def list_article_ids(self) -> list[str]: ...
|
|
42
|
+
|
|
43
|
+
def load_all_articles(self) -> list[Article]:
|
|
44
|
+
articles = (self.load_article(article_id) for article_id in self.list_article_ids())
|
|
45
|
+
return [article for article in articles if article is not None]
|
|
46
|
+
|
|
47
|
+
# Pages
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def save_page(self, page: Page) -> None: ...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def load_page(self, page_id: str) -> Page | None: ...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def delete_page(self, page_id: str) -> bool: ...
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def list_page_ids(self) -> list[str]: ...
|
|
60
|
+
|
|
61
|
+
def load_all_pages(self) -> list[Page]:
|
|
62
|
+
pages = (self.load_page(page_id) for page_id in self.list_page_ids())
|
|
63
|
+
return [page for page in pages if page is not None]
|
|
64
|
+
|
|
65
|
+
# Media
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def save_media_asset(self, asset: MediaAsset) -> None: ...
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def load_media_asset(self, asset_id: str) -> MediaAsset | None: ...
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def delete_media_asset(self, asset_id: str) -> bool: ...
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def list_media_ids(self) -> list[str]: ...
|
|
78
|
+
|
|
79
|
+
def load_all_media_assets(self) -> list[MediaAsset]:
|
|
80
|
+
assets = (self.load_media_asset(asset_id) for asset_id in self.list_media_ids())
|
|
81
|
+
return [asset for asset in assets if asset is not None]
|
|
82
|
+
|
|
83
|
+
def has_content(self) -> bool:
|
|
84
|
+
"""True when any article, page or media asset exists."""
|
|
85
|
+
return bool(self.list_article_ids() or self.list_page_ids() or self.list_media_ids())
|
|
86
|
+
|
|
87
|
+
# Admin accounts (never exported — see cms_core.accounts)
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def save_user(self, user: User) -> None: ...
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def load_user(self, username: str) -> User | None: ...
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def delete_user(self, username: str) -> bool: ...
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def list_usernames(self) -> list[str]: ...
|
|
100
|
+
|
|
101
|
+
# Admin sessions
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def save_session(self, session: AdminSession) -> None: ...
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def load_session(self, token_hash: str) -> AdminSession | None: ...
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def delete_session(self, token_hash: str) -> bool: ...
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def delete_expired_sessions(self, now: datetime) -> int: ...
|
|
114
|
+
|
|
115
|
+
# Context manager
|
|
116
|
+
|
|
117
|
+
def __enter__(self) -> "StorageBackend":
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def __exit__(
|
|
121
|
+
self,
|
|
122
|
+
exc_type: type[BaseException] | None,
|
|
123
|
+
exc_value: BaseException | None,
|
|
124
|
+
traceback: TracebackType | None,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.close()
|