diff --git a/bakerydemo/base/migrations/0027_alter_person_first_name_alter_person_last_name.py b/bakerydemo/base/migrations/0027_alter_person_first_name_alter_person_last_name.py new file mode 100644 index 000000000..8ee3622f1 --- /dev/null +++ b/bakerydemo/base/migrations/0027_alter_person_first_name_alter_person_last_name.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-03-06 04:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0026_alter_formpage_body_alter_gallerypage_body_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='first_name', + field=models.CharField(default='', max_length=100), + ), + migrations.AlterField( + model_name='person', + name='last_name', + field=models.CharField(default='', max_length=100), + ), + ] diff --git a/bakerydemo/base/models.py b/bakerydemo/base/models.py index dfd73147c..c4d07074a 100644 --- a/bakerydemo/base/models.py +++ b/bakerydemo/base/models.py @@ -64,8 +64,10 @@ class Person( https://github.com/wagtail/django-modelcluster """ - first_name = models.CharField("First name", max_length=254) - last_name = models.CharField("Last name", max_length=254) + # first_name = models.CharField("First name", max_length=254) + # last_name = models.CharField("Last name", max_length=254) + first_name = models.CharField(max_length=100, default='') + last_name = models.CharField(max_length=100, default='') job_title = models.CharField("Job title", max_length=254) image = models.ForeignKey( @@ -641,3 +643,4 @@ def get_task_states_user_can_moderate(self, user, **kwargs): @classmethod def get_description(cls): return _("Only a specific user can approve this task") + diff --git a/bakerydemo/breads/tests/test_bread_page.py b/bakerydemo/breads/tests/test_bread_page.py new file mode 100644 index 000000000..53e71bfec --- /dev/null +++ b/bakerydemo/breads/tests/test_bread_page.py @@ -0,0 +1,43 @@ +from wagtail.models import Page, Site +from wagtail.test.utils import WagtailPageTestCase + +from bakerydemo.breads.models import BreadPage + + +class BreadPageRenderTest(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + """ + Create the page tree and site once for all tests in this class. + + Steps: + 1. Identify the root page. + 2. Create a Site pointing at the root. + 3. Create a BreadPage instance with the required fields. + 4. Add it to the tree and publish so it is live. + """ + # 1. Identify the root page + cls.root = Page.get_first_root_node() + + # 2. Create the Site that will serve our test pages + cls.site = Site.objects.create( + hostname="testserver", + root_page=cls.root, + is_default_site=True, + ) + + # 3. Create the BreadPage instance we want to test + cls.bread_page = BreadPage( + title="Test bread", + slug="test-bread", + introduction="A test bread page.", + ) + + # 4. Add to the tree and publish so it is live + cls.root.add_child(instance=cls.bread_page) + cls.bread_page.save_revision().publish() + + def test_bread_page_renders(self): + response = self.client.get(self.bread_page.url) + self.assertEqual(response.status_code, 200) + diff --git a/bakerydemo/people/__init__.py b/bakerydemo/people/__init__.py index e69de29bb..d3f5a12fa 100644 --- a/bakerydemo/people/__init__.py +++ b/bakerydemo/people/__init__.py @@ -0,0 +1 @@ + diff --git a/bakerydemo/people/apps.py b/bakerydemo/people/apps.py new file mode 100644 index 000000000..03c6a6463 --- /dev/null +++ b/bakerydemo/people/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PeopleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bakerydemo.people" + diff --git a/bakerydemo/people/management/__init__.py b/bakerydemo/people/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bakerydemo/people/management/commands/__init__.py b/bakerydemo/people/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bakerydemo/people/management/commands/generate_people.py b/bakerydemo/people/management/commands/generate_people.py new file mode 100644 index 000000000..d8d64094e --- /dev/null +++ b/bakerydemo/people/management/commands/generate_people.py @@ -0,0 +1,61 @@ +from django.core.management.base import BaseCommand +from faker import Faker +from bakerydemo.people.models import PersonIndexPage, PersonPage + +fake = Faker() + +DEPARTMENTS = [ + "Engineering", "Design", "Marketing", + "Research", "Operations", "Communications" +] + +ROLES = [ + "Senior Engineer", "Product Designer", "Research Lead", + "Communications Manager", "Operations Director", "UX Researcher", + "Software Engineer", "Data Scientist", "Project Manager", + "Content Strategist" +] + +class Command(BaseCommand): + help = "Generate fake people pages for demo" + + def add_arguments(self, parser): + parser.add_argument( + '--count', + type=int, + default=50, + help='Number of people pages to generate' + ) + + def handle(self, *args, **options): + index = PersonIndexPage.objects.first() + if not index: + self.stdout.write(self.style.ERROR( + 'No PersonIndexPage found. ' + 'Create one in the admin first.' + )) + return + + count = options['count'] + for i in range(count): + first_name = fake.first_name() + last_name = fake.last_name() + person = PersonPage( + title=f"{first_name} {last_name}", + first_name=first_name, + last_name=last_name, + role=fake.random_element(ROLES), + department=fake.random_element(DEPARTMENTS), + bio=fake.paragraph(nb_sentences=5), + email=fake.company_email(), + slug=fake.unique.slug(), + ) + index.add_child(instance=person) + person.save_revision().publish() + self.stdout.write(f"Created: {person.title}") + + self.stdout.write( + self.style.SUCCESS( + f'Successfully created {count} people pages' + ) + ) \ No newline at end of file diff --git a/bakerydemo/people/migrations/0004_personindexpage_alter_personpage_options_and_more.py b/bakerydemo/people/migrations/0004_personindexpage_alter_personpage_options_and_more.py new file mode 100644 index 000000000..4245dc07e --- /dev/null +++ b/bakerydemo/people/migrations/0004_personindexpage_alter_personpage_options_and_more.py @@ -0,0 +1,95 @@ +# Generated by Django 6.0.3 on 2026-03-06 04:26 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0003_personpage_social_links'), + ('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'), + ('wagtailimages', '0027_image_description'), + ] + + operations = [ + migrations.CreateModel( + name='PersonIndexPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('intro', wagtail.fields.RichTextField(blank=True)), + ], + options={ + 'verbose_name': 'Person Index Page', + }, + bases=('wagtailcore.page',), + ), + migrations.AlterModelOptions( + name='personpage', + options={'verbose_name': 'Person Page'}, + ), + migrations.RemoveField( + model_name='personpage', + name='body', + ), + migrations.RemoveField( + model_name='personpage', + name='image', + ), + migrations.RemoveField( + model_name='personpage', + name='introduction', + ), + migrations.RemoveField( + model_name='personpage', + name='location', + ), + migrations.RemoveField( + model_name='personpage', + name='social_links', + ), + migrations.AddField( + model_name='personpage', + name='bio', + field=wagtail.fields.RichTextField(blank=True), + ), + migrations.AddField( + model_name='personpage', + name='department', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='personpage', + name='email', + field=models.EmailField(blank=True, max_length=254), + ), + migrations.AddField( + model_name='personpage', + name='first_name', + field=models.CharField(default='', max_length=100), + ), + migrations.AddField( + model_name='personpage', + name='last_name', + field=models.CharField(default='', max_length=100), + ), + migrations.AddField( + model_name='personpage', + name='linkedin_url', + field=models.URLField(blank=True), + ), + migrations.AddField( + model_name='personpage', + name='photo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image'), + ), + migrations.AddField( + model_name='personpage', + name='role', + field=models.CharField(default='', max_length=200), + ), + migrations.DeleteModel( + name='PeopleIndexPage', + ), + ] diff --git a/bakerydemo/people/models.py b/bakerydemo/people/models.py index 82e1119e1..c479ca5d3 100644 --- a/bakerydemo/people/models.py +++ b/bakerydemo/people/models.py @@ -1,189 +1,92 @@ -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db import models -from wagtail.admin.panels import FieldPanel -from wagtail.api import APIField -from wagtail.blocks import ChoiceBlock, StructBlock, StructValue, URLBlock -from wagtail.fields import StreamField -from wagtail.models import Page -from wagtail.search import index - -from bakerydemo.base.blocks import BaseStreamBlock - -from ..breads.models import Country - - -class SocialMediaValue(StructValue): - def get_platform_label(self): - return dict(self.block.child_blocks["platform"].field.choices).get( - self["platform"] - ) - - -class SocialMediaBlock(StructBlock): - """ - Block for social media links - """ - - platform = ChoiceBlock( - choices=[ - ("github", "GitHub"), - ("twitter", "Twitter/X"), - ("linkedin", "LinkedIn"), - ("instagram", "Instagram"), - ("facebook", "Facebook"), - ("mastodon", "Mastodon"), - ("website", "Personal Website"), - ], - help_text="Select the social media platform", - ) - url = URLBlock( - label="URL", - help_text="Full URL to your profile (e.g., https://github.com/username)", - ) - - class Meta: - icon = "link" - label = "Social Media Link" - value_class = SocialMediaValue - - -class PersonPage(Page): - """ - Detail view for a specific person - """ - - introduction = models.TextField(help_text="Text to describe the page", blank=True) - image = models.ForeignKey( - "wagtailimages.Image", - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name="+", - help_text="Landscape mode only; horizontal width between 1000px and 3000px.", - ) - body = StreamField(BaseStreamBlock(), verbose_name="Page body", blank=True) - - location = models.ForeignKey( - Country, - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - - social_links = StreamField( - [("social", SocialMediaBlock())], - blank=True, - help_text="Add social media profiles", - ) - - content_panels = Page.content_panels + [ - FieldPanel("introduction"), - FieldPanel("image"), - FieldPanel("location"), - FieldPanel("body"), - FieldPanel("social_links"), - ] - - search_fields = Page.search_fields + [ - index.SearchField("introduction"), - index.SearchField("body"), - ] - - parent_page_types = ["PeopleIndexPage"] - - api_fields = [ - APIField("introduction"), - APIField("image"), - APIField("body"), - APIField("location"), - APIField("social_links"), - ] - - def get_context(self, request): - context = super(PersonPage, self).get_context(request) - - platform_block = SocialMediaBlock().child_blocks["platform"] - platform_labels = dict(platform_block.field.choices) - social_links = [ - { - "platform": link.value["platform"], - "label": platform_labels.get( - link.value["platform"], link.value["platform"] - ), - "url": link.value["url"], - } - for link in (self.social_links or []) - ] - - context["social_links"] = social_links - - return context - - -class PeopleIndexPage(Page): - """ - Index page for people. - - Lists all People objects with pagination. - """ - - introduction = models.TextField(help_text="Text to describe the page", blank=True) - image = models.ForeignKey( - "wagtailimages.Image", - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name="+", - help_text="Landscape mode only; horizontal width between 1000px and 3000px.", - ) - - content_panels = Page.content_panels + [ - FieldPanel("introduction"), - FieldPanel("image"), - ] - - # Can only have PersonPage children - subpage_types = ["PersonPage"] - - api_fields = [ - APIField("introduction"), - APIField("image"), - ] - - # Returns a queryset of PersonPage objects that are live, that are direct - # descendants of this index page with most recent first - def get_people(self): - return ( - PersonPage.objects.live() - .descendant_of(self) - .order_by("-first_published_at") - ) - - # Allows child objects (e.g. PersonPage objects) to be accessible via the - # template - def children(self): - return self.get_children().specific().live() - - # Pagination for the index page - def paginate(self, request, *args): - page = request.GET.get("page") - paginator = Paginator(self.get_people(), 12) - try: - pages = paginator.page(page) - except PageNotAnInteger: - pages = paginator.page(1) - except EmptyPage: - pages = paginator.page(paginator.num_pages) - return pages - - # Returns the above to the get_context method that is used to populate the - # template - def get_context(self, request): - context = super(PeopleIndexPage, self).get_context(request) - - # PersonPage objects (get_people) are passed through pagination - people = self.paginate(request, self.get_people()) - - context["people"] = people - - return context +from django.db import models +from wagtail.admin.panels import FieldPanel, MultiFieldPanel +from wagtail.fields import RichTextField +from wagtail.models import Page +from wagtail.search import index + + +class PersonIndexPage(Page): + intro = RichTextField(blank=True) + + content_panels = Page.content_panels + [ + FieldPanel("intro"), + ] + + subpage_types = ["PersonPage"] + + class Meta: + verbose_name = "Person Index Page" + + def get_context(self, request): + context = super().get_context(request) + context["people"] = ( + PersonPage.objects.child_of(self).live().order_by("last_name", "first_name") + ) + return context + + +class PersonPage(Page): + first_name = models.CharField(max_length=100, default="") + last_name = models.CharField(max_length=100, default="") + role = models.CharField(max_length=200, default="") + department = models.CharField(max_length=200, blank=True) + bio = RichTextField(blank=True) + photo = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + ) + email = models.EmailField(blank=True) + linkedin_url = models.URLField(blank=True) + + parent_page_types = ["PersonIndexPage"] + subpage_types = [] + + search_fields = Page.search_fields + [ + index.SearchField("first_name"), + index.SearchField("last_name"), + index.SearchField("role"), + index.SearchField("bio"), + ] + + content_panels = Page.content_panels + [ + MultiFieldPanel( + [ + FieldPanel("first_name"), + FieldPanel("last_name"), + ], + heading="Name", + ), + MultiFieldPanel( + [ + FieldPanel("role"), + FieldPanel("department"), + ], + heading="Role", + ), + MultiFieldPanel( + [ + FieldPanel("photo"), + ], + heading="Photo", + ), + MultiFieldPanel( + [ + FieldPanel("bio"), + ], + heading="Bio", + ), + MultiFieldPanel( + [ + FieldPanel("email"), + FieldPanel("linkedin_url"), + ], + heading="Contact", + ), + ] + + class Meta: + verbose_name = "Person Page" + diff --git a/bakerydemo/people/tests/__init__.py b/bakerydemo/people/tests/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/bakerydemo/people/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/bakerydemo/people/tests/test_people_page.py b/bakerydemo/people/tests/test_people_page.py new file mode 100644 index 000000000..f76d58934 --- /dev/null +++ b/bakerydemo/people/tests/test_people_page.py @@ -0,0 +1,100 @@ +from django.contrib.auth.models import User +from wagtail.models import Page, Site +from wagtail.test.utils import WagtailPageTestCase + +from bakerydemo.people.models import PersonIndexPage, PersonPage + + +class PersonIndexPageRenderTest(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + cls.root = Page.get_first_root_node() + + cls.site, _ = Site.objects.update_or_create( + hostname="testserver", + defaults={ + "root_page": cls.root, + "is_default_site": True, + }, + ) + + cls.index_page = PersonIndexPage( + title="People", + slug="people", + intro="

Meet our team

", + ) + cls.root.add_child(instance=cls.index_page) + cls.index_page.save_revision().publish() + + cls.person_page = PersonPage( + title="Jane Doe", + slug="jane-doe", + first_name="Jane", + last_name="Doe", + role="Head Baker", + department="Kitchen", + bio="

Experienced artisan baker.

", + ) + cls.index_page.add_child(instance=cls.person_page) + cls.person_page.save_revision().publish() + + def setUp(self): + super().setUp() + self.user = User.objects.create_superuser( + username="testadmin", email="test@example.com", password="password" + ) + self.client.login(username="testadmin", password="password") + + def test_person_index_page_renders(self): + response = self.client.get(self.index_page.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "people/person_index_page.html") + self.assertContains(response, "Meet our team") + + +class PersonPageRenderTest(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + cls.root = Page.get_first_root_node() + + cls.site, _ = Site.objects.update_or_create( + hostname="testserver", + defaults={ + "root_page": cls.root, + "is_default_site": True, + }, + ) + + cls.index_page = PersonIndexPage( + title="People Detail", + slug="people-detail", + ) + cls.root.add_child(instance=cls.index_page) + cls.index_page.save_revision().publish() + + cls.person_page = PersonPage( + title="John Smith", + slug="john-smith", + first_name="John", + last_name="Smith", + role="Pastry Chef", + department="Pastry", + bio="

Creates delicious pastries.

", + ) + cls.index_page.add_child(instance=cls.person_page) + cls.person_page.save_revision().publish() + + def setUp(self): + super().setUp() + self.user = User.objects.create_superuser( + username="testadmin2", email="test2@example.com", password="password" + ) + self.client.login(username="testadmin2", password="password") + + def test_person_page_renders(self): + response = self.client.get(self.person_page.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "people/person_page.html") + self.assertContains(response, "John Smith") + self.assertContains(response, "Pastry Chef") + diff --git a/bakerydemo/press_releases/__init__.py b/bakerydemo/press_releases/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bakerydemo/press_releases/admin.py b/bakerydemo/press_releases/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/bakerydemo/press_releases/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/bakerydemo/press_releases/apps.py b/bakerydemo/press_releases/apps.py new file mode 100644 index 000000000..c2734cbd6 --- /dev/null +++ b/bakerydemo/press_releases/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PressReleasesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bakerydemo.press_releases" \ No newline at end of file diff --git a/bakerydemo/press_releases/management/__init__.py b/bakerydemo/press_releases/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bakerydemo/press_releases/management/commands/__init__.py b/bakerydemo/press_releases/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bakerydemo/press_releases/management/commands/generate_press_releases.py b/bakerydemo/press_releases/management/commands/generate_press_releases.py new file mode 100644 index 000000000..e69fbcafa --- /dev/null +++ b/bakerydemo/press_releases/management/commands/generate_press_releases.py @@ -0,0 +1,50 @@ +from django.core.management.base import BaseCommand +from faker import Faker +from bakerydemo.press_releases.models import PressReleaseIndexPage, PressReleasePage + +fake = Faker() + +SOURCES = [ + "Reuters", "Associated Press", "PR Newswire", + "Business Wire", "Globe Newswire", "PR Web" +] + +class Command(BaseCommand): + help = "Generate fake press release pages for demo" + + def add_arguments(self, parser): + parser.add_argument( + '--count', + type=int, + default=10, + help='Number of press release pages to generate' + ) + + def handle(self, *args, **options): + index = PressReleaseIndexPage.objects.first() + if not index: + self.stdout.write(self.style.ERROR( + 'No PressReleaseIndexPage found. ' + 'Create one in the admin first.' + )) + return + + count = options['count'] + for i in range(count): + title = fake.sentence(nb_words=6).rstrip('.') + release = PressReleasePage( + title=title, + date=fake.date_between(start_date='-2y', end_date='today'), + intro=fake.paragraph(nb_sentences=2), + body=f"

{fake.paragraph(nb_sentences=5)}

", + source=fake.random_element(SOURCES), + contact_email=fake.company_email(), + slug=fake.unique.slug(), + ) + index.add_child(instance=release) + release.save_revision().publish() + self.stdout.write(f"Created: {release.title}") + + self.stdout.write(self.style.SUCCESS( + f'Successfully created {count} press release pages' + )) \ No newline at end of file diff --git a/bakerydemo/press_releases/migrations/0001_initial.py b/bakerydemo/press_releases/migrations/0001_initial.py new file mode 100644 index 000000000..4780b2c84 --- /dev/null +++ b/bakerydemo/press_releases/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.2 on 2026-03-09 13:27 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PressReleaseIndexPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('intro', wagtail.fields.RichTextField(blank=True)), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + migrations.CreateModel( + name='PressReleasePage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('date', models.DateField(verbose_name='Press release date')), + ('intro', models.CharField(default='', max_length=300)), + ('body', wagtail.fields.RichTextField()), + ('source', models.CharField(default='', max_length=200)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + ] diff --git a/bakerydemo/press_releases/migrations/__init__.py b/bakerydemo/press_releases/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bakerydemo/press_releases/models.py b/bakerydemo/press_releases/models.py new file mode 100644 index 000000000..5f8e6742e --- /dev/null +++ b/bakerydemo/press_releases/models.py @@ -0,0 +1,48 @@ +from django.db import models + +from django.db import models +from wagtail.models import Page +from wagtail.fields import RichTextField +from wagtail.admin.panels import FieldPanel + + +class PressReleaseIndexPage(Page): + intro = RichTextField(blank=True) + + content_panels = Page.content_panels + [ + FieldPanel("intro"), + ] + + parent_page_types = ["wagtailcore.Page"] + subpage_types = ["press_releases.PressReleasePage"] + + def get_context(self, request): + context = super().get_context(request) + context["press_releases"] = ( + PressReleasePage.objects.child_of(self).live().order_by("-date") + ) + return context + + template = "press_releases/press_release_index_page.html" + parent_page_types = ["base.HomePage"] +class PressReleasePage(Page): + date = models.DateField("Press release date") + intro = models.CharField(max_length=300, default="") + body = RichTextField() + source = models.CharField(max_length=200, default="") + contact_email = models.EmailField(blank=True) + + content_panels = Page.content_panels + [ + FieldPanel("date"), + FieldPanel("intro"), + FieldPanel("body"), + FieldPanel("source"), + FieldPanel("contact_email"), + ] + + parent_page_types = ["press_releases.PressReleaseIndexPage"] + subpage_types = [] + template = "press_releases/press_release_page.html" # ← add this line + date = models.DateField("Press release date") + + diff --git a/bakerydemo/press_releases/tests.py b/bakerydemo/press_releases/tests.py new file mode 100644 index 000000000..fbdf431e1 --- /dev/null +++ b/bakerydemo/press_releases/tests.py @@ -0,0 +1,99 @@ +from django.contrib.auth.models import User +from wagtail.models import Page, Site +from wagtail.test.utils import WagtailPageTestCase +from bakerydemo.press_releases.models import PressReleaseIndexPage, PressReleasePage +import datetime + + +class PressReleaseIndexPageTest(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + cls.root = Page.get_first_root_node() + + cls.site, _ = Site.objects.update_or_create( + hostname="testserver", + defaults={ + "root_page": cls.root, + "is_default_site": True, + }, + ) + + cls.index = PressReleaseIndexPage( + title="Press Releases", + slug="press-releases-test", + ) + cls.root.add_child(instance=cls.index) + cls.index.save_revision().publish() + + def setUp(self): + super().setUp() + self.user = User.objects.create_superuser( + username="testadmin", email="test@example.com", password="password" + ) + self.client.login(username="testadmin", password="password") + + def test_index_page_status_code(self): + response = self.client.get(self.index.url) + self.assertEqual(response.status_code, 200) + + def test_index_page_uses_correct_template(self): + response = self.client.get(self.index.url) + self.assertTemplateUsed( + response, + "press_releases/press_release_index_page.html" + ) + + +class PressReleasePageTest(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + cls.root = Page.get_first_root_node() + + cls.site, _ = Site.objects.update_or_create( + hostname="testserver", + defaults={ + "root_page": cls.root, + "is_default_site": True, + }, + ) + + cls.index = PressReleaseIndexPage( + title="Press Releases", + slug="press-releases-page-test", + ) + cls.root.add_child(instance=cls.index) + cls.index.save_revision().publish() + + cls.release = PressReleasePage( + title="Bakery Wins Award", + slug="bakery-wins-award", + date=datetime.date.today(), + intro="We are thrilled to announce...", + body="

Full details here.

", + source="Bakery News", + ) + cls.index.add_child(instance=cls.release) + cls.release.save_revision().publish() + + def setUp(self): + super().setUp() + self.user = User.objects.create_superuser( + username="testadmin2", email="test2@example.com", password="password" + ) + self.client.login(username="testadmin2", password="password") + + def test_press_release_page_status_code(self): + response = self.client.get(self.release.url) + self.assertEqual(response.status_code, 200) + + def test_press_release_page_uses_correct_template(self): + response = self.client.get(self.release.url) + self.assertTemplateUsed( + response, + "press_releases/press_release_page.html" + ) + + def test_press_release_shows_title(self): + response = self.client.get(self.release.url) + self.assertContains(response, "Bakery Wins Award") +# Create your tests here. diff --git a/bakerydemo/press_releases/tests/test_press_release_page.py b/bakerydemo/press_releases/tests/test_press_release_page.py new file mode 100644 index 000000000..72f0deda8 --- /dev/null +++ b/bakerydemo/press_releases/tests/test_press_release_page.py @@ -0,0 +1,87 @@ +from django.contrib.auth.models import User +from wagtail.models import Page, Site +from wagtail.test.utils import WagtailPageTestCase + +from bakerydemo.press_releases.models import PressReleaseIndexPage, PressReleasePage + + +class PressReleaseIndexPageRenderTest(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + cls.root = Page.get_first_root_node() + + cls.site, _ = Site.objects.update_or_create( + hostname="testserver", + defaults={ + "root_page": cls.root, + "is_default_site": True, + }, + ) + + cls.index_page = PressReleaseIndexPage( + title="Press Releases", + slug="press-releases-test", + intro="

Latest news

", + ) + cls.root.add_child(instance=cls.index_page) + cls.index_page.save_revision().publish() + + def setUp(self): + super().setUp() + self.user = User.objects.create_superuser( + username="testadmin", email="test@example.com", password="password" + ) + self.client.login(username="testadmin", password="password") + + def test_press_release_index_page_renders(self): + response = self.client.get(self.index_page.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "press_releases/press_release_index_page.html") + self.assertContains(response, "Latest news") + + +class PressReleasePageRenderTest(WagtailPageTestCase): + @classmethod + def setUpTestData(cls): + cls.root = Page.get_first_root_node() + + cls.site, _ = Site.objects.update_or_create( + hostname="testserver", + defaults={ + "root_page": cls.root, + "is_default_site": True, + }, + ) + + cls.index_page = PressReleaseIndexPage( + title="Press Releases Detail", + slug="press-releases-detail-test", + ) + cls.root.add_child(instance=cls.index_page) + cls.index_page.save_revision().publish() + + cls.press_release_page = PressReleasePage( + title="Bakery Opens New Location", + slug="bakery-opens-new-location", + date="2026-01-15", + intro="The bakery is expanding.", + body="

We are excited to announce a new location.

", + source="PR Newswire", + contact_email="press@example.com", + ) + cls.index_page.add_child(instance=cls.press_release_page) + cls.press_release_page.save_revision().publish() + + def setUp(self): + super().setUp() + self.user = User.objects.create_superuser( + username="testadmin2", email="test2@example.com", password="password" + ) + self.client.login(username="testadmin2", password="password") + + def test_press_release_page_renders(self): + response = self.client.get(self.press_release_page.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "press_releases/press_release_page.html") + self.assertContains(response, "Bakery Opens New Location") + self.assertContains(response, "PR Newswire") diff --git a/bakerydemo/press_releases/views.py b/bakerydemo/press_releases/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/bakerydemo/press_releases/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/bakerydemo/settings/base.py b/bakerydemo/settings/base.py index 18173618b..c67e252c9 100644 --- a/bakerydemo/settings/base.py +++ b/bakerydemo/settings/base.py @@ -78,6 +78,7 @@ "django.contrib.staticfiles", "django.contrib.sitemaps", "bakerydemo.people", + "bakerydemo.press_releases", ] MIDDLEWARE = [ diff --git a/bakerydemo/static/css/main.css b/bakerydemo/static/css/main.css index 0f5fece64..945224145 100644 --- a/bakerydemo/static/css/main.css +++ b/bakerydemo/static/css/main.css @@ -41,6 +41,22 @@ each detail view /* stylelint-enable */ --font-sm: 1rem; --font-md: 1.125rem; + /* Theme tokens */ + --color-bg: var(--white); + --color-text: var(--dark); + --color-border: var(--border-grey); + --color-surface: var(--cream); + /* Layout */ + --max-width: 1400px; + --grid-gutter: 20px; +} + +/* Dark theme overrides (applied when JS sets data-theme="dark" on ) */ +html[data-theme='dark'] { + --color-bg: #121212; + --color-text: #e0e0e0; + --color-border: #333333; + --color-surface: #1e1e1e; } html { @@ -59,8 +75,8 @@ body { line-height: 1.5; padding-top: 0; padding-bottom: 0; - background: var(--white); - color: var(--dark); + background: var(--color-bg); + color: var(--color-text); min-height: 100vh; } @@ -449,7 +465,7 @@ caption { .header { padding: 0; width: 100%; - background: var(--white); + background: var(--color-surface); z-index: 10; } @@ -603,14 +619,9 @@ caption { .navigation__search-icon { display: block; position: absolute; - right: 13px; - top: 13px; -} - -@media (min-width: 768px) { - .navigation__search-icon { - top: 11px; - } + left: 13px; + top: 50%; + transform: translateY(-50%); } .navigation__search-input { @@ -631,14 +642,42 @@ caption { display: none; } +/* Theme toggle button */ +.theme-toggle { + margin-left: 20px; + border-radius: 999px; + border: 1px solid var(--border-grey); + background-color: transparent; + color: var(--dark); + font-size: 0.875rem; + padding: 4px 12px; + cursor: pointer; +} + +.theme-toggle:focus-visible { + outline: 2px solid var(--orange); + outline-offset: 2px; +} + +html[data-theme='dark'] .theme-toggle { + color: var(--color-text, #e0e0e0); + border-color: var(--color-border, #333333); +} + @media (min-width: 1150px) { .navigation__desktop { - display: block; + display: flex; + flex-direction: row; + align-items: center; + margin-left: auto; + flex: 1; + min-width: 0; } .navigation__search { display: block; - margin: 0 0 0 auto; + margin-left: auto; + flex-shrink: 0; } .navigation__items { @@ -776,7 +815,7 @@ hr { footer { padding: 55px 0 30px; - background-color: var(--white); + background-color: var(--color-surface); } .footer__icon a { @@ -802,16 +841,35 @@ footer { } .container { - width: auto; - padding-left: 20px; - padding-right: 20px; + width: 100%; + max-width: var(--max-width); + margin-inline: auto; + padding-inline: var(--grid-gutter); } @media (min-width: 768px) { - .container { - max-width: 1400px; - padding-left: 40px; - padding-right: 40px; + :root { + --grid-gutter: 40px; + } +} + +.grid { + display: grid; + gap: var(--grid-gutter); + grid-template-columns: 1fr; +} + +/* Tablet */ +@media (min-width: 768px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Desktop */ +@media (min-width: 1200px) { + .grid { + grid-template-columns: repeat(3, 1fr); } } @@ -2099,3 +2157,43 @@ input[type='radio'] { max-height: 100vh; margin-inline: auto; } + + +/* Search form: relative so absolute clear button doesn't affect flex layout */ +.navigation__search form, +.navigation__mobile-search form { + position: relative; + display: flex; + align-items: center; +} + +/* Clear button: right side of input, vertically centered; no layout shift (absolute) */ +.search-clear-btn { + position: absolute; + right: 40px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + font-size: 1.5rem; + color: var(--grey); + cursor: pointer; + padding: 0; + line-height: 1; + z-index: 5; + transition: color 0.2s ease; +} + +.search-clear-btn:hover { + color: var(--orange); +} + +.is-hidden { + display: none !important; +} + +/* Input padding so text doesn't overlap clear button and search icon */ +.navigation__search-input { + padding-left: 50px !important; + padding-right: 65px !important; +} \ No newline at end of file diff --git a/bakerydemo/static/js/main.js b/bakerydemo/static/js/main.js index 7492deb76..cd5c83319 100644 --- a/bakerydemo/static/js/main.js +++ b/bakerydemo/static/js/main.js @@ -1,24 +1,113 @@ -document.addEventListener('DOMContentLoaded', () => { - const navigation = document.querySelector('[data-navigation]'); - const mobileNavigation = navigation.querySelector('[data-mobile-navigation]'); - const body = document.querySelector('body'); - const mobileNavigationToggle = navigation.querySelector( - '[data-mobile-navigation-toggle]', - ); - - function toggleMobileNavigation() { - if (mobileNavigation.hidden) { - body.classList.add('no-scroll'); - mobileNavigation.hidden = false; - mobileNavigationToggle.setAttribute('aria-expanded', 'true'); - } else { - body.classList.remove('no-scroll'); - mobileNavigation.hidden = true; - mobileNavigationToggle.setAttribute('aria-expanded', 'false'); + +// document.addEventListener('DOMContentLoaded', function() { +// const searchInput = document.querySelector('.navigation__search-input'); +// const clearBtn = document.getElementById('search-clear'); + +// if (searchInput && clearBtn) { +// // Show/Hide button based on input value +// searchInput.addEventListener('input', () => { +// if (searchInput.value.length > 0) { +// clearBtn.classList.remove('is-hidden'); +// } else { +// clearBtn.classList.add('is-hidden'); +// } +// }); + +// // Clear input when clicked +// clearBtn.addEventListener('click', () => { +// searchInput.value = ''; +// clearBtn.classList.add('is-hidden'); +// searchInput.focus(); +// }); +// } +// }); + + +// document.addEventListener('DOMContentLoaded', () => { +// const navigation = document.querySelector('[data-navigation]'); +// const mobileNavigation = navigation.querySelector('[data-mobile-navigation]'); +// const body = document.querySelector('body'); +// const mobileNavigationToggle = navigation.querySelector( +// '[data-mobile-navigation-toggle]', +// ); + + +// function toggleMobileNavigation() { +// if (mobileNavigation.hidden) { +// body.classList.add('no-scroll'); +// mobileNavigation.hidden = false; +// mobileNavigationToggle.setAttribute('aria-expanded', 'true'); +// } else { +// body.classList.remove('no-scroll'); +// mobileNavigation.hidden = true; +// mobileNavigationToggle.setAttribute('aria-expanded', 'false'); +// } +// } + +// mobileNavigationToggle.addEventListener('click', () => { +// toggleMobileNavigation(); +// }); +// }); +document.addEventListener('DOMContentLoaded', function() { + // Select all potential search inputs (mobile and desktop) + const searchInputs = document.querySelectorAll('.navigation__search-input'); + // Select the reset buttons + const clearBtnDesktop = document.getElementById('search-clear'); + const clearBtnMobile = document.getElementById('search-clear-mobile'); + + searchInputs.forEach(input => { + input.addEventListener('input', () => { + const currentBtn = input.id === 'mobile-search-input' ? clearBtnMobile : clearBtnDesktop; + + if (currentBtn) { + // Show button if text exists, hide if empty + if (input.value.length > 0) { + currentBtn.classList.remove('is-hidden'); + } else { + currentBtn.classList.add('is-hidden'); + } + } + }); + }); + + // Clear button click: clear input, hide button, refocus input + if (clearBtnDesktop) { + clearBtnDesktop.addEventListener('click', () => { + const desktopInput = document.querySelector('.navigation__search .navigation__search-input'); + if (desktopInput) { + desktopInput.value = ''; + clearBtnDesktop.classList.add('is-hidden'); + desktopInput.focus(); + } + }); + } + if (clearBtnMobile) { + clearBtnMobile.addEventListener('click', () => { + const mobileInput = document.getElementById('mobile-search-input'); + if (mobileInput) { + mobileInput.value = ''; + clearBtnMobile.classList.add('is-hidden'); + mobileInput.focus(); + } + }); + } + + // Theme: apply stored preference on load + const storedTheme = localStorage.getItem('theme'); + if (storedTheme === 'dark' || storedTheme === 'light') { + document.documentElement.setAttribute('data-theme', storedTheme); } - } - mobileNavigationToggle.addEventListener('click', () => { - toggleMobileNavigation(); - }); + // Minimal vanilla JS theme toggle (supports multiple buttons if needed) + const themeToggles = document.querySelectorAll('[data-theme-toggle]'); + + themeToggles.forEach((toggle) => { + toggle.addEventListener('click', () => { + const current = document.documentElement.getAttribute('data-theme') || 'light'; + const next = current === 'dark' ? 'light' : 'dark'; + + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('theme', next); + }); + }); }); diff --git a/bakerydemo/templates/includes/header.html b/bakerydemo/templates/includes/header.html index 8c547bcf2..a36e00e75 100644 --- a/bakerydemo/templates/includes/header.html +++ b/bakerydemo/templates/includes/header.html @@ -1,4 +1,4 @@ -{% load navigation_tags %} +{% load navigation_tags wagtailcore_tags %}
@@ -18,41 +18,53 @@ - + {% get_site_root as site_root %} - + + + -
diff --git a/bakerydemo/templates/people/person_index_page.html b/bakerydemo/templates/people/person_index_page.html new file mode 100644 index 000000000..0108ea52a --- /dev/null +++ b/bakerydemo/templates/people/person_index_page.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% load wagtailcore_tags navigation_tags wagtailimages_tags %} + +{% block content %} + {% include "base/include/header-index.html" %} + +
+
+
+

{{ page.title }}

+
+ {% if page.intro %} +
+ {{ page.intro|richtext }} +
+ {% endif %} +
+ +
+ {% for person in people %} +
+ {% if person.photo %} +
+ {% picture person.photo format-{avif,webp,jpeg} fill-600x400 %} +
+ {% endif %} + +
+

+ + {{ person.first_name }} {{ person.last_name }} + +

+

+ {{ person.role }}{% if person.department %} — {{ person.department }}{% endif %} +

+
+
+ {% empty %} +

No people have been added yet.

+ {% endfor %} +
+
+{% endblock content %} + diff --git a/bakerydemo/templates/people/person_page.html b/bakerydemo/templates/people/person_page.html index 0ebecb698..ee1035550 100644 --- a/bakerydemo/templates/people/person_page.html +++ b/bakerydemo/templates/people/person_page.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load wagtailimages_tags %} +{% load wagtailcore_tags wagtailimages_tags %} {% block content %} {% include "base/include/header-hero.html" %} @@ -9,62 +9,46 @@
- {% if page.introduction %} -

