diff --git a/sage_newsletter/__init__.py b/sage_newsletter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sage_newsletter/actions/__init__.py b/sage_newsletter/actions/__init__.py new file mode 100644 index 0000000..1ddda6f --- /dev/null +++ b/sage_newsletter/actions/__init__.py @@ -0,0 +1,5 @@ +from .newsletter import NewsletterSubscriptionActions + +__all__ = [ + "NewsletterSubscriptionActions" +] diff --git a/sage_newsletter/actions/newsletter.py b/sage_newsletter/actions/newsletter.py new file mode 100644 index 0000000..f476fa7 --- /dev/null +++ b/sage_newsletter/actions/newsletter.py @@ -0,0 +1,15 @@ +from django.utils.translation import gettext_lazy as _ + + +class NewsletterSubscriptionActions: + @staticmethod + def confirm_subscriptions(modeladmin, request, queryset): + queryset.update(confirmed=True) + + confirm_subscriptions.short_description = _("Confirm selected subscriptions") + + @staticmethod + def deactivate_subscriptions(modeladmin, request, queryset): + queryset.update(is_active=False) + + deactivate_subscriptions.short_description = _("Deactivate selected subscriptions") diff --git a/sage_newsletter/admin.py b/sage_newsletter/admin.py new file mode 100644 index 0000000..0f78467 --- /dev/null +++ b/sage_newsletter/admin.py @@ -0,0 +1,48 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .actions import NewsletterSubscriptionActions +from .models import NewsletterSubscriber + + +@admin.register(NewsletterSubscriber) +class NewsletterSubscriberAdmin(admin.ModelAdmin): + """ + Newsletter Subscriber Admin + """ + + save_on_top = True + list_display = ( + "email", + "date_subscribed", + "confirmed", + "preferences", + "frequency", + "language", + "is_active", + ) + list_filter = ( + "confirmed", + "preferences", + "frequency", + "language", + "is_active", + "gdpr_consent", + ) + search_fields = ("email",) + readonly_fields = ("date_subscribed", "unsubscribe_token", "last_sent") + fieldsets = ( + ( + _("Subscriber Information"), + {"fields": ("email", "date_subscribed", "confirmed")}, + ), + (_("Preferences"), {"fields": ("preferences", "frequency", "language")}), + ( + _("Subscription Status"), + {"fields": ("is_active", "gdpr_consent", "unsubscribe_token", "last_sent")}, + ), + ) + actions = [ + NewsletterSubscriptionActions.confirm_subscriptions, + NewsletterSubscriptionActions.deactivate_subscriptions, + ] diff --git a/sage_newsletter/apps.py b/sage_newsletter/apps.py new file mode 100644 index 0000000..8ec3673 --- /dev/null +++ b/sage_newsletter/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class NewsletterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "sage_newsletter" + verbose_name = _("Newsletter") diff --git a/sage_newsletter/forms.py b/sage_newsletter/forms.py new file mode 100644 index 0000000..28bf298 --- /dev/null +++ b/sage_newsletter/forms.py @@ -0,0 +1,57 @@ +from django import forms +from django.core.exceptions import ValidationError + +from .models import NewsletterSubscriber + + +class NewsletterSubscriptionForm(forms.ModelForm): + """ + A Django ModelForm for handling newsletter subscriptions. + + This form is associated with the NewsletterSubscriber model and is used for + subscribing users to a newsletter service. It handles both new subscriptions + and the reactivation of existing but inactive subscriptions. + + The form only exposes the 'email' field for input, as it's the primary field + required for newsletter subscriptions. + + Attributes: + Meta: An inner class that defines form-specific details like the associated model + and the fields to be included in the form. + """ + + class Meta: + model = NewsletterSubscriber + fields = ["email"] + + def clean_email(self): + """ + Validates and processes the email field input. + + Checks if the provided email already exists in the database. If it does, the method + determines whether the associated subscription is active or inactive. Active + subscriptions will cause the method to raise a ValidationError, indicating that + the email is already in use. Inactive subscriptions will be reactivated. + + Returns: + str: The cleaned email data. + + Raises: + ValidationError: If the email address is already subscribed and active. + """ + email = self.cleaned_data.get("email") + try: + subscriber = NewsletterSubscriber.objects.get(email=email) + if subscriber.is_active: + raise ValidationError( + "This email address is already subscribed and active." + ) + else: + # Mark the subscriber as reactivated and save + self.instance = subscriber + self.instance.is_active = True + self.instance.save() + except NewsletterSubscriber.DoesNotExist: + # Email not found, it's a new subscriber + pass + return email diff --git a/sage_newsletter/helpers/__init__.py b/sage_newsletter/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sage_newsletter/helpers/text_choices.py b/sage_newsletter/helpers/text_choices.py new file mode 100644 index 0000000..824e2a6 --- /dev/null +++ b/sage_newsletter/helpers/text_choices.py @@ -0,0 +1,20 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ContentPreferences(models.TextChoices): + NEWS = "NEWS", _("News") + DEALS = "DEALS", _("Deals") + TIPS = "TIPS", _("Tips") + + +class FrequencyPreferences(models.TextChoices): + DAILY = "DAILY", _("Daily") + WEEKLY = "WEEKLY", _("Weekly") + MONTHLY = "MONTHLY", _("Monthly") + + +class LanguagePreferences(models.TextChoices): + EN = "EN", _("English") + ES = "FA", _("Spanish") + FR = "AR", _("Arabic") diff --git a/sage_newsletter/migrations/__init__.py b/sage_newsletter/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sage_newsletter/models.py b/sage_newsletter/models.py new file mode 100644 index 0000000..b1e8afe --- /dev/null +++ b/sage_newsletter/models.py @@ -0,0 +1,102 @@ +import uuid + +from django.conf import settings +from django.db import models +from django.utils import timezone as tz +from django.utils.translation import gettext_lazy as _ + +from .helpers.text_choices import ContentPreferences, FrequencyPreferences + + +class NewsletterSubscriber(models.Model): + """ + Newsletter Subscriber + """ + + email = models.EmailField( + unique=True, + verbose_name=_("Email Address"), + help_text="The email address of the subscriber.", + db_comment="Unique email address used for newsletter subscription.", + ) + date_subscribed = models.DateTimeField( + default=tz.now, + verbose_name=_("Date Subscribed"), + help_text="The date and time when the subscription was created.", + db_comment="Timestamp of when the subscriber was added to the list.", + ) + confirmed = models.BooleanField( + default=False, + verbose_name=_("Confirmed Subscription"), + help_text="Whether the subscriber has confirmed their email address.", + db_comment="Boolean flag indicating confirmed subscription status.", + ) + unsubscribe_token = models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name=_("Unsubscribe Token"), + help_text="A unique token used for securely unsubscribing from the newsletter.", + db_comment="Unique token for secure unsubscribe functionality.", + ) + preferences = models.CharField( + max_length=50, + choices=ContentPreferences.choices, + default="NEWS", + verbose_name=_("Content Preferences"), + help_text="The type of content the subscriber prefers to receive.", + db_comment="Subscriber's content preference selection.", + ) + frequency = models.CharField( + max_length=50, + choices=FrequencyPreferences.choices, + default="WEEKLY", + verbose_name=_("Frequency Preferences"), + help_text="How often the subscriber wishes to receive the newsletter.", + db_comment="Subscriber's preferred frequency of newsletter delivery.", + ) + language = models.CharField( + max_length=2, + choices=settings.LANGUAGES, + default=settings.LANGUAGE_CODE, + verbose_name=_("Language Preference"), + help_text="The preferred language for the newsletter.", + db_comment="Subscriber's preferred language for the newsletter.", + ) + gdpr_consent = models.BooleanField( + default=False, + verbose_name=_("GDPR Consent"), + help_text="Whether the subscriber has given consent under GDPR.", + db_comment="Flag indicating GDPR consent has been given by the subscriber.", + ) + last_sent = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Last Newsletter Sent"), + help_text="The date and time when the last newsletter was sent to this subscriber.", + db_comment="Timestamp of the last newsletter sent to the subscriber.", + ) + is_active = models.BooleanField( + default=True, + verbose_name=_("Is Active"), + help_text="Whether the subscription is currently active.", + db_comment="Boolean flag indicating whether the subscription is active.", + ) + + objects = models.Manager() + + class Meta: + """ + Meta + """ + + verbose_name = _("Newsletter Subscriber") + verbose_name_plural = _("Newsletter Subscribers") + db_table = "sage_newsletter_subscriber" + db_table_comment = "Table for storing newsletter subscriber information." + + def __str__(self): + return self.email + + def __repr__(self): + return self.email diff --git a/sage_newsletter/views.py b/sage_newsletter/views.py new file mode 100644 index 0000000..5999863 --- /dev/null +++ b/sage_newsletter/views.py @@ -0,0 +1,93 @@ +from django.contrib import messages +from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import redirect, render +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView +from django.views.generic.base import ContextMixin + +from .forms import NewsletterSubscriptionForm + + +class NewsletterViewMixin(ContextMixin): + """ + A mixin to add newsletter subscription functionality to a view. + + This mixin provides functionalities for handling the newsletter subscription form. + It can be mixed into any Django view to add these capabilities. + """ + + form_class = NewsletterSubscriptionForm + form_context_object = "newsletter_form" + success_url_name = None + + def __init__(self, *args, **kwargs): + """ + Initialize the view. + + Raises: + ImproperlyConfigured: If success_url_name is not set in the subclass. + """ + super().__init__(*args, **kwargs) + if not self.success_url_name: + raise ImproperlyConfigured( + f"{self.__class__.__name__} is missing the 'success_url_name' attribute. " + "You must define 'success_url_name' in your view." + ) + + def get_context_data(self, **kwargs): + """ + Inserts the form into the context dict for rendering. + + This method extends the base `get_context_data` method to add the newsletter + subscription form to the context, making it available in the template. + + Args: + **kwargs: Keyword arguments from the view. + + Returns: + dict: The context dictionary with the form included. + """ + context = super().get_context_data(**kwargs) + context[self.form_context_object] = self.form_class() + return context + + def post(self, request, *args, **kwargs): + """ + Handles POST requests for newsletter subscription. + + This method processes the newsletter subscription form. If the form is valid, + it either adds a new subscription or reactivates an existing one. Appropriate + success messages are displayed to the user after processing. + + Args: + request (HttpRequest): The request object. + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + HttpResponseRedirect: Redirects to the specified URL on success. + HttpResponse: Renders the template with context on failure. + """ + form = self.form_class(request.POST) + if form.is_valid(): + form.save() + if hasattr(form, "reactivated") and form.reactivated: + messages.success( + request, + _( + "We've reactivated your email address. Thanks for subscribing again!" + ), + ) + else: + messages.success( + request, _("You have successfully subscribed to the newsletter.") + ) + return redirect(request.path) + self.object_list = self.get_queryset() + + if isinstance(self, DetailView): + self.object = self.get_object() + + context = self.get_context_data() + context[self.form_context_object] = form + return render(request, self.template_name, context)