diff --git a/README.md b/README.md index 3adec25..f75d9da 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,17 @@ The project includes custom Django management commands to help with development For detailed documentation on all available commands, see [Management Commands Documentation](./docs/management-commands.md). -### Quick Examples +### Quick Examples (recommended running order) ```bash # Create sample images and documents for testing docker exec -it wagtail-starter-kit-app-1 python manage.py create_sample_media -# Reset all sample content -docker exec -it wagtail-starter-kit-app-1 python manage.py create_sample_media --reset +# Populate the home page with sample content +docker exec -it wagtail-starter-kit-app-1 python manage.py populate_homepage + +# Create a complete blog structure with sample posts and authors +docker exec -it wagtail-starter-kit-app-1 python manage.py populate_blog ``` ## View the site diff --git a/app/blog/__init__.py b/app/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blog/apps.py b/app/blog/apps.py new file mode 100644 index 0000000..e0e9de1 --- /dev/null +++ b/app/blog/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "app.blog" diff --git a/app/blog/management/__init__.py b/app/blog/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blog/management/commands/__init__.py b/app/blog/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blog/management/commands/populate_blog.py b/app/blog/management/commands/populate_blog.py new file mode 100644 index 0000000..bfaf936 --- /dev/null +++ b/app/blog/management/commands/populate_blog.py @@ -0,0 +1,361 @@ +import random +from datetime import date, timedelta + +from django.core.management.base import BaseCommand +from wagtail.images.models import Image + +from app.blog.models import ( + Author, + BlogIndexPage, + BlogPage, + BlogPageGalleryImage, + BlogTagIndexPage, +) +from app.home.models import HomePage + + +class Command(BaseCommand): + help = ( + "Creates a complete blog structure with sample content including blog index pages, " + "blog posts with unique titles, authors, tags, and gallery images. Can be run " + "multiple times safely to add more content without conflicts." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--posts", + type=int, + default=10, + help=( + "Number of blog posts to create (default: 10). Each post will have " + "unique titles, slugs, content, authors, and tags." + ), + ) + parser.add_argument( + "--authors", + type=int, + default=3, + help=( + "Number of authors to create (default: 3). If authors already exist, " + "they will be reused instead of creating new ones." + ), + ) + parser.add_argument( + "--clear", + action="store_true", + help=( + "Clear existing blog content (posts and index pages) before creating " + "new content. Authors are preserved to maintain relationships." + ), + ) + + def handle(self, *args, **options): + try: + # Get the home page + home_page = HomePage.objects.first() + if not home_page: + self.stdout.write( + self.style.ERROR( + "No home page found. Please create a home page first." + ) + ) + return + + # Clear existing content if requested + if options["clear"]: + self.clear_blog_content() + self.clear_authors() + self.clear_tags() + # Refresh home page from database after clearing content + home_page.refresh_from_db() + + # Create or get blog index page + blog_index = self.create_blog_index(home_page) + + # Create blog tag index page + self.create_blog_tag_index(home_page) + + # Create sample authors + authors = self.create_authors(options["authors"]) + + # Create blog posts + self.create_blog_posts(blog_index, authors, options["posts"]) + + self.stdout.write( + self.style.SUCCESS( + f"Successfully created blog structure with {options['posts']} posts " + f"and {len(authors)} authors" + ) + ) + + except Exception as e: + self.stdout.write(self.style.ERROR(f"An error occurred: {str(e)}")) + + def clear_blog_content(self): + """Clear existing blog content""" + + # Then delete the index pages, children also deleted + blog_indices = BlogIndexPage.objects.all() + for index in blog_indices: + index.delete() + self.stdout.write("Cleared existing blog index pages") + + tag_indices = BlogTagIndexPage.objects.all() + for tag_index in tag_indices: + tag_index.delete() + self.stdout.write("Cleared existing blog tag pages") + + def clear_authors(self): + """Clear all authors""" + Author.objects.all().delete() + self.stdout.write("Cleared all authors") + + def clear_tags(self): + """Clear all blog tags""" + BlogPage.tags.through.objects.all().delete() + self.stdout.write("Cleared all blog tags") + + def create_blog_index(self, home_page): + """Create or get the blog index page""" + # Try to get existing blog index that's a child of home page + blog_index = home_page.get_children().type(BlogIndexPage).first() + + if not blog_index: + blog_index = BlogIndexPage( + title="Blog", + slug="blog", + intro=( + "

Welcome to our blog! Here you'll find the latest news, " + "insights, and stories from our team.

