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.
Files changed (189) hide show
  1. pagebuilder/__init__.py +0 -0
  2. pagebuilder/components/DiffSummary.tsx +107 -0
  3. pagebuilder/components/ScheduledBadge.tsx +34 -0
  4. pagebuilder/components/StatusBadge.tsx +21 -0
  5. pagebuilder/components/blockRegistry.test.ts +56 -0
  6. pagebuilder/components/blockRegistry.ts +80 -0
  7. pagebuilder/components/blocks/Button.tsx +142 -0
  8. pagebuilder/components/blocks/Columns.tsx +64 -0
  9. pagebuilder/components/blocks/Heading.tsx +92 -0
  10. pagebuilder/components/blocks/Image.tsx +125 -0
  11. pagebuilder/components/blocks/MediaPicker.tsx +178 -0
  12. pagebuilder/components/blocks/Spacer.tsx +31 -0
  13. pagebuilder/components/blocks/Text.tsx +80 -0
  14. pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
  15. pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
  16. pagebuilder/components/editor/SchedulePanel.tsx +85 -0
  17. pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
  18. pagebuilder/components/layoutPuckConfig.tsx +109 -0
  19. pagebuilder/components/media/MediaFilters.tsx +83 -0
  20. pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
  21. pagebuilder/components/media/MediaGrid.tsx +76 -0
  22. pagebuilder/components/media/MediaHeader.tsx +37 -0
  23. pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
  24. pagebuilder/components/media/types.ts +20 -0
  25. pagebuilder/components/migrateContent.test.ts +146 -0
  26. pagebuilder/components/migrateContent.ts +135 -0
  27. pagebuilder/components/puckConfig.test.tsx +118 -0
  28. pagebuilder/components/puckConfig.tsx +121 -0
  29. pagebuilder/components/widgetCatalog.test.tsx +123 -0
  30. pagebuilder/components/widgetCatalog.ts +208 -0
  31. pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
  32. pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
  33. pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
  34. pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
  35. pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
  36. pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
  37. pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
  38. pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
  39. pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
  40. pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
  41. pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
  42. pagebuilder/components/widgets/_shared/heading.tsx +88 -0
  43. pagebuilder/components/widgets/_shared/index.ts +39 -0
  44. pagebuilder/components/widgets/_shared/mask.ts +54 -0
  45. pagebuilder/components/widgets/_shared/section.tsx +60 -0
  46. pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
  47. pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
  48. pagebuilder/components/widgets/accordion-widget.tsx +64 -0
  49. pagebuilder/components/widgets/alert-widget.tsx +47 -0
  50. pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
  51. pagebuilder/components/widgets/article-cards-render.tsx +132 -0
  52. pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
  53. pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
  54. pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
  55. pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
  56. pagebuilder/components/widgets/carousel-widget.tsx +97 -0
  57. pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
  58. pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
  59. pagebuilder/components/widgets/column-widget.tsx +63 -0
  60. pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
  61. pagebuilder/components/widgets/contact-form-render.tsx +293 -0
  62. pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
  63. pagebuilder/components/widgets/container-widget.tsx +93 -0
  64. pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
  65. pagebuilder/components/widgets/divider-widget.tsx +56 -0
  66. pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
  67. pagebuilder/components/widgets/faq-render.tsx +268 -0
  68. pagebuilder/components/widgets/faq-widget.tsx +64 -0
  69. pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
  70. pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
  71. pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
  72. pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
  73. pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
  74. pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
  75. pagebuilder/components/widgets/gallery-widget.tsx +139 -0
  76. pagebuilder/components/widgets/grid-widget.tsx +59 -0
  77. pagebuilder/components/widgets/hero-widget.tsx +292 -0
  78. pagebuilder/components/widgets/html-widget.tsx +42 -0
  79. pagebuilder/components/widgets/iframe-widget.tsx +44 -0
  80. pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
  81. pagebuilder/components/widgets/list-widget.tsx +56 -0
  82. pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
  83. pagebuilder/components/widgets/markdown-widget.tsx +148 -0
  84. pagebuilder/components/widgets/media-object-layout.ts +156 -0
  85. pagebuilder/components/widgets/media-object-render.tsx +284 -0
  86. pagebuilder/components/widgets/media-object-text.tsx +202 -0
  87. pagebuilder/components/widgets/media-object-widget.tsx +176 -0
  88. pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
  89. pagebuilder/components/widgets/page-header-widget.tsx +118 -0
  90. pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
  91. pagebuilder/components/widgets/quote-widget.tsx +51 -0
  92. pagebuilder/components/widgets/row-widget.tsx +74 -0
  93. pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
  94. pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
  95. pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
  96. pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
  97. pagebuilder/components/widgets/site-header-nav.tsx +153 -0
  98. pagebuilder/components/widgets/site-header-widget.tsx +182 -0
  99. pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
  100. pagebuilder/components/widgets/stats-widget.tsx +200 -0
  101. pagebuilder/components/widgets/steps-widget.tsx +211 -0
  102. pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
  103. pagebuilder/components/widgets/table-widget.tsx +86 -0
  104. pagebuilder/components/widgets/tabs-widget.tsx +95 -0
  105. pagebuilder/components/widgets/tags-widget.tsx +85 -0
  106. pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
  107. pagebuilder/components/widgets/timeline-layout.ts +73 -0
  108. pagebuilder/components/widgets/timeline-render.tsx +294 -0
  109. pagebuilder/components/widgets/timeline-widget.tsx +106 -0
  110. pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
  111. pagebuilder/components/widgets/video-widget.tsx +103 -0
  112. pagebuilder/components/widgets/welcome-widget.tsx +38 -0
  113. pagebuilder/contracts/__init__.py +57 -0
  114. pagebuilder/contracts/events.py +25 -0
  115. pagebuilder/contracts/schemas.py +239 -0
  116. pagebuilder/conversion/blocks-to-html.test.ts +105 -0
  117. pagebuilder/conversion/blocks-to-html.ts +102 -0
  118. pagebuilder/conversion/index.ts +2 -0
  119. pagebuilder/conversion/types.ts +5 -0
  120. pagebuilder/deps.py +26 -0
  121. pagebuilder/diff.py +129 -0
  122. pagebuilder/endpoints/__init__.py +0 -0
  123. pagebuilder/endpoints/api/__init__.py +21 -0
  124. pagebuilder/endpoints/api/_deps.py +21 -0
  125. pagebuilder/endpoints/api/layout.py +78 -0
  126. pagebuilder/endpoints/api/pages.py +85 -0
  127. pagebuilder/endpoints/api/revisions.py +79 -0
  128. pagebuilder/endpoints/api/uploads.py +92 -0
  129. pagebuilder/endpoints/api/workflow.py +124 -0
  130. pagebuilder/endpoints/seo.py +95 -0
  131. pagebuilder/endpoints/views.py +228 -0
  132. pagebuilder/fields/checkbox-field.tsx +22 -0
  133. pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
  134. pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
  135. pagebuilder/fields/file-picker/index.ts +6 -0
  136. pagebuilder/fields/file-picker/types.ts +15 -0
  137. pagebuilder/fields/index.ts +9 -0
  138. pagebuilder/fields/media-adapter.ts +47 -0
  139. pagebuilder/hooks/useAutosave.ts +134 -0
  140. pagebuilder/hooks/useEditorForm.ts +193 -0
  141. pagebuilder/hooks/useMediaUploads.ts +96 -0
  142. pagebuilder/hooks/usePageRevisions.ts +59 -0
  143. pagebuilder/hooks/usePageSchedule.ts +72 -0
  144. pagebuilder/hooks/usePageWorkflow.ts +173 -0
  145. pagebuilder/layout_service.py +101 -0
  146. pagebuilder/media_images.py +185 -0
  147. pagebuilder/media_queries.py +92 -0
  148. pagebuilder/media_service.py +222 -0
  149. pagebuilder/models.py +209 -0
  150. pagebuilder/module.py +279 -0
  151. pagebuilder/package.json +18 -0
  152. pagebuilder/pages/LayoutEditor.tsx +183 -0
  153. pagebuilder/pages/MediaLibrary.tsx +249 -0
  154. pagebuilder/pages/PageEditor.tsx +181 -0
  155. pagebuilder/pages/PageList.tsx +129 -0
  156. pagebuilder/pages/PendingReview.tsx +128 -0
  157. pagebuilder/pages/PublicPage.tsx +126 -0
  158. pagebuilder/permissions.py +44 -0
  159. pagebuilder/py.typed +0 -0
  160. pagebuilder/security.py +230 -0
  161. pagebuilder/service/__init__.py +141 -0
  162. pagebuilder/service/_common.py +24 -0
  163. pagebuilder/service/_revisions.py +92 -0
  164. pagebuilder/service/_workflow.py +207 -0
  165. pagebuilder/services.py +19 -0
  166. pagebuilder/settings.py +161 -0
  167. pagebuilder/static/widgets-base.css +57 -0
  168. pagebuilder/translation/apply.ts +54 -0
  169. pagebuilder/translation/extract.ts +141 -0
  170. pagebuilder/translation/index.ts +20 -0
  171. pagebuilder/translation/translatable-fields.ts +65 -0
  172. pagebuilder/translation/translation.test.ts +167 -0
  173. pagebuilder/utils/api.ts +12 -0
  174. pagebuilder/utils/datetime.ts +25 -0
  175. pagebuilder/utils/decorative-image.ts +15 -0
  176. pagebuilder/utils/editorSnapshot.ts +39 -0
  177. pagebuilder/utils/markdown.ts +160 -0
  178. pagebuilder/utils/mediaApi.ts +112 -0
  179. pagebuilder/utils/mediaFormat.ts +19 -0
  180. pagebuilder/utils/pagesApi.ts +111 -0
  181. pagebuilder/utils/parse-list.ts +7 -0
  182. pagebuilder/utils/request.ts +52 -0
  183. pagebuilder/utils/slugify.ts +9 -0
  184. pagebuilder/utils/types.ts +171 -0
  185. pagebuilder/utils/widgetUtils.ts +11 -0
  186. simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
  187. simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
  188. simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
  189. 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
+ )