heartwood every commit a ring

Add newsletter system

d3b50cff by Isaac Bythewood · 3 years ago

modified .dockerignore
@@ -5,10 +5,14 @@.gitignore*.md*.sampledb.sqlite-journaldb.sqlite3db.sqlite3-shmdb.sqlite3-waldocker-compose.ymlDockerfileLICENSE.mdmedianode_modulesREADME.mdsamplefiles
modified .gitignore
@@ -1,5 +1,6 @@.env.venvdb.sqlite-journaldb.sqlite3db.sqlite3-shmdb.sqlite3-wal
modified admin/wagtail_hooks.py
@@ -7,6 +7,8 @@ from wagtail.rich_text import LinkHandlerfrom pages.models import BlogPostPagefrom scheduler.models import ScheduledTaskfrom mail.models import Subscriber# @hooks.register("insert_global_admin_js", order=100)# def global_admin_js():
@@ -78,3 +80,17 @@ class ScheduledTaskAdmin(ModelAdmin):modeladmin_register(ScheduledTaskAdmin)class SubscriberAdmin(ModelAdmin):    model = Subscriber    menu_label = 'Subscribers'    menu_icon = 'mail'    add_to_settings_menu = True    menu_order = 1000    list_display = ('email', 'created_at')    search_fields = ('email',)    ordering = ('-created_at',)modeladmin_register(SubscriberAdmin)
modified blog/context_processors.py
@@ -3,7 +3,7 @@ from taggit.models import Tagfrom accounts.models import Userfrom pages.models import BlogIndexPagefrom pages.models import BlogIndexPage, NewsletterPagedef canonical(request):
@@ -46,3 +46,10 @@ def nav_items(request):            continue    sorted_tags = sorted(tags, key=lambda tag: tag.name)    return {"nav_items": sorted_tags}def newsletter_page(request):    """    Provides the newsletter page.    """    return {"newsletter_page": NewsletterPage.objects.first()}
modified blog/settings/__init__.py
@@ -40,6 +40,7 @@ INSTALLED_APPS = [    'accounts',    'pages',    'scheduler',    'mail',]MIDDLEWARE = [
@@ -74,6 +75,7 @@ TEMPLATES = [                'blog.context_processors.base_url',                'blog.context_processors.nav_items',                'blog.context_processors.site_owner',                'blog.context_processors.newsletter_page',            ],        },    },
@@ -132,12 +134,6 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'AUTH_USER_MODEL = "accounts.User"# Email# https://docs.djangoproject.com/en/4.0/topics/email/#console-backendEMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'# Wagtail# https://docs.wagtail.org/en/stable/reference/settings.html#settings
modified blog/settings/development.py
@@ -45,6 +45,12 @@ DATABASES = {}# Email# https://docs.djangoproject.com/en/4.0/topics/email/#console-backendEMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'# Media files (Images, Videos)# https://docs.djangoproject.com/en/4.0/ref/settings/#media-root
modified blog/settings/production.py
@@ -55,6 +55,13 @@ DATABASES = {}# Email# https://docs.djangoproject.com/en/4.0/topics/email/#smtp-backendEMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"EMAIL_HOST = "email"# Media files (Images, Videos)# https://docs.djangoproject.com/en/4.0/ref/settings/#media-root
modified blog/static_src/styles/bootstrap.scss
@@ -25,8 +25,8 @@  border-color: $yellow-500 !important;}.border-orange-800 {  border-color: $orange-800 !important;.text-orange-300 {  color: $orange-300 !important;}.text-gray-300 {
modified blog/templates/base.html
@@ -99,23 +99,20 @@  {% block footer %}  <footer class="py-5 bg-dark text-light d-print-none">    <div class="container">      {% if newsletter_page %}      <div class="row bg-gray-925 border border-yellow-500 shadow rounded p-4 mb-5">        <div class="col-12 col-md-8">          <div class="h1 fw-bolder">Let me know what you think</div>          <div class="h5 text-gray-300">Any comments or you'd just like to chat about the            subject? Send me and email! Especially if you find a spelling mistake.</div>        </div>        <div class="col-12 col-md-4 d-flex align-items-center justify-content-md-end">          <div>            <a href="mailto:{{ self.owner.email }}" class="btn btn-dark btn-lg border-orange-800 rounded fs-5 d-flex align-items-center">              <span>Send email</span>              <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-arrow-right-short ms-1" viewBox="0 0 16 16">                <path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>              </svg>            </a>        <div class="col-12 col-lg-7 mb-2 mb-lg-0">          <div class="h1 fw-bolder">Get new posts to your inbox</div>          <div class="h5 text-gray-300">            I only post once every week or so and store your email securely on            my own server. It's free tips and tricks with no spam!          </div>        </div>        <div class="col-12 col-lg-5 d-flex align-items-center">          {% include "includes/subscribe_form.html" %}        </div>      </div>      {% endif %}      <div class="row mb-3">        <div class="col-12 col-md-6 mb-4 mb-md-0">          <div class="display-5 text-light fs-2 mb-3">Blog</div>
@@ -128,9 +125,9 @@          <div class="display-5 text-light fs-2 mb-3">Links</div>          <div>            <a href="https://isaacbythewood.com/" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Home</a>            <a href="https://analytics.bythewood.me/" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Analytics</a>            <a href="https://blog.bythewood.me/" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Blog</a>            <a href="https://timelite.bythewood.me/" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Timelite</a>            <a href="https://analytics.bythewood.me/properties/0d379e18-9ea7-4228-a8bf-82369c25ab84/" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Analytics</a>            <a href="https://status.bythewood.me/properties/dbc133c9-ef2a-40a9-a3f0-a26c64bede0a/" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Status</a>          </div>        </div>        <div class="col-12 col-md-3 mb-4 mb-md-0">
@@ -139,8 +136,10 @@            <a href="https://github.com/overshard" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">GitHub</a>            <a href="https://github.com/overshard/analytics" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Analytics</a>            <a href="https://github.com/overshard/blog" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Blog</a>            <a href="https://github.com/overshard/status" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Status</a>            <a href="https://github.com/overshard/dockerfiles" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Dockerfiles</a>            <a href="https://github.com/overshard/dotfiles" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Dotfiles</a>            <a href="https://github.com/overshard/alpinefiles" target="_blank" class="btn btn-sm btn-outline-secondary text-light rounded-pill mb-2">Alpinefiles</a>          </div>        </div>      </div>
modified docker-compose.yml
@@ -6,6 +6,10 @@version: "3"services:  email:    container_name: blog_email    image: overshard/exim    restart: unless-stopped  web:    container_name: blog_web    build: .
added mail/__init__.py
added mail/forms.py
@@ -0,0 +1,28 @@from django import formsfrom .models import Subscriberclass SubscriberForm(forms.ModelForm):    subscribe = forms.BooleanField(required=False)    class Meta:        model = Subscriber        fields = ['email']    def clean_email(self):        email = self.cleaned_data['email']        email = email.lower().strip()        return email    def save(self, commit=True):        subscriber = super().save(commit=False)        if self.cleaned_data['subscribe'] is True:            existing = Subscriber.objects.filter(email=subscriber.email)            if existing.exists():                subscriber = existing.first()            if commit:                subscriber.save()        else:            subscriber = Subscriber.objects.filter(email=subscriber.email).delete()        return subscriber
added mail/migrations/0001_initial.py
@@ -0,0 +1,26 @@# Generated by Django 4.0.5 on 2022-06-25 20:55from django.db import migrations, modelsclass Migration(migrations.Migration):    initial = True    dependencies = [    ]    operations = [        migrations.CreateModel(            name='Subscriber',            fields=[                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),                ('email', models.EmailField(max_length=254)),                ('created_at', models.DateTimeField(auto_now_add=True)),            ],            options={                'verbose_name': 'Subscriber',                'verbose_name_plural': 'Subscribers',            },        ),    ]
added mail/migrations/__init__.py
added mail/models.py
@@ -0,0 +1,13 @@from django.db import modelsclass Subscriber(models.Model):    email = models.EmailField()    created_at = models.DateTimeField(auto_now_add=True)    def __str__(self):        return self.email    class Meta:        verbose_name = "Subscriber"        verbose_name_plural = "Subscribers"
added pages/migrations/0007_newsletterpage.py
@@ -0,0 +1,34 @@# Generated by Django 4.0.5 on 2022-06-26 00:24from django.db import migrations, modelsimport django.db.models.deletionimport wagtail.blocksimport wagtail.contrib.routable_page.modelsimport wagtail.embeds.blocksimport wagtail.fieldsimport wagtail.images.blocksclass Migration(migrations.Migration):    dependencies = [        ('wagtailimages', '0024_index_image_file_hash'),        ('wagtailcore', '0069_log_entry_jsonfield'),        ('pages', '0006_alter_blogpostpage_options'),    ]    operations = [        migrations.CreateModel(            name='NewsletterPage',            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')),                ('body', wagtail.fields.StreamField([('rich_text', wagtail.blocks.RichTextBlock()), ('image', wagtail.images.blocks.ImageChooserBlock()), ('code', wagtail.blocks.StructBlock([('language', wagtail.blocks.ChoiceBlock(choices=[('python', 'Python'), ('javascript', 'Javascript'), ('htmlmixed', 'HTML'), ('css', 'CSS'), ('shell', 'Shell')])), ('text', wagtail.blocks.TextBlock())])), ('embed', wagtail.embeds.blocks.EmbedBlock())], blank=True, use_json_field=True)),                ('cover_image', models.ForeignKey(blank=True, help_text='Cover image for this page, used in listings and at the top of the page.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),            ],            options={                'verbose_name': 'Newsletter Page',                'verbose_name_plural': 'Newsletter Pages',            },            bases=(wagtail.contrib.routable_page.models.RoutablePageMixin, 'wagtailcore.page'),        ),    ]
modified pages/models.py
@@ -1,5 +1,4 @@import hashlibimport osfrom django.conf import settingsfrom django.core.files.storage import default_storage
@@ -7,7 +6,6 @@ from django.db import modelsfrom django.db.models import Qfrom django.http import HttpResponse, JsonResponsefrom django.shortcuts import redirectfrom django.utils import timezonefrom modelcluster.contrib.taggit import ClusterTaggableManagerfrom modelcluster.fields import ParentalKeyfrom taggit.models import Tag, TaggedItemBase
@@ -21,6 +19,7 @@ from wagtail.images.blocks import ImageChooserBlockfrom wagtail.search import indexfrom blog.chromium import generate_pdf_from_htmlfrom mail.forms import SubscriberFormfrom .utils import og_image
@@ -86,7 +85,7 @@ class HomePage(StreamPageAbstract):    page_description = "The home of our website, you should only have one of these"    parent_page_types = ['wagtailcore.Page']    subpage_types = ['BlogIndexPage', 'SearchPage']    subpage_types = ['BlogIndexPage', 'SearchPage', 'NewsletterPage']    class Meta:        verbose_name = 'Home Page'
@@ -144,6 +143,75 @@ class SearchPage(RoutablePageMixin, StreamPageAbstract):        return JsonResponse(results, safe=False)class NewsletterPage(RoutablePageMixin, StreamPageAbstract):    max_count = 1    page_description = "The newsletter page of our website, you should only have one of these"    parent_page_types = ['HomePage']    subpage_types = []    class Meta:        verbose_name = 'Newsletter Page'        verbose_name_plural = 'Newsletter Pages'    def get_sitemap_urls(self, request=None):        urls = [            {                "location": self.full_url + self.reverse_subpage('subscribe'),                "lastmod": self.last_published_at,            },            {                "location": self.full_url + self.reverse_subpage('unsubscribe'),                "lastmod": self.last_published_at,            },        ]        return urls    def get_context(self, request, *args, **kwargs):        context = super().get_context(request, *args, **kwargs)        context['random_posts'] = BlogPostPage.objects.live().public().order_by('?')[:6]        return context    @route(r'^$')    def newsletter(self, request, *args, **kwargs):        return redirect(self.url + self.reverse_subpage('subscribe'))    @route(r'^subscribe/$')    def subscribe(self, request, *args, **kwargs):        form = SubscriberForm(request.POST or None)        if form.is_valid():            form.save()            return redirect(self.url + self.reverse_subpage('subscribe_success'))        return self.render(request, context_overrides={'subscribe': True})    @route(r'^subscribe/success/$')    def subscribe_success(self, request, *args, **kwargs):        return self.render(            request,            context_overrides={                'success': 'Thank you for subscribing to my newsletter!'            }        )    @route(r'^unsubscribe/$')    def unsubscribe(self, request, *args, **kwargs):        form = SubscriberForm(request.POST or None)        if form.is_valid():            form.save()            return redirect(self.url + self.reverse_subpage('unsubscribe_success'))        return self.render(request, context_overrides={'unsubscribe': True})    @route(r'^unsubscribe/success/$')    def unsubscribe_success(self, request, *args, **kwargs):        return self.render(            request,            context_overrides={                'success': 'Sorry to see you go! You have been unsubscribed from my newsletter.'            }        )class BlogIndexPage(RoutablePageMixin, StreamPageAbstract):    max_count = 1
added pages/templates/includes/subscribe_form.html
@@ -0,0 +1,17 @@{% load wagtailroutablepage_tags %}<form method="post" action="{% routablepageurl newsletter_page 'subscribe' %}" class="d-flex flex-grow-1 reverse-invert">  {% csrf_token %}  <input type="hidden" name="subscribe" value="True">  <div class="form-floating flex-grow-1">    <input type="email" name="email" class="form-control bg-dark border-0 text-white rounded-0 rounded-start border-end-0" placeholder="Your email address">    <label for="email" class="text-white">Your email address</label>  </div>  <button type="submit" class="btn btn-dark btn-lg border-0 rounded fs-5 d-flex align-items-center rounded-0 rounded-end border-start-0 text-orange-300">    <span>Subscribe</span>    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-right ms-1" viewBox="0 0 16 16">      <path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>    </svg>  </button></form>
added pages/templates/includes/unsubscribe_form.html
@@ -0,0 +1,17 @@{% load wagtailroutablepage_tags %}<form method="post" action="{% routablepageurl newsletter_page 'unsubscribe' %}" class="d-flex flex-grow-1 reverse-invert">  {% csrf_token %}  <input type="hidden" name="subscribe" value="False">  <div class="form-floating flex-grow-1">    <input type="email" name="email" class="form-control bg-dark border-0 text-white rounded-0 rounded-start border-end-0" placeholder="Your email address">    <label for="email" class="text-white">Your email address</label>  </div>  <button type="submit" class="btn btn-dark btn-lg border-0 rounded fs-5 d-flex align-items-center rounded-0 rounded-end border-start-0 text-orange-300">    <span>Unsubscribe</span>    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-right ms-1" viewBox="0 0 16 16">      <path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>    </svg>  </button></form>
modified pages/templates/pages/blog_index_page.html
@@ -25,6 +25,7 @@{% endif %}{% endblock %}{% block extra_description %}{% if active_tag %}Currently filtered by tag {{ active_tag.name|title }}.
added pages/templates/pages/newsletter_page.html
@@ -0,0 +1,75 @@{% extends 'base.html' %}{% load static wagtailcore_tags wagtailimages_tags %}{% block extra_head %}{% include "includes/social.html" %}{% endblock %}{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet">{% endblock %}{% block extra_js %}<script src="{% static 'pages.js' %}"></script>{% endblock %}{% block main %}<div class="container mt-5">  <div class="row">    <div class="col">      <h1>{{ page.title }}</h1>    </div>  </div></div><div class="container">  <div class="row">    <div class="col-sm-6">      {% if not success %}        {% if subscribe %}          {% include "includes/subscribe_form.html" %}        {% elif unsubscribe %}          {% include "includes/unsubscribe_form.html" %}        {% endif %}      {% else %}        <div class="alert alert-success d-flex align-items-center row g-1 shadow-sm reverse-invert">          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-check-circle col-2 col-md-1" viewBox="0 0 16 16">            <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>            <path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>          </svg>          <div class="col-10 col-md-11">{{ success }}</div>        </div>      {% endif %}    </div>  </div></div><div class="container mt-5">  <div class="row">    {% if random_posts %}    <div class="container mt-5 d-print-none">      <div class="row">        <div class="col">          <div class="text-muted">            Sample Posts          </div>          <p>            Sign up and you'll get posts like these straight to your email once            every week or so.          </p>        </div>      </div>      <div class="row">        {% for blog_post in random_posts %}        <div class="col-12 col-sm-6 col-md-4 mb-5">          {% include "includes/blog_post_card.html" %}        </div>        {% endfor %}      </div>    </div>    {% endif %}  </div></div>{% endblock %}