" + ), + ) + home_page.add_child(instance=blog_index) + blog_index.save_revision().publish() + self.stdout.write("Created blog index page") + else: + self.stdout.write("Blog index page already exists") + + return blog_index + + def create_blog_tag_index(self, home_page): + """Create or get the blog tag index page""" + # Try to get existing blog tag index that's a child of home page + tag_index = home_page.get_children().type(BlogTagIndexPage).first() + + if not tag_index: + tag_index = BlogTagIndexPage( + title="Blog Tags", + slug="blog-tags", + ) + home_page.add_child(instance=tag_index) + tag_index.save_revision().publish() + self.stdout.write("Created blog tag index page") + else: + self.stdout.write("Blog tag index page already exists") + + return tag_index + + def create_authors(self, num_authors): + """Create sample authors""" + existing_authors = Author.objects.all() + if existing_authors.exists(): + self.stdout.write(f"Using {existing_authors.count()} existing authors") + return list(existing_authors) + + author_names = [ + "Alice Johnson", + "Bob Smith", + "Carol Davis", + "David Wilson", + "Eva Brown", + "Frank Miller", + "Grace Lee", + "Henry Taylor", + "Ivy Chen", + "Jack Thompson", + ] + + authors = [] + available_images = list(Image.objects.all()) + + for i in range(min(num_authors, len(author_names))): + author = Author.objects.create( + name=author_names[i], + author_image=( + random.choice(available_images) if available_images else None + ), + ) + authors.append(author) + + self.stdout.write(f"Created {len(authors)} authors") + return authors + + def create_blog_posts(self, blog_index, authors, num_posts): + """Create sample blog posts""" + + # Sample blog post data + post_topics = [ + "Getting Started with Wagtail CMS", + "Building Modern Web Applications", + "The Future of Content Management", + "Django Development Best Practices", + "Responsive Web Design Tips", + "SEO Optimization Strategies", + "User Experience Design Principles", + "Digital Marketing Trends", + "Web Performance Optimization", + "Accessibility in Web Development", + "Progressive Web Applications", + "API Design and Development", + "Database Optimization Techniques", + "Security Best Practices", + "DevOps and Deployment Strategies", + ] + + blog_tags = [ + "wagtail", + "django", + "python", + "web-development", + "cms", + "frontend", + "backend", + "design", + "ux", + "seo", + "performance", + "security", + "api", + "database", + "devops", + ] + + available_images = list(Image.objects.all()) + + for i in range(num_posts): + # Generate unique blog post content + topic = post_topics[i % len(post_topics)] + post_date = date.today() - timedelta(days=random.randint(1, 365)) + + # Select random authors (1-3 authors per post) + post_authors = random.sample( + authors, k=random.randint(1, min(3, len(authors))) + ) + + # Select random tags (2-4 tags per post) + post_tags = random.sample(blog_tags, k=random.randint(2, 4)) + + # Generate unique title + unique_title = f"{topic} - {post_date.strftime('%B %Y')} Edition" + intro = self.generate_intro(topic) + body = self.generate_body(topic) + + blog_post = blog_index.add_child( + instance=BlogPage( + title=unique_title, + date=post_date, + intro=intro, + body=body, + ) + ) + + # Add authors + blog_post.authors.set(post_authors) + + # Add tags + for tag in post_tags: + blog_post.tags.add(tag) + + # Add gallery images (1-6 images per post) + if available_images: + num_images = random.randint(1, min(6, len(available_images))) + selected_images = random.sample(available_images, k=num_images) + + for idx, image in enumerate(selected_images): + BlogPageGalleryImage.objects.create( + page=blog_post, + image=image, + caption=f"Image {idx + 1} for {blog_post.title}", + ) + + revision = blog_post.save_revision() + revision.publish() + + self.stdout.write(f"Created {num_posts} blog posts") + + def generate_intro(self, topic): + """Generate sample introduction for blog post""" + intros = { + "Getting Started with Wagtail CMS": ( + "Learn the basics of Wagtail CMS and how to build your first " + "content-driven website with this powerful Django-based platform." + ), + "Building Modern Web Applications": ( + "Explore the latest techniques and tools for creating responsive, " + "scalable web applications that meet today's user expectations." + ), + "The Future of Content Management": ( + "Discover emerging trends in content management systems and how " + "they're shaping the digital landscape." + ), + "Django Development Best Practices": ( + "Master the art of Django development with proven patterns, " + "conventions, and techniques used by experienced developers." + ), + "Responsive Web Design Tips": ( + "Create websites that look great on any device with these essential " + "responsive design principles and practical implementation tips." + ), + } + return intros.get( + topic, + f"An in-depth look at {topic.lower()} and its practical applications " + f"in modern web development.", + ) + + def generate_body(self, topic): + """Generate sample body content for blog post""" + return f""" +

Introduction

+

In this comprehensive guide, we'll explore {topic.lower()} and its + importance in modern web development. Whether you're a beginner or an experienced developer, + this article will provide valuable insights and practical examples.

+ +

Key Concepts

+

Understanding the fundamental concepts is crucial for success. We'll cover the most + important aspects that every developer should know about this topic.

+ + + +

Implementation Guide

+

Let's dive into the practical implementation details. This section provides step-by-step + instructions and code examples to help you get started.

+ +

The implementation process involves several key steps:

+
    +
  1. Planning and Setup: Prepare your development environment and plan + your approach
  2. +
  3. Core Implementation: Build the main functionality following best + practices
  4. +
  5. Testing and Optimization: Ensure your implementation works correctly + and performs well
  6. +
  7. Deployment and Maintenance: Deploy your solution and keep it + updated
  8. +
+ +

Advanced Techniques

+

For those looking to take their skills to the next level, here are some advanced + techniques and considerations that can make a significant difference in your projects.

+ +

Conclusion

+

By following the guidelines and best practices outlined in this article, you'll be + well-equipped to tackle {topic.lower()} in your own projects. Remember to + keep learning and stay updated with the latest developments in the field.

+ +

Happy coding!

