diff --git a/server/apps/newsletter/__init__.py b/server/apps/newsletter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/apps/newsletter/admin.py b/server/apps/newsletter/admin.py new file mode 100644 index 0000000..cf24709 --- /dev/null +++ b/server/apps/newsletter/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin, messages +from .models import Newsletter, Subscriber +from .tasks import send_newsletter_via_email + +@admin.action(description='Send selected newsletters') +def send_selected_newsletters(modeladmin, request, queryset): + for newsletter in queryset: + send_newsletter_via_email.delay() + + messages.success(request, f"{queryset.count()} newsletters have been scheduled for sending.") + + +@admin.register(Newsletter) +class NewsletterAdmin(admin.ModelAdmin): + list_display = ['subject', 'is_sent', 'scheduled_send_time', 'last_sent'] + readonly_fields = ['last_sent'] + actions = [send_selected_newsletters] + + def save_model(self, request, obj, form, change): + if change: # If editing an existing object + obj.is_sent = False # Set 'is_sent' to False when editing + super().save_model(request, obj, form, change) + +@admin.register(Subscriber) +class SubscriberAdmin(admin.ModelAdmin): + list_display = ['email', 'is_active', 'subscribed_at'] + list_filter = ['is_active'] + search_fields = ['email'] + diff --git a/server/apps/newsletter/apps.py b/server/apps/newsletter/apps.py new file mode 100644 index 0000000..04dd81f --- /dev/null +++ b/server/apps/newsletter/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class NewsletterConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.newsletter' diff --git a/server/apps/newsletter/forms.py b/server/apps/newsletter/forms.py new file mode 100644 index 0000000..5a7d0e0 --- /dev/null +++ b/server/apps/newsletter/forms.py @@ -0,0 +1,4 @@ +from django import forms + +class SubscribeForm(forms.Form): + email = forms.EmailField(label='Enter your email', required=True) diff --git a/server/apps/newsletter/migrations/0011_alter_newsletter_scheduled_send_time.py b/server/apps/newsletter/migrations/0011_alter_newsletter_scheduled_send_time.py new file mode 100644 index 0000000..fb28be2 --- /dev/null +++ b/server/apps/newsletter/migrations/0011_alter_newsletter_scheduled_send_time.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-08-19 23:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('newsletter', '0010_newsletter_last_sent'), + ] + + operations = [ + migrations.AlterField( + model_name='newsletter', + name='scheduled_send_time', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/server/apps/newsletter/migrations/__init__.py b/server/apps/newsletter/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/apps/newsletter/models.py b/server/apps/newsletter/models.py new file mode 100644 index 0000000..c269740 --- /dev/null +++ b/server/apps/newsletter/models.py @@ -0,0 +1,25 @@ +from django.db import models +from apps.common.models import BaseModel +from django.conf import settings +from django.utils import timezone + +# Create your models here. +class Subscriber(BaseModel): + email = models.EmailField(unique=True) + is_active = models.BooleanField(default=True) + subscribed_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.email + +class Newsletter(BaseModel): + """Model for storing newsletters.""" + subject = models.CharField(max_length=255) + content = models.TextField() + is_sent = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + last_sent = models.DateTimeField(blank=True, null=True) + scheduled_send_time = models.DateTimeField(blank=True, null=True) + + def __str__(self): + return self.subject \ No newline at end of file diff --git a/server/apps/newsletter/tasks.py b/server/apps/newsletter/tasks.py new file mode 100644 index 0000000..58a0ef1 --- /dev/null +++ b/server/apps/newsletter/tasks.py @@ -0,0 +1,46 @@ +from celery import shared_task +from django.core.mail import EmailMessage, send_mail +from django.conf import settings +from .models import Newsletter, Subscriber +from django.utils import timezone +from django.utils.html import format_html + +@shared_task +def send_newsletter_via_email(): + now = timezone.now() + # Get newsletters that need to be sent + newsletters = Newsletter.objects.filter(scheduled_send_time__lte=now, is_sent=False) + + for newsletter in newsletters: + subscribers = Subscriber.objects.filter(is_active=True) + + for subscriber in subscribers: + try: + + unsubscribe_link = format_html( + '{}/newsletter/unsubscribe/{}/', + settings.SITE_URL, # Ensure this is set in your settings, e.g., 'http://127.0.0.1:8000' + subscriber.email + ) + + content = newsletter.content.replace('{unsubscribe_link}', unsubscribe_link) + + send_mail( + subject=newsletter.subject, + message='', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[subscriber.email], + html_message=content + ) + + except Exception as e: + print(f"Error sending email to {subscriber.email}: {e}") + + # Mark newsletter as sent + newsletter.is_sent = True + newsletter.last_sent = timezone.now() + newsletter.save() + + # Return a success message + subscriber_count = Subscriber.objects.filter(is_active=True).count() + print(f'Newsletter sent to {subscriber_count} subscribers') \ No newline at end of file diff --git a/server/apps/newsletter/tests.py b/server/apps/newsletter/tests.py new file mode 100644 index 0000000..648c0bc --- /dev/null +++ b/server/apps/newsletter/tests.py @@ -0,0 +1,44 @@ +# tests.py +from django.test import TestCase, Client +from django.urls import reverse +from .models import Subscriber +from .forms import SubscribeForm + +class NewsletterViewsTest(TestCase): + + def setUp(self): + self.client = Client() + self.subscribe_url = reverse('subscribe') # Adjust if URL name is different + self.unsubscribe_url = reverse('unsubscribe', args=['test@example.com']) + + def test_subscribe_view_get(self): + response = self.client.get(self.subscribe_url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'newsletter/subscribe.html') + self.assertIsInstance(response.context['form'], SubscribeForm) + + def test_subscribe_view_post_valid(self): + response = self.client.post(self.subscribe_url, {'email': 'test@example.com'}) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'newsletter/success.html') + self.assertEqual(Subscriber.objects.count(), 1) + self.assertEqual(Subscriber.objects.get().email, 'test@example.com') + + def test_subscribe_view_post_invalid(self): + response = self.client.post(self.subscribe_url, {'email': ''}) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'newsletter/subscribe.html') + self.assertFormError(response, 'form', 'email', 'This field is required.') + + def test_unsubscribe_view_valid(self): + Subscriber.objects.create(email='test@example.com', is_active=True) + response = self.client.get(reverse('unsubscribe', args=['test@example.com'])) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'newsletter/unsubscribe_success.html') + self.assertFalse(Subscriber.objects.get(email='test@example.com').is_active) + + def test_unsubscribe_view_invalid(self): + response = self.client.get(reverse('unsubscribe', args=['nonexistent@example.com'])) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'newsletter/unsubscribe_fail.html') + self.assertEqual(Subscriber.objects.count(), 0) diff --git a/server/apps/newsletter/urls.py b/server/apps/newsletter/urls.py new file mode 100644 index 0000000..01d0ad5 --- /dev/null +++ b/server/apps/newsletter/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +app_name = 'newsletter' + +urlpatterns = [ + path('subscribe/', views.subscribe, name='subscribe'), + path('unsubscribe//', views.unsubscribe, name='unsubscribe'), +] \ No newline at end of file diff --git a/server/apps/newsletter/views.py b/server/apps/newsletter/views.py new file mode 100644 index 0000000..da14dd8 --- /dev/null +++ b/server/apps/newsletter/views.py @@ -0,0 +1,28 @@ +from django.shortcuts import render, redirect +from django.http import HttpResponse +from .models import Subscriber +from .forms import SubscribeForm +from django.views.decorators.csrf import csrf_exempt + + +@csrf_exempt # Only use this if you can't handle CSRF token in your frontend +def subscribe(request): + if request.method == 'POST': + form = SubscribeForm(request.POST) + if form.is_valid(): + email = form.cleaned_data['email'] + Subscriber.objects.create(email=email) + return HttpResponse('You have successfully subscribed.') + else: + form = SubscribeForm() + + return render(request, 'newsletter/subscribe.html', {'form': form}) + +def unsubscribe(request, email): + try: + subscriber = Subscriber.objects.get(email=email) + subscriber.is_active = False + subscriber.save() + return render(request, 'newsletter/unsubscribe_success.html', {'email': email}) + except Subscriber.DoesNotExist: + return render(request, 'newsletter/unsubscribe_fail.html', {'email': None}) diff --git a/server/core/celery.py b/server/core/celery.py index 58392c7..bb7db42 100644 --- a/server/core/celery.py +++ b/server/core/celery.py @@ -25,6 +25,11 @@ 'task': 'apps.research.tasks.publish_scheduled_articles', 'schedule': crontab(minute='*/1'), # Runs every minute }, + + 'send-scheduled-newsletter': { + 'task': 'apps.newsletter.tasks.send_newsletter_via_email', + 'schedule': crontab(minute='*/1'), # Runs every minute + }, } @app.task(bind=True) diff --git a/server/core/config/base.py b/server/core/config/base.py index 7eca1cb..10d15b1 100644 --- a/server/core/config/base.py +++ b/server/core/config/base.py @@ -47,6 +47,7 @@ LOCAL_APPS = [ 'apps.common', 'apps.research', + 'apps.newsletter', ] THIRD_PARTY_APPS = [ @@ -187,3 +188,4 @@ from .jazzmin import * from .ckeditor import * from .celery_config import * +from .mail import * diff --git a/server/core/config/celery_config.py b/server/core/config/celery_config.py index 3b15cce..2d82eb6 100644 --- a/server/core/config/celery_config.py +++ b/server/core/config/celery_config.py @@ -1,6 +1,6 @@ # Celery settings -CELERY_BROKER_URL = 'redis://localhost:6378/0' # Redis as a broker -CELERY_RESULT_BACKEND = 'redis://localhost:6378/0' +CELERY_BROKER_URL = 'redis://localhost:6379/0' # Redis as a broker +CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' CELERY_ACCEPT_CONTENT = ['json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' diff --git a/server/core/config/local.py b/server/core/config/local.py index cfee317..773cfc4 100644 --- a/server/core/config/local.py +++ b/server/core/config/local.py @@ -1,2 +1 @@ -from .base import * - +from .base import * \ No newline at end of file diff --git a/server/core/config/mail.py b/server/core/config/mail.py new file mode 100644 index 0000000..f5f3f4b --- /dev/null +++ b/server/core/config/mail.py @@ -0,0 +1,12 @@ +from decouple import config + +SITE_URL=config('SITE_URL') + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = config('EMAIL_HOST') +EMAIL_PORT = 465 +EMAIL_USE_TLS = False +EMAIL_USE_SSL = True +EMAIL_HOST_USER = config('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD') +DEFAULT_FROM_EMAIL = '2077 Collective' \ No newline at end of file diff --git a/server/core/token.py b/server/core/token.py new file mode 100644 index 0000000..9475b2e --- /dev/null +++ b/server/core/token.py @@ -0,0 +1,5 @@ +from django.http import JsonResponse +from django.middleware.csrf import get_token + +def csrf_token_view(request): + return JsonResponse({'csrfToken': get_token(request)}) diff --git a/server/core/urls.py b/server/core/urls.py index b9d07a2..43aff11 100644 --- a/server/core/urls.py +++ b/server/core/urls.py @@ -2,6 +2,7 @@ from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include +from .token import csrf_token_view urlpatterns = [ path('admin/', admin.site.urls), @@ -9,6 +10,8 @@ # Custom URLS path('', include('apps.research.urls')), path('api/', include('apps.research.urls')), + path('newsletter/', include('apps.newsletter.urls')), + path('get-csrf-token/', csrf_token_view, name='csrf_token'), # Ckeditor URL path('ckeditor5/', include('django_ckeditor_5.urls')), diff --git a/server/templates/newsletter/newsletter.html b/server/templates/newsletter/newsletter.html new file mode 100644 index 0000000..be57c2b --- /dev/null +++ b/server/templates/newsletter/newsletter.html @@ -0,0 +1,126 @@ + + + + + + Newsletter + + + + +
+
+

