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.
Files changed (72) hide show
  1. sum/__init__.py +1 -0
  2. sum/boilerplate/.env.example +124 -0
  3. sum/boilerplate/.gitea/workflows/ci.yml +33 -0
  4. sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
  5. sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
  6. sum/boilerplate/.github/workflows/ci.yml +36 -0
  7. sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
  8. sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
  9. sum/boilerplate/.gitignore +45 -0
  10. sum/boilerplate/README.md +259 -0
  11. sum/boilerplate/manage.py +34 -0
  12. sum/boilerplate/project_name/__init__.py +5 -0
  13. sum/boilerplate/project_name/home/__init__.py +5 -0
  14. sum/boilerplate/project_name/home/apps.py +20 -0
  15. sum/boilerplate/project_name/home/management/__init__.py +0 -0
  16. sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
  17. sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
  18. sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
  19. sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
  20. sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
  21. sum/boilerplate/project_name/home/models.py +13 -0
  22. sum/boilerplate/project_name/settings/__init__.py +5 -0
  23. sum/boilerplate/project_name/settings/base.py +348 -0
  24. sum/boilerplate/project_name/settings/local.py +78 -0
  25. sum/boilerplate/project_name/settings/production.py +106 -0
  26. sum/boilerplate/project_name/urls.py +33 -0
  27. sum/boilerplate/project_name/wsgi.py +16 -0
  28. sum/boilerplate/pytest.ini +5 -0
  29. sum/boilerplate/requirements.txt +25 -0
  30. sum/boilerplate/static/client/.gitkeep +3 -0
  31. sum/boilerplate/templates/overrides/.gitkeep +3 -0
  32. sum/boilerplate/tests/__init__.py +3 -0
  33. sum/boilerplate/tests/test_health.py +51 -0
  34. sum/cli.py +42 -0
  35. sum/commands/__init__.py +10 -0
  36. sum/commands/backup.py +308 -0
  37. sum/commands/check.py +128 -0
  38. sum/commands/init.py +265 -0
  39. sum/commands/promote.py +758 -0
  40. sum/commands/run.py +96 -0
  41. sum/commands/themes.py +56 -0
  42. sum/commands/update.py +301 -0
  43. sum/config.py +61 -0
  44. sum/docs/USER_GUIDE.md +663 -0
  45. sum/exceptions.py +45 -0
  46. sum/setup/__init__.py +17 -0
  47. sum/setup/auth.py +184 -0
  48. sum/setup/database.py +58 -0
  49. sum/setup/deps.py +73 -0
  50. sum/setup/git_ops.py +463 -0
  51. sum/setup/infrastructure.py +576 -0
  52. sum/setup/orchestrator.py +354 -0
  53. sum/setup/remote_themes.py +371 -0
  54. sum/setup/scaffold.py +500 -0
  55. sum/setup/seed.py +110 -0
  56. sum/setup/site_orchestrator.py +441 -0
  57. sum/setup/venv.py +89 -0
  58. sum/system_config.py +330 -0
  59. sum/themes_registry.py +180 -0
  60. sum/utils/__init__.py +25 -0
  61. sum/utils/django.py +97 -0
  62. sum/utils/environment.py +76 -0
  63. sum/utils/output.py +78 -0
  64. sum/utils/project.py +110 -0
  65. sum/utils/prompts.py +36 -0
  66. sum/utils/validation.py +313 -0
  67. sum_cli-3.0.0.dist-info/METADATA +127 -0
  68. sum_cli-3.0.0.dist-info/RECORD +72 -0
  69. sum_cli-3.0.0.dist-info/WHEEL +5 -0
  70. sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
  71. sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
  72. 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("-")