Skip to content

Commit 56ae258

Browse files
Phinart98lwasser
authored andcommitted
Implement Wagtail blog migration system
- Add Wagtail blog models (Author, BlogPage, EventPage) matching Jekyll site structure - Create responsive blog templates reflecting PyOpenSci colour scheme - Configure hybrid URL routing: Django for homepage, Wagtail for blog - Set up proper Wagtail admin and page hierarchy
1 parent 8e9d31d commit 56ae258

File tree

8 files changed

+622
-13
lines changed

8 files changed

+622
-13
lines changed

.gitignore

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,11 @@ cython_debug/
183183
.abstra/
184184

185185
# Visual Studio Code
186-
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
187187
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
188-
# and can be added to the global gitignore or merged into this file. However, if you prefer,
188+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
189189
# you could uncomment the following to ignore the entire vscode folder
190-
# .vscode/
190+
.vscode/
191191

192192
# Ruff stuff:
193193
.ruff_cache/
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Generated by Django 5.2.4 on 2025-09-14 00:49
2+
3+
import django.db.models.deletion
4+
import modelcluster.contrib.taggit
5+
import modelcluster.fields
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('blog', '0002_delete_homepage'),
13+
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='Author',
19+
fields=[
20+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21+
('name', models.CharField(max_length=255)),
22+
('slug', models.SlugField(max_length=100, unique=True)),
23+
('bio', models.TextField(blank=True, help_text='Short bio of the author')),
24+
('avatar', models.ImageField(blank=True, null=True, upload_to='authors/')),
25+
('email', models.EmailField(blank=True, max_length=254)),
26+
('website', models.URLField(blank=True)),
27+
('github', models.CharField(blank=True, max_length=100)),
28+
('linkedin', models.URLField(blank=True)),
29+
('mastodon', models.CharField(blank=True, max_length=100)),
30+
('discord', models.CharField(blank=True, max_length=100)),
31+
],
32+
options={
33+
'verbose_name': 'Author',
34+
'verbose_name_plural': 'Authors',
35+
},
36+
),
37+
migrations.CreateModel(
38+
name='EventPage',
39+
fields=[
40+
('blogpage_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='blog.blogpage')),
41+
('start_date', models.DateField(help_text='Date when the event starts', verbose_name='Event start date')),
42+
('end_date', models.DateField(blank=True, help_text='Date when the event ends (optional)', null=True, verbose_name='Event end date')),
43+
('location', models.CharField(blank=True, help_text='Where the event takes place', max_length=255)),
44+
('event_type', models.CharField(choices=[('workshop', 'Workshop'), ('webinar', 'Webinar'), ('conference', 'Conference'), ('meetup', 'Meetup'), ('other', 'Other')], default='other', help_text='Type of event', max_length=20)),
45+
],
46+
options={
47+
'verbose_name': 'Event Page',
48+
},
49+
bases=('blog.blogpage',),
50+
),
51+
migrations.RemoveField(
52+
model_name='blogpage',
53+
name='intro',
54+
),
55+
migrations.AddField(
56+
model_name='blogpage',
57+
name='enable_comments',
58+
field=models.BooleanField(default=False, help_text='Show comments section on this post'),
59+
),
60+
migrations.AddField(
61+
model_name='blogpage',
62+
name='excerpt',
63+
field=models.TextField(blank=True, help_text='Brief description of the post', max_length=500),
64+
),
65+
migrations.AddField(
66+
model_name='blogpage',
67+
name='header_image',
68+
field=models.ImageField(blank=True, help_text='Featured image for the post', null=True, upload_to='blog/headers/'),
69+
),
70+
migrations.AddField(
71+
model_name='blogpage',
72+
name='header_image_alt',
73+
field=models.CharField(blank=True, help_text='Alt text for the header image', max_length=255),
74+
),
75+
migrations.AddField(
76+
model_name='blogpage',
77+
name='last_modified',
78+
field=models.DateTimeField(auto_now=True),
79+
),
80+
migrations.AlterField(
81+
model_name='blogpage',
82+
name='date',
83+
field=models.DateField(help_text='Date when the post was published', verbose_name='Post date'),
84+
),
85+
migrations.AddField(
86+
model_name='blogpage',
87+
name='author',
88+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blog_posts', to='blog.author'),
89+
),
90+
migrations.CreateModel(
91+
name='BlogPageTag',
92+
fields=[
93+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
94+
('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='blog.blogpage')),
95+
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')),
96+
],
97+
options={
98+
'abstract': False,
99+
},
100+
),
101+
migrations.AddField(
102+
model_name='blogpage',
103+
name='tags',
104+
field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='blog.BlogPageTag', to='taggit.Tag', verbose_name='Tags'),
105+
),
106+
]