Welcome to Our Newsletter

+
+ +
+

Latest Updates

+

+ We are excited to bring you the latest news and updates from our team. + This month has been full of exciting developments, and we are thrilled + to share them with you! +

+

+ Stay tuned for more updates and be sure to follow us on our social + media channels. +

+ + Read More +
+ + +
+ + diff --git a/server/templates/newsletter/subscribe.html b/server/templates/newsletter/subscribe.html new file mode 100644 index 0000000..509e261 --- /dev/null +++ b/server/templates/newsletter/subscribe.html @@ -0,0 +1,80 @@ + + + + + Subscribe + + + +
+

Subscribe to our Newsletter

+
+ {% csrf_token %} + + +
+ {% if message %} +
{{ message }}
+ {% endif %} +
+ + diff --git a/server/templates/newsletter/success.html b/server/templates/newsletter/success.html new file mode 100644 index 0000000..55b291a --- /dev/null +++ b/server/templates/newsletter/success.html @@ -0,0 +1,20 @@ + + + + + + successfully + + + + +
+
+

Congrats! You have successfully subscribed.

+

Let's make Ethereum Cool Again, stay tuned for exciting newletters.

+
+
+ + + \ No newline at end of file diff --git a/server/templates/newsletter/unsubscribe_fail.html b/server/templates/newsletter/unsubscribe_fail.html new file mode 100644 index 0000000..4049d76 --- /dev/null +++ b/server/templates/newsletter/unsubscribe_fail.html @@ -0,0 +1,12 @@ + + + + + + Unsubscribe Failed + + +

Unsubscribe Failed

+

We couldn't find a subscription associated with your email address.

+ + diff --git a/server/templates/newsletter/unsubscribe_success.html b/server/templates/newsletter/unsubscribe_success.html new file mode 100644 index 0000000..64d0071 --- /dev/null +++ b/server/templates/newsletter/unsubscribe_success.html @@ -0,0 +1,15 @@ + + + + + + Unsubscribe Success + + +

You've been unsubscribed

+

+ {{ email }}, we're sorry to see you go. If you change your mind, you can + always subscribe again. +

+ +