sum-cli 3.0.0__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.
- sum/__init__.py +1 -0
- sum/boilerplate/.env.example +124 -0
- sum/boilerplate/.gitea/workflows/ci.yml +33 -0
- sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
- sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
- sum/boilerplate/.github/workflows/ci.yml +36 -0
- sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
- sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
- sum/boilerplate/.gitignore +45 -0
- sum/boilerplate/README.md +259 -0
- sum/boilerplate/manage.py +34 -0
- sum/boilerplate/project_name/__init__.py +5 -0
- sum/boilerplate/project_name/home/__init__.py +5 -0
- sum/boilerplate/project_name/home/apps.py +20 -0
- sum/boilerplate/project_name/home/management/__init__.py +0 -0
- sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
- sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
- sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
- sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
- sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
- sum/boilerplate/project_name/home/models.py +13 -0
- sum/boilerplate/project_name/settings/__init__.py +5 -0
- sum/boilerplate/project_name/settings/base.py +348 -0
- sum/boilerplate/project_name/settings/local.py +78 -0
- sum/boilerplate/project_name/settings/production.py +106 -0
- sum/boilerplate/project_name/urls.py +33 -0
- sum/boilerplate/project_name/wsgi.py +16 -0
- sum/boilerplate/pytest.ini +5 -0
- sum/boilerplate/requirements.txt +25 -0
- sum/boilerplate/static/client/.gitkeep +3 -0
- sum/boilerplate/templates/overrides/.gitkeep +3 -0
- sum/boilerplate/tests/__init__.py +3 -0
- sum/boilerplate/tests/test_health.py +51 -0
- sum/cli.py +42 -0
- sum/commands/__init__.py +10 -0
- sum/commands/backup.py +308 -0
- sum/commands/check.py +128 -0
- sum/commands/init.py +265 -0
- sum/commands/promote.py +758 -0
- sum/commands/run.py +96 -0
- sum/commands/themes.py +56 -0
- sum/commands/update.py +301 -0
- sum/config.py +61 -0
- sum/docs/USER_GUIDE.md +663 -0
- sum/exceptions.py +45 -0
- sum/setup/__init__.py +17 -0
- sum/setup/auth.py +184 -0
- sum/setup/database.py +58 -0
- sum/setup/deps.py +73 -0
- sum/setup/git_ops.py +463 -0
- sum/setup/infrastructure.py +576 -0
- sum/setup/orchestrator.py +354 -0
- sum/setup/remote_themes.py +371 -0
- sum/setup/scaffold.py +500 -0
- sum/setup/seed.py +110 -0
- sum/setup/site_orchestrator.py +441 -0
- sum/setup/venv.py +89 -0
- sum/system_config.py +330 -0
- sum/themes_registry.py +180 -0
- sum/utils/__init__.py +25 -0
- sum/utils/django.py +97 -0
- sum/utils/environment.py +76 -0
- sum/utils/output.py +78 -0
- sum/utils/project.py +110 -0
- sum/utils/prompts.py +36 -0
- sum/utils/validation.py +313 -0
- sum_cli-3.0.0.dist-info/METADATA +127 -0
- sum_cli-3.0.0.dist-info/RECORD +72 -0
- sum_cli-3.0.0.dist-info/WHEEL +5 -0
- sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
- sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
- sum_cli-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1661 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Seed a theme showroom for SUM Platform client projects.
|
|
3
|
+
|
|
4
|
+
This management command is intended to run inside any generated client project
|
|
5
|
+
(`sum init <client> --theme <theme_slug>`), creating a predictable showroom
|
|
6
|
+
site tree and navigation settings so theme development can start immediately.
|
|
7
|
+
|
|
8
|
+
It creates:
|
|
9
|
+
- A HomePage (client-owned model) and sets it as the default Wagtail Site root
|
|
10
|
+
- A StandardPage showroom (optional) + a Contact StandardPage
|
|
11
|
+
- A ServiceIndexPage and two ServicePage children
|
|
12
|
+
- A "Kitchen Sink" page with all blocks
|
|
13
|
+
- Legal pages (Terms, Privacy, Cookies) with legal section blocks
|
|
14
|
+
- Example content that showcases *all* blocks available in sum_core.PageStreamBlock,
|
|
15
|
+
spread across multiple pages (not all on one page)
|
|
16
|
+
- Branding SiteSettings and Navigation (HeaderNavigation / FooterNavigation)
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python manage.py seed_showroom
|
|
20
|
+
python manage.py seed_showroom --clear
|
|
21
|
+
python manage.py seed_showroom --profile starter
|
|
22
|
+
python manage.py seed_showroom --hostname localhost --port 8000
|
|
23
|
+
python manage.py seed_showroom --homepage-model home.HomePage
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from io import BytesIO
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from django.apps import apps
|
|
34
|
+
from django.core.files.base import ContentFile
|
|
35
|
+
from django.core.management.base import BaseCommand, CommandParser
|
|
36
|
+
from django.db import transaction
|
|
37
|
+
from sum_core.blocks import PageStreamBlock
|
|
38
|
+
from sum_core.branding.models import SiteSettings
|
|
39
|
+
from sum_core.navigation.cache import invalidate_nav_cache
|
|
40
|
+
from sum_core.navigation.models import FooterNavigation, HeaderNavigation
|
|
41
|
+
from sum_core.pages import ServiceIndexPage, ServicePage, StandardPage
|
|
42
|
+
from wagtail.models import Page, Site
|
|
43
|
+
|
|
44
|
+
PILImage: Any | None
|
|
45
|
+
try:
|
|
46
|
+
from PIL import Image as PILImage
|
|
47
|
+
except Exception: # pragma: no cover
|
|
48
|
+
PILImage = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class _ShowroomSlugs:
|
|
53
|
+
home: str = "showroom-home"
|
|
54
|
+
showroom: str = "showroom"
|
|
55
|
+
contact: str = "contact"
|
|
56
|
+
services: str = "services"
|
|
57
|
+
service_one: str = "solar-installation"
|
|
58
|
+
service_two: str = "roofing"
|
|
59
|
+
kitchen_sink: str = "kitchen-sink"
|
|
60
|
+
terms: str = "terms"
|
|
61
|
+
privacy: str = "privacy"
|
|
62
|
+
cookies: str = "cookies"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
PROFILE_STARTER = "starter"
|
|
66
|
+
PROFILE_SHOWROOM = "showroom"
|
|
67
|
+
VALID_PROFILES = {PROFILE_STARTER, PROFILE_SHOWROOM}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Command(BaseCommand):
|
|
71
|
+
help = "Create a theme showroom site tree, blocks, and navigation."
|
|
72
|
+
|
|
73
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--clear",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Delete existing showroom pages (by slug) before re-seeding.",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--profile",
|
|
81
|
+
choices=sorted(VALID_PROFILES),
|
|
82
|
+
default=PROFILE_SHOWROOM,
|
|
83
|
+
help="Seed profile to apply (starter or showroom).",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--hostname",
|
|
87
|
+
default=None,
|
|
88
|
+
help="Set the default Site hostname (defaults to existing or 'localhost').",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--port",
|
|
92
|
+
type=int,
|
|
93
|
+
default=None,
|
|
94
|
+
help="Set the default Site port (defaults to existing or 8000).",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--homepage-model",
|
|
98
|
+
default=None,
|
|
99
|
+
help=(
|
|
100
|
+
"Override HomePage model as 'app_label.ModelName' "
|
|
101
|
+
"(defaults to first installed HomePage)."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@transaction.atomic
|
|
106
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
107
|
+
profile = (options.get("profile") or PROFILE_STARTER).lower()
|
|
108
|
+
if profile not in VALID_PROFILES:
|
|
109
|
+
self.stdout.write(
|
|
110
|
+
self.style.ERROR(
|
|
111
|
+
f"Unknown profile '{profile}'. Use one of: {', '.join(sorted(VALID_PROFILES))}."
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
slugs = _ShowroomSlugs()
|
|
117
|
+
|
|
118
|
+
home_page_model = self._resolve_home_page_model(options.get("homepage_model"))
|
|
119
|
+
if home_page_model is None:
|
|
120
|
+
self.stdout.write(
|
|
121
|
+
self.style.ERROR(
|
|
122
|
+
"Could not find a HomePage model. Ensure your client 'home' app is in INSTALLED_APPS."
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
legal_page_model = self._resolve_legal_page_model()
|
|
127
|
+
|
|
128
|
+
root = Page.get_first_root_node()
|
|
129
|
+
site = self._get_or_create_default_site(
|
|
130
|
+
options.get("hostname"), options.get("port"), root
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if options.get("clear"):
|
|
134
|
+
self._clear_showroom(
|
|
135
|
+
site=site, slugs=slugs, home_page_model=home_page_model
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Pages
|
|
139
|
+
# NOTE: We do not blindly grab the first HomePage anymore. We check for our specific slug.
|
|
140
|
+
home = self._get_or_create_homepage(
|
|
141
|
+
site=site, root=root, home_page_model=home_page_model, slugs=slugs
|
|
142
|
+
)
|
|
143
|
+
contact = self._get_or_create_standard_page(
|
|
144
|
+
parent=home, title="Contact", slug=slugs.contact
|
|
145
|
+
)
|
|
146
|
+
showroom = None
|
|
147
|
+
kitchen_sink = None
|
|
148
|
+
if profile == PROFILE_SHOWROOM:
|
|
149
|
+
showroom = self._get_or_create_standard_page(
|
|
150
|
+
parent=home, title="Showroom", slug=slugs.showroom
|
|
151
|
+
)
|
|
152
|
+
kitchen_sink = self._get_or_create_standard_page(
|
|
153
|
+
parent=home, title="Kitchen Sink", slug=slugs.kitchen_sink
|
|
154
|
+
)
|
|
155
|
+
services_index = self._get_or_create_services_index(
|
|
156
|
+
parent=home, title="Services", slug=slugs.services
|
|
157
|
+
)
|
|
158
|
+
service_one = self._get_or_create_service_page(
|
|
159
|
+
parent=services_index,
|
|
160
|
+
title="Solar Installation",
|
|
161
|
+
slug=slugs.service_one,
|
|
162
|
+
short_description="Premium solar installs with clean, modern finishing.",
|
|
163
|
+
)
|
|
164
|
+
service_two = self._get_or_create_service_page(
|
|
165
|
+
parent=services_index,
|
|
166
|
+
title="Roofing",
|
|
167
|
+
slug=slugs.service_two,
|
|
168
|
+
short_description="Durable, weather-ready roofing from a trusted local team.",
|
|
169
|
+
)
|
|
170
|
+
terms = self._get_or_create_legal_page(
|
|
171
|
+
parent=home, title="Terms", slug=slugs.terms, page_model=legal_page_model
|
|
172
|
+
)
|
|
173
|
+
privacy = self._get_or_create_legal_page(
|
|
174
|
+
parent=home,
|
|
175
|
+
title="Privacy",
|
|
176
|
+
slug=slugs.privacy,
|
|
177
|
+
page_model=legal_page_model,
|
|
178
|
+
)
|
|
179
|
+
cookies = self._get_or_create_legal_page(
|
|
180
|
+
parent=home,
|
|
181
|
+
title="Cookies",
|
|
182
|
+
slug=slugs.cookies,
|
|
183
|
+
page_model=legal_page_model,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Media (placeholder images)
|
|
187
|
+
images = self._get_or_create_showroom_images()
|
|
188
|
+
|
|
189
|
+
if profile == PROFILE_STARTER:
|
|
190
|
+
home.title = "Starter Home"
|
|
191
|
+
# Set hero fields on HomePage model (used by theme template)
|
|
192
|
+
home.hero_headline = "<p>Build with <em>confidence</em></p>"
|
|
193
|
+
home.hero_subheadline = (
|
|
194
|
+
"Starter content to help you validate layouts and branding quickly."
|
|
195
|
+
)
|
|
196
|
+
home.hero_image_id = images.hero_id
|
|
197
|
+
home.hero_image_alt = "Starter hero placeholder image"
|
|
198
|
+
home.hero_overlay_opacity = "light"
|
|
199
|
+
home.body = self._build_starter_home_stream(
|
|
200
|
+
images=images, contact_page=contact
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
home.title = "Theme Showroom"
|
|
204
|
+
# Set hero fields on HomePage model (used by theme template)
|
|
205
|
+
home.hero_headline = "<p>Theme <em>Showroom</em></p>"
|
|
206
|
+
home.hero_subheadline = (
|
|
207
|
+
"A seeded site tree with every block, ready for theme development."
|
|
208
|
+
)
|
|
209
|
+
home.hero_image_id = images.hero_id
|
|
210
|
+
home.hero_image_alt = "A placeholder hero image for theme showcase"
|
|
211
|
+
home.hero_overlay_opacity = "medium"
|
|
212
|
+
home.body = self._build_home_stream(images=images, contact_page=contact)
|
|
213
|
+
home.save_revision().publish()
|
|
214
|
+
|
|
215
|
+
# Add hero CTAs if the model supports them
|
|
216
|
+
self._seed_hero_ctas(home, profile, contact)
|
|
217
|
+
|
|
218
|
+
if showroom is not None:
|
|
219
|
+
showroom.body = self._build_showroom_stream(
|
|
220
|
+
images=images,
|
|
221
|
+
services_index=services_index,
|
|
222
|
+
service_one=service_one,
|
|
223
|
+
contact_page=contact,
|
|
224
|
+
)
|
|
225
|
+
showroom.save_revision().publish()
|
|
226
|
+
|
|
227
|
+
# Kitchen Sink - All blocks in one place
|
|
228
|
+
if kitchen_sink is not None:
|
|
229
|
+
kitchen_sink.body = self._build_kitchen_sink_stream(
|
|
230
|
+
images=images,
|
|
231
|
+
services_index=services_index,
|
|
232
|
+
service_one=service_one,
|
|
233
|
+
contact_page=contact,
|
|
234
|
+
)
|
|
235
|
+
kitchen_sink.save_revision().publish()
|
|
236
|
+
|
|
237
|
+
services_index.intro = self._build_services_index_intro_stream(images=images)
|
|
238
|
+
services_index.save_revision().publish()
|
|
239
|
+
|
|
240
|
+
service_one.featured_image_id = images.service_featured_one_id
|
|
241
|
+
service_one.body = self._build_service_page_stream(
|
|
242
|
+
images=images, page=service_one, contact_page=contact
|
|
243
|
+
)
|
|
244
|
+
service_one.save_revision().publish()
|
|
245
|
+
|
|
246
|
+
service_two.featured_image_id = images.service_featured_two_id
|
|
247
|
+
service_two.body = self._build_service_page_stream(
|
|
248
|
+
images=images, page=service_two, contact_page=contact
|
|
249
|
+
)
|
|
250
|
+
service_two.save_revision().publish()
|
|
251
|
+
|
|
252
|
+
contact.body = self._build_contact_stream(images=images)
|
|
253
|
+
contact.save_revision().publish()
|
|
254
|
+
|
|
255
|
+
terms_intro, terms_sections = self._build_terms_sections()
|
|
256
|
+
self._apply_legal_page_content(
|
|
257
|
+
terms,
|
|
258
|
+
heading="Terms & Conditions",
|
|
259
|
+
intro=terms_intro,
|
|
260
|
+
sections=terms_sections,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
privacy_intro, privacy_sections = self._build_privacy_sections()
|
|
264
|
+
self._apply_legal_page_content(
|
|
265
|
+
privacy,
|
|
266
|
+
heading="Privacy Notice",
|
|
267
|
+
intro=privacy_intro,
|
|
268
|
+
sections=privacy_sections,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
cookies_intro, cookies_sections = self._build_cookie_sections()
|
|
272
|
+
self._apply_legal_page_content(
|
|
273
|
+
cookies,
|
|
274
|
+
heading="Cookie Policy",
|
|
275
|
+
intro=cookies_intro,
|
|
276
|
+
sections=cookies_sections,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Site settings (branding + navigation)
|
|
280
|
+
self._seed_branding(
|
|
281
|
+
site=site,
|
|
282
|
+
images=images,
|
|
283
|
+
terms=terms,
|
|
284
|
+
privacy=privacy,
|
|
285
|
+
cookies=cookies,
|
|
286
|
+
)
|
|
287
|
+
self._seed_navigation(
|
|
288
|
+
site=site,
|
|
289
|
+
home=home,
|
|
290
|
+
contact=contact,
|
|
291
|
+
services_index=services_index,
|
|
292
|
+
service_one=service_one,
|
|
293
|
+
service_two=service_two,
|
|
294
|
+
terms=terms,
|
|
295
|
+
privacy=privacy,
|
|
296
|
+
cookies=cookies,
|
|
297
|
+
showroom=showroom,
|
|
298
|
+
kitchen_sink=kitchen_sink,
|
|
299
|
+
)
|
|
300
|
+
invalidate_nav_cache(site.id)
|
|
301
|
+
|
|
302
|
+
self.stdout.write(self.style.SUCCESS(f"✓ Showroom seeded ({profile})"))
|
|
303
|
+
self.stdout.write(f" - Home: / (Wagtail site root -> {home.title})")
|
|
304
|
+
if showroom is not None:
|
|
305
|
+
self.stdout.write(f" - Showroom: {showroom.url}")
|
|
306
|
+
if kitchen_sink is not None:
|
|
307
|
+
self.stdout.write(f" - Kitchen Sink: {kitchen_sink.url}")
|
|
308
|
+
self.stdout.write(f" - Services: {services_index.url}")
|
|
309
|
+
self.stdout.write(f" - Contact: {contact.url}")
|
|
310
|
+
self.stdout.write(f" - Terms: {terms.url}")
|
|
311
|
+
self.stdout.write(f" - Privacy: {privacy.url}")
|
|
312
|
+
self.stdout.write(f" - Cookies: {cookies.url}")
|
|
313
|
+
|
|
314
|
+
# -----------------------------------------------------------------------------
|
|
315
|
+
# Model resolution / site helpers
|
|
316
|
+
# -----------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
def _resolve_home_page_model(self, dotted: str | None) -> Any | None:
|
|
319
|
+
"""
|
|
320
|
+
Resolve the client-owned HomePage model.
|
|
321
|
+
|
|
322
|
+
Strategy:
|
|
323
|
+
- If --homepage-model is provided (app_label.ModelName), use it
|
|
324
|
+
- Otherwise, prefer any app labeled 'home' that exposes HomePage
|
|
325
|
+
- Fallback: first installed model named 'HomePage' that is a Page subclass
|
|
326
|
+
"""
|
|
327
|
+
from wagtail.models import Page as WagtailPage
|
|
328
|
+
|
|
329
|
+
if dotted:
|
|
330
|
+
if "." not in dotted:
|
|
331
|
+
raise ValueError("--homepage-model must be 'app_label.ModelName'")
|
|
332
|
+
app_label, model_name = dotted.split(".", 1)
|
|
333
|
+
return apps.get_model(app_label, model_name)
|
|
334
|
+
|
|
335
|
+
# Prefer a 'home' app
|
|
336
|
+
for app_config in apps.get_app_configs():
|
|
337
|
+
# Fix: Check app_config.name for dotted paths, though label is usually simple.
|
|
338
|
+
# safe check: (app_config.label == "home") or (app_config.name.endswith(".home"))
|
|
339
|
+
if app_config.label == "home" or app_config.name.endswith(".home"):
|
|
340
|
+
try:
|
|
341
|
+
return apps.get_model(app_config.label, "HomePage")
|
|
342
|
+
except LookupError:
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
# Fallback: any installed HomePage model
|
|
346
|
+
for model in apps.get_models():
|
|
347
|
+
try:
|
|
348
|
+
if model.__name__ == "HomePage" and issubclass(model, WagtailPage):
|
|
349
|
+
return model
|
|
350
|
+
except TypeError:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
def _resolve_legal_page_model(self) -> Any:
|
|
356
|
+
"""
|
|
357
|
+
Resolve LegalPage if available, otherwise fall back to StandardPage.
|
|
358
|
+
"""
|
|
359
|
+
from wagtail.models import Page as WagtailPage
|
|
360
|
+
|
|
361
|
+
for model in apps.get_models():
|
|
362
|
+
try:
|
|
363
|
+
if model.__name__ == "LegalPage" and issubclass(model, WagtailPage):
|
|
364
|
+
return model
|
|
365
|
+
except TypeError:
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
return StandardPage
|
|
369
|
+
|
|
370
|
+
def _get_or_create_default_site(
|
|
371
|
+
self, hostname: str | None, port: int | None, root: Page
|
|
372
|
+
) -> Site:
|
|
373
|
+
Site.clear_site_root_paths_cache()
|
|
374
|
+
|
|
375
|
+
site = Site.objects.filter(is_default_site=True).first()
|
|
376
|
+
if site is None:
|
|
377
|
+
site = Site.objects.create(
|
|
378
|
+
hostname=hostname or "localhost",
|
|
379
|
+
port=port or 8000,
|
|
380
|
+
root_page=root,
|
|
381
|
+
is_default_site=True,
|
|
382
|
+
site_name="Showroom",
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
changed = False
|
|
386
|
+
if hostname and site.hostname != hostname:
|
|
387
|
+
site.hostname = hostname
|
|
388
|
+
changed = True
|
|
389
|
+
if port and site.port != port:
|
|
390
|
+
site.port = port
|
|
391
|
+
changed = True
|
|
392
|
+
if not site.is_default_site:
|
|
393
|
+
site.is_default_site = True
|
|
394
|
+
changed = True
|
|
395
|
+
if changed:
|
|
396
|
+
site.save()
|
|
397
|
+
|
|
398
|
+
Site.clear_site_root_paths_cache()
|
|
399
|
+
return site
|
|
400
|
+
|
|
401
|
+
def _clear_showroom(
|
|
402
|
+
self, *, site: Site, slugs: _ShowroomSlugs, home_page_model: Any
|
|
403
|
+
) -> None:
|
|
404
|
+
"""
|
|
405
|
+
Remove previously seeded pages safely.
|
|
406
|
+
Only deletes content if we can find the seeded HomePage by its specific slug.
|
|
407
|
+
"""
|
|
408
|
+
self.stdout.write("Clearing existing showroom pages...")
|
|
409
|
+
|
|
410
|
+
# Locate the seeded home page
|
|
411
|
+
home = home_page_model.objects.filter(slug=slugs.home).first()
|
|
412
|
+
if not home:
|
|
413
|
+
self.stdout.write(
|
|
414
|
+
self.style.WARNING(
|
|
415
|
+
f"No existing showroom home with slug '{slugs.home}' found. "
|
|
416
|
+
"Skipping deletion to prevent data loss."
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
known_child_slugs = {
|
|
422
|
+
slugs.showroom,
|
|
423
|
+
slugs.contact,
|
|
424
|
+
slugs.services,
|
|
425
|
+
slugs.kitchen_sink,
|
|
426
|
+
slugs.terms,
|
|
427
|
+
slugs.privacy,
|
|
428
|
+
slugs.cookies,
|
|
429
|
+
}
|
|
430
|
+
service_child_slugs = {slugs.service_one, slugs.service_two}
|
|
431
|
+
|
|
432
|
+
for child in home.get_children().filter(slug__in=known_child_slugs):
|
|
433
|
+
if child.slug == slugs.services:
|
|
434
|
+
for service in child.get_children().filter(
|
|
435
|
+
slug__in=service_child_slugs
|
|
436
|
+
):
|
|
437
|
+
service.specific.delete()
|
|
438
|
+
child.specific.delete()
|
|
439
|
+
|
|
440
|
+
Site.clear_site_root_paths_cache()
|
|
441
|
+
|
|
442
|
+
# -----------------------------------------------------------------------------
|
|
443
|
+
# Page creation helpers
|
|
444
|
+
# -----------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
def _get_or_create_homepage(
|
|
447
|
+
self, *, site: Site, root: Page, home_page_model: Any, slugs: _ShowroomSlugs
|
|
448
|
+
) -> Any:
|
|
449
|
+
# Strict retrieval by slug - do not grab unrelated homepages
|
|
450
|
+
home = home_page_model.objects.filter(slug=slugs.home).first()
|
|
451
|
+
if not home:
|
|
452
|
+
home = home_page_model(title="Theme Showroom", slug=slugs.home, body=None)
|
|
453
|
+
root.add_child(instance=home)
|
|
454
|
+
home.save_revision().publish()
|
|
455
|
+
|
|
456
|
+
# Point default site root at the HomePage (homepage URL becomes "/")
|
|
457
|
+
if site.root_page_id != home.id:
|
|
458
|
+
site.root_page = home
|
|
459
|
+
site.site_name = site.site_name or "Showroom"
|
|
460
|
+
site.save()
|
|
461
|
+
Site.clear_site_root_paths_cache()
|
|
462
|
+
return home
|
|
463
|
+
|
|
464
|
+
def _get_or_create_standard_page(
|
|
465
|
+
self, *, parent: Page, title: str, slug: str
|
|
466
|
+
) -> StandardPage:
|
|
467
|
+
existing = parent.get_children().type(StandardPage).filter(slug=slug).first()
|
|
468
|
+
if existing:
|
|
469
|
+
return existing.specific
|
|
470
|
+
|
|
471
|
+
page = StandardPage(title=title, slug=slug, body=None)
|
|
472
|
+
parent.add_child(instance=page)
|
|
473
|
+
return page
|
|
474
|
+
|
|
475
|
+
def _get_or_create_legal_page(
|
|
476
|
+
self, *, parent: Page, title: str, slug: str, page_model: Any
|
|
477
|
+
) -> Page:
|
|
478
|
+
existing = parent.get_children().type(page_model).filter(slug=slug).first()
|
|
479
|
+
if existing:
|
|
480
|
+
return existing.specific
|
|
481
|
+
|
|
482
|
+
page = page_model(title=title, slug=slug)
|
|
483
|
+
if hasattr(page, "body"):
|
|
484
|
+
page.body = None
|
|
485
|
+
parent.add_child(instance=page)
|
|
486
|
+
return page
|
|
487
|
+
|
|
488
|
+
def _get_or_create_services_index(
|
|
489
|
+
self, *, parent: Page, title: str, slug: str
|
|
490
|
+
) -> ServiceIndexPage:
|
|
491
|
+
existing = (
|
|
492
|
+
parent.get_children().type(ServiceIndexPage).filter(slug=slug).first()
|
|
493
|
+
)
|
|
494
|
+
if existing:
|
|
495
|
+
return existing.specific
|
|
496
|
+
|
|
497
|
+
page = ServiceIndexPage(title=title, slug=slug, intro=None)
|
|
498
|
+
parent.add_child(instance=page)
|
|
499
|
+
return page
|
|
500
|
+
|
|
501
|
+
def _get_or_create_service_page(
|
|
502
|
+
self,
|
|
503
|
+
*,
|
|
504
|
+
parent: ServiceIndexPage,
|
|
505
|
+
title: str,
|
|
506
|
+
slug: str,
|
|
507
|
+
short_description: str,
|
|
508
|
+
) -> ServicePage:
|
|
509
|
+
existing = parent.get_children().type(ServicePage).filter(slug=slug).first()
|
|
510
|
+
if existing:
|
|
511
|
+
svc = existing.specific
|
|
512
|
+
svc.short_description = short_description
|
|
513
|
+
svc.save()
|
|
514
|
+
return svc
|
|
515
|
+
|
|
516
|
+
page = ServicePage(
|
|
517
|
+
title=title,
|
|
518
|
+
slug=slug,
|
|
519
|
+
short_description=short_description,
|
|
520
|
+
featured_image=None,
|
|
521
|
+
body=None,
|
|
522
|
+
)
|
|
523
|
+
parent.add_child(instance=page)
|
|
524
|
+
return page
|
|
525
|
+
|
|
526
|
+
# -----------------------------------------------------------------------------
|
|
527
|
+
# Images
|
|
528
|
+
# -----------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
@dataclass(frozen=True)
|
|
531
|
+
class _Images:
|
|
532
|
+
hero_id: int
|
|
533
|
+
legacy_hero_id: int
|
|
534
|
+
comparison_before_id: int
|
|
535
|
+
comparison_after_id: int
|
|
536
|
+
gallery_one_id: int
|
|
537
|
+
gallery_two_id: int
|
|
538
|
+
gallery_three_id: int
|
|
539
|
+
portfolio_one_id: int
|
|
540
|
+
portfolio_two_id: int
|
|
541
|
+
trust_logo_one_id: int
|
|
542
|
+
trust_logo_two_id: int
|
|
543
|
+
image_block_id: int
|
|
544
|
+
service_featured_one_id: int
|
|
545
|
+
service_featured_two_id: int
|
|
546
|
+
brand_logo_id: int
|
|
547
|
+
favicon_id: int
|
|
548
|
+
|
|
549
|
+
def _get_or_create_showroom_images(self) -> _Images:
|
|
550
|
+
# Seed into specific collection
|
|
551
|
+
collection = self._get_or_create_collection("Showroom")
|
|
552
|
+
|
|
553
|
+
hero = self._get_or_create_image(
|
|
554
|
+
"Showroom Hero", (1400, 900), "#0ea5e9", collection
|
|
555
|
+
)
|
|
556
|
+
legacy_hero = self._get_or_create_image(
|
|
557
|
+
"Legacy Hero", (1200, 800), "#14b8a6", collection
|
|
558
|
+
)
|
|
559
|
+
before = self._get_or_create_image(
|
|
560
|
+
"Comparison Before", (1400, 900), "#334155", collection
|
|
561
|
+
)
|
|
562
|
+
after = self._get_or_create_image(
|
|
563
|
+
"Comparison After", (1400, 900), "#f97316", collection
|
|
564
|
+
)
|
|
565
|
+
g1 = self._get_or_create_image("Gallery 1", (1200, 800), "#a855f7", collection)
|
|
566
|
+
g2 = self._get_or_create_image("Gallery 2", (1200, 800), "#22c55e", collection)
|
|
567
|
+
g3 = self._get_or_create_image("Gallery 3", (1200, 800), "#eab308", collection)
|
|
568
|
+
p1 = self._get_or_create_image(
|
|
569
|
+
"Portfolio 1", (1200, 900), "#0f172a", collection
|
|
570
|
+
)
|
|
571
|
+
p2 = self._get_or_create_image(
|
|
572
|
+
"Portfolio 2", (1200, 900), "#1f2937", collection
|
|
573
|
+
)
|
|
574
|
+
l1 = self._get_or_create_image(
|
|
575
|
+
"Trust Logo 1", (600, 360), "#111827", collection
|
|
576
|
+
)
|
|
577
|
+
l2 = self._get_or_create_image(
|
|
578
|
+
"Trust Logo 2", (600, 360), "#0b1220", collection
|
|
579
|
+
)
|
|
580
|
+
ib = self._get_or_create_image(
|
|
581
|
+
"Content Image", (1600, 900), "#64748b", collection
|
|
582
|
+
)
|
|
583
|
+
sf1 = self._get_or_create_image(
|
|
584
|
+
"Service Featured 1", (1600, 900), "#2563eb", collection
|
|
585
|
+
)
|
|
586
|
+
sf2 = self._get_or_create_image(
|
|
587
|
+
"Service Featured 2", (1600, 900), "#dc2626", collection
|
|
588
|
+
)
|
|
589
|
+
brand = self._get_or_create_image(
|
|
590
|
+
"Brand Logo", (800, 400), "#0f172a", collection
|
|
591
|
+
)
|
|
592
|
+
favicon = self._get_or_create_image(
|
|
593
|
+
"Favicon", (256, 256), "#0f172a", collection
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
return self._Images(
|
|
597
|
+
hero_id=hero.id,
|
|
598
|
+
legacy_hero_id=legacy_hero.id,
|
|
599
|
+
comparison_before_id=before.id,
|
|
600
|
+
comparison_after_id=after.id,
|
|
601
|
+
gallery_one_id=g1.id,
|
|
602
|
+
gallery_two_id=g2.id,
|
|
603
|
+
gallery_three_id=g3.id,
|
|
604
|
+
portfolio_one_id=p1.id,
|
|
605
|
+
portfolio_two_id=p2.id,
|
|
606
|
+
trust_logo_one_id=l1.id,
|
|
607
|
+
trust_logo_two_id=l2.id,
|
|
608
|
+
image_block_id=ib.id,
|
|
609
|
+
service_featured_one_id=sf1.id,
|
|
610
|
+
service_featured_two_id=sf2.id,
|
|
611
|
+
brand_logo_id=brand.id,
|
|
612
|
+
favicon_id=favicon.id,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def _get_or_create_collection(self, name: str) -> Any:
|
|
616
|
+
from wagtail.models import Collection
|
|
617
|
+
|
|
618
|
+
root = Collection.get_first_root_node()
|
|
619
|
+
existing = root.get_children().filter(name=name).first()
|
|
620
|
+
if existing:
|
|
621
|
+
return existing
|
|
622
|
+
return root.add_child(name=name)
|
|
623
|
+
|
|
624
|
+
def _get_or_create_image(
|
|
625
|
+
self, title: str, size: tuple[int, int], color_hex: str, collection: Any
|
|
626
|
+
) -> Any:
|
|
627
|
+
from wagtail.images import get_image_model
|
|
628
|
+
|
|
629
|
+
image_model = get_image_model()
|
|
630
|
+
existing = image_model.objects.filter(title=title).first()
|
|
631
|
+
if existing:
|
|
632
|
+
return existing
|
|
633
|
+
|
|
634
|
+
if PILImage is None: # pragma: no cover
|
|
635
|
+
raise RuntimeError(
|
|
636
|
+
"Pillow is required to generate placeholder images. "
|
|
637
|
+
"Install it (it is usually included with Wagtail)."
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
rgb = self._hex_to_rgb(color_hex)
|
|
641
|
+
img = PILImage.new("RGB", size, rgb)
|
|
642
|
+
buf = BytesIO()
|
|
643
|
+
img.save(buf, format="PNG")
|
|
644
|
+
buf.seek(0)
|
|
645
|
+
|
|
646
|
+
safe = self._slugify(title)
|
|
647
|
+
filename = f"showroom-{safe}.png"
|
|
648
|
+
|
|
649
|
+
return image_model.objects.create(
|
|
650
|
+
title=title,
|
|
651
|
+
file=ContentFile(buf.read(), name=filename),
|
|
652
|
+
collection=collection,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def _hex_to_rgb(self, value: str) -> tuple[int, int, int]:
|
|
656
|
+
v = value.strip().lstrip("#")
|
|
657
|
+
if len(v) != 6:
|
|
658
|
+
return (127, 127, 127)
|
|
659
|
+
return (int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16))
|
|
660
|
+
|
|
661
|
+
# -----------------------------------------------------------------------------
|
|
662
|
+
# Stream builders (PageStreamBlock)
|
|
663
|
+
# -----------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
def _build_starter_home_stream(
|
|
666
|
+
self, *, images: _Images, contact_page: StandardPage
|
|
667
|
+
) -> Any:
|
|
668
|
+
# NOTE: Hero is rendered from HomePage model fields (hero_headline, etc.)
|
|
669
|
+
# not from body StreamField blocks. Body contains content after the hero.
|
|
670
|
+
stream_block = PageStreamBlock()
|
|
671
|
+
return stream_block.to_python(
|
|
672
|
+
[
|
|
673
|
+
{
|
|
674
|
+
"type": "content",
|
|
675
|
+
"value": {
|
|
676
|
+
"align": "left",
|
|
677
|
+
"body": "<h2>Crafted for your next project</h2>"
|
|
678
|
+
"<p>Use this section to introduce your brand and explain the next steps.</p>"
|
|
679
|
+
"<ul><li>Highlight core services.</li>"
|
|
680
|
+
"<li>Explain your process.</li>"
|
|
681
|
+
"<li>Invite visitors to book a quote.</li></ul>",
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
"type": "testimonials",
|
|
686
|
+
"value": {
|
|
687
|
+
"eyebrow": "Testimonials",
|
|
688
|
+
"heading": "<p>What clients <em>say</em></p>",
|
|
689
|
+
"testimonials": [
|
|
690
|
+
{
|
|
691
|
+
"quote": "The team kept everything on schedule and the finish is perfect.",
|
|
692
|
+
"author_name": "Jordan Lee",
|
|
693
|
+
"company": "Lee Renovations",
|
|
694
|
+
"photo": None,
|
|
695
|
+
"rating": 5,
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
"quote": "Clear communication from start to finish.",
|
|
699
|
+
"author_name": "Morgan Cruz",
|
|
700
|
+
"company": "Cruz Design",
|
|
701
|
+
"photo": None,
|
|
702
|
+
"rating": 5,
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
]
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
def _build_home_stream(self, *, images: _Images, contact_page: StandardPage) -> Any:
|
|
711
|
+
# NOTE: Hero is rendered from HomePage model fields (hero_headline, etc.)
|
|
712
|
+
# not from body StreamField blocks. Body contains content after the hero.
|
|
713
|
+
stream_block = PageStreamBlock()
|
|
714
|
+
return stream_block.to_python(
|
|
715
|
+
[
|
|
716
|
+
{
|
|
717
|
+
"type": "trust_strip_logos",
|
|
718
|
+
"value": {
|
|
719
|
+
"eyebrow": "Trusted by",
|
|
720
|
+
"items": [
|
|
721
|
+
{
|
|
722
|
+
"logo": images.trust_logo_one_id,
|
|
723
|
+
"alt_text": "Trust badge one",
|
|
724
|
+
"url": "https://example.com/",
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
"logo": images.trust_logo_two_id,
|
|
728
|
+
"alt_text": "Trust badge two",
|
|
729
|
+
"url": "https://example.com/",
|
|
730
|
+
},
|
|
731
|
+
],
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
"type": "service_cards",
|
|
736
|
+
"value": {
|
|
737
|
+
"eyebrow": "Services",
|
|
738
|
+
"heading": "<p>Browse our <em>services</em></p>",
|
|
739
|
+
"intro": "Use this section to test card layouts, hover states, and responsive grids.",
|
|
740
|
+
"view_all_link": "/services/",
|
|
741
|
+
"view_all_label": "View all services",
|
|
742
|
+
"layout_style": "default",
|
|
743
|
+
"cards": [
|
|
744
|
+
{
|
|
745
|
+
"icon": "☀️",
|
|
746
|
+
"image": None,
|
|
747
|
+
"title": "Solar Installation",
|
|
748
|
+
"description": "<p>Modern solar installs with clean finishing.</p>",
|
|
749
|
+
"link_url": "/services/solar-installation/",
|
|
750
|
+
"link_label": "Learn more",
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
"icon": "🏠",
|
|
754
|
+
"image": None,
|
|
755
|
+
"title": "Roofing",
|
|
756
|
+
"description": "<p>Durable roofing, designed for UK weather.</p>",
|
|
757
|
+
"link_url": "/services/roofing/",
|
|
758
|
+
"link_label": "Learn more",
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
"icon": "🔋",
|
|
762
|
+
"image": None,
|
|
763
|
+
"title": "Battery Storage",
|
|
764
|
+
"description": "<p>Store energy and improve self-consumption.</p>",
|
|
765
|
+
"link_url": "/showroom/",
|
|
766
|
+
"link_label": "See demo",
|
|
767
|
+
},
|
|
768
|
+
],
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
"type": "testimonials",
|
|
773
|
+
"value": {
|
|
774
|
+
"eyebrow": "Client stories",
|
|
775
|
+
"heading": "<p>People <em>love</em> this</p>",
|
|
776
|
+
"testimonials": [
|
|
777
|
+
{
|
|
778
|
+
"quote": "Everything looked great across mobile and desktop — perfect for our brand.",
|
|
779
|
+
"author_name": "Alex Taylor",
|
|
780
|
+
"company": "Taylor & Sons",
|
|
781
|
+
"photo": None,
|
|
782
|
+
"rating": 5,
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
"quote": "The design tokens made it easy to adjust colours and typography site-wide.",
|
|
786
|
+
"author_name": "Sam Patel",
|
|
787
|
+
"company": "Patel Renovations",
|
|
788
|
+
"photo": None,
|
|
789
|
+
"rating": 5,
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
"quote": "Fast, clean, and consistent. Exactly what we need for client rollouts.",
|
|
793
|
+
"author_name": "Jamie Kim",
|
|
794
|
+
"company": "Kim Home Improvements",
|
|
795
|
+
"photo": None,
|
|
796
|
+
"rating": 5,
|
|
797
|
+
},
|
|
798
|
+
],
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
"type": "gallery",
|
|
803
|
+
"value": {
|
|
804
|
+
"eyebrow": "Gallery",
|
|
805
|
+
"heading": "<p>Theme <em>imagery</em></p>",
|
|
806
|
+
"intro": "Use this gallery to check image ratios, captions, and grid behaviour.",
|
|
807
|
+
"images": [
|
|
808
|
+
{
|
|
809
|
+
"image": images.gallery_one_id,
|
|
810
|
+
"alt_text": "Gallery image one",
|
|
811
|
+
"caption": "Clean layout",
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
"image": images.gallery_two_id,
|
|
815
|
+
"alt_text": "Gallery image two",
|
|
816
|
+
"caption": "Responsive grid",
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
"image": images.gallery_three_id,
|
|
820
|
+
"alt_text": "Gallery image three",
|
|
821
|
+
"caption": "Typography scale",
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
"type": "stats",
|
|
828
|
+
"value": {
|
|
829
|
+
"eyebrow": "By the numbers",
|
|
830
|
+
"intro": "Stats are a great place to validate spacing, type rhythm, and colour contrast.",
|
|
831
|
+
"items": [
|
|
832
|
+
{
|
|
833
|
+
"prefix": "",
|
|
834
|
+
"value": "500",
|
|
835
|
+
"suffix": "+",
|
|
836
|
+
"label": "Projects",
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
"prefix": "",
|
|
840
|
+
"value": "15",
|
|
841
|
+
"suffix": "yrs",
|
|
842
|
+
"label": "Experience",
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
"prefix": "",
|
|
846
|
+
"value": "98",
|
|
847
|
+
"suffix": "%",
|
|
848
|
+
"label": "Satisfaction",
|
|
849
|
+
},
|
|
850
|
+
],
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
]
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
def _build_showroom_stream(
|
|
857
|
+
self,
|
|
858
|
+
*,
|
|
859
|
+
images: _Images,
|
|
860
|
+
services_index: ServiceIndexPage,
|
|
861
|
+
service_one: ServicePage,
|
|
862
|
+
contact_page: StandardPage,
|
|
863
|
+
) -> Any:
|
|
864
|
+
stream_block = PageStreamBlock()
|
|
865
|
+
return stream_block.to_python(
|
|
866
|
+
[
|
|
867
|
+
{
|
|
868
|
+
"type": "hero_gradient",
|
|
869
|
+
"value": {
|
|
870
|
+
"headline": "<p>Block <em>Showroom</em></p>",
|
|
871
|
+
"subheadline": "A curated tour: every block type, spread across pages.",
|
|
872
|
+
"ctas": [
|
|
873
|
+
{
|
|
874
|
+
"label": "Services",
|
|
875
|
+
"url": "/services/",
|
|
876
|
+
"style": "primary",
|
|
877
|
+
"open_in_new_tab": False,
|
|
878
|
+
}
|
|
879
|
+
],
|
|
880
|
+
"status": "Theme QA",
|
|
881
|
+
"gradient_style": "primary",
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
"type": "features",
|
|
886
|
+
"value": {
|
|
887
|
+
"heading": "Features",
|
|
888
|
+
"intro": "Check icons, alignment, and spacing across viewports.",
|
|
889
|
+
"features": [
|
|
890
|
+
{
|
|
891
|
+
"icon": "⚡",
|
|
892
|
+
"title": "Fast",
|
|
893
|
+
"description": "Token-driven styling and reusable patterns.",
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
"icon": "🧱",
|
|
897
|
+
"title": "Composable",
|
|
898
|
+
"description": "StreamField blocks let editors build pages without dev.",
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
"icon": "🔍",
|
|
902
|
+
"title": "SEO-ready",
|
|
903
|
+
"description": "Sitemap, robots.txt, meta tags, and schema helpers.",
|
|
904
|
+
},
|
|
905
|
+
],
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
"type": "comparison",
|
|
910
|
+
"value": {
|
|
911
|
+
"accent_text": "Before / After",
|
|
912
|
+
"title": "Comparison slider",
|
|
913
|
+
"description": "Validate handle styling, overlays, and image cropping.",
|
|
914
|
+
"image_before": images.comparison_before_id,
|
|
915
|
+
"image_after": images.comparison_after_id,
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
"type": "manifesto",
|
|
920
|
+
"value": {
|
|
921
|
+
"eyebrow": "Manifesto",
|
|
922
|
+
"heading": "<p>Build with <em>consistency</em></p>",
|
|
923
|
+
"body": "<p>This section helps validate prose styling, link colours, and list rendering.</p>"
|
|
924
|
+
"<ul><li>Token-first</li><li>Accessible defaults</li><li>Theme override friendly</li></ul>",
|
|
925
|
+
"quote": "Good design is what you don’t notice — it just works.",
|
|
926
|
+
"cta_label": "See services",
|
|
927
|
+
"cta_url": "/services/",
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
"type": "portfolio",
|
|
932
|
+
"value": {
|
|
933
|
+
"eyebrow": "Portfolio",
|
|
934
|
+
"heading": "<p>Featured <em>work</em></p>",
|
|
935
|
+
"intro": "Check alternating layout offsets and typography scale.",
|
|
936
|
+
"view_all_label": "View all",
|
|
937
|
+
"view_all_link": "/",
|
|
938
|
+
"items": [
|
|
939
|
+
{
|
|
940
|
+
"image": images.portfolio_one_id,
|
|
941
|
+
"alt_text": "Portfolio project one",
|
|
942
|
+
"title": "Solar + battery upgrade",
|
|
943
|
+
"category": "Residential",
|
|
944
|
+
"location": "Kensington, London",
|
|
945
|
+
"services": "Solar • Battery",
|
|
946
|
+
"constraint": "Tight access",
|
|
947
|
+
"material": "Slate roof",
|
|
948
|
+
"outcome": "Lower bills",
|
|
949
|
+
"link_url": "/services/solar-installation/",
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
"image": images.portfolio_two_id,
|
|
953
|
+
"alt_text": "Portfolio project two",
|
|
954
|
+
"title": "Full roof replacement",
|
|
955
|
+
"category": "Commercial",
|
|
956
|
+
"location": "Richmond, London",
|
|
957
|
+
"services": "Roofing",
|
|
958
|
+
"constraint": "Winter schedule",
|
|
959
|
+
"material": "Clay tiles",
|
|
960
|
+
"outcome": "Weatherproof",
|
|
961
|
+
"link_url": "/services/",
|
|
962
|
+
},
|
|
963
|
+
],
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
"type": "trust_strip",
|
|
968
|
+
"value": {
|
|
969
|
+
"items": [
|
|
970
|
+
{"text": "Fully insured"},
|
|
971
|
+
{"text": "5★ reviews"},
|
|
972
|
+
{"text": "Local team"},
|
|
973
|
+
{"text": "Transparent pricing"},
|
|
974
|
+
]
|
|
975
|
+
},
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
"type": "editorial_header",
|
|
979
|
+
"value": {
|
|
980
|
+
"align": "center",
|
|
981
|
+
"eyebrow": "Editorial",
|
|
982
|
+
"heading": "<p>Content <em>blocks</em></p>",
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
"type": "content",
|
|
987
|
+
"value": {
|
|
988
|
+
"align": "left",
|
|
989
|
+
"body": "<h2>Rich text content</h2><p>This is a general-purpose content block.</p>"
|
|
990
|
+
"<p>Use it to validate headings, lists, links, and spacing.</p>",
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
"type": "table_of_contents",
|
|
995
|
+
"value": {
|
|
996
|
+
"items": [
|
|
997
|
+
{"label": "Scope of Works", "anchor": "scope-of-works"},
|
|
998
|
+
{"label": "Payments", "anchor": "payments"},
|
|
999
|
+
{"label": "Warranty", "anchor": "warranty"},
|
|
1000
|
+
]
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
"type": "legal_section",
|
|
1005
|
+
"value": {
|
|
1006
|
+
"anchor": "scope-of-works",
|
|
1007
|
+
"heading": "Scope of Works",
|
|
1008
|
+
"body": "<p>Legal sections ensure anchors and typography render correctly.</p>"
|
|
1009
|
+
"<ul><li>Use clear subheadings.</li><li>Keep lists readable.</li></ul>",
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
"type": "legal_section",
|
|
1014
|
+
"value": {
|
|
1015
|
+
"anchor": "payments",
|
|
1016
|
+
"heading": "Payments",
|
|
1017
|
+
"body": "<p>Payment terms render as rich text.</p>"
|
|
1018
|
+
"<p><strong>Pro tip:</strong> keep anchor IDs stable to avoid broken links.</p>",
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
"type": "legal_section",
|
|
1023
|
+
"value": {
|
|
1024
|
+
"anchor": "warranty",
|
|
1025
|
+
"heading": "Warranty",
|
|
1026
|
+
"body": "<p>Warranty language can mix paragraphs and lists.</p>"
|
|
1027
|
+
"<ul><li>Outline coverage.</li><li>Clarify exclusions.</li></ul>",
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
"type": "quote",
|
|
1032
|
+
"value": {
|
|
1033
|
+
"quote": "Design systems are what keep themes consistent as they scale.",
|
|
1034
|
+
"author": "SUM Platform",
|
|
1035
|
+
"role": "Core team",
|
|
1036
|
+
},
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
"type": "image_block",
|
|
1040
|
+
"value": {
|
|
1041
|
+
"image": images.image_block_id,
|
|
1042
|
+
"alt_text": "A cinematic placeholder image",
|
|
1043
|
+
"caption": "Full-bleed image block with caption.",
|
|
1044
|
+
"full_width": False,
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
"type": "buttons",
|
|
1049
|
+
"value": {
|
|
1050
|
+
"alignment": "left",
|
|
1051
|
+
"buttons": [
|
|
1052
|
+
{"label": "Generic Button", "style": "primary", "url": "/"},
|
|
1053
|
+
{
|
|
1054
|
+
"label": "Secondary Style",
|
|
1055
|
+
"style": "secondary",
|
|
1056
|
+
"url": "/",
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
]
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
def _build_kitchen_sink_stream(
|
|
1065
|
+
self,
|
|
1066
|
+
*,
|
|
1067
|
+
images: _Images,
|
|
1068
|
+
services_index: ServiceIndexPage,
|
|
1069
|
+
service_one: ServicePage,
|
|
1070
|
+
contact_page: StandardPage,
|
|
1071
|
+
) -> Any:
|
|
1072
|
+
"""
|
|
1073
|
+
Combine all block types into a single page stream for rapid testing.
|
|
1074
|
+
"""
|
|
1075
|
+
stream_block = PageStreamBlock()
|
|
1076
|
+
|
|
1077
|
+
# Combine home stream + showroom stream blocks to cover everything
|
|
1078
|
+
home_data = self._build_home_stream(images=images, contact_page=contact_page)
|
|
1079
|
+
showroom_data = self._build_showroom_stream(
|
|
1080
|
+
images=images,
|
|
1081
|
+
services_index=services_index,
|
|
1082
|
+
service_one=service_one,
|
|
1083
|
+
contact_page=contact_page,
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
# NOTE:
|
|
1087
|
+
# home_data/showroom_data are StreamValues (iterating yields StreamChild objects).
|
|
1088
|
+
# StreamBlock.to_python expects *raw* stream data (list of dicts/tuples), so we
|
|
1089
|
+
# convert back to raw and only to_python() once.
|
|
1090
|
+
home_raw = (
|
|
1091
|
+
home_data.get_prep_value()
|
|
1092
|
+
if hasattr(home_data, "get_prep_value")
|
|
1093
|
+
else home_data
|
|
1094
|
+
)
|
|
1095
|
+
showroom_raw = (
|
|
1096
|
+
showroom_data.get_prep_value()
|
|
1097
|
+
if hasattr(showroom_data, "get_prep_value")
|
|
1098
|
+
else showroom_data
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
combined_raw = list(home_raw) + list(showroom_raw)
|
|
1102
|
+
return stream_block.to_python(combined_raw)
|
|
1103
|
+
|
|
1104
|
+
def _build_services_index_intro_stream(self, *, images: _Images) -> Any:
|
|
1105
|
+
stream_block = PageStreamBlock()
|
|
1106
|
+
return stream_block.to_python(
|
|
1107
|
+
[
|
|
1108
|
+
{
|
|
1109
|
+
"type": "content",
|
|
1110
|
+
"value": {
|
|
1111
|
+
"body": (
|
|
1112
|
+
"<h2>Our <em>Services</em></h2>"
|
|
1113
|
+
"<p>Professional trades for every requirement.</p>"
|
|
1114
|
+
),
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
]
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
def _build_service_page_stream(
|
|
1121
|
+
self, *, images: _Images, page: ServicePage, contact_page: StandardPage
|
|
1122
|
+
) -> Any:
|
|
1123
|
+
stream_block = PageStreamBlock()
|
|
1124
|
+
return stream_block.to_python(
|
|
1125
|
+
[
|
|
1126
|
+
{
|
|
1127
|
+
"type": "content",
|
|
1128
|
+
"value": {
|
|
1129
|
+
"align": "left",
|
|
1130
|
+
"body": f"<p>Detail content for {page.title}.</p>"
|
|
1131
|
+
"<h3>Why choose us?</h3>"
|
|
1132
|
+
"<ul><li>Experienced team</li><li>Guaranteed work</li><li>Fast turnaround</li></ul>",
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
"type": "cta_banner",
|
|
1137
|
+
"value": {
|
|
1138
|
+
"heading": "Ready to start?",
|
|
1139
|
+
"description": "Get a free quote today.",
|
|
1140
|
+
"cta_label": "Contact us",
|
|
1141
|
+
"cta_url": "/contact/", # or contact_page.url if available
|
|
1142
|
+
},
|
|
1143
|
+
},
|
|
1144
|
+
]
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
def _build_contact_stream(self, *, images: _Images) -> Any:
|
|
1148
|
+
stream_block = PageStreamBlock()
|
|
1149
|
+
return stream_block.to_python(
|
|
1150
|
+
[
|
|
1151
|
+
{
|
|
1152
|
+
"type": "hero_gradient",
|
|
1153
|
+
"value": {
|
|
1154
|
+
"headline": "<p>Get in <em>touch</em></p>",
|
|
1155
|
+
"subheadline": "We’d love to hear from you.",
|
|
1156
|
+
"ctas": [],
|
|
1157
|
+
"status": "Open",
|
|
1158
|
+
"gradient_style": "default",
|
|
1159
|
+
},
|
|
1160
|
+
},
|
|
1161
|
+
# Note: 'form' block would go here if we had a FormPage or embedded form block.
|
|
1162
|
+
# For now using content placeholder.
|
|
1163
|
+
{
|
|
1164
|
+
"type": "content",
|
|
1165
|
+
"value": {
|
|
1166
|
+
"align": "center",
|
|
1167
|
+
"body": "<p>Phone: 020 1234 5678<br>Email: hello@example.com</p>",
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
]
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
def _build_terms_sections(self) -> tuple[str, list[dict[str, str]]]:
|
|
1174
|
+
intro = "Ground rules for using this starter site and reviewing layouts."
|
|
1175
|
+
sections = [
|
|
1176
|
+
{
|
|
1177
|
+
"anchor": "scope-of-works",
|
|
1178
|
+
"heading": "Scope of Works",
|
|
1179
|
+
"body": "<p>This starter content is for layout preview only.</p>"
|
|
1180
|
+
"<ul><li>Keep experiments to non-sensitive data.</li>"
|
|
1181
|
+
"<li>Use it to validate typography and spacing.</li></ul>",
|
|
1182
|
+
},
|
|
1183
|
+
{
|
|
1184
|
+
"anchor": "payments",
|
|
1185
|
+
"heading": "Payments",
|
|
1186
|
+
"body": "<p>Payment terms render as rich text with lists and links.</p>"
|
|
1187
|
+
"<p>Update this copy with client-approved wording before launch.</p>",
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
"anchor": "warranty",
|
|
1191
|
+
"heading": "Warranty",
|
|
1192
|
+
"body": "<p>Replace this placeholder with your project-specific warranty details.</p>",
|
|
1193
|
+
},
|
|
1194
|
+
]
|
|
1195
|
+
return intro, sections
|
|
1196
|
+
|
|
1197
|
+
def _build_terms_stream(self) -> Any:
|
|
1198
|
+
intro, sections = self._build_terms_sections()
|
|
1199
|
+
return self._build_legal_stream(
|
|
1200
|
+
heading="Terms & Conditions",
|
|
1201
|
+
intro=intro,
|
|
1202
|
+
sections=sections,
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
def _build_privacy_sections(self) -> tuple[str, list[dict[str, str]]]:
|
|
1206
|
+
intro = (
|
|
1207
|
+
"How this starter site handles demo requests and placeholder contact data."
|
|
1208
|
+
)
|
|
1209
|
+
sections = [
|
|
1210
|
+
{
|
|
1211
|
+
"anchor": "data-collection",
|
|
1212
|
+
"heading": "Data we collect",
|
|
1213
|
+
"body": "<p>Contact details submitted through demo forms.</p>"
|
|
1214
|
+
"<p>Anonymous analytics used to validate reporting flows.</p>",
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
"anchor": "data-usage",
|
|
1218
|
+
"heading": "How we use it",
|
|
1219
|
+
"body": "<p>Submissions route to the default email in Branding settings.</p>"
|
|
1220
|
+
"<p>Analytics data powers reporting dashboards only.</p>",
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
"anchor": "your-choices",
|
|
1224
|
+
"heading": "Your choices",
|
|
1225
|
+
"body": "<p>Clear seeded data anytime by rerunning the command or editing in Wagtail.</p>",
|
|
1226
|
+
},
|
|
1227
|
+
]
|
|
1228
|
+
return intro, sections
|
|
1229
|
+
|
|
1230
|
+
def _build_privacy_stream(self) -> Any:
|
|
1231
|
+
intro, sections = self._build_privacy_sections()
|
|
1232
|
+
return self._build_legal_stream(
|
|
1233
|
+
heading="Privacy Notice",
|
|
1234
|
+
intro=intro,
|
|
1235
|
+
sections=sections,
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
def _build_cookie_sections(self) -> tuple[str, list[dict[str, str]]]:
|
|
1239
|
+
intro = "Details on cookie usage and consent controls for this starter site."
|
|
1240
|
+
sections = [
|
|
1241
|
+
{
|
|
1242
|
+
"anchor": "cookies-we-use",
|
|
1243
|
+
"heading": "Cookies we use",
|
|
1244
|
+
"body": "<p>Consent and analytics cookies are used to support the demo site.</p>"
|
|
1245
|
+
"<ul><li>Consent status</li><li>Analytics identifiers</li></ul>",
|
|
1246
|
+
},
|
|
1247
|
+
{
|
|
1248
|
+
"anchor": "consent-controls",
|
|
1249
|
+
"heading": "Consent controls",
|
|
1250
|
+
"body": "<p>Use the Manage cookies link in the footer to update your preferences.</p>",
|
|
1251
|
+
},
|
|
1252
|
+
{
|
|
1253
|
+
"anchor": "updates",
|
|
1254
|
+
"heading": "Updates",
|
|
1255
|
+
"body": "<p>Cookie settings may change as policies are updated.</p>",
|
|
1256
|
+
},
|
|
1257
|
+
]
|
|
1258
|
+
return intro, sections
|
|
1259
|
+
|
|
1260
|
+
def _build_cookie_stream(self) -> Any:
|
|
1261
|
+
intro, sections = self._build_cookie_sections()
|
|
1262
|
+
return self._build_legal_stream(
|
|
1263
|
+
heading="Cookie Policy",
|
|
1264
|
+
intro=intro,
|
|
1265
|
+
sections=sections,
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
def _build_legal_stream(
|
|
1269
|
+
self, *, heading: str, intro: str, sections: list[dict[str, str]]
|
|
1270
|
+
) -> Any:
|
|
1271
|
+
stream_block = PageStreamBlock()
|
|
1272
|
+
toc_items = [
|
|
1273
|
+
{"label": section["heading"], "anchor": section["anchor"]}
|
|
1274
|
+
for section in sections
|
|
1275
|
+
]
|
|
1276
|
+
stream: list[dict[str, Any]] = [
|
|
1277
|
+
{
|
|
1278
|
+
"type": "editorial_header",
|
|
1279
|
+
"value": {
|
|
1280
|
+
"align": "center",
|
|
1281
|
+
"eyebrow": "Legal",
|
|
1282
|
+
"heading": f"<p>{heading}</p>",
|
|
1283
|
+
},
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
"type": "content",
|
|
1287
|
+
"value": {
|
|
1288
|
+
"align": "left",
|
|
1289
|
+
"body": f"<p>{intro}</p>",
|
|
1290
|
+
},
|
|
1291
|
+
},
|
|
1292
|
+
]
|
|
1293
|
+
if toc_items:
|
|
1294
|
+
stream.append({"type": "table_of_contents", "value": {"items": toc_items}})
|
|
1295
|
+
for section in sections:
|
|
1296
|
+
stream.append(
|
|
1297
|
+
{
|
|
1298
|
+
"type": "legal_section",
|
|
1299
|
+
"value": {
|
|
1300
|
+
"anchor": section["anchor"],
|
|
1301
|
+
"heading": section["heading"],
|
|
1302
|
+
"content": [
|
|
1303
|
+
{"type": "rich_text", "value": {"body": section["body"]}},
|
|
1304
|
+
],
|
|
1305
|
+
},
|
|
1306
|
+
}
|
|
1307
|
+
)
|
|
1308
|
+
return stream_block.to_python(stream)
|
|
1309
|
+
|
|
1310
|
+
def _build_legal_sections(
|
|
1311
|
+
self, sections: list[dict[str, str]]
|
|
1312
|
+
) -> list[tuple[str, dict[str, str]]]:
|
|
1313
|
+
return [
|
|
1314
|
+
(
|
|
1315
|
+
"section",
|
|
1316
|
+
{
|
|
1317
|
+
"anchor": section["anchor"],
|
|
1318
|
+
"heading": section["heading"],
|
|
1319
|
+
"content": [("rich_text", {"body": section["body"]})],
|
|
1320
|
+
},
|
|
1321
|
+
)
|
|
1322
|
+
for section in sections
|
|
1323
|
+
]
|
|
1324
|
+
|
|
1325
|
+
def _apply_legal_page_content(
|
|
1326
|
+
self,
|
|
1327
|
+
page: Page,
|
|
1328
|
+
*,
|
|
1329
|
+
heading: str,
|
|
1330
|
+
intro: str,
|
|
1331
|
+
sections: list[dict[str, str]],
|
|
1332
|
+
) -> None:
|
|
1333
|
+
"""
|
|
1334
|
+
Apply legal content to a page, supporting both LegalPage (sections field)
|
|
1335
|
+
and StandardPage (body StreamField) models.
|
|
1336
|
+
|
|
1337
|
+
Args:
|
|
1338
|
+
page: The page instance to populate (LegalPage or StandardPage).
|
|
1339
|
+
heading: The main heading for the legal content.
|
|
1340
|
+
intro: Introductory text/description.
|
|
1341
|
+
sections: List of dicts with 'anchor', 'title', and 'content' keys.
|
|
1342
|
+
"""
|
|
1343
|
+
if hasattr(page, "sections"):
|
|
1344
|
+
page.sections = self._build_legal_sections(sections)
|
|
1345
|
+
page.hero_intro = intro
|
|
1346
|
+
else:
|
|
1347
|
+
page.body = self._build_legal_stream(
|
|
1348
|
+
heading=heading,
|
|
1349
|
+
intro=intro,
|
|
1350
|
+
sections=sections,
|
|
1351
|
+
)
|
|
1352
|
+
page.save_revision().publish()
|
|
1353
|
+
|
|
1354
|
+
# -----------------------------------------------------------------------------
|
|
1355
|
+
# Branding & Navigation
|
|
1356
|
+
# -----------------------------------------------------------------------------
|
|
1357
|
+
|
|
1358
|
+
def _seed_hero_ctas(self, home: Page, profile: str, contact: Page) -> None:
|
|
1359
|
+
"""Add hero CTAs to the homepage if the model supports them."""
|
|
1360
|
+
# Check if the home page model has hero_ctas (InlinePanel relation)
|
|
1361
|
+
if not hasattr(home, "hero_ctas"):
|
|
1362
|
+
return
|
|
1363
|
+
|
|
1364
|
+
# Get the CTA model from the related manager
|
|
1365
|
+
# This works because hero_ctas is a ParentalKey relation
|
|
1366
|
+
cta_manager = home.hero_ctas
|
|
1367
|
+
|
|
1368
|
+
# Clear existing CTAs first
|
|
1369
|
+
cta_manager.all().delete()
|
|
1370
|
+
|
|
1371
|
+
# Get the CTA model class
|
|
1372
|
+
cta_model = cta_manager.model
|
|
1373
|
+
|
|
1374
|
+
if profile == PROFILE_STARTER:
|
|
1375
|
+
ctas = [
|
|
1376
|
+
{"label": "Get in touch", "url": "/contact/", "style": "primary"},
|
|
1377
|
+
{"label": "Browse services", "url": "/services/", "style": "secondary"},
|
|
1378
|
+
]
|
|
1379
|
+
else:
|
|
1380
|
+
ctas = [
|
|
1381
|
+
{"label": "View the showroom", "url": "/showroom/", "style": "primary"},
|
|
1382
|
+
{"label": "Contact", "url": "/contact/", "style": "secondary"},
|
|
1383
|
+
]
|
|
1384
|
+
|
|
1385
|
+
for i, cta_data in enumerate(ctas):
|
|
1386
|
+
cta_model.objects.create(
|
|
1387
|
+
page=home,
|
|
1388
|
+
sort_order=i,
|
|
1389
|
+
label=cta_data["label"],
|
|
1390
|
+
url=cta_data["url"],
|
|
1391
|
+
style=cta_data["style"],
|
|
1392
|
+
)
|
|
1393
|
+
|
|
1394
|
+
# Re-publish the page to include CTAs in the revision
|
|
1395
|
+
home.save_revision().publish()
|
|
1396
|
+
|
|
1397
|
+
def _seed_branding(
|
|
1398
|
+
self, *, site: Site, images: _Images, terms: Page, privacy: Page, cookies: Page
|
|
1399
|
+
) -> None:
|
|
1400
|
+
settings = SiteSettings.for_site(site)
|
|
1401
|
+
# SiteSettings lives in sum_core and uses explicit fields.
|
|
1402
|
+
settings.company_name = "Showroom"
|
|
1403
|
+
settings.header_logo_id = images.brand_logo_id
|
|
1404
|
+
settings.footer_logo_id = images.brand_logo_id
|
|
1405
|
+
settings.favicon_id = images.favicon_id
|
|
1406
|
+
settings.email = "hello@example.com"
|
|
1407
|
+
settings.phone_number = "0800 123 4567"
|
|
1408
|
+
settings.facebook_url = "https://facebook.com"
|
|
1409
|
+
settings.instagram_url = "https://instagram.com"
|
|
1410
|
+
settings.cookie_banner_enabled = True
|
|
1411
|
+
settings.cookie_consent_version = "2024-01"
|
|
1412
|
+
settings.terms_page = terms
|
|
1413
|
+
settings.privacy_policy_page = privacy
|
|
1414
|
+
settings.cookie_policy_page = cookies
|
|
1415
|
+
settings.save()
|
|
1416
|
+
|
|
1417
|
+
def _seed_navigation(
|
|
1418
|
+
self,
|
|
1419
|
+
*,
|
|
1420
|
+
site: Site,
|
|
1421
|
+
home: Page,
|
|
1422
|
+
contact: Page,
|
|
1423
|
+
services_index: Page,
|
|
1424
|
+
service_one: Page,
|
|
1425
|
+
service_two: Page,
|
|
1426
|
+
terms: Page,
|
|
1427
|
+
privacy: Page,
|
|
1428
|
+
cookies: Page,
|
|
1429
|
+
showroom: Page | None,
|
|
1430
|
+
kitchen_sink: Page | None,
|
|
1431
|
+
) -> None:
|
|
1432
|
+
# HeaderNavigation / FooterNavigation are StreamField-based settings.
|
|
1433
|
+
header = HeaderNavigation.for_site(site)
|
|
1434
|
+
|
|
1435
|
+
menu_items = [
|
|
1436
|
+
{
|
|
1437
|
+
"type": "item",
|
|
1438
|
+
"value": {
|
|
1439
|
+
"label": "Home",
|
|
1440
|
+
"link": {
|
|
1441
|
+
"link_type": "page",
|
|
1442
|
+
"page": home.id,
|
|
1443
|
+
"link_text": "Home",
|
|
1444
|
+
"open_in_new_tab": False,
|
|
1445
|
+
},
|
|
1446
|
+
"children": [],
|
|
1447
|
+
},
|
|
1448
|
+
},
|
|
1449
|
+
{
|
|
1450
|
+
"type": "item",
|
|
1451
|
+
"value": {
|
|
1452
|
+
"label": "Services",
|
|
1453
|
+
"link": {
|
|
1454
|
+
"link_type": "page",
|
|
1455
|
+
"page": services_index.id,
|
|
1456
|
+
"link_text": "Services",
|
|
1457
|
+
"open_in_new_tab": False,
|
|
1458
|
+
},
|
|
1459
|
+
"children": [
|
|
1460
|
+
{
|
|
1461
|
+
"label": service_one.title,
|
|
1462
|
+
"link": {
|
|
1463
|
+
"link_type": "page",
|
|
1464
|
+
"page": service_one.id,
|
|
1465
|
+
"link_text": service_one.title,
|
|
1466
|
+
"open_in_new_tab": False,
|
|
1467
|
+
},
|
|
1468
|
+
"children": [],
|
|
1469
|
+
},
|
|
1470
|
+
{
|
|
1471
|
+
"label": service_two.title,
|
|
1472
|
+
"link": {
|
|
1473
|
+
"link_type": "page",
|
|
1474
|
+
"page": service_two.id,
|
|
1475
|
+
"link_text": service_two.title,
|
|
1476
|
+
"open_in_new_tab": False,
|
|
1477
|
+
},
|
|
1478
|
+
"children": [],
|
|
1479
|
+
},
|
|
1480
|
+
],
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
]
|
|
1484
|
+
if showroom is not None:
|
|
1485
|
+
menu_items.append(
|
|
1486
|
+
{
|
|
1487
|
+
"type": "item",
|
|
1488
|
+
"value": {
|
|
1489
|
+
"label": "Showroom",
|
|
1490
|
+
"link": {
|
|
1491
|
+
"link_type": "page",
|
|
1492
|
+
"page": showroom.id,
|
|
1493
|
+
"link_text": "Showroom",
|
|
1494
|
+
"open_in_new_tab": False,
|
|
1495
|
+
},
|
|
1496
|
+
"children": [],
|
|
1497
|
+
},
|
|
1498
|
+
}
|
|
1499
|
+
)
|
|
1500
|
+
menu_items.append(
|
|
1501
|
+
{
|
|
1502
|
+
"type": "item",
|
|
1503
|
+
"value": {
|
|
1504
|
+
"label": "Contact",
|
|
1505
|
+
"link": {
|
|
1506
|
+
"link_type": "page",
|
|
1507
|
+
"page": contact.id,
|
|
1508
|
+
"link_text": "Contact",
|
|
1509
|
+
"open_in_new_tab": False,
|
|
1510
|
+
},
|
|
1511
|
+
"children": [],
|
|
1512
|
+
},
|
|
1513
|
+
}
|
|
1514
|
+
)
|
|
1515
|
+
header.menu_items = menu_items
|
|
1516
|
+
|
|
1517
|
+
header.header_cta_enabled = True
|
|
1518
|
+
header.header_cta_text = "Contact"
|
|
1519
|
+
header.header_cta_link = [
|
|
1520
|
+
{
|
|
1521
|
+
"type": "link",
|
|
1522
|
+
"value": {
|
|
1523
|
+
"link_type": "page",
|
|
1524
|
+
"page": contact.id,
|
|
1525
|
+
"link_text": "Contact",
|
|
1526
|
+
"open_in_new_tab": False,
|
|
1527
|
+
},
|
|
1528
|
+
}
|
|
1529
|
+
]
|
|
1530
|
+
|
|
1531
|
+
header.mobile_cta_enabled = True
|
|
1532
|
+
header.mobile_cta_button_enabled = True
|
|
1533
|
+
header.mobile_cta_button_text = "Contact"
|
|
1534
|
+
header.mobile_cta_button_link = [
|
|
1535
|
+
{
|
|
1536
|
+
"type": "link",
|
|
1537
|
+
"value": {
|
|
1538
|
+
"link_type": "page",
|
|
1539
|
+
"page": contact.id,
|
|
1540
|
+
"link_text": "Contact",
|
|
1541
|
+
"open_in_new_tab": False,
|
|
1542
|
+
},
|
|
1543
|
+
}
|
|
1544
|
+
]
|
|
1545
|
+
header.save()
|
|
1546
|
+
|
|
1547
|
+
footer = FooterNavigation.for_site(site)
|
|
1548
|
+
|
|
1549
|
+
explore_links = [
|
|
1550
|
+
{
|
|
1551
|
+
"link_type": "page",
|
|
1552
|
+
"page": home.id,
|
|
1553
|
+
"link_text": "Home",
|
|
1554
|
+
"open_in_new_tab": False,
|
|
1555
|
+
},
|
|
1556
|
+
]
|
|
1557
|
+
if showroom is not None:
|
|
1558
|
+
explore_links.append(
|
|
1559
|
+
{
|
|
1560
|
+
"link_type": "page",
|
|
1561
|
+
"page": showroom.id,
|
|
1562
|
+
"link_text": "Showroom",
|
|
1563
|
+
"open_in_new_tab": False,
|
|
1564
|
+
}
|
|
1565
|
+
)
|
|
1566
|
+
if kitchen_sink is not None:
|
|
1567
|
+
explore_links.append(
|
|
1568
|
+
{
|
|
1569
|
+
"link_type": "page",
|
|
1570
|
+
"page": kitchen_sink.id,
|
|
1571
|
+
"link_text": "Kitchen Sink",
|
|
1572
|
+
"open_in_new_tab": False,
|
|
1573
|
+
}
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
footer.link_sections = [
|
|
1577
|
+
{
|
|
1578
|
+
"type": "section",
|
|
1579
|
+
"value": {
|
|
1580
|
+
"title": "Explore",
|
|
1581
|
+
"links": explore_links,
|
|
1582
|
+
},
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
"type": "section",
|
|
1586
|
+
"value": {
|
|
1587
|
+
"title": "Services",
|
|
1588
|
+
"links": [
|
|
1589
|
+
{
|
|
1590
|
+
"link_type": "page",
|
|
1591
|
+
"page": services_index.id,
|
|
1592
|
+
"link_text": "All Services",
|
|
1593
|
+
"open_in_new_tab": False,
|
|
1594
|
+
},
|
|
1595
|
+
{
|
|
1596
|
+
"link_type": "page",
|
|
1597
|
+
"page": service_one.id,
|
|
1598
|
+
"link_text": service_one.title,
|
|
1599
|
+
"open_in_new_tab": False,
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
"link_type": "page",
|
|
1603
|
+
"page": service_two.id,
|
|
1604
|
+
"link_text": service_two.title,
|
|
1605
|
+
"open_in_new_tab": False,
|
|
1606
|
+
},
|
|
1607
|
+
],
|
|
1608
|
+
},
|
|
1609
|
+
},
|
|
1610
|
+
{
|
|
1611
|
+
"type": "section",
|
|
1612
|
+
"value": {
|
|
1613
|
+
"title": "Company",
|
|
1614
|
+
"links": [
|
|
1615
|
+
{
|
|
1616
|
+
"link_type": "page",
|
|
1617
|
+
"page": contact.id,
|
|
1618
|
+
"link_text": "Contact",
|
|
1619
|
+
"open_in_new_tab": False,
|
|
1620
|
+
},
|
|
1621
|
+
],
|
|
1622
|
+
},
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
"type": "section",
|
|
1626
|
+
"value": {
|
|
1627
|
+
"title": "Legal",
|
|
1628
|
+
"links": [
|
|
1629
|
+
{
|
|
1630
|
+
"link_type": "page",
|
|
1631
|
+
"page": terms.id,
|
|
1632
|
+
"link_text": "Terms",
|
|
1633
|
+
"open_in_new_tab": False,
|
|
1634
|
+
},
|
|
1635
|
+
{
|
|
1636
|
+
"link_type": "page",
|
|
1637
|
+
"page": privacy.id,
|
|
1638
|
+
"link_text": "Privacy",
|
|
1639
|
+
"open_in_new_tab": False,
|
|
1640
|
+
},
|
|
1641
|
+
{
|
|
1642
|
+
"link_type": "page",
|
|
1643
|
+
"page": cookies.id,
|
|
1644
|
+
"link_text": "Cookies",
|
|
1645
|
+
"open_in_new_tab": False,
|
|
1646
|
+
},
|
|
1647
|
+
],
|
|
1648
|
+
},
|
|
1649
|
+
},
|
|
1650
|
+
]
|
|
1651
|
+
|
|
1652
|
+
# Keep footer social links blank so templates can demonstrate the
|
|
1653
|
+
# effective-settings fallback to Branding SiteSettings.
|
|
1654
|
+
footer.social_facebook = ""
|
|
1655
|
+
footer.social_instagram = ""
|
|
1656
|
+
footer.save()
|
|
1657
|
+
|
|
1658
|
+
def _slugify(self, text: str) -> str:
|
|
1659
|
+
# Simple slugify for filenames
|
|
1660
|
+
text = text.lower()
|
|
1661
|
+
return re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|