Skip to content

Comprehensive Newsletter functionality #3939

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
18 changes: 10 additions & 8 deletions blt/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@
import sys

import dj_database_url
import environ

# Initialize Sentry
import sentry_sdk
from django.utils.translation import gettext_lazy as _
from environ import Env
from google.oauth2 import service_account
from sentry_sdk.integrations.django import DjangoIntegration

environ.Env.read_env()
# Initialize environment variables
env = Env()
env.read_env()

BASE_DIR = os.path.dirname(os.path.dirname(__file__))
env = environ.Env()
env_file = os.path.join(BASE_DIR, ".env")
environ.Env.read_env(env_file)
env.read_env(env_file)

print(f"Reading .env file from {env_file}")
print(f"DATABASE_URL: {os.environ.get('DATABASE_URL', 'not set')}")
Expand Down Expand Up @@ -169,6 +170,7 @@
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.i18n",
"website.views.core.newsletter_context_processor",
],
"loaders": (
[
Expand Down Expand Up @@ -313,10 +315,10 @@
if not TESTING:
DEBUG = True

# use this to debug emails locally
# python -m smtpd -n -c DebuggingServer localhost:1025
# if DEBUG:
# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# use this to debug emails locally
# python -m smtpd -n -c DebuggingServer localhost:1025
# if DEBUG:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

DATABASES = {
"default": {
Expand Down
17 changes: 16 additions & 1 deletion blt/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.decorators import login_required
from django.urls import path, re_path
from django.urls import include, path, re_path
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.views.generic import TemplateView
from django.views.generic.base import RedirectView
Expand Down Expand Up @@ -310,6 +310,13 @@
invite_friend,
mark_as_read,
messaging_home,
newsletter_confirm,
newsletter_detail,
newsletter_home,
newsletter_preferences,
newsletter_resend_confirmation,
newsletter_subscribe,
newsletter_unsubscribe,
profile,
profile_edit,
referral_signup,
Expand Down Expand Up @@ -1092,6 +1099,14 @@
path("api/messaging/<int:thread_id>/messages/", view_thread, name="thread_messages"),
path("api/messaging/set-public-key/", set_public_key, name="set_public_key"),
path("api/messaging/<int:thread_id>/get-public-key/", get_public_key, name="get_public_key"),
# Newsletter URLs
path("newsletter/", newsletter_home, name="newsletter_home"),
path("newsletter/subscribe/", newsletter_subscribe, name="newsletter_subscribe"),
path("newsletter/confirm/<uuid:token>/", newsletter_confirm, name="newsletter_confirm"),
path("newsletter/unsubscribe/<uuid:token>/", newsletter_unsubscribe, name="newsletter_unsubscribe"),
path("newsletter/preferences/", newsletter_preferences, name="newsletter_preferences"),
path("newsletter/resend-confirmation/", newsletter_resend_confirmation, name="newsletter_resend_confirmation"),
path("newsletter/<slug:slug>/", newsletter_detail, name="newsletter_detail"), # This pattern must come last
]

if settings.DEBUG:
Expand Down
1,051 changes: 455 additions & 596 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ dj-database-url = "^2.3.0"
django-allauth = "^0.61.1"
beautifulsoup4 = "^4.13.3"
django-email-obfuscator = "^0.1.5"
django-gravatar2 = "^1.4.5"
django-gravatar2 = "1.4.4"
django-import-export = "^4.3.7"
django-annoying = "^0.10.7"
dj-rest-auth = "^5.0.2"
Expand Down
73 changes: 73 additions & 0 deletions website/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
LectureStatus,
Message,
Monitor,
Newsletter,
NewsletterSubscriber,
Notification,
Organization,
OrganizationAdmin,
Expand Down Expand Up @@ -706,6 +708,77 @@ def mark_as_launched(self, request, queryset):
mark_as_launched.short_description = "Mark selected items as launched"


@admin.register(Newsletter)
class NewsletterAdmin(admin.ModelAdmin):
list_display = ("title", "status", "published_at", "email_sent", "view_count")
list_filter = ("status", "email_sent")
search_fields = ("title", "content")
prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("view_count", "email_sent_at")
date_hierarchy = "created_at"
fieldsets = (
("Content", {"fields": ("title", "slug", "content", "featured_image")}),
("Publication", {"fields": ("status", "published_at")}),
("Email Settings", {"fields": ("email_subject", "email_sent", "email_sent_at")}),
("Content Sections", {"fields": ("recent_bugs_section", "leaderboard_section", "reported_ips_section")}),
("Statistics", {"fields": ("view_count",)}),
)

actions = ["send_newsletter"]

def send_newsletter(self, request, queryset):
from django.core.management import call_command

count = 0
for newsletter in queryset:
if newsletter.status == "published" and not newsletter.email_sent:
call_command("send_newsletter", newsletter_id=newsletter.id)
count += 1

self.message_user(request, f"{count} newsletters were sent successfully.")

send_newsletter.short_description = "Send selected newsletters"


@admin.register(NewsletterSubscriber)
class NewsletterSubscriberAdmin(admin.ModelAdmin):
list_display = ("email", "name", "user", "subscription_status", "subscribed_at")
list_filter = ("is_active", "confirmed", "wants_bug_reports", "wants_leaderboard_updates", "wants_security_news")
search_fields = ("email", "name", "user__email", "user__username")
raw_id_fields = ("user",)
readonly_fields = ("confirmation_token",)

actions = ["send_confirmation_email", "mark_as_confirmed", "mark_as_unsubscribed"]

def subscription_status(self, obj):
return obj.subscription_status

def send_confirmation_email(self, request, queryset):
from website.views.user import send_confirmation_email

count = 0
for subscriber in queryset:
if not subscriber.confirmed and subscriber.is_active:
send_confirmation_email(subscriber)
count += 1

self.message_user(request, f"Confirmation emails sent to {count} subscribers.")

send_confirmation_email.short_description = "Send confirmation email"

def mark_as_confirmed(self, request, queryset):
queryset.update(confirmed=True)
self.message_user(request, f"{queryset.count()} subscribers marked as confirmed.")

mark_as_confirmed.short_description = "Mark selected subscribers as confirmed"

def mark_as_unsubscribed(self, request, queryset):
queryset.update(is_active=False)
self.message_user(request, f"{queryset.count()} subscribers marked as unsubscribed.")

mark_as_unsubscribed.short_description = "Mark selected subscribers as unsubscribed"


admin.site.register(Project, ProjectAdmin)
admin.site.register(Repo, RepoAdmin)
admin.site.register(Contributor, ContributorAdmin)
Expand Down
120 changes: 120 additions & 0 deletions website/management/commands/send_newsletter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import logging

from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.core.management.base import BaseCommand
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone

from website.models import Newsletter, NewsletterSubscriber

logger = logging.getLogger(__name__)


class Command(BaseCommand):
help = "Send published newsletter to subscribers"

def add_arguments(self, parser):
parser.add_argument("--newsletter_id", type=int, help="ID of the specific newsletter to send")
parser.add_argument("--test", action="store_true", help="Send a test email to the admin")

def handle(self, *args, **options):
newsletter_id = options.get("newsletter_id")
test_mode = options.get("test", False)

if newsletter_id:
# Send specific newsletter
try:
newsletter = Newsletter.objects.get(id=newsletter_id, status="published")
self.stdout.write(f"Preparing to send newsletter: {newsletter.title}")
self.send_newsletter(newsletter, test_mode)
except Newsletter.DoesNotExist:
self.stderr.write(f"Newsletter with ID {newsletter_id} does not exist or is not published")
else:
# Find newsletters that are published but not sent yet
newsletters = Newsletter.objects.filter(
status="published", email_sent=False, published_at__lte=timezone.now()
)

self.stdout.write(f"Found {newsletters.count()} newsletters to send")

for newsletter in newsletters:
self.send_newsletter(newsletter, test_mode)

def send_newsletter(self, newsletter, test_mode):
"""Send a specific newsletter to subscribers"""
if test_mode:
# Send only to admin email for testing
self.stdout.write(f"Sending test email for '{newsletter.title}' to admin")
if settings.ADMINS and len(settings.ADMINS) > 0:
self.send_to_subscriber(settings.ADMINS[0][1], newsletter, is_test=True)
else:
self.stderr.write("No admin email configured. Cannot send test email.")
return

# Get active, confirmed subscribers
subscribers = NewsletterSubscriber.objects.filter(is_active=True, confirmed=True)

if subscribers.exists():
self.stdout.write(f"Sending '{newsletter.title}' to {subscribers.count()} subscribers")

successful_sends = 0
for subscriber in subscribers:
try:
self.send_to_subscriber(subscriber.email, newsletter, subscriber=subscriber)
successful_sends += 1
except Exception as e:
logger.error(f"Failed to send newsletter to {subscriber.email}: {str(e)}")

# Mark as sent if there were any successful sends
if successful_sends > 0:
newsletter.email_sent = True
newsletter.email_sent_at = timezone.now()
newsletter.save()

self.stdout.write(
self.style.SUCCESS(
f"Successfully sent newsletter '{newsletter.title}' to {successful_sends} subscribers"
)
)
else:
self.stdout.write(self.style.WARNING("No active subscribers found"))

def send_to_subscriber(self, email, newsletter, subscriber=None, is_test=False):
"""Send the newsletter to a specific subscriber"""
subject = newsletter.email_subject or f"{settings.PROJECT_NAME} Newsletter: {newsletter.title}"

if is_test:
subject = f"[TEST] {subject}"

# Build URL scheme based on settings
scheme = "https" if not settings.DEBUG else "http"

# Newsletter context
context = {
"newsletter": newsletter,
"subscriber": subscriber,
"unsubscribe_url": f"{scheme}://{settings.DOMAIN_NAME}"
+ reverse("newsletter_unsubscribe", args=[subscriber.confirmation_token])
if subscriber is not None
else "#",
"view_in_browser_url": f"{scheme}://{settings.DOMAIN_NAME}" + newsletter.get_absolute_url(),
"project_name": settings.PROJECT_NAME,
"recent_bugs": newsletter.get_recent_bugs(),
"leaderboard": newsletter.get_leaderboard_updates(),
"reported_ips": newsletter.get_reported_ips(),
}

# Create HTML and plain text versions
html_content = render_to_string("newsletter/email/newsletter_email.html", context)
text_content = f"View this newsletter in your browser: {context['view_in_browser_url']}\n\n"
text_content += newsletter.content

# Create email message
email_message = EmailMultiAlternatives(
subject=subject, body=text_content, from_email=settings.DEFAULT_FROM_EMAIL, to=[email]
)

email_message.attach_alternative(html_content, "text/html")
email_message.send()
78 changes: 78 additions & 0 deletions website/migrations/0233_newsletter_newslettersubscriber.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Generated by Django 5.1.6 on 2025-03-13 09:57

import uuid

import django.db.models.deletion
import mdeditor.fields
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("website", "0232_bannedapp"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="Newsletter",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(max_length=255)),
("slug", models.SlugField(blank=True, unique=True)),
("content", mdeditor.fields.MDTextField(help_text="Write newsletter content in Markdown format")),
("featured_image", models.ImageField(blank=True, null=True, upload_to="newsletter_images")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("published_at", models.DateTimeField(blank=True, null=True)),
(
"status",
models.CharField(
choices=[("draft", "Draft"), ("published", "Published")], default="draft", max_length=10
),
),
("recent_bugs_section", models.BooleanField(default=True, help_text="Include recently reported bugs")),
("leaderboard_section", models.BooleanField(default=True, help_text="Include leaderboard updates")),
("reported_ips_section", models.BooleanField(default=False, help_text="Include recently reported IPs")),
("email_subject", models.CharField(blank=True, max_length=255, null=True)),
("email_sent", models.BooleanField(default=False)),
("email_sent_at", models.DateTimeField(blank=True, null=True)),
("view_count", models.PositiveIntegerField(default=0)),
],
options={
"verbose_name": "Newsletter",
"verbose_name_plural": "Newsletters",
"ordering": ["-published_at"],
},
),
migrations.CreateModel(
name="NewsletterSubscriber",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("email", models.EmailField(max_length=254, unique=True)),
("name", models.CharField(blank=True, max_length=100, null=True)),
("subscribed_at", models.DateTimeField(auto_now_add=True)),
("is_active", models.BooleanField(default=True)),
("confirmation_token", models.UUIDField(default=uuid.uuid4, editable=False)),
("confirmed", models.BooleanField(default=False)),
("wants_bug_reports", models.BooleanField(default=True)),
("wants_leaderboard_updates", models.BooleanField(default=True)),
("wants_security_news", models.BooleanField(default=True)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="newsletter_subscriptions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Newsletter Subscriber",
"verbose_name_plural": "Newsletter Subscribers",
},
),
]
Loading
Loading