Skip to content

Commit 5db604e

Browse files
authored
Merge pull request #17 from happychuks/main
feat: Newsletter Feature Added
2 parents c246480 + e30f2c6 commit 5db604e

23 files changed

+491
-4
lines changed

server/apps/newsletter/__init__.py

Whitespace-only changes.

server/apps/newsletter/admin.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from django.contrib import admin, messages
2+
from .models import Newsletter, Subscriber
3+
from .tasks import send_newsletter_via_email
4+
5+
@admin.action(description='Send selected newsletters')
6+
def send_selected_newsletters(modeladmin, request, queryset):
7+
for newsletter in queryset:
8+
send_newsletter_via_email.delay()
9+
10+
messages.success(request, f"{queryset.count()} newsletters have been scheduled for sending.")
11+
12+
13+
@admin.register(Newsletter)
14+
class NewsletterAdmin(admin.ModelAdmin):
15+
list_display = ['subject', 'is_sent', 'scheduled_send_time', 'last_sent']
16+
readonly_fields = ['last_sent']
17+
actions = [send_selected_newsletters]
18+
19+
def save_model(self, request, obj, form, change):
20+
if change: # If editing an existing object
21+
obj.is_sent = False # Set 'is_sent' to False when editing
22+
super().save_model(request, obj, form, change)
23+
24+
@admin.register(Subscriber)
25+
class SubscriberAdmin(admin.ModelAdmin):
26+
list_display = ['email', 'is_active', 'subscribed_at']
27+
list_filter = ['is_active']
28+
search_fields = ['email']
29+

server/apps/newsletter/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
class NewsletterConfig(AppConfig):
4+
default_auto_field = 'django.db.models.BigAutoField'
5+
name = 'apps.newsletter'

server/apps/newsletter/forms.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from django import forms
2+
3+
class SubscribeForm(forms.Form):
4+
email = forms.EmailField(label='Enter your email', required=True)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.0.8 on 2024-08-19 23:13
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('newsletter', '0010_newsletter_last_sent'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='newsletter',
15+
name='scheduled_send_time',
16+
field=models.DateTimeField(blank=True, null=True),
17+
),
18+
]

server/apps/newsletter/migrations/__init__.py

Whitespace-only changes.

server/apps/newsletter/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from django.db import models
2+
from apps.common.models import BaseModel
3+
from django.conf import settings
4+
from django.utils import timezone
5+
6+
# Create your models here.
7+
class Subscriber(BaseModel):
8+
email = models.EmailField(unique=True)
9+
is_active = models.BooleanField(default=True)
10+
subscribed_at = models.DateTimeField(auto_now_add=True)
11+
12+
def __str__(self):
13+
return self.email
14+
15+
class Newsletter(BaseModel):
16+
"""Model for storing newsletters."""
17+
subject = models.CharField(max_length=255)
18+
content = models.TextField()
19+
is_sent = models.BooleanField(default=False)
20+
created_at = models.DateTimeField(auto_now_add=True)
21+
last_sent = models.DateTimeField(blank=True, null=True)
22+
scheduled_send_time = models.DateTimeField(blank=True, null=True)
23+
24+
def __str__(self):
25+
return self.subject

server/apps/newsletter/tasks.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from celery import shared_task
2+
from django.core.mail import EmailMessage, send_mail
3+
from django.conf import settings
4+
from .models import Newsletter, Subscriber
5+
from django.utils import timezone
6+
from django.utils.html import format_html
7+
8+
@shared_task
9+
def send_newsletter_via_email():
10+
now = timezone.now()
11+
# Get newsletters that need to be sent
12+
newsletters = Newsletter.objects.filter(scheduled_send_time__lte=now, is_sent=False)
13+
14+
for newsletter in newsletters:
15+
subscribers = Subscriber.objects.filter(is_active=True)
16+
17+
for subscriber in subscribers:
18+
try:
19+
20+
unsubscribe_link = format_html(
21+
'{}/newsletter/unsubscribe/{}/',
22+
settings.SITE_URL, # Ensure this is set in your settings, e.g., 'http://127.0.0.1:8000'
23+
subscriber.email
24+
)
25+
26+
content = newsletter.content.replace('{unsubscribe_link}', unsubscribe_link)
27+
28+
send_mail(
29+
subject=newsletter.subject,
30+
message='',
31+
from_email=settings.DEFAULT_FROM_EMAIL,
32+
recipient_list=[subscriber.email],
33+
html_message=content
34+
)
35+
36+
except Exception as e:
37+
print(f"Error sending email to {subscriber.email}: {e}")
38+
39+
# Mark newsletter as sent
40+
newsletter.is_sent = True
41+
newsletter.last_sent = timezone.now()
42+
newsletter.save()
43+
44+
# Return a success message
45+
subscriber_count = Subscriber.objects.filter(is_active=True).count()
46+
print(f'Newsletter sent to {subscriber_count} subscribers')

