-
-
Notifications
You must be signed in to change notification settings - Fork 313
feat: Add newsletter page with new releases (#3725) #5130
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
base: main
Are you sure you want to change the base?
Changes from all commits
892ba3d
c03abc0
0cbd748
e5486e8
18a53c9
efd6bea
bd612f6
6fed376
8c7dcd0
9039df8
b50633b
8f9d817
519a0e8
5ab4aad
be64f1b
2285f26
36dc277
1b77cc8
d887bc8
a54678d
4c1bef7
f38b6c3
96efa19
b626e70
7a6f3c9
29d9f03
bd65f2f
8249156
3ebb7b4
56528de
8f09f13
5cc85ff
9e15b5a
8a1a0a5
d5a08ae
7dd88b6
ac3b427
22e2bbb
6762977
5c74c60
24873df
8417ae1
0b3d218
d7d3386
14b29bf
1f378f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ staticfiles | |
| /env | ||
| /pvenv | ||
| db.sqlite3 | ||
| *.db | ||
| .idea | ||
| /media | ||
| .vagrant | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -211,6 +211,7 @@ | |||||||||
| update_content_comment, | ||||||||||
| vote_count, | ||||||||||
| ) | ||||||||||
| from website.views.newsletter import NewsletterView | ||||||||||
| from website.views.organization import ( | ||||||||||
| BountyPayoutsView, | ||||||||||
| CreateHunt, | ||||||||||
|
|
@@ -350,6 +351,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, | ||||||||||
|
|
@@ -622,6 +630,7 @@ | |||||||||
| EachmonthLeaderboardView.as_view(), | ||||||||||
| name="leaderboard_eachmonth", | ||||||||||
| ), | ||||||||||
| re_path(r"^newsletter/$", NewsletterView.as_view(), name="newsletter"), | ||||||||||
|
||||||||||
| re_path(r"^newsletter/$", NewsletterView.as_view(), name="newsletter"), | |
| re_path(r"^newsletter/$", RedirectView.as_view(url="/newsletter/archive/", permanent=False), name="newsletter"), |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The URL pattern for newsletter_detail at line 1229 (/newsletter/<slug:slug>/) must come after all other /newsletter/* patterns, otherwise it will match URLs like /newsletter/subscribe/, /newsletter/archive/, etc. and try to find a newsletter with slug "subscribe" or "archive". Move this pattern to the end of the newsletter URL group (after line 1228) to prevent it from capturing other newsletter URLs.
| path("newsletter/<slug:slug>/", newsletter_detail, name="newsletter_detail"), | |
| path("bounty_payout/", bounty_payout, name="bounty_payout"), | |
| path("bounty_payout/", bounty_payout, name="bounty_payout"), | |
| path("newsletter/<slug:slug>/", newsletter_detail, name="newsletter_detail"), |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,124 @@ | ||||||||||||||||||||
| 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)}") | ||||||||||||||||||||
|
||||||||||||||||||||
| logger.error(f"Failed to send newsletter to {subscriber.email}: {str(e)}") | |
| logger.error(f"Failed to send newsletter to {subscriber.email}", exc_info=True) |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The newsletter sending implementation sends emails sequentially in a loop (lines 63-68). This can be very slow for a large subscriber base and may timeout. Consider using bulk email sending with Django's send_mass_mail() or implementing a celery task queue to handle email sending asynchronously in batches.
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The unsubscribe URL construction will fail when subscriber is None (in test mode). The code checks if subscriber is not None after attempting to use subscriber.confirmation_token, which will raise an AttributeError. The ternary operator needs to be properly structured: reverse("newsletter_unsubscribe", args=[subscriber.confirmation_token]) if subscriber else "#" and the f-string should wrap the entire expression.
| "unsubscribe_url": f"{scheme}://{settings.DOMAIN_NAME}" | |
| + reverse("newsletter_unsubscribe", args=[subscriber.confirmation_token]) | |
| if subscriber is not None | |
| else "#", | |
| "unsubscribe_url": ( | |
| f"{scheme}://{settings.DOMAIN_NAME}{reverse('newsletter_unsubscribe', args=[subscriber.confirmation_token])}" | |
| if subscriber is not None | |
| else "#" | |
| ), |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The scheme determination uses settings.DEBUG to decide between http and https, which has the same issue as in other parts of the code. Production environments behind SSL-terminating proxies might have DEBUG=False but still need https URLs.
Additionally, this code accesses settings.DOMAIN_NAME directly without checking if it exists, which could raise an AttributeError.
Consider using a dedicated BASE_URL setting or falling back to the Site framework.
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage: The send_newsletter management command lacks automated tests. This is a critical function that sends emails to all subscribers and should be thoroughly tested.
Recommended tests:
- Test sending to specific newsletter by ID
- Test sending unpublished newsletters (should fail)
- Test test mode functionality
- Test handling of email send failures
- Test marking newsletters as sent
- Test with no active subscribers
- Test unsubscribe URL generation in emails
Add tests similar to test_weekly_bug_digest.py pattern.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # Generated by Django 5.1.8 on 2025-12-02 16:33 | ||
|
|
||
| import uuid | ||
|
|
||
| import django.db.models.deletion | ||
| import django.utils.timezone | ||
| import mdeditor.fields | ||
| from django.conf import settings | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ("website", "0259_add_search_history"), | ||
| 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(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)), | ||
| ( | ||
| "token_created_at", | ||
| models.DateTimeField( | ||
| default=django.utils.timezone.now, help_text="Timestamp when confirmation token was created" | ||
| ), | ||
| ), | ||
| ("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", | ||
| }, | ||
| ), | ||
| ] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
URL pattern conflict: The pattern
re_path(r"^newsletter/$", ...)at line 632 will conflict with the more specific patterns added at lines 1222-1228.The
^newsletter/$pattern should be removed since:newsletter/archive/(line 1222)NewsletterViewclass-based view duplicates functionality withnewsletter_homefunction-based viewRecommendation: Remove line 632 and use only the function-based views, or consolidate all newsletter URLs to use either class-based or function-based views consistently. Based on the PR description mentioning
/newsletter/as the main page, you might want to change line 1222 fromnewsletter/archive/tonewsletter/instead.