simple-module-pagebuilder 0.0.1__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.
- pagebuilder/__init__.py +0 -0
- pagebuilder/components/DiffSummary.tsx +107 -0
- pagebuilder/components/ScheduledBadge.tsx +34 -0
- pagebuilder/components/StatusBadge.tsx +21 -0
- pagebuilder/components/blockRegistry.test.ts +56 -0
- pagebuilder/components/blockRegistry.ts +80 -0
- pagebuilder/components/blocks/Button.tsx +142 -0
- pagebuilder/components/blocks/Columns.tsx +64 -0
- pagebuilder/components/blocks/Heading.tsx +92 -0
- pagebuilder/components/blocks/Image.tsx +125 -0
- pagebuilder/components/blocks/MediaPicker.tsx +178 -0
- pagebuilder/components/blocks/Spacer.tsx +31 -0
- pagebuilder/components/blocks/Text.tsx +80 -0
- pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
- pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
- pagebuilder/components/editor/SchedulePanel.tsx +85 -0
- pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
- pagebuilder/components/layoutPuckConfig.tsx +109 -0
- pagebuilder/components/media/MediaFilters.tsx +83 -0
- pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
- pagebuilder/components/media/MediaGrid.tsx +76 -0
- pagebuilder/components/media/MediaHeader.tsx +37 -0
- pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
- pagebuilder/components/media/types.ts +20 -0
- pagebuilder/components/migrateContent.test.ts +146 -0
- pagebuilder/components/migrateContent.ts +135 -0
- pagebuilder/components/puckConfig.test.tsx +118 -0
- pagebuilder/components/puckConfig.tsx +121 -0
- pagebuilder/components/widgetCatalog.test.tsx +123 -0
- pagebuilder/components/widgetCatalog.ts +208 -0
- pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
- pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
- pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
- pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
- pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
- pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
- pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
- pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
- pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
- pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
- pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
- pagebuilder/components/widgets/_shared/heading.tsx +88 -0
- pagebuilder/components/widgets/_shared/index.ts +39 -0
- pagebuilder/components/widgets/_shared/mask.ts +54 -0
- pagebuilder/components/widgets/_shared/section.tsx +60 -0
- pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
- pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
- pagebuilder/components/widgets/accordion-widget.tsx +64 -0
- pagebuilder/components/widgets/alert-widget.tsx +47 -0
- pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
- pagebuilder/components/widgets/article-cards-render.tsx +132 -0
- pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
- pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
- pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
- pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
- pagebuilder/components/widgets/carousel-widget.tsx +97 -0
- pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
- pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
- pagebuilder/components/widgets/column-widget.tsx +63 -0
- pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
- pagebuilder/components/widgets/contact-form-render.tsx +293 -0
- pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
- pagebuilder/components/widgets/container-widget.tsx +93 -0
- pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
- pagebuilder/components/widgets/divider-widget.tsx +56 -0
- pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
- pagebuilder/components/widgets/faq-render.tsx +268 -0
- pagebuilder/components/widgets/faq-widget.tsx +64 -0
- pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
- pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
- pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
- pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
- pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
- pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
- pagebuilder/components/widgets/gallery-widget.tsx +139 -0
- pagebuilder/components/widgets/grid-widget.tsx +59 -0
- pagebuilder/components/widgets/hero-widget.tsx +292 -0
- pagebuilder/components/widgets/html-widget.tsx +42 -0
- pagebuilder/components/widgets/iframe-widget.tsx +44 -0
- pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
- pagebuilder/components/widgets/list-widget.tsx +56 -0
- pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
- pagebuilder/components/widgets/markdown-widget.tsx +148 -0
- pagebuilder/components/widgets/media-object-layout.ts +156 -0
- pagebuilder/components/widgets/media-object-render.tsx +284 -0
- pagebuilder/components/widgets/media-object-text.tsx +202 -0
- pagebuilder/components/widgets/media-object-widget.tsx +176 -0
- pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
- pagebuilder/components/widgets/page-header-widget.tsx +118 -0
- pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
- pagebuilder/components/widgets/quote-widget.tsx +51 -0
- pagebuilder/components/widgets/row-widget.tsx +74 -0
- pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
- pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
- pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
- pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
- pagebuilder/components/widgets/site-header-nav.tsx +153 -0
- pagebuilder/components/widgets/site-header-widget.tsx +182 -0
- pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
- pagebuilder/components/widgets/stats-widget.tsx +200 -0
- pagebuilder/components/widgets/steps-widget.tsx +211 -0
- pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
- pagebuilder/components/widgets/table-widget.tsx +86 -0
- pagebuilder/components/widgets/tabs-widget.tsx +95 -0
- pagebuilder/components/widgets/tags-widget.tsx +85 -0
- pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
- pagebuilder/components/widgets/timeline-layout.ts +73 -0
- pagebuilder/components/widgets/timeline-render.tsx +294 -0
- pagebuilder/components/widgets/timeline-widget.tsx +106 -0
- pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
- pagebuilder/components/widgets/video-widget.tsx +103 -0
- pagebuilder/components/widgets/welcome-widget.tsx +38 -0
- pagebuilder/contracts/__init__.py +57 -0
- pagebuilder/contracts/events.py +25 -0
- pagebuilder/contracts/schemas.py +239 -0
- pagebuilder/conversion/blocks-to-html.test.ts +105 -0
- pagebuilder/conversion/blocks-to-html.ts +102 -0
- pagebuilder/conversion/index.ts +2 -0
- pagebuilder/conversion/types.ts +5 -0
- pagebuilder/deps.py +26 -0
- pagebuilder/diff.py +129 -0
- pagebuilder/endpoints/__init__.py +0 -0
- pagebuilder/endpoints/api/__init__.py +21 -0
- pagebuilder/endpoints/api/_deps.py +21 -0
- pagebuilder/endpoints/api/layout.py +78 -0
- pagebuilder/endpoints/api/pages.py +85 -0
- pagebuilder/endpoints/api/revisions.py +79 -0
- pagebuilder/endpoints/api/uploads.py +92 -0
- pagebuilder/endpoints/api/workflow.py +124 -0
- pagebuilder/endpoints/seo.py +95 -0
- pagebuilder/endpoints/views.py +228 -0
- pagebuilder/fields/checkbox-field.tsx +22 -0
- pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
- pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
- pagebuilder/fields/file-picker/index.ts +6 -0
- pagebuilder/fields/file-picker/types.ts +15 -0
- pagebuilder/fields/index.ts +9 -0
- pagebuilder/fields/media-adapter.ts +47 -0
- pagebuilder/hooks/useAutosave.ts +134 -0
- pagebuilder/hooks/useEditorForm.ts +193 -0
- pagebuilder/hooks/useMediaUploads.ts +96 -0
- pagebuilder/hooks/usePageRevisions.ts +59 -0
- pagebuilder/hooks/usePageSchedule.ts +72 -0
- pagebuilder/hooks/usePageWorkflow.ts +173 -0
- pagebuilder/layout_service.py +101 -0
- pagebuilder/media_images.py +185 -0
- pagebuilder/media_queries.py +92 -0
- pagebuilder/media_service.py +222 -0
- pagebuilder/models.py +209 -0
- pagebuilder/module.py +279 -0
- pagebuilder/package.json +18 -0
- pagebuilder/pages/LayoutEditor.tsx +183 -0
- pagebuilder/pages/MediaLibrary.tsx +249 -0
- pagebuilder/pages/PageEditor.tsx +181 -0
- pagebuilder/pages/PageList.tsx +129 -0
- pagebuilder/pages/PendingReview.tsx +128 -0
- pagebuilder/pages/PublicPage.tsx +126 -0
- pagebuilder/permissions.py +44 -0
- pagebuilder/py.typed +0 -0
- pagebuilder/security.py +230 -0
- pagebuilder/service/__init__.py +141 -0
- pagebuilder/service/_common.py +24 -0
- pagebuilder/service/_revisions.py +92 -0
- pagebuilder/service/_workflow.py +207 -0
- pagebuilder/services.py +19 -0
- pagebuilder/settings.py +161 -0
- pagebuilder/static/widgets-base.css +57 -0
- pagebuilder/translation/apply.ts +54 -0
- pagebuilder/translation/extract.ts +141 -0
- pagebuilder/translation/index.ts +20 -0
- pagebuilder/translation/translatable-fields.ts +65 -0
- pagebuilder/translation/translation.test.ts +167 -0
- pagebuilder/utils/api.ts +12 -0
- pagebuilder/utils/datetime.ts +25 -0
- pagebuilder/utils/decorative-image.ts +15 -0
- pagebuilder/utils/editorSnapshot.ts +39 -0
- pagebuilder/utils/markdown.ts +160 -0
- pagebuilder/utils/mediaApi.ts +112 -0
- pagebuilder/utils/mediaFormat.ts +19 -0
- pagebuilder/utils/pagesApi.ts +111 -0
- pagebuilder/utils/parse-list.ts +7 -0
- pagebuilder/utils/request.ts +52 -0
- pagebuilder/utils/slugify.ts +9 -0
- pagebuilder/utils/types.ts +171 -0
- pagebuilder/utils/widgetUtils.ts +11 -0
- simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
- simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
- simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
- simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Storage + DB operations for uploaded media assets.
|
|
2
|
+
|
|
3
|
+
Files live on the local filesystem under
|
|
4
|
+
:attr:`PagebuilderSettings.media_root` and are served via a StaticFiles
|
|
5
|
+
mount registered in :meth:`PagebuilderModule.on_startup`. The DB row
|
|
6
|
+
holds the sanitized filename, original name, content-type, byte size,
|
|
7
|
+
intrinsic dimensions, and a ``variants`` map of server-generated
|
|
8
|
+
thumbnails. Public URLs are derived as
|
|
9
|
+
``f"{settings.media_url_prefix}/{filename}"``.
|
|
10
|
+
|
|
11
|
+
Filename sanitising, content sniffing, and thumbnail generation live in
|
|
12
|
+
:mod:`pagebuilder.media_images`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from fastapi import HTTPException, UploadFile
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
23
|
+
from sqlmodel import select
|
|
24
|
+
|
|
25
|
+
from pagebuilder import media_queries
|
|
26
|
+
from pagebuilder.contracts.schemas import MediaAssetRead, MediaAssetVariant
|
|
27
|
+
from pagebuilder.media_images import (
|
|
28
|
+
CONTENT_TYPE_EXTENSIONS,
|
|
29
|
+
SNIFF_BYTES,
|
|
30
|
+
normalize_folder,
|
|
31
|
+
process_image,
|
|
32
|
+
safe_extension,
|
|
33
|
+
sniff_content_type,
|
|
34
|
+
)
|
|
35
|
+
from pagebuilder.models import MediaAsset
|
|
36
|
+
from pagebuilder.settings import PagebuilderSettings
|
|
37
|
+
|
|
38
|
+
_DEFAULT_PAGE_SIZE = 60
|
|
39
|
+
_MAX_PAGE_SIZE = 200
|
|
40
|
+
|
|
41
|
+
# Re-exported: callers reach for these through the service module.
|
|
42
|
+
__all__ = ["MediaService", "normalize_folder"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MediaService:
|
|
46
|
+
def __init__(self, db: AsyncSession, settings: PagebuilderSettings) -> None:
|
|
47
|
+
self.db = db
|
|
48
|
+
self.settings = settings
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def storage_root(self) -> Path:
|
|
52
|
+
return Path(self.settings.media_root).resolve()
|
|
53
|
+
|
|
54
|
+
def url_for(self, filename: str) -> str:
|
|
55
|
+
return f"{self.settings.media_url_prefix.rstrip('/')}/{filename}"
|
|
56
|
+
|
|
57
|
+
def to_read(self, asset: MediaAsset) -> MediaAssetRead:
|
|
58
|
+
variants = {
|
|
59
|
+
key: MediaAssetVariant(
|
|
60
|
+
filename=meta["filename"],
|
|
61
|
+
url=self.url_for(meta["filename"]),
|
|
62
|
+
content_type=meta["content_type"],
|
|
63
|
+
width=meta["width"],
|
|
64
|
+
height=meta.get("height"),
|
|
65
|
+
size_bytes=meta.get("size_bytes", 0),
|
|
66
|
+
)
|
|
67
|
+
for key, meta in (asset.variants or {}).items()
|
|
68
|
+
}
|
|
69
|
+
return MediaAssetRead(
|
|
70
|
+
id=asset.id or 0,
|
|
71
|
+
filename=asset.filename,
|
|
72
|
+
original_filename=asset.original_filename,
|
|
73
|
+
content_type=asset.content_type,
|
|
74
|
+
size_bytes=asset.size_bytes,
|
|
75
|
+
url=self.url_for(asset.filename),
|
|
76
|
+
width=asset.width,
|
|
77
|
+
height=asset.height,
|
|
78
|
+
folder=asset.folder,
|
|
79
|
+
variants=variants,
|
|
80
|
+
created_at=asset.created_at,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def count_assets(self, **filters) -> int:
|
|
84
|
+
"""Total rows matching *filters* — pairs with offset paging."""
|
|
85
|
+
return await media_queries.count_assets(self.db, **filters)
|
|
86
|
+
|
|
87
|
+
async def list_assets(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
cursor: int | None = None,
|
|
91
|
+
offset: int | None = None,
|
|
92
|
+
limit: int = _DEFAULT_PAGE_SIZE,
|
|
93
|
+
**filters,
|
|
94
|
+
) -> tuple[list[MediaAsset], int | None]:
|
|
95
|
+
"""One page of assets newest-first, plus the next cursor.
|
|
96
|
+
|
|
97
|
+
See :func:`pagebuilder.media_queries.list_assets` for the two paging
|
|
98
|
+
modes; the image-picker gallery uses the offset one.
|
|
99
|
+
"""
|
|
100
|
+
return await media_queries.list_assets(
|
|
101
|
+
self.db, cursor=cursor, offset=offset, limit=limit, **filters
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def list_folders(self) -> list[str]:
|
|
105
|
+
"""Return every distinct non-empty folder name, sorted."""
|
|
106
|
+
result = await self.db.execute(
|
|
107
|
+
select(MediaAsset.folder)
|
|
108
|
+
.where(MediaAsset.folder.is_not(None))
|
|
109
|
+
.distinct()
|
|
110
|
+
)
|
|
111
|
+
return sorted({row for row in result.scalars().all() if row})
|
|
112
|
+
|
|
113
|
+
async def get_asset(self, asset_id: int) -> MediaAsset:
|
|
114
|
+
asset = await self.db.get(MediaAsset, asset_id)
|
|
115
|
+
if asset is None:
|
|
116
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
117
|
+
return asset
|
|
118
|
+
|
|
119
|
+
async def upload(
|
|
120
|
+
self,
|
|
121
|
+
upload: UploadFile,
|
|
122
|
+
*,
|
|
123
|
+
folder: str | None = None,
|
|
124
|
+
) -> MediaAsset:
|
|
125
|
+
folder_value = normalize_folder(folder)
|
|
126
|
+
original = upload.filename or "upload"
|
|
127
|
+
declared = upload.content_type or "application/octet-stream"
|
|
128
|
+
allowed = self.settings.media_allowed_content_types
|
|
129
|
+
if declared not in allowed:
|
|
130
|
+
raise HTTPException(
|
|
131
|
+
status_code=415,
|
|
132
|
+
detail=f"Content-type {declared!r} is not allowed",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
head = await upload.read(SNIFF_BYTES)
|
|
136
|
+
sniffed = sniff_content_type(head)
|
|
137
|
+
if sniffed is None or sniffed not in allowed:
|
|
138
|
+
raise HTTPException(
|
|
139
|
+
status_code=415,
|
|
140
|
+
detail="File contents do not match an allowed image format",
|
|
141
|
+
)
|
|
142
|
+
if sniffed != declared:
|
|
143
|
+
raise HTTPException(
|
|
144
|
+
status_code=415,
|
|
145
|
+
detail=(
|
|
146
|
+
f"Declared content-type {declared!r} does not match "
|
|
147
|
+
f"sniffed type {sniffed!r}"
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
content_type = sniffed
|
|
151
|
+
|
|
152
|
+
# Pin the on-disk extension to the sniffed type so we never store
|
|
153
|
+
# a JPEG under a `.png` name (StaticFiles serves the mime based
|
|
154
|
+
# on the extension).
|
|
155
|
+
ext = CONTENT_TYPE_EXTENSIONS.get(content_type) or safe_extension(original)
|
|
156
|
+
filename = f"{uuid.uuid4().hex}{ext}"
|
|
157
|
+
|
|
158
|
+
root = self.storage_root
|
|
159
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
target = root / filename
|
|
161
|
+
|
|
162
|
+
size = len(head)
|
|
163
|
+
max_bytes = self.settings.media_max_bytes
|
|
164
|
+
if size > max_bytes:
|
|
165
|
+
raise HTTPException(
|
|
166
|
+
status_code=413,
|
|
167
|
+
detail=f"File exceeds {max_bytes} bytes",
|
|
168
|
+
)
|
|
169
|
+
chunk_size = 64 * 1024
|
|
170
|
+
with target.open("wb") as fh:
|
|
171
|
+
fh.write(head)
|
|
172
|
+
while True:
|
|
173
|
+
chunk = await upload.read(chunk_size)
|
|
174
|
+
if not chunk:
|
|
175
|
+
break
|
|
176
|
+
size += len(chunk)
|
|
177
|
+
if size > max_bytes:
|
|
178
|
+
fh.close()
|
|
179
|
+
target.unlink(missing_ok=True)
|
|
180
|
+
raise HTTPException(
|
|
181
|
+
status_code=413,
|
|
182
|
+
detail=f"File exceeds {max_bytes} bytes",
|
|
183
|
+
)
|
|
184
|
+
fh.write(chunk)
|
|
185
|
+
|
|
186
|
+
# Pillow's decode + webp encode are sync and CPU-bound — running
|
|
187
|
+
# them on the event loop blocks other concurrent uploads. The
|
|
188
|
+
# thread offload is essentially free for a single request and
|
|
189
|
+
# turns parallel uploads back into parallel work.
|
|
190
|
+
width, height, variants = await asyncio.to_thread(
|
|
191
|
+
process_image,
|
|
192
|
+
target,
|
|
193
|
+
thumbnail_widths=self.settings.media_thumbnail_widths,
|
|
194
|
+
webp_quality=self.settings.media_webp_quality,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
asset = MediaAsset(
|
|
198
|
+
filename=filename,
|
|
199
|
+
original_filename=original,
|
|
200
|
+
content_type=content_type,
|
|
201
|
+
size_bytes=size,
|
|
202
|
+
width=width,
|
|
203
|
+
height=height,
|
|
204
|
+
folder=folder_value,
|
|
205
|
+
variants=variants,
|
|
206
|
+
)
|
|
207
|
+
self.db.add(asset)
|
|
208
|
+
await self.db.flush()
|
|
209
|
+
await self.db.refresh(asset)
|
|
210
|
+
return asset
|
|
211
|
+
|
|
212
|
+
async def delete(self, asset_id: int) -> None:
|
|
213
|
+
asset = await self.get_asset(asset_id)
|
|
214
|
+
root = self.storage_root
|
|
215
|
+
(root / asset.filename).unlink(missing_ok=True)
|
|
216
|
+
for meta in (asset.variants or {}).values():
|
|
217
|
+
if isinstance(meta, dict):
|
|
218
|
+
name = meta.get("filename")
|
|
219
|
+
if name:
|
|
220
|
+
(root / name).unlink(missing_ok=True)
|
|
221
|
+
await self.db.delete(asset)
|
|
222
|
+
await self.db.flush()
|
pagebuilder/models.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""SQLModel tables for the pagebuilder module.
|
|
2
|
+
|
|
3
|
+
The page document itself is stored as opaque JSON; only the slug,
|
|
4
|
+
title, status, SEO fields, and timestamps are queried. ``draft_data`` is
|
|
5
|
+
what the editor saves; ``published_data`` is the immutable snapshot
|
|
6
|
+
served at ``/p/{slug}`` until a new publish overwrites it. Each publish
|
|
7
|
+
also appends a row to ``PageRevision`` so the history is recoverable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import enum
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from simple_module_db.base import create_module_base
|
|
17
|
+
from simple_module_db.mixins import AuditMixin
|
|
18
|
+
from sqlalchemy import JSON, Column, DateTime
|
|
19
|
+
from sqlalchemy import Enum as SAEnum
|
|
20
|
+
from sqlmodel import Field
|
|
21
|
+
|
|
22
|
+
Base = create_module_base("pagebuilder")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Deliberately (str, Enum) rather than enum.StrEnum: these values are persisted
|
|
26
|
+
# and serialised, and StrEnum changes what str()/f-strings produce for a member
|
|
27
|
+
# ("draft" instead of "PageStatus.DRAFT"). Switching is a data-format change,
|
|
28
|
+
# not a style fix.
|
|
29
|
+
class PageStatus(str, enum.Enum): # noqa: UP042
|
|
30
|
+
DRAFT = "draft"
|
|
31
|
+
SUBMITTED_FOR_REVIEW = "submitted_for_review"
|
|
32
|
+
PUBLISHED = "published"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RevisionEvent(str, enum.Enum): # noqa: UP042 — see PageStatus above
|
|
36
|
+
"""Status-transition kind recorded in :class:`PageRevision`.
|
|
37
|
+
|
|
38
|
+
``PUBLISH`` and ``APPROVE`` rows snapshot the bytes served at
|
|
39
|
+
``/p/{slug}`` (approve also publishes), so the editor's
|
|
40
|
+
Restore-as-draft affordance treats them identically. ``SUBMIT`` /
|
|
41
|
+
``REJECT`` / ``UNPUBLISH`` are audit-only — their ``data`` is the
|
|
42
|
+
current draft, kept for forensics.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
PUBLISH = "publish"
|
|
46
|
+
UNPUBLISH = "unpublish"
|
|
47
|
+
SUBMIT = "submit"
|
|
48
|
+
APPROVE = "approve"
|
|
49
|
+
REJECT = "reject"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Page(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
53
|
+
"""A page composed in the visual editor."""
|
|
54
|
+
|
|
55
|
+
__tablename__ = "pagebuilder_pages"
|
|
56
|
+
|
|
57
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
58
|
+
slug: str = Field(max_length=200, unique=True, index=True)
|
|
59
|
+
title: str = Field(max_length=300)
|
|
60
|
+
meta_description: str | None = Field(default=None, max_length=500)
|
|
61
|
+
og_image: str | None = Field(default=None, max_length=500)
|
|
62
|
+
status: PageStatus = Field(
|
|
63
|
+
default=PageStatus.DRAFT,
|
|
64
|
+
sa_column=Column(
|
|
65
|
+
SAEnum(PageStatus, name="pagebuilder_page_status"),
|
|
66
|
+
nullable=False,
|
|
67
|
+
index=True,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
draft_data: dict[str, Any] = Field(
|
|
71
|
+
default_factory=dict,
|
|
72
|
+
sa_column=Column(JSON, nullable=False, default=dict),
|
|
73
|
+
)
|
|
74
|
+
published_data: dict[str, Any] | None = Field(
|
|
75
|
+
default=None,
|
|
76
|
+
sa_column=Column(JSON, nullable=True),
|
|
77
|
+
)
|
|
78
|
+
canonical_url: str | None = Field(default=None, max_length=500)
|
|
79
|
+
"""Override the canonical URL emitted on the public page.
|
|
80
|
+
|
|
81
|
+
Defaults to the page's own absolute URL (built from
|
|
82
|
+
``PagebuilderSettings.public_base_url`` + ``public_route_prefix`` +
|
|
83
|
+
``slug``) when unset, so the most common case requires no input.
|
|
84
|
+
"""
|
|
85
|
+
index_in_search: bool = Field(default=True)
|
|
86
|
+
"""When ``False``, emit ``<meta name="robots" content="noindex,nofollow">``
|
|
87
|
+
on the public page and exclude it from the sitemap."""
|
|
88
|
+
json_ld: dict[str, Any] | None = Field(
|
|
89
|
+
default=None,
|
|
90
|
+
sa_column=Column(JSON, nullable=True),
|
|
91
|
+
)
|
|
92
|
+
"""Optional schema.org JSON-LD document rendered inside a
|
|
93
|
+
``<script type="application/ld+json">`` tag on the public page."""
|
|
94
|
+
rejection_note: str | None = Field(default=None, max_length=2000)
|
|
95
|
+
"""Latest approver-supplied reason for sending a submission back to draft.
|
|
96
|
+
|
|
97
|
+
Cleared on every ``submit`` / ``approve`` / ``publish`` so a stale
|
|
98
|
+
note doesn't shadow a new round.
|
|
99
|
+
"""
|
|
100
|
+
publish_at: datetime | None = Field(
|
|
101
|
+
default=None,
|
|
102
|
+
sa_column=Column(DateTime(timezone=True), nullable=True),
|
|
103
|
+
)
|
|
104
|
+
"""When set, the scheduler flips a ``DRAFT`` page to ``PUBLISHED`` at
|
|
105
|
+
or after this UTC instant. Cleared on every flip (auto or manual) so
|
|
106
|
+
the schedule is single-shot."""
|
|
107
|
+
unpublish_at: datetime | None = Field(
|
|
108
|
+
default=None,
|
|
109
|
+
sa_column=Column(DateTime(timezone=True), nullable=True),
|
|
110
|
+
)
|
|
111
|
+
"""When set, the scheduler flips a ``PUBLISHED`` page back to draft at
|
|
112
|
+
or after this UTC instant. Independent of ``publish_at``; both can be
|
|
113
|
+
set so a draft goes live on Monday and takes itself down on Friday."""
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def has_published(self) -> bool:
|
|
117
|
+
return self.published_data is not None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class PageRevision(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
121
|
+
"""Append-only audit row written on every status transition.
|
|
122
|
+
|
|
123
|
+
``Page.rejection_note`` mirrors the most recent ``REJECT`` row's
|
|
124
|
+
``note`` so the editor banner avoids a join.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
__tablename__ = "pagebuilder_page_revisions"
|
|
128
|
+
|
|
129
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
130
|
+
page_id: int = Field(foreign_key="pagebuilder_pages.id", index=True)
|
|
131
|
+
title: str = Field(max_length=300)
|
|
132
|
+
meta_description: str | None = Field(default=None, max_length=500)
|
|
133
|
+
og_image: str | None = Field(default=None, max_length=500)
|
|
134
|
+
data: dict[str, Any] = Field(
|
|
135
|
+
default_factory=dict,
|
|
136
|
+
sa_column=Column(JSON, nullable=False, default=dict),
|
|
137
|
+
)
|
|
138
|
+
event: RevisionEvent = Field(
|
|
139
|
+
default=RevisionEvent.PUBLISH,
|
|
140
|
+
sa_column=Column(
|
|
141
|
+
SAEnum(RevisionEvent, name="pagebuilder_revision_event"),
|
|
142
|
+
nullable=False,
|
|
143
|
+
index=True,
|
|
144
|
+
server_default=RevisionEvent.PUBLISH.value,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
note: str | None = Field(default=None, max_length=2000)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Layout(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
151
|
+
"""Singleton — :meth:`LayoutService.get` creates the row on first read."""
|
|
152
|
+
|
|
153
|
+
__tablename__ = "pagebuilder_layout"
|
|
154
|
+
|
|
155
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
156
|
+
header_data: dict[str, Any] = Field(
|
|
157
|
+
default_factory=dict,
|
|
158
|
+
sa_column=Column(JSON, nullable=False, default=dict),
|
|
159
|
+
)
|
|
160
|
+
footer_data: dict[str, Any] = Field(
|
|
161
|
+
default_factory=dict,
|
|
162
|
+
sa_column=Column(JSON, nullable=False, default=dict),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class LayoutRevision(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
167
|
+
__tablename__ = "pagebuilder_layout_revisions"
|
|
168
|
+
|
|
169
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
170
|
+
layout_id: int = Field(foreign_key="pagebuilder_layout.id", index=True)
|
|
171
|
+
header_data: dict[str, Any] = Field(
|
|
172
|
+
default_factory=dict,
|
|
173
|
+
sa_column=Column(JSON, nullable=False, default=dict),
|
|
174
|
+
)
|
|
175
|
+
footer_data: dict[str, Any] = Field(
|
|
176
|
+
default_factory=dict,
|
|
177
|
+
sa_column=Column(JSON, nullable=False, default=dict),
|
|
178
|
+
)
|
|
179
|
+
note: str | None = Field(default=None, max_length=2000)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class MediaAsset(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
183
|
+
"""An uploaded media file stored on local disk.
|
|
184
|
+
|
|
185
|
+
The bytes themselves live under :attr:`PagebuilderSettings.media_root`;
|
|
186
|
+
this row holds the metadata + sanitized filename used to build the
|
|
187
|
+
public URL.
|
|
188
|
+
|
|
189
|
+
``width`` / ``height`` are populated for raster images so the Image
|
|
190
|
+
block can emit them on ``<img>`` and avoid layout shift; ``variants``
|
|
191
|
+
lists server-generated thumbnails / webp transcodes keyed by a stable
|
|
192
|
+
label (e.g. ``"w640"``, ``"webp"``) — each entry stores at minimum
|
|
193
|
+
``{filename, width, height, content_type}``.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
__tablename__ = "pagebuilder_media"
|
|
197
|
+
|
|
198
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
199
|
+
filename: str = Field(max_length=300, unique=True, index=True)
|
|
200
|
+
original_filename: str = Field(max_length=300)
|
|
201
|
+
content_type: str = Field(max_length=120)
|
|
202
|
+
size_bytes: int = Field(default=0)
|
|
203
|
+
width: int | None = Field(default=None)
|
|
204
|
+
height: int | None = Field(default=None)
|
|
205
|
+
folder: str | None = Field(default=None, max_length=300, index=True)
|
|
206
|
+
variants: dict[str, Any] = Field(
|
|
207
|
+
default_factory=dict,
|
|
208
|
+
sa_column=Column(JSON, nullable=False, default=dict),
|
|
209
|
+
)
|