Self-hostable blog for developers built on Wagtail and Django, with webpack, Bootstrap, and CodeMirror.
blogbootstrapdjangodockerhandcodedpythonself-hostedwagtailwebpack
1import hashlib
2
3from django.conf import settings
4from django.core.files.storage import default_storage
5from django.db import models
6from django.db.models import Q
7from django.http import HttpResponse, JsonResponse
8from django.shortcuts import redirect
9from modelcluster.contrib.taggit import ClusterTaggableManager
10from modelcluster.fields import ParentalKey
11from taggit.models import Tag, TaggedItemBase
12from wagtail.admin.panels import FieldPanel
13from wagtail.contrib.routable_page.models import RoutablePageMixin, route
14from wagtail.blocks import ChoiceBlock, RichTextBlock, StructBlock, TextBlock
15from wagtail.fields import StreamField
16from wagtail.models import Page
17from wagtail.embeds.blocks import EmbedBlock
18from wagtail.images.blocks import ImageChooserBlock
19from wagtail.search import index
20
21from blog.chromium import generate_pdf_from_html
22from mail.forms import SubscriberForm
23
24from .utils import og_image
25
26
27class StreamPageAbstract(Page):
28 body = StreamField(
29 [
30 ('rich_text', RichTextBlock()),
31 ('image', ImageChooserBlock()),
32 ('code', StructBlock([
33 ('language', ChoiceBlock(choices=[
34 ('python', 'Python'),
35 ('javascript', 'Javascript'),
36 ('htmlmixed', 'HTML'),
37 ('css', 'CSS'),
38 ('shell', 'Shell'),
39 ])),
40 ('text', TextBlock()),
41 ])),
42 ('embed', EmbedBlock()),
43 ],
44 use_json_field=True,
45 blank=True,
46 )
47 cover_image = models.ForeignKey(
48 'wagtailimages.Image',
49 null=True,
50 blank=True,
51 on_delete=models.SET_NULL,
52 related_name='+',
53 help_text='Cover image for this page, used in listings and at the top of the page.'
54 )
55
56 content_panels = Page.content_panels + [
57 FieldPanel('body'),
58 ]
59
60 promote_panels = Page.promote_panels + [
61 FieldPanel('cover_image'),
62 ]
63 promote_panels.pop(1) # Remove the 'For site menus' item
64
65 settings_panels = Page.settings_panels + [
66 FieldPanel('first_published_at'),
67 ]
68
69 search_feilds = Page.search_fields + [
70 index.SearchField('body'),
71 ]
72
73 class Meta:
74 abstract = True
75
76 def save(self, *args, **kwargs):
77 super_save = super().save(*args, **kwargs)
78 og_image(self.full_url, force=True)
79 return super_save
80
81
82class HomePage(StreamPageAbstract):
83 max_count = 1
84
85 page_description = "The home of our website, you should only have one of these"
86
87 parent_page_types = ['wagtailcore.Page']
88 subpage_types = ['BlogIndexPage', 'SearchPage', 'NewsletterPage']
89
90 class Meta:
91 verbose_name = 'Home Page'
92 verbose_name_plural = 'Home Pages'
93
94 def get_context(self, request, *args, **kwargs):
95 context = super().get_context(request, *args, **kwargs)
96 context['latest_post'] = BlogPostPage.objects.live().public().order_by('-first_published_at').first()
97 context['random_blog_posts'] = BlogPostPage.objects.live().public().exclude(id=context['latest_post'].id).order_by('?')[:3]
98 return context
99
100
101class SearchPage(RoutablePageMixin, StreamPageAbstract):
102 max_count = 1
103
104 page_description = "The search page of our website, you should only have one of these"
105
106 parent_page_types = ['HomePage']
107 subpage_types = []
108
109 class Meta:
110 verbose_name = 'Search Page'
111 verbose_name_plural = 'Search Pages'
112
113 @route(r'^$')
114 def search(self, request, *args, **kwargs):
115 q = request.GET.get('q', '')
116 random_posts = None
117 results = BlogPostPage.objects.live().public().search(q)
118 # combine live search results too just incase the user hits enter
119 # instead of clicking a result
120 if q != '':
121 results = list(results) + list(BlogPostPage.objects.live().public().filter(
122 Q(title__icontains=q) | Q(search_description__icontains=q) | Q(tags__name__icontains=q)
123 ).exclude(id__in=[r.id for r in results]).distinct()[:5])
124 else:
125 random_posts = BlogPostPage.objects.live().public().order_by('?')[:6]
126 return self.render(request, context_overrides={'results': results, 'random_posts': random_posts, 'q': q})
127
128 @route(r'^live/$')
129 def live_search(self, request, *args, **kwargs):
130 q = request.GET.get('q', '')
131 # NOTE: wagtail's autocomplete feature doesn't work with sqlite so I'm
132 # doing a bit of a workaround, you should use autocomplete if you can
133 results_queryset = BlogPostPage.objects.live().public().filter(
134 Q(title__icontains=q) | Q(search_description__icontains=q) | Q(tags__name__icontains=q)
135 ).distinct()[:5]
136 results = []
137 for result in results_queryset:
138 results.append({
139 'title': result.title,
140 'description': result.search_description,
141 'url': result.full_url,
142 })
143 return JsonResponse(results, safe=False)
144
145
146class NewsletterPage(RoutablePageMixin, StreamPageAbstract):
147 max_count = 1
148
149 page_description = "The newsletter page of our website, you should only have one of these"
150
151 parent_page_types = ['HomePage']
152 subpage_types = []
153
154 class Meta:
155 verbose_name = 'Newsletter Page'
156 verbose_name_plural = 'Newsletter Pages'
157
158 def get_sitemap_urls(self, request=None):
159 urls = [
160 {
161 "location": self.full_url + self.reverse_subpage('subscribe'),
162 "lastmod": self.last_published_at,
163 },
164 {
165 "location": self.full_url + self.reverse_subpage('unsubscribe'),
166 "lastmod": self.last_published_at,
167 },
168 ]
169 return urls
170
171 def get_context(self, request, *args, **kwargs):
172 context = super().get_context(request, *args, **kwargs)
173 context['random_posts'] = BlogPostPage.objects.live().public().order_by('?')[:6]
174 return context
175
176 @route(r'^$')
177 def newsletter(self, request, *args, **kwargs):
178 return redirect(self.url + self.reverse_subpage('subscribe'))
179
180 @route(r'^subscribe/$')
181 def subscribe(self, request, *args, **kwargs):
182 form = SubscriberForm(request.POST or None)
183 if form.is_valid():
184 form.save()
185 return redirect(self.url + self.reverse_subpage('subscribe_success'))
186 return self.render(request, context_overrides={'subscribe': True})
187
188 @route(r'^subscribe/success/$')
189 def subscribe_success(self, request, *args, **kwargs):
190 return self.render(
191 request,
192 context_overrides={
193 'success': 'Thank you for subscribing to my newsletter!'
194 }
195 )
196
197 @route(r'^unsubscribe/$')
198 def unsubscribe(self, request, *args, **kwargs):
199 form = SubscriberForm(request.POST or None)
200 if form.is_valid():
201 form.save()
202 return redirect(self.url + self.reverse_subpage('unsubscribe_success'))
203 return self.render(request, context_overrides={'unsubscribe': True})
204
205 @route(r'^unsubscribe/success/$')
206 def unsubscribe_success(self, request, *args, **kwargs):
207 return self.render(
208 request,
209 context_overrides={
210 'success': 'Sorry to see you go! You have been unsubscribed from my newsletter.'
211 }
212 )
213
214
215class BlogIndexPage(RoutablePageMixin, StreamPageAbstract):
216 max_count = 1
217
218 page_description = "For showing our blog posts and allowing filtering, you should only have one of these"
219
220 parent_page_types = ['HomePage']
221 subpage_types = ['BlogPostPage']
222
223 class Meta:
224 verbose_name = 'Blog Index Page'
225 verbose_name_plural = 'Blog Index Pages'
226
227 def get_blog_posts(self):
228 return BlogPostPage.objects.live().public().order_by('-first_published_at')
229
230 def get_tags(self):
231 tag_ids = list(set(BlogPostPage.objects.live().public().values_list('tags', flat=True)))
232 return Tag.objects.filter(id__in=tag_ids)
233
234 def get_years(self):
235 return list(set(BlogPostPage.objects.live().public().values_list('first_published_at__year', flat=True)))
236
237 def get_sitemap_urls(self, request=None):
238 # add original url
239 urls = [
240 {
241 "location": self.get_full_url(request),
242 "lastmod": self.last_published_at,
243 }
244 ]
245 # add tag urls
246 for tag in self.get_tags():
247 urls.append({
248 "location": self.get_full_url(request) + self.reverse_subpage('tag', kwargs={'tag': tag.slug}),
249 "lastmod": self.last_published_at,
250 })
251 # add year urls
252 for year in self.get_years():
253 urls.append({
254 "location": self.get_full_url(request) + self.reverse_subpage('year', kwargs={'year': year}),
255 "lastmod": self.last_published_at,
256 })
257 # TODO: make the lastmod the last modified date of the latest post on the pages
258 return urls
259
260 @route(r'^$')
261 def index(self, request):
262 blog_posts = self.get_blog_posts()
263 return self.render(request, context_overrides={'blog_posts': blog_posts})
264
265 @route(r'^tag/(?P<tag>[-\w]+)/$')
266 def tag(self, request, tag):
267 blog_posts = self.get_blog_posts().filter(tags__slug=tag)
268 extra_posts = None
269 if blog_posts.count() < 5:
270 # if we don't have enough results lets grab the latest posts and
271 # to avoid thin content issues
272 extra_posts = self.get_blog_posts().exclude(tags__slug=tag)[:4]
273 active_tag = Tag.objects.get(slug=tag)
274 return self.render(request, context_overrides={'blog_posts': blog_posts, 'extra_posts': extra_posts, 'active_tag': active_tag})
275
276 @route(r'^year/(?P<year>\d+)/$')
277 def year(self, request, year):
278 blog_posts = self.get_blog_posts().filter(first_published_at__year=year)
279 extra_posts = None
280 if blog_posts.count() < 5:
281 # if we don't have enough results lets grab the latest posts and
282 # to avoid thin content issues
283 extra_posts = self.get_blog_posts().exclude(id__in=[r.id for r in blog_posts])[:4]
284 active_year = year
285 return self.render(request, context_overrides={'blog_posts': blog_posts, 'extra_posts': extra_posts, 'active_year': active_year})
286
287
288class BlogPostPageTags(TaggedItemBase):
289 content_object = ParentalKey(
290 "BlogPostPage", on_delete=models.CASCADE, related_name="tagged_items"
291 )
292
293
294class BlogPostPage(RoutablePageMixin, StreamPageAbstract):
295 tags = ClusterTaggableManager(through=BlogPostPageTags, blank=True)
296
297 promote_panels = StreamPageAbstract.promote_panels + [
298 FieldPanel('tags'),
299 ]
300
301 page_description = "For the majority of our blog posts"
302
303 parent_page_types = ['BlogIndexPage']
304 subpage_types = []
305
306 class Meta:
307 verbose_name = "Blog Post Page"
308 verbose_name_plural = "Blog Post Pages"
309 ordering = ['-first_published_at']
310
311 def save(self, *args, **kwargs):
312 filename = self.get_pdf_filename()
313 if filename and default_storage.exists(filename):
314 default_storage.delete(filename)
315 return super().save(*args, **kwargs)
316
317 def get_related(self):
318 similar_objects = [x.id for x in self.tags.similar_objects()]
319 return (
320 BlogPostPage
321 .objects
322 .live()
323 .public()
324 .filter(
325 id__in=similar_objects,
326 )
327 .distinct()[:3]
328 )
329
330 def get_pdf_filename(self):
331 if self.full_url:
332 filename = hashlib.md5(self.full_url.encode("utf-8")).hexdigest()
333 return f"post_pdfs/{filename}.pdf"
334
335 def get_read_time(self):
336 """
337 Get the number of words from the body of the post and calculate the
338 number of minutes it will take to read.
339
340 Most people read at 200 words per minute.
341
342 :return: The number of minutes it will take to read the post.
343 """
344 words = str(self.body).split()
345 return round(len(words) / 200)
346
347 @route(r'^$')
348 def post(self, request):
349 return self.render(request)
350
351 @route(r'^pdf/$')
352 def pdf(self, request):
353 filename = self.get_pdf_filename()
354 if not default_storage.exists(filename):
355 html = self.render(request, context_overrides={'BASE_URL': settings.BASE_URL}).rendered_content
356 generate_pdf_from_html(html, filename)
357 response = HttpResponse(default_storage.open(filename).read(), content_type='application/pdf')
358 response['Content-Disposition'] = 'filename="{}.pdf"'.format(self.title)
359 return response