blog/models.py

Lines changed: 232 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,264 @@
11
from django.db import models
22
from wagtail.models import Page
33
from wagtail.fields import RichTextField
4-
from wagtail.admin.panels import FieldPanel
4+
from wagtail.admin.panels import FieldPanel, InlinePanel
5+
from wagtail.snippets.models import register_snippet
6+
from wagtail import hooks
7+
from modelcluster.fields import ParentalKey
8+
from modelcluster.contrib.taggit import ClusterTaggableManager
9+
from taggit.models import TaggedItemBase
10+
11+
12+
@register_snippet
13+
class Author(models.Model):
14+
"""
15+
Author snippet for blog posts.
16+
17+
Reusable snippet containing author information that can be referenced
18+
by blog posts. Based on Jekyll's _data/authors.yml structure.
19+
20+
Attributes
21+
----------
22+
name : CharField
23+
The author's full name as it appears in bylines
24+
slug : SlugField
25+
URL-safe version of the author's name, unique identifier
26+
bio : TextField
27+
Short biographical description of the author
28+
avatar : ImageField
29+
Profile photo of the author
30+
email : EmailField
31+
Author's contact email address
32+
website : URLField
33+
Personal or professional website URL
34+
github : CharField
35+
GitHub username (without @ symbol)
36+
linkedin : URLField
37+
LinkedIn profile URL
38+
mastodon : CharField
39+
Mastodon handle (without @ symbol)
40+
discord : CharField
41+
Discord username
42+
"""
43+
name = models.CharField(max_length=255)
44+
slug = models.SlugField(max_length=100, unique=True)
45+
bio = models.TextField(blank=True, help_text="Short bio of the author")
46+
avatar = models.ImageField(upload_to='authors/', blank=True, null=True)
47+
email = models.EmailField(blank=True)
48+
website = models.URLField(blank=True)
49+
github = models.CharField(max_length=100, blank=True)
50+
linkedin = models.URLField(blank=True)
51+
mastodon = models.CharField(max_length=100, blank=True)
52+
discord = models.CharField(max_length=100, blank=True)
53+
54+
panels = [
55+
FieldPanel('name'),
56+
FieldPanel('slug'),
57+
FieldPanel('bio'),
58+
FieldPanel('avatar'),
59+
FieldPanel('email'),
60+
FieldPanel('website'),
61+
FieldPanel('github'),
62+
FieldPanel('linkedin'),
63+
FieldPanel('mastodon'),
64+
FieldPanel('discord'),
65+
]
66+
67+
def __str__(self):
68+
return self.name
69+
70+
class Meta:
71+
verbose_name = "Author"
72+
verbose_name_plural = "Authors"
73+
74+
75+
class BlogPageTag(TaggedItemBase):
76+
"""
77+
Tag model for blog posts.
78+
79+
Enables tagging functionality for blog posts using django-taggit.
80+
"""
81+
content_object = ParentalKey(
82+
'BlogPage',
83+
related_name='tagged_items',
84+
on_delete=models.CASCADE
85+
)
586

687

788
class BlogIndexPage(Page):
889
"""
990
Index page for blog posts.
10-
11-
Wagtail page model for the main blog listing page.
91+
92+
Landing page that displays a listing of all blog posts and events.
93+
Provides context for rendering the blog index template.
94+
95+
Attributes
96+
----------
97+
intro : RichTextField
98+
Introduction text displayed at the top of the blog index
1299
"""
13100
intro = RichTextField(blank=True)
14101

15102
content_panels = Page.content_panels + [
16103
FieldPanel('intro'),
17104
]
18105

106+
def get_context(self, request):
107+
"""
108+
Add blog posts to template context.
109+
110+
Returns
111+
-------
112+
dict
113+
Template context including blog_posts queryset
114+
"""
115+
context = super().get_context(request)
116+
blog_posts = self.get_children().live().order_by('-first_published_at')
117+
context['blog_posts'] = blog_posts
118+
return context
119+
19120
class Meta:
20121
verbose_name = "Blog Index Page"
21122

22123

