repos

blog.bythewood.me-wagtail

mirror archived upstream

Self-hostable blog for developers built on Wagtail and Django, with webpack, Bootstrap, and CodeMirror.

blogbootstrapdjangodockerhandcodedpythonself-hostedwagtailwebpack

12.5 KB · 360 lines · Python Raw History
  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