Skip to content
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