server/apps/newsletter/tests.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# tests.py
2+
from django.test import TestCase, Client
3+
from django.urls import reverse
4+
from .models import Subscriber
5+
from .forms import SubscribeForm
6+
7+
class NewsletterViewsTest(TestCase):
8+
9+
def setUp(self):
10+
self.client = Client()
11+
self.subscribe_url = reverse('subscribe') # Adjust if URL name is different
12+
self.unsubscribe_url = reverse('unsubscribe', args=['test@example.com'])
13+
14+
def test_subscribe_view_get(self):
15+
response = self.client.get(self.subscribe_url)
16+
self.assertEqual(response.status_code, 200)
17+
self.assertTemplateUsed(response, 'newsletter/subscribe.html')
18+
self.assertIsInstance(response.context['form'], SubscribeForm)
19+
20+
def test_subscribe_view_post_valid(self):
21+
response = self.client.post(self.subscribe_url, {'email': 'test@example.com'})
22+
self.assertEqual(response.status_code, 200)
23+
self.assertTemplateUsed(response, 'newsletter/success.html')
24+
self.assertEqual(Subscriber.objects.count(), 1)
25+
self.assertEqual(Subscriber.objects.get().email, 'test@example.com')
26+
27+
def test_subscribe_view_post_invalid(self):
28+
response = self.client.post(self.subscribe_url, {'email': ''})
29+
self.assertEqual(response.status_code, 200)
30+
self.assertTemplateUsed(response, 'newsletter/subscribe.html')
31+
self.assertFormError(response, 'form', 'email', 'This field is required.')
32+
33+
def test_unsubscribe_view_valid(self):
34+
Subscriber.objects.create(email='test@example.com', is_active=True)
35+
response = self.client.get(reverse('unsubscribe', args=['test@example.com']))
36+
self.assertEqual(response.status_code, 200)
37+
self.assertTemplateUsed(response, 'newsletter/unsubscribe_success.html')
38+
self.assertFalse(Subscriber.objects.get(email='test@example.com').is_active)
39+
40+
def test_unsubscribe_view_invalid(self):
41+
response = self.client.get(reverse('unsubscribe', args=['nonexistent@example.com']))
42+
self.assertEqual(response.status_code, 200)
43+
self.assertTemplateUsed(response, 'newsletter/unsubscribe_fail.html')
44+
self.assertEqual(Subscriber.objects.count(), 0)

server/apps/newsletter/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.urls import path
2+
from . import views
3+
4+
app_name = 'newsletter'
5+
6+
urlpatterns = [
7+
path('subscribe/', views.subscribe, name='subscribe'),
8+
path('unsubscribe/<str:email>/', views.unsubscribe, name='unsubscribe'),
9+
]

server/apps/newsletter/views.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from django.shortcuts import render, redirect
2+
from django.http import HttpResponse
3+
from .models import Subscriber
4+
from .forms import SubscribeForm
5+
from django.views.decorators.csrf import csrf_exempt
6+
7+
8+
@csrf_exempt # Only use this if you can't handle CSRF token in your frontend
9+
def subscribe(request):
10+
if request.method == 'POST':
11+
form = SubscribeForm(request.POST)
12+
if form.is_valid():
13+
email = form.cleaned_data['email']
14+
Subscriber.objects.create(email=email)
15+
return HttpResponse('You have successfully subscribed.')
16+
else:
17+
form = SubscribeForm()
18+
19+
return render(request, 'newsletter/subscribe.html', {'form': form})
20+
21+
def unsubscribe(request, email):
22+
try:
23+
subscriber = Subscriber.objects.get(email=email)
24+
subscriber.is_active = False
25+
subscriber.save()
26+
return render(request, 'newsletter/unsubscribe_success.html', {'email': email})
27+
except Subscriber.DoesNotExist:
28+
return render(request, 'newsletter/unsubscribe_fail.html', {'email': None})

server/core/celery.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
'task': 'apps.research.tasks.publish_scheduled_articles',
2626
'schedule': crontab(minute='*/1'), # Runs every minute
2727
},
28+
29+
'send-scheduled-newsletter': {
30+
'task': 'apps.newsletter.tasks.send_newsletter_via_email',
31+
'schedule': crontab(minute='*/1'), # Runs every minute
32+
},
2833
}
2934

3035
@app.task(bind=True)

server/core/config/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
LOCAL_APPS = [
4848
'apps.common',
4949
'apps.research',
50+
'apps.newsletter',
5051
]
5152

5253
THIRD_PARTY_APPS = [
@@ -187,3 +188,4 @@
187188
from .jazzmin import *
188189
from .ckeditor import *
189190
from .celery_config import *
191+
from .mail import *

server/core/config/celery_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Celery settings
2-
CELERY_BROKER_URL = 'redis://localhost:6378/0' # Redis as a broker
3-
CELERY_RESULT_BACKEND = 'redis://localhost:6378/0'
2+
CELERY_BROKER_URL = 'redis://localhost:6379/0' # Redis as a broker
3+
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
44
CELERY_ACCEPT_CONTENT = ['json']
55
CELERY_TASK_SERIALIZER = 'json'
66
CELERY_RESULT_SERIALIZER = 'json'

server/core/config/local.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
from .base import *
2-
1+
from .base import *

server/core/config/mail.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from decouple import config
2+
3+
SITE_URL=config('SITE_URL')
4+
5+
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
6+
EMAIL_HOST = config('EMAIL_HOST')
7+
EMAIL_PORT = 465
8+
EMAIL_USE_TLS = False
9+
EMAIL_USE_SSL = True
10+
EMAIL_HOST_USER = config('EMAIL_HOST_USER')
11+
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD')
12+
DEFAULT_FROM_EMAIL = '2077 Collective'

server/core/token.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.http import JsonResponse
2+
from django.middleware.csrf import get_token
3+
4+
def csrf_token_view(request):
5+
return JsonResponse({'csrfToken': get_token(request)})

server/core/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
from django.conf.urls.static import static
33
from django.contrib import admin
44
from django.urls import path, include
5+
from .token import csrf_token_view
56

67
urlpatterns = [
78
path('admin/', admin.site.urls),
89

910
# Custom URLS
1011
path('', include('apps.research.urls')),
1112
path('api/', include('apps.research.urls')),
13+
path('newsletter/', include('apps.newsletter.urls')),
14+
path('get-csrf-token/', csrf_token_view, name='csrf_token'),
1215

1316
# Ckeditor URL
1417
path('ckeditor5/', include('django_ckeditor_5.urls')),

0 commit comments

Comments
 (0)