23124
class BlogPage(Page):
24125
"""
25126
Individual blog post page.
26-
27-
Wagtail page model for individual blog posts with date, intro, and content.
127+
128+
Page model for individual blog posts with full Jekyll frontmatter compatibility.
129+
Supports authors, categories/tags, header images, and rich content.
130+
131+
Attributes
132+
----------
133+
date : DateField
134+
Publication date of the blog post
135+
last_modified : DateTimeField
136+
Last modification timestamp
137+
author : ForeignKey
138+
Reference to Author snippet
139+
excerpt : TextField
140+
Short description/summary of the post
141+
header_image : ImageField
142+
Featured image for the post
143+
header_image_alt : CharField
144+
Alt text for the header image
145+
body : RichTextField
146+
Main content of the blog post
147+
enable_comments : BooleanField
148+
Whether to show comments section
149+
tags : ClusterTaggableManager
150+
Categories/tags for the post
28151
"""
29-
date = models.DateField("Post date")
30-
intro = models.CharField(max_length=250)
152+
date = models.DateField("Post date", help_text="Date when the post was published")
153+
last_modified = models.DateTimeField(auto_now=True)
154+
author = models.ForeignKey(
155+
'Author',
156+
null=True,
157+
blank=True,
158+
on_delete=models.SET_NULL,
159+
related_name='blog_posts'
160+
)
161+
excerpt = models.TextField(
162+
blank=True,
163+
max_length=500,
164+
help_text="Brief description of the post"
165+
)
166+
header_image = models.ImageField(
167+
upload_to='blog/headers/',
168+
blank=True,
169+
null=True,
170+
help_text="Featured image for the post"
171+
)
172+
header_image_alt = models.CharField(
173+
max_length=255,
174+
blank=True,
175+
help_text="Alt text for the header image"
176+
)
31177
body = RichTextField(blank=True)
178+
enable_comments = models.BooleanField(
179+
default=False,
180+
help_text="Show comments section on this post"
181+
)
182+
tags = ClusterTaggableManager(through=BlogPageTag, blank=True)
32183

33184
content_panels = Page.content_panels + [
34185
FieldPanel('date'),
35-
FieldPanel('intro'),
186+
FieldPanel('author'),
187+
FieldPanel('excerpt'),
188+
FieldPanel('header_image'),
189+
FieldPanel('header_image_alt'),
36190
FieldPanel('body'),
191+
FieldPanel('enable_comments'),
192+
FieldPanel('tags'),
37193
]
38194

39195
class Meta:
40196
verbose_name = "Blog Page"
197+
198+
199+
class EventPage(BlogPage):
200+
"""
201+
Event page extending blog post functionality.
202+
203+
Specialized blog page for events with additional event-specific fields.
204+
Inherits all blog post functionality while adding event metadata.
205+
206+
Attributes
207+
----------
208+
start_date : DateField
209+
Date when the event starts/started
210+
end_date : DateField
211+
Date when the event ends/ended (optional)
212+
location : CharField
213+
Where the event takes place
214+
event_type : CharField
215+
Type of event (workshop, webinar, conference, etc.)
216+
"""
217+
start_date = models.DateField(
218+
"Event start date",
219+
help_text="Date when the event starts"
220+
)
221+
end_date = models.DateField(
222+
"Event end date",
223+
blank=True,
224+
null=True,
225+
help_text="Date when the event ends (optional)"
226+
)
227+
location = models.CharField(
228+
max_length=255,
229+
blank=True,
230+
help_text="Where the event takes place"
231+
)
232+
233+
EVENT_TYPES = [
234+
('workshop', 'Workshop'),
235+
('webinar', 'Webinar'),
236+
('conference', 'Conference'),
237+
('meetup', 'Meetup'),
238+
('other', 'Other'),
239+
]
240+
241+
event_type = models.CharField(
242+
max_length=20,
243+
choices=EVENT_TYPES,
244+
default='other',
245+
help_text="Type of event"
246+
)
247+
248+
content_panels = Page.content_panels + [
249+
FieldPanel('date'),
250+
FieldPanel('start_date'),
251+
FieldPanel('end_date'),
252+
FieldPanel('location'),
253+
FieldPanel('event_type'),
254+
FieldPanel('author'),
255+
FieldPanel('excerpt'),
256+
FieldPanel('header_image'),
257+
FieldPanel('header_image_alt'),
258+
FieldPanel('body'),
259+
FieldPanel('enable_comments'),
260+
FieldPanel('tags'),
261+
]
262+
263+
class Meta:
264+
verbose_name = "Event Page"

0 commit comments

Comments
 (0)