- {{ page.introduction }} -

- {% endif %} +

+ {{ page.first_name }} {{ page.last_name }} +

-
- {{ page.body }} -
+

+ {{ page.role }}{% if page.department %} — {{ page.department }}{% endif %} +

+ + {% if page.bio %} +
+ {{ page.bio|richtext }} +
+ {% endif %}
- {% if page.location %} -

Location

-

{{ page.location }}

+ {% if page.photo %} +

Photo

+
+ {% picture page.photo format-{avif,webp,jpeg} fill-400x400 %} +
{% endif %} - {% if page.social_links %} -

Socials

- +

+ {% endif %} {% endif %}
@@ -72,7 +56,9 @@
- {{ page.body }} + {% if page.bio %} + {{ page.bio|richtext }} + {% endif %}
diff --git a/bakerydemo/templates/press_releases/press_release_index_page.html b/bakerydemo/templates/press_releases/press_release_index_page.html new file mode 100644 index 000000000..1061441be --- /dev/null +++ b/bakerydemo/templates/press_releases/press_release_index_page.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% load wagtailcore_tags %} + +{% block content %} +
+

{{ page.title }}

+ {% if page.intro %} +
{{ page.intro|richtext }}
+ {% endif %} + +
+ {% for release in press_releases %} +
+ {{ release.date }} +

{{ release.title }}

+

{{ release.intro }}

+ {% if release.source %} + {{ release.source }} + {% endif %} +
+ {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/bakerydemo/templates/press_releases/press_release_page.html b/bakerydemo/templates/press_releases/press_release_page.html new file mode 100644 index 000000000..4fa7d23e7 --- /dev/null +++ b/bakerydemo/templates/press_releases/press_release_page.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% load wagtailcore_tags %} + +{% block content %} +
+
+ {{ page.date }} +

{{ page.title }}

+ {% if page.source %} + Source: {{ page.source }} + {% endif %} +
+ +
{{ page.intro }}
+
{{ page.body|richtext }}
+ + {% if page.contact_email %} + + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/requirements/base.txt b/requirements/base.txt index b2221c86e..d233eb0e8 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,8 +1,9 @@ Django>=6.0,<6.1 -python-dotenv>=1.2.1,<2 +python-dotenv==1.2.1 wagtail>=7.2,<7.3 wagtail-font-awesome-svg>=1,<2 django-debug-toolbar>=4.2,<5 -django-extensions>=4.1,<5 -django-csp>=3.8,<4 -dj-database-url>=3.1.0,<4 +django-extensions==3.2.3 +django-csp==3.7 +dj-database-url==2.1.0 +faker