+ """.strip() diff --git a/app/blog/migrations/0001_initial.py b/app/blog/migrations/0001_initial.py new file mode 100644 index 0000000..8978d32 --- /dev/null +++ b/app/blog/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.1 on 2024-10-06 12:26 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.CreateModel( + name="BlogIndexPage", + 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",), + ), + ] diff --git a/app/blog/migrations/0002_blogpage.py b/app/blog/migrations/0002_blogpage.py new file mode 100644 index 0000000..816ec55 --- /dev/null +++ b/app/blog/migrations/0002_blogpage.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.1 on 2024-10-06 12:31 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0001_initial"), + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.CreateModel( + name="BlogPage", + 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="Post date")), + ("intro", models.CharField(max_length=250)), + ("body", wagtail.fields.RichTextField(blank=True)), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page",), + ), + ] diff --git a/app/blog/migrations/0003_blogpagegalleryimage.py b/app/blog/migrations/0003_blogpagegalleryimage.py new file mode 100644 index 0000000..7b75e12 --- /dev/null +++ b/app/blog/migrations/0003_blogpagegalleryimage.py @@ -0,0 +1,55 @@ +# Generated by Django 5.1.1 on 2024-10-06 12:46 + +import django.db.models.deletion +import modelcluster.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0002_blogpage"), + ("wagtailimages", "0026_delete_uploadedimage"), + ] + + operations = [ + migrations.CreateModel( + name="BlogPageGalleryImage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sort_order", + models.IntegerField(blank=True, editable=False, null=True), + ), + ("caption", models.CharField(blank=True, max_length=250)), + ( + "image", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="wagtailimages.image", + ), + ), + ( + "page", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="gallery_images", + to="blog.blogpage", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/app/blog/migrations/0004_author.py b/app/blog/migrations/0004_author.py new file mode 100644 index 0000000..6c63a3d --- /dev/null +++ b/app/blog/migrations/0004_author.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.1 on 2024-10-06 20:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0003_blogpagegalleryimage"), + ("wagtailimages", "0026_delete_uploadedimage"), + ] + + operations = [ + migrations.CreateModel( + name="Author", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "author_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + ), + ), + ], + options={ + "verbose_name_plural": "Authors", + }, + ), + ] diff --git a/app/blog/migrations/0005_blogpage_authors.py b/app/blog/migrations/0005_blogpage_authors.py new file mode 100644 index 0000000..e0e4965 --- /dev/null +++ b/app/blog/migrations/0005_blogpage_authors.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.1 on 2024-10-06 20:15 + +import modelcluster.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0004_author"), + ] + + operations = [ + migrations.AddField( + model_name="blogpage", + name="authors", + field=modelcluster.fields.ParentalManyToManyField( + blank=True, to="blog.author" + ), + ), + ] diff --git a/app/blog/migrations/0006_blogpagetag_blogpage_tags.py b/app/blog/migrations/0006_blogpagetag_blogpage_tags.py new file mode 100644 index 0000000..fc4b7b6 --- /dev/null +++ b/app/blog/migrations/0006_blogpagetag_blogpage_tags.py @@ -0,0 +1,64 @@ +# Generated by Django 5.1.1 on 2024-10-06 20:23 + +import django.db.models.deletion +import modelcluster.contrib.taggit +import modelcluster.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0005_blogpage_authors"), + ( + "taggit", + "0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx", + ), + ] + + operations = [ + migrations.CreateModel( + name="BlogPageTag", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "content_object", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tagged_items", + to="blog.blogpage", + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_items", + to="taggit.tag", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="blogpage", + name="tags", + field=modelcluster.contrib.taggit.ClusterTaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="blog.BlogPageTag", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ] diff --git a/app/blog/migrations/0007_blogtagindexpage.py b/app/blog/migrations/0007_blogtagindexpage.py new file mode 100644 index 0000000..0087249 --- /dev/null +++ b/app/blog/migrations/0007_blogtagindexpage.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.1 on 2024-10-06 20:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0006_blogpagetag_blogpage_tags"), + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.CreateModel( + name="BlogTagIndexPage", + 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", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page",), + ), + ] diff --git a/app/blog/migrations/__init__.py b/app/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blog/models.py b/app/blog/models.py new file mode 100644 index 0000000..76c8e66 --- /dev/null +++ b/app/blog/models.py @@ -0,0 +1,113 @@ +from django import forms +from django.db import models +from modelcluster.contrib.taggit import ClusterTaggableManager +from modelcluster.fields import ParentalKey, ParentalManyToManyField +from taggit.models import TaggedItemBase +from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel +from wagtail.fields import RichTextField +from wagtail.models import Orderable, Page +from wagtail.search import index +from wagtail.snippets.models import register_snippet + + +class BlogIndexPage(Page): + intro = RichTextField(blank=True) + + content_panels = Page.content_panels + [FieldPanel("intro")] + + def get_context(self, request): + # Update context to include only published posts, ordered by reverse-chron + context = super().get_context(request) + blogpages = self.get_children().live().order_by("-first_published_at") + context["blogpages"] = blogpages + return context + + +class BlogPageTag(TaggedItemBase): + content_object = ParentalKey( + "BlogPage", related_name="tagged_items", on_delete=models.CASCADE + ) + + +class BlogPage(Page): + date = models.DateField("Post date") + intro = models.CharField(max_length=250) + body = RichTextField(blank=True) + authors = ParentalManyToManyField("blog.Author", blank=True) + tags = ClusterTaggableManager(through=BlogPageTag, blank=True) + + search_fields = Page.search_fields + [ + index.SearchField("intro"), + index.SearchField("body"), + ] + + content_panels = Page.content_panels + [ + MultiFieldPanel( + [ + FieldPanel("date"), + FieldPanel("authors", widget=forms.CheckboxSelectMultiple), + FieldPanel("tags"), + ], + heading="Blog information", + ), + FieldPanel("intro"), + FieldPanel("body"), + InlinePanel("gallery_images", label="Gallery images"), + ] + + def main_image(self): + gallery_item = self.gallery_images.first() + if gallery_item: + return gallery_item.image + else: + return None + + +class BlogPageGalleryImage(Orderable): + page = ParentalKey( + BlogPage, on_delete=models.CASCADE, related_name="gallery_images" + ) + image = models.ForeignKey( + "wagtailimages.Image", on_delete=models.CASCADE, related_name="+" + ) + caption = models.CharField(blank=True, max_length=250) + + panels = [ + FieldPanel("image"), + FieldPanel("caption"), + ] + + +@register_snippet +class Author(models.Model): + name = models.CharField(max_length=255) + author_image = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + ) + + panels = [ + FieldPanel("name"), + FieldPanel("author_image"), + ] + + class Meta: + verbose_name_plural = "Authors" + + def __str__(self): + return self.name + + +class BlogTagIndexPage(Page): + def get_context(self, request): + # Filter by tag + tag = request.GET.get("tag") + blogpages = BlogPage.objects.filter(tags__name=tag) + + # Update template context + context = super().get_context(request) + context["blogpages"] = blogpages + return context diff --git a/app/blog/templates/blog/blog_index_page.html b/app/blog/templates/blog/blog_index_page.html new file mode 100644 index 0000000..f208bca --- /dev/null +++ b/app/blog/templates/blog/blog_index_page.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% load wagtailcore_tags wagtailimages_tags %} + +{% block body_class %}template-blogindexpage{% endblock %} + +{% block content %} +
+

{{ page.title }}

+
{{ page.intro|richtext }}
+
+ + {% for post in blogpages %} + + {% if forloop.first or forloop.counter0|divisibleby:2 %} + {% comment %} Start a new row {% endcomment %} +
+ {% endif %} + +
+ {% with post=post.specific %} +
+

{{ post.title }}

+
+
+ {% with post.main_image as main_image %} + {% if main_image %} +
+ {% image main_image fill-160x100 %} +
+ {% endif %} + {% endwith %} +
+

{{ post.intro }}

+ {{ post.body|richtext }} +
+
+ {% endwith %} +
+ + {% if forloop.last or forloop.counter|divisibleby:2 %} + {% comment %} End the row {% endcomment %} +
+ {% endif %} + + {% endfor %} + +{% endblock %} diff --git a/app/blog/templates/blog/blog_page.html b/app/blog/templates/blog/blog_page.html new file mode 100644 index 0000000..9313091 --- /dev/null +++ b/app/blog/templates/blog/blog_page.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% load static wagtailcore_tags wagtailimages_tags wagtailadmin_tags blog_tags %} + +{% block body_class %}template-blogpage{% endblock %} + +{% block content %} + +
+
+
+

{{ page.title }}

+

{{ page.date }}

+
+

Return to blog

+
+ +
+ {% with authors=page.authors.all %} + {% if authors %} +

Posted by:

+ {% for author in authors %} + + {% image author.author_image fill-40x40 as author_image %} + {% if author_image %} + {{ author.name }} + {% else %} + user + {% endif %} + {{ author.name }} + + {% endfor %} + {% endif %} + {% endwith %} +
+ {% blog_tags_page as tags_page %} + {% with tags=page.tags.all %} + {% if tags %} + Tags: + {% if tags_page %} + {% for tag in tags %} + {{ tag }} + {% endfor %} + {% else %} +

Create a blog tags page

+ {% endif %} + {% endif %} + {% endwith %} +
+ +
+ +
+
+
+

{{ page.intro }}

+ {{ page.body|richtext }} +
+ +
+ + {% for item in page.gallery_images.all %} + {% if forloop.first or forloop.counter0|divisibleby:3 %} + {% comment %} Start a new row {% endcomment %} +
+ {% endif %} +
+ {% image item.image fill-600x400 %} +
+ {{ item.caption }} +
+
+ {% if forloop.last or forloop.counter|divisibleby:3 %} +
+ {% endif %} + {% endfor %} + + +
+ + +{% endblock %} diff --git a/app/blog/templates/blog/blog_tag_index_page.html b/app/blog/templates/blog/blog_tag_index_page.html new file mode 100644 index 0000000..8b70a85 --- /dev/null +++ b/app/blog/templates/blog/blog_tag_index_page.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% load wagtailcore_tags %} + +{% block body_class %}template-blogtagindexpage{% endblock %} + +{% block content %} + +
+ {% if request.GET.tag %} +

Showing pages tagged "{{ request.GET.tag }}"

+ {% endif %} + + + +
+ +{% endblock %} diff --git a/app/blog/templatetags/__init__.py b/app/blog/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blog/templatetags/blog_tags.py b/app/blog/templatetags/blog_tags.py new file mode 100644 index 0000000..310d0ae --- /dev/null +++ b/app/blog/templatetags/blog_tags.py @@ -0,0 +1,23 @@ +from django import template + +from app.blog.models import BlogIndexPage, BlogTagIndexPage + +register = template.Library() + + +@register.simple_tag +def blog_index_page(): + """ + Get the blog index page if it exists. + """ + + return BlogIndexPage.objects.live().first() or None + + +@register.simple_tag +def blog_tags_page(): + """ + Get the blog tags page if it exists. + """ + + return BlogTagIndexPage.objects.live().first() or None diff --git a/app/blog/tests.py b/app/blog/tests.py new file mode 100644 index 0000000..fb5c465 --- /dev/null +++ b/app/blog/tests.py @@ -0,0 +1,122 @@ +from io import StringIO + +from django.core.management import call_command +from django.test import TestCase +from wagtail.models import Site + +from app.blog.models import Author, BlogIndexPage, BlogPage, BlogTagIndexPage +from app.home.models import HomePage + + +class PopulateBlogTestCase(TestCase): + def setUp(self): + # Get or create the root page and site + from wagtail.models import Page + + try: + root = Page.objects.get(slug="root") + except Page.DoesNotExist: + root = Page.add_root(title="Root", slug="root") + + # Create a home page for testing + self.home_page = HomePage( + title="Test Home", + slug="test-home", + ) + root.add_child(instance=self.home_page) + + # Create site if it doesn't exist + if not Site.objects.exists(): + Site.objects.create( + hostname="localhost", + port=8000, + root_page=self.home_page, + is_default_site=True, + ) + + def test_populate_blog_basic(self): + """Test basic blog population""" + out = StringIO() + + # Run the command + call_command("populate_blog", "--posts", "3", "--authors", "2", stdout=out) + + # Check that content was created + self.assertEqual(BlogIndexPage.objects.count(), 1) + self.assertEqual(BlogTagIndexPage.objects.count(), 1) + self.assertEqual(BlogPage.objects.count(), 3) + self.assertEqual(Author.objects.count(), 2) + + # Check success message + self.assertIn( + "Successfully created blog structure with 3 posts and 2 authors", + out.getvalue(), + ) + + def test_populate_blog_with_existing_authors(self): + """Test that existing authors are reused""" + # Create an existing author + Author.objects.create(name="Existing Author") + + out = StringIO() + + # Run the command + call_command("populate_blog", "--posts", "2", "--authors", "3", stdout=out) + + # Should still have only 1 author (the existing one) + self.assertEqual(Author.objects.count(), 1) + self.assertIn("Using 1 existing authors", out.getvalue()) + + def test_populate_blog_clear_option(self): + """Test the clear option functionality""" + # Create some initial content + call_command("populate_blog", "--posts", "2", "--authors", "1") + + # Verify initial content exists + self.assertEqual(BlogPage.objects.count(), 2) + + out = StringIO() + + # Run with clear option - focus on testing the clear messages + call_command( + "populate_blog", "--clear", "--posts", "1", "--authors", "1", stdout=out + ) + + # Check that clear messages appeared + output = out.getvalue() + self.assertIn("Cleared existing blog index pages", output) + self.assertIn("Cleared existing blog tag pages", output) + self.assertIn("Cleared all authors", output) + self.assertIn("Cleared all blog tags", output) + + def test_blog_post_structure(self): + """Test that blog posts have proper structure and relationships""" + call_command("populate_blog", "--posts", "2", "--authors", "2") + + blog_post = BlogPage.objects.first() + + # Check that the blog post has required fields + self.assertTrue(blog_post.title) + self.assertTrue(blog_post.intro) + self.assertTrue(blog_post.body) + self.assertTrue(blog_post.date) + + # Check that it has authors and tags + self.assertTrue(blog_post.authors.exists()) + self.assertTrue(blog_post.tags.exists()) + + # Check that it's a child of blog index + blog_index = BlogIndexPage.objects.first() + self.assertEqual(blog_post.get_parent().specific, blog_index) + + def test_blog_index_structure(self): + """Test that blog index page is properly structured""" + call_command("populate_blog", "--posts", "1", "--authors", "1") + + blog_index = BlogIndexPage.objects.first() + home_page = HomePage.objects.first() + + # Check that blog index is a child of home page + self.assertEqual(blog_index.get_parent().specific, home_page) + self.assertEqual(blog_index.slug, "blog") + self.assertTrue(blog_index.intro) diff --git a/app/home/management/commands/populate_homepage.py b/app/home/management/commands/populate_homepage.py new file mode 100644 index 0000000..29f39c7 --- /dev/null +++ b/app/home/management/commands/populate_homepage.py @@ -0,0 +1,121 @@ +import random + +from django.core.management.base import BaseCommand +from wagtail.images.models import Image + +from app.home.models import HomePage + + +class Command(BaseCommand): + help = "Populates the existing home page with sample body content" + + def add_arguments(self, parser): + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing body content if it exists", + ) + + def handle(self, *args, **options): + try: + # Get the existing home page + home_page = HomePage.objects.get() + + # Check if body content already exists + if home_page.body and not options["overwrite"]: + self.stdout.write( + self.style.WARNING( + "Home page already has body content. Use --overwrite to replace it." + ) + ) + self.stdout.write( + f"Current content length: {len(home_page.body)} characters" + ) + return + + # Check for existing images + available_images = Image.objects.all() + selected_image = None + image_embed = "" + + if available_images.exists(): + # Select a random image + selected_image = random.choice(available_images) + # Create embed code for the image + image_embed = ( + f'' + ) + self.stdout.write(f"Selected image: {selected_image.title}") + else: + self.stdout.write( + "No images found - content will be created without images" + ) + + # Sample body content with optional image + sample_content = f""" +

Welcome to Your Wagtail Site

+ +

This is your homepage, built with Wagtail CMS. Wagtail is a powerful, +developer-friendly content management system built on Django that makes it easy to create +beautiful, engaging websites.

+ +{image_embed} + +

Getting Started

+ +

You can edit this content by going to the Wagtail admin and +selecting your home page. From there, you can:

+ + + +

Features

+ +

This starter kit includes:

+ + + +

Next Steps

+ +

Now that you have your site running, consider:

+ +
    +
  1. Exploring the Style Guide to see available components
  2. +
  3. Reading the Wagtail documentation to learn more
  4. +
  5. Creating custom page types for your specific needs
  6. +
  7. Setting up your production deployment
  8. +
+ +

Happy building with Wagtail!

+ """.strip() + + # Update the home page body content + home_page.body = sample_content + home_page.save() + + # Create a new revision to track the change + home_page.save_revision().publish() + + success_message = f"Successfully populated home page body content ({len(sample_content)} characters)" + if selected_image: + success_message += f" with image: {selected_image.title}" + + self.stdout.write(self.style.SUCCESS(success_message)) + + except HomePage.DoesNotExist: + self.stdout.write( + self.style.ERROR("No home page found. Please create a home page first.") + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f"An error occurred: {str(e)}")) diff --git a/app/home/migrations/0003_homepage_body.py b/app/home/migrations/0003_homepage_body.py new file mode 100644 index 0000000..7a81014 --- /dev/null +++ b/app/home/migrations/0003_homepage_body.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.1 on 2024-10-06 12:19 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0002_create_homepage"), + ] + + operations = [ + migrations.AddField( + model_name="homepage", + name="body", + field=wagtail.fields.RichTextField(blank=True), + ), + ] diff --git a/app/home/models.py b/app/home/models.py index a8a0cc2..fe0adbe 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -1,9 +1,11 @@ -from wagtail import __version__ as WAGTAIL_VERSION +from wagtail.admin.panels import FieldPanel +from wagtail.fields import RichTextField from wagtail.models import Page class HomePage(Page): - def get_context(self, request): - context = super().get_context(request) - context["wagtail_version"] = WAGTAIL_VERSION - return context + body = RichTextField(blank=True) + + content_panels = Page.content_panels + [ + FieldPanel("body"), + ] diff --git a/app/home/templates/home/home_page.html b/app/home/templates/home/home_page.html index 0b1dc65..86b072e 100644 --- a/app/home/templates/home/home_page.html +++ b/app/home/templates/home/home_page.html @@ -1,143 +1,15 @@ {% extends "base.html" %} -{% load static %} - -{% block body_class %}template-homepage{% endblock %} - -{% block extra_css %} - -{% comment %} -Delete the line below if you're just getting started and want to remove the welcome screen! -{% endcomment %} - -{% endblock extra_css %} +{% block body_class %}template-homepage{% endblock %} {% block content %} - -{% comment %} -Delete the line below if you're just getting started and want to remove the welcome screen! -{% endcomment %} -{% include 'home/welcome_page.html' %} - -{% endblock content %} +
+ {% if page.body %} + {{ page.body|richtext }} + {% else %} +

No content has been added to this page body yet.

+ {% endif %} +
+{% endblock %} diff --git a/app/home/templates/home/welcome_page.html b/app/home/templates/home/welcome_page.html deleted file mode 100644 index 23e51fd..0000000 --- a/app/home/templates/home/welcome_page.html +++ /dev/null @@ -1,175 +0,0 @@ -{% load i18n wagtailcore_tags %} - -
-
- - - - - {% trans "Wagtail core release notes" %} - - - {% trans "Starter Kit Style Guide" %} - -
-
- -
-
-
- -
-
-
-

{% trans "Welcome to your new Wagtail site!" %}

-

{% trans " by Wagtail Starter Kit" %}

-
-
    -
  • - - archive-content - - - - - - - - Docker Development Environment -
  • -
  • - - database - - - - - - Postgresql, Mysql or Sqlite3 Database -
  • -
  • - - cycle - - - - - - Frontend Node SASS and Javascript compilation -
  • -
  • - - style - - - - - - - - - Pico CSS for almost classless styling -
  • -
  • - - questionnaire - - - - - - - - esbuild javascript bundler -
  • -
  • - - patch-19 - - - - - - - - - - - - - - - - - Wagtail CMS v{{ wagtail_version }} -
  • -
-
-
-
-
{% trans 'Join the Wagtail community on Slack, or get started with one of the links below.' %}
-
-
- - diff --git a/app/home/tests.py b/app/home/tests.py index 5b9f502..c681f59 100644 --- a/app/home/tests.py +++ b/app/home/tests.py @@ -1,5 +1,10 @@ +from io import StringIO + from django.contrib.auth.models import User +from django.core.management import call_command from django.test import TestCase +from wagtail.images.models import Image +from wagtail.images.tests.utils import get_test_image_file from app.home.models import HomePage @@ -30,7 +35,7 @@ def test_home_frontend_returns_200(self): """Test that the home page frontend returns 200 OK.""" response = self.client.get("/") self.assertEqual(response.status_code, 200) - self.assertContains(response, "Welcome to your new Wagtail site!") + self.assertContains(response, "Wagtail Blog Tutorial") self.assertTemplateUsed(response, "home/home_page.html") def test_home_admin_edit_returns_200(self): @@ -64,3 +69,124 @@ def test_home_admin_history_returns_200(self): self._login_as_admin() response = self.client.get(f"/admin/pages/{self.home_page.pk}/history/") self.assertEqual(response.status_code, 200) + + def test_populate_homepage_command(self): + """Test the populate_homepage management command""" + home_page = HomePage.objects.first() + + # Ensure home page starts with empty body + home_page.body = "" + home_page.save() + + # Create output capture + out = StringIO() + + # Run the command + call_command("populate_homepage", stdout=out) + + # Refresh from database + home_page.refresh_from_db() + + # Check that body content was added + self.assertNotEqual(home_page.body, "") + self.assertIn("Welcome to Your Wagtail Site", home_page.body) + self.assertIn("Successfully populated home page body content", out.getvalue()) + + def test_populate_homepage_command_with_existing_content(self): + """Test the populate_homepage management command when content already exists""" + home_page = HomePage.objects.first() + + # Set some existing content + home_page.body = "Existing content" + home_page.save() + + # Create output capture + out = StringIO() + + # Run the command without overwrite + call_command("populate_homepage", stdout=out) + + # Refresh from database + home_page.refresh_from_db() + + # Check that content wasn't changed + self.assertEqual(home_page.body, "Existing content") + self.assertIn("already has body content", out.getvalue()) + + def test_populate_homepage_command_with_overwrite(self): + """Test the populate_homepage management command with overwrite option""" + home_page = HomePage.objects.first() + + # Set some existing content + home_page.body = "Existing content" + home_page.save() + + # Create output capture + out = StringIO() + + # Run the command with overwrite + call_command("populate_homepage", "--overwrite", stdout=out) + + # Refresh from database + home_page.refresh_from_db() + + # Check that content was changed + self.assertNotEqual(home_page.body, "Existing content") + self.assertIn("Welcome to Your Wagtail Site", home_page.body) + self.assertIn("Successfully populated home page body content", out.getvalue()) + + def test_populate_homepage_command_with_image(self): + """Test the populate_homepage command when images are available""" + home_page = HomePage.objects.first() + + # Create a test image + Image.objects.create( + title="Test Image", + file=get_test_image_file(), + ) + + # Ensure home page starts with empty body + home_page.body = "" + home_page.save() + + # Create output capture + out = StringIO() + + # Run the command + call_command("populate_homepage", stdout=out) + + # Refresh from database + home_page.refresh_from_db() + + # Check that body content was added with image + self.assertNotEqual(home_page.body, "") + self.assertIn("Welcome to Your Wagtail Site", home_page.body) + self.assertIn('embedtype="image"', home_page.body) + self.assertIn("Selected image:", out.getvalue()) + self.assertIn("with image:", out.getvalue()) + + def test_populate_homepage_command_without_images(self): + """Test the populate_homepage command when no images are available""" + home_page = HomePage.objects.first() + + # Remove all images + Image.objects.all().delete() + + # Ensure home page starts with empty body + home_page.body = "" + home_page.save() + + # Create output capture + out = StringIO() + + # Run the command + call_command("populate_homepage", stdout=out) + + # Refresh from database + home_page.refresh_from_db() + + # Check that body content was added without image + self.assertNotEqual(home_page.body, "") + self.assertIn("Welcome to Your Wagtail Site", home_page.body) + self.assertNotIn('embedtype="image"', home_page.body) + self.assertIn("No images found", out.getvalue()) diff --git a/app/settings/base.py b/app/settings/base.py index d0cf7b9..c85d76a 100644 --- a/app/settings/base.py +++ b/app/settings/base.py @@ -27,6 +27,7 @@ INSTALLED_APPS = [ "app.home", "app.search", + "app.blog", "wagtail.contrib.forms", "wagtail.contrib.redirects", "wagtail.contrib.table_block", diff --git a/app/templates/base.html b/app/templates/base.html index 92e1099..0d550de 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,4 +1,4 @@ -{% load static wagtailcore_tags wagtailuserbar %} +{% load static wagtailcore_tags wagtailuserbar blog_tags %} @@ -34,7 +34,29 @@ {% wagtailuserbar %} - {% block content %}{% endblock %} + {% blog_index_page as blog %} +
+ +
+ +
+ {% block content %}{% endblock %} +
+ + {# Global javascript #} diff --git a/docs/management-commands.md b/docs/management-commands.md index 669be3e..38bf4f2 100644 --- a/docs/management-commands.md +++ b/docs/management-commands.md @@ -5,6 +5,8 @@ This document provides detailed information about the custom Django management c ## Table of Contents - [create_sample_media](#create_sample_media) +- [populate_homepage](#populate_homepage) +- [populate_blog](#populate_blog) - [Future Commands](#future-commands) --- @@ -15,7 +17,7 @@ This document provides detailed information about the custom Django management c **Purpose**: Creates sample images and documents (media files) for testing and demonstration purposes in your Wagtail CMS. -### Description +### Overview The `create_sample_media` command generates realistic sample content including: @@ -29,7 +31,7 @@ This command is perfect for: - Testing search functionality with varied content - Creating realistic data for development and staging environments -### Usage +### Command Usage ```bash # Basic usage (creates 75 images and 50 documents by default) @@ -48,7 +50,7 @@ python manage.py create_sample_media --no-zip python manage.py create_sample_media --reset ``` -### Options +### Command Options | Option | Type | Default | Description | |--------|------|---------|-------------| @@ -58,7 +60,7 @@ python manage.py create_sample_media --reset | `--no-zip` | Flag | False | Skip creating ZIP archives of the documents | | `--reset` | Flag | False | Delete all existing images and documents without creating new ones | -### Generated Content Details +### Content Details #### Images - **Formats**: JPEG with 85% quality @@ -101,7 +103,7 @@ Each ZIP file includes: - A README.txt file explaining the archive contents - Proper file organization -### Examples +### Command Examples #### Creating a small test set ```bash @@ -118,12 +120,149 @@ docker exec -it wagtail-starter-kit-app-1 python manage.py create_sample_media - docker exec -it wagtail-starter-kit-app-1 python manage.py create_sample_media --reset ``` -### Technical Implementation +--- + +## populate_homepage + +**Location**: `app/home/management/commands/populate_homepage.py` + +**Purpose**: Populates the existing home page with sample body content for testing and demonstration purposes. If images exist in the media library, one will be randomly selected and included in the content. + +### Overview + +The `populate_homepage` command adds rich HTML content to the body field of your site's home page. This is useful for: + +- Setting up a new Wagtail site with meaningful placeholder content +- Demonstrating Wagtail's rich text editing capabilities including image embeds +- Providing a starting point for content editors +- Testing page layouts and styling with realistic content and media + +The generated content includes: + +- **Welcome message** with Wagtail introduction +- **Random image embed** (if images are available in the media library) +- **Getting started guide** with admin links +- **Feature overview** of the starter kit +- **Next steps** for development + +### Command Usage + +```bash +# Basic usage (populates empty home page) +python manage.py populate_homepage + +# Overwrite existing content +python manage.py populate_homepage --overwrite +``` + +### Command Options + +| Option | Description | +|--------|-------------| +| `--overwrite` | Overwrite existing body content if it already exists | + +### Command Examples + +#### Populate empty home page +```bash +docker exec -it wagtail-starter-kit-app-1 python manage.py populate_homepage +``` + +#### Replace existing content +```bash +docker exec -it wagtail-starter-kit-app-1 python manage.py populate_homepage --overwrite +``` + +--- + +## populate_blog + +**Location**: `app/blog/management/commands/populate_blog.py` + +**Purpose**: Creates a complete blog structure with sample blog posts, authors, and index pages for testing and demonstration purposes in your Wagtail CMS. + +### Overview -#### Dependencies -- **PIL (Pillow)**: For dynamic image generation -- **zipfile**: For creating compressed archives -- **Wagtail**: Uses `wagtail.images.models.Image` and `wagtail.documents.models.Document` +The `populate_blog` command generates a fully functional blog section including: + +- **Blog Index Page**: Main landing page for the blog section +- **Blog Tag Index Page**: Page for organizing and filtering blog posts by tags +- **Sample Authors**: Realistic author profiles with optional images +- **Blog Posts**: Rich content blog posts with unique titles, content, tags, and gallery images +- **Relationships**: Proper parent-child page relationships and many-to-many author associations + +### Command Usage + +```bash +# Basic usage (creates 10 posts and 3 authors by default) +python manage.py populate_blog + +# Custom amounts +python manage.py populate_blog --posts 5 --authors 2 + +# Clear existing blog content and create new +python manage.py populate_blog --clear --posts 15 --authors 4 + +# Add more posts to existing blog (runs multiple times safely) +python manage.py populate_blog --posts 3 +python manage.py populate_blog --posts 5 +``` + +### Command Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `--posts` | Integer | 10 | Number of blog posts to create | +| `--authors` | Integer | 3 | Number of authors to create (reuses existing authors if available) | +| `--clear` | Flag | False | Clear existing blog content before creating new content | + +### Content Structure + +#### Blog Posts +- **Topics**: 15 predefined topics covering web development, CMS, Django, design, SEO, security, and more +- **Unique Titles**: Format: `"{Topic} - {Month Year} Edition"` (e.g., "Building Modern Web Applications - March 2025 Edition") +- **Smart Slugs**: Format: `{topic-slug}-{YYYYMMDD}-{random-3-digits}` with collision detection +- **Rich Content**: Structured HTML with headings, paragraphs, lists, and emphasized text +- **Random Dates**: Posts dated within the last year with random distribution +- **Tags**: 2-4 relevant tags per post from: wagtail, django, python, web-development, cms, frontend, backend, design, ux, seo, performance, security, api, database, devops + +#### Authors +- **Predefined Names**: 10 realistic author names (Alice Johnson, Bob Smith, etc.) +- **Image Assignment**: Random selection from available images in media library +- **Reuse Logic**: Existing authors are reused when command runs multiple times +- **Preserved on Clear**: Authors are preserved when using `--clear` flag (by design) + +#### Page Structure +``` +Home Page +├── Blog (BlogIndexPage) +│ ├── Blog Post 1 (BlogPage) +│ ├── Blog Post 2 (BlogPage) +│ └── ... +└── Blog Tags (BlogTagIndexPage) +``` + +### Command Examples + +#### Set up initial blog +```bash +docker exec -it wagtail-starter-kit-app-1 python manage.py populate_blog +``` + +#### Create a large blog for demo +```bash +docker exec -it wagtail-starter-kit-app-1 python manage.py populate_blog --clear --posts 25 --authors 5 +``` + +#### Add more content to existing blog +```bash +docker exec -it wagtail-starter-kit-app-1 python manage.py populate_blog --posts 8 +``` + +#### Reset and start fresh +```bash +docker exec -it wagtail-starter-kit-app-1 python manage.py populate_blog --clear --posts 12 --authors 3 +``` ---