diff --git a/mailing/__init__.py b/mailing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailing/admin.py b/mailing/admin.py new file mode 100644 index 0000000..158556e --- /dev/null +++ b/mailing/admin.py @@ -0,0 +1,317 @@ +from django.contrib import admin +from django.shortcuts import render, redirect +from django.contrib import messages +from django.urls import path, reverse +from django.utils.html import format_html +from django.contrib.auth import get_user_model +from django.http import HttpResponseRedirect +from django.core.mail import send_mail +from django.conf import settings +from django.utils import timezone + +from .models import MailingList, Subscriber, EmailTemplate, Campaign, EmailLog +from biz.models import Prospect +from sync.admin import SyncedObjectAdmin + +User = get_user_model() + + +@admin.register(MailingList) +class MailingListAdmin(SyncedObjectAdmin): + list_display = ('name', 'subscriber_count', 'creation_date') + search_fields = ('name', 'description') + readonly_fields = ('subscriber_count',) + + +@admin.register(EmailTemplate) +class EmailTemplateAdmin(SyncedObjectAdmin): + list_display = ('name', 'subject', 'creation_date') + search_fields = ('name', 'subject') + fieldsets = ( + (None, { + 'fields': ('name', 'subject') + }), + ('Content', { + 'fields': ('html_content', 'text_content'), + 'description': 'HTML content supports images and rich formatting. Text content is a fallback for plain text email clients.' + }), + ) + + +@admin.register(Subscriber) +class SubscriberAdmin(SyncedObjectAdmin): + list_display = ('email', 'full_name', 'is_active', 'creation_date', 'get_mailing_lists') + list_filter = ('is_active', 'mailing_lists', 'creation_date') + search_fields = ('email', 'first_name', 'last_name') + filter_horizontal = ('mailing_lists',) + readonly_fields = ('unsubscribe_token',) + + fieldsets = ( + (None, { + 'fields': ('email', 'first_name', 'last_name', 'is_active') + }), + ('Mailing Lists', { + 'fields': ('mailing_lists',) + }), + ('System', { + 'fields': ('unsubscribe_token', 'user', 'prospect'), + 'classes': ('collapse',) + }), + ) + + actions = ['import_from_users', 'import_from_prospects', 'deactivate_subscribers'] + + def get_mailing_lists(self, obj): + return ", ".join([ml.name for ml in obj.mailing_lists.all()]) + get_mailing_lists.short_description = 'Mailing Lists' + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('import-users/', self.admin_site.admin_view(self.import_users_view), name='mailing_subscriber_import_users'), + path('import-prospects/', self.admin_site.admin_view(self.import_prospects_view), name='mailing_subscriber_import_prospects'), + ] + return custom_urls + urls + + def import_users_view(self, request): + if request.method == 'POST': + mailing_list_id = request.POST.get('mailing_list') + try: + mailing_list = MailingList.objects.get(id=mailing_list_id) + users = User.objects.all() + + created_count = 0 + for user in users: + if user.email: + subscriber, created = Subscriber.objects.get_or_create( + email=user.email, + defaults={ + 'first_name': user.first_name, + 'last_name': user.last_name, + 'user': user, + } + ) + subscriber.mailing_lists.add(mailing_list) + if created: + created_count += 1 + + messages.success(request, f'Imported {created_count} users into mailing list "{mailing_list.name}"') + return redirect('admin:mailing_subscriber_changelist') + except MailingList.DoesNotExist: + messages.error(request, 'Selected mailing list does not exist') + + mailing_lists = MailingList.objects.all() + context = { + 'title': 'Import Users to Mailing List', + 'mailing_lists': mailing_lists, + } + return render(request, 'admin/mailing/subscriber/import_users.html', context) + + def import_prospects_view(self, request): + if request.method == 'POST': + mailing_list_id = request.POST.get('mailing_list') + try: + mailing_list = MailingList.objects.get(id=mailing_list_id) + prospects = Prospect.objects.filter(email__isnull=False) + + created_count = 0 + for prospect in prospects: + subscriber, created = Subscriber.objects.get_or_create( + email=prospect.email, + defaults={ + 'first_name': prospect.first_name, + 'last_name': prospect.last_name, + 'prospect': prospect, + } + ) + subscriber.mailing_lists.add(mailing_list) + if created: + created_count += 1 + + messages.success(request, f'Imported {created_count} prospects into mailing list "{mailing_list.name}"') + return redirect('admin:mailing_subscriber_changelist') + except MailingList.DoesNotExist: + messages.error(request, 'Selected mailing list does not exist') + + mailing_lists = MailingList.objects.all() + context = { + 'title': 'Import Prospects to Mailing List', + 'mailing_lists': mailing_lists, + } + return render(request, 'admin/mailing/subscriber/import_prospects.html', context) + + def import_from_users(self, request, queryset): + return redirect('admin:mailing_subscriber_import_users') + import_from_users.short_description = "Import from Users" + + def import_from_prospects(self, request, queryset): + return redirect('admin:mailing_subscriber_import_prospects') + import_from_prospects.short_description = "Import from Prospects" + + def deactivate_subscribers(self, request, queryset): + count = queryset.update(is_active=False) + messages.success(request, f'Deactivated {count} subscribers') + deactivate_subscribers.short_description = "Deactivate selected subscribers" + + +@admin.register(Campaign) +class CampaignAdmin(SyncedObjectAdmin): + list_display = ('name', 'subject', 'is_sent', 'sent_at', 'total_sent', 'total_opened', 'total_clicked') + list_filter = ('is_sent', 'sent_at', 'mailing_lists') + search_fields = ('name', 'subject') + filter_horizontal = ('mailing_lists',) + readonly_fields = ('is_sent', 'sent_at', 'total_sent', 'total_delivered', 'total_opened', 'total_clicked', 'total_bounced') + + fieldsets = ( + (None, { + 'fields': ('name', 'subject', 'mailing_lists') + }), + ('Content', { + 'fields': ('template', 'html_content', 'text_content'), + 'description': 'Use a template OR fill in content directly' + }), + ('Statistics', { + 'fields': ('is_sent', 'sent_at', 'total_sent', 'total_delivered', 'total_opened', 'total_clicked', 'total_bounced'), + 'classes': ('collapse',) + }), + ) + + actions = ['send_campaign'] + + def send_campaign(self, request, queryset): + if queryset.count() != 1: + messages.error(request, "Please select exactly one campaign to send.") + return + + campaign = queryset.first() + if campaign.is_sent: + messages.error(request, "This campaign has already been sent.") + return + + return redirect('admin:mailing_campaign_send', campaign.id) + send_campaign.short_description = "Send campaign" + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('/send/', self.admin_site.admin_view(self.send_campaign_view), name='mailing_campaign_send'), + ] + return custom_urls + urls + + def send_campaign_view(self, request, campaign_id): + try: + campaign = Campaign.objects.get(id=campaign_id) + except Campaign.DoesNotExist: + messages.error(request, "Campaign not found.") + return redirect('admin:mailing_campaign_changelist') + + if campaign.is_sent: + messages.error(request, "This campaign has already been sent.") + return redirect('admin:mailing_campaign_changelist') + + if request.method == 'POST': + # Send the campaign + subscribers = Subscriber.objects.filter( + mailing_lists__in=campaign.mailing_lists.all(), + is_active=True + ).distinct() + + content = campaign.get_content() + subject = campaign.get_subject() + + sent_count = 0 + failed_count = 0 + + for subscriber in subscribers: + try: + # Create email log entry + email_log, created = EmailLog.objects.get_or_create( + campaign=campaign, + subscriber=subscriber, + defaults={'sent_at': timezone.now()} + ) + + if not created and email_log.is_sent: + continue # Skip if already sent + + # Prepare email content with tracking and unsubscribe link + unsubscribe_url = request.build_absolute_uri( + reverse('mailing:unsubscribe', kwargs={'token': subscriber.unsubscribe_token}) + ) + + # Add tracking pixel for email opens + tracking_pixel_url = request.build_absolute_uri( + reverse('mailing:track_open', kwargs={'tracking_id': email_log.tracking_id}) + ) + + html_content = content['html'] + if html_content: + # Add tracking pixel (invisible 1x1 image) + html_content += f'' + html_content += f'

Unsubscribe' + + text_content = content['text'] + if text_content: + text_content += f'\n\nUnsubscribe: {unsubscribe_url}' + + # Send email + send_mail( + subject=subject, + message=text_content or '', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[subscriber.email], + html_message=html_content, + fail_silently=False, + ) + + email_log.sent_at = timezone.now() + email_log.save() + sent_count += 1 + + except Exception as e: + email_log.error_message = str(e) + email_log.save() + failed_count += 1 + + # Update campaign statistics + campaign.total_sent = sent_count + campaign.sent_at = timezone.now() + campaign.is_sent = True + campaign.save() + + if failed_count > 0: + messages.warning(request, f'Campaign sent to {sent_count} subscribers. {failed_count} emails failed.') + else: + messages.success(request, f'Campaign sent successfully to {sent_count} subscribers.') + + return redirect('admin:mailing_campaign_changelist') + + # Calculate how many subscribers would receive this email + subscriber_count = Subscriber.objects.filter( + mailing_lists__in=campaign.mailing_lists.all(), + is_active=True + ).distinct().count() + + context = { + 'title': f'Send Campaign: {campaign.name}', + 'campaign': campaign, + 'subscriber_count': subscriber_count, + } + return render(request, 'admin/mailing/campaign/send_campaign.html', context) + + +@admin.register(EmailLog) +class EmailLogAdmin(SyncedObjectAdmin): + list_display = ('campaign', 'subscriber', 'is_sent', 'is_opened', 'is_clicked', 'sent_at') + list_filter = ('campaign', 'sent_at', 'opened_at', 'clicked_at', 'bounced_at') + search_fields = ('subscriber__email', 'campaign__name') + readonly_fields = ('tracking_id', 'sent_at', 'delivered_at', 'opened_at', 'clicked_at', 'bounced_at') + + fieldsets = ( + (None, { + 'fields': ('campaign', 'subscriber', 'tracking_id') + }), + ('Tracking', { + 'fields': ('sent_at', 'delivered_at', 'opened_at', 'clicked_at', 'bounced_at', 'error_message') + }), + ) diff --git a/mailing/apps.py b/mailing/apps.py new file mode 100644 index 0000000..0a8291f --- /dev/null +++ b/mailing/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MailingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'mailing' + verbose_name = 'Mailing System' diff --git a/mailing/migrations/0001_initial.py b/mailing/migrations/0001_initial.py new file mode 100644 index 0000000..837b67f --- /dev/null +++ b/mailing/migrations/0001_initial.py @@ -0,0 +1,126 @@ +# Generated by Django 5.1 on 2025-10-06 13:38 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('biz', '0007_prospectgroup_delete_campaign'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EmailTemplate', + fields=[ + ('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_update', models.DateTimeField(default=django.utils.timezone.now)), + ('data_access_ids', models.JSONField(default=list)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('subject', models.CharField(max_length=200)), + ('html_content', models.TextField(help_text='HTML content with image support')), + ('text_content', models.TextField(blank=True, help_text='Plain text fallback', null=True)), + ('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='MailingList', + fields=[ + ('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_update', models.DateTimeField(default=django.utils.timezone.now)), + ('data_access_ids', models.JSONField(default=list)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True, null=True)), + ('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Campaign', + fields=[ + ('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_update', models.DateTimeField(default=django.utils.timezone.now)), + ('data_access_ids', models.JSONField(default=list)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('subject', models.CharField(max_length=200)), + ('html_content', models.TextField(blank=True, null=True)), + ('text_content', models.TextField(blank=True, null=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('is_sent', models.BooleanField(default=False)), + ('total_sent', models.IntegerField(default=0)), + ('total_delivered', models.IntegerField(default=0)), + ('total_opened', models.IntegerField(default=0)), + ('total_clicked', models.IntegerField(default=0)), + ('total_bounced', models.IntegerField(default=0)), + ('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='mailing.emailtemplate')), + ('mailing_lists', models.ManyToManyField(related_name='campaigns', to='mailing.mailinglist')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Subscriber', + fields=[ + ('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_update', models.DateTimeField(default=django.utils.timezone.now)), + ('data_access_ids', models.JSONField(default=list)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254)), + ('first_name', models.CharField(blank=True, max_length=100, null=True)), + ('last_name', models.CharField(blank=True, max_length=100, null=True)), + ('is_active', models.BooleanField(default=True)), + ('unsubscribe_token', models.UUIDField(default=uuid.uuid4, unique=True)), + ('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('mailing_lists', models.ManyToManyField(blank=True, related_name='subscribers', to='mailing.mailinglist')), + ('prospect', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='biz.prospect')), + ('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('email',)}, + }, + ), + migrations.CreateModel( + name='EmailLog', + fields=[ + ('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_update', models.DateTimeField(default=django.utils.timezone.now)), + ('data_access_ids', models.JSONField(default=list)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('tracking_id', models.UUIDField(default=uuid.uuid4, unique=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('delivered_at', models.DateTimeField(blank=True, null=True)), + ('opened_at', models.DateTimeField(blank=True, null=True)), + ('clicked_at', models.DateTimeField(blank=True, null=True)), + ('bounced_at', models.DateTimeField(blank=True, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_logs', to='mailing.campaign')), + ('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('subscriber', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_logs', to='mailing.subscriber')), + ], + options={ + 'unique_together': {('campaign', 'subscriber')}, + }, + ), + ] diff --git a/mailing/migrations/__init__.py b/mailing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailing/models.py b/mailing/models.py new file mode 100644 index 0000000..fcb547a --- /dev/null +++ b/mailing/models.py @@ -0,0 +1,158 @@ +import uuid +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone +from django.conf import settings +from sync.models import BaseModel + +User = get_user_model() + + +class MailingList(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + name = models.CharField(max_length=100) + description = models.TextField(blank=True, null=True) + + def delete_dependencies(self): + pass + + def __str__(self): + return self.name + + def subscriber_count(self): + return self.subscribers.filter(is_active=True).count() + + +class Subscriber(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + email = models.EmailField() + first_name = models.CharField(max_length=100, blank=True, null=True) + last_name = models.CharField(max_length=100, blank=True, null=True) + mailing_lists = models.ManyToManyField(MailingList, related_name='subscribers', blank=True) + is_active = models.BooleanField(default=True) + unsubscribe_token = models.UUIDField(default=uuid.uuid4, unique=True) + + # Optional reference to actual user models + user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) + prospect = models.ForeignKey('biz.Prospect', on_delete=models.CASCADE, blank=True, null=True) + + class Meta: + unique_together = ['email'] + + def delete_dependencies(self): + pass + + def __str__(self): + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name} ({self.email})" + return self.email + + def full_name(self): + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name}" + elif self.first_name: + return self.first_name + elif self.last_name: + return self.last_name + return "" + + +class EmailTemplate(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + name = models.CharField(max_length=100) + subject = models.CharField(max_length=200) + html_content = models.TextField(help_text="HTML content with image support") + text_content = models.TextField(blank=True, null=True, help_text="Plain text fallback") + + def delete_dependencies(self): + pass + + def __str__(self): + return self.name + + +class Campaign(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + name = models.CharField(max_length=100) + subject = models.CharField(max_length=200) + template = models.ForeignKey(EmailTemplate, on_delete=models.PROTECT, blank=True, null=True) + html_content = models.TextField(blank=True, null=True) + text_content = models.TextField(blank=True, null=True) + mailing_lists = models.ManyToManyField(MailingList, related_name='campaigns') + + sent_at = models.DateTimeField(blank=True, null=True) + is_sent = models.BooleanField(default=False) + + # Email tracking + total_sent = models.IntegerField(default=0) + total_delivered = models.IntegerField(default=0) + total_opened = models.IntegerField(default=0) + total_clicked = models.IntegerField(default=0) + total_bounced = models.IntegerField(default=0) + + def delete_dependencies(self): + pass + + def __str__(self): + return self.name + + def get_content(self): + if self.template: + return { + 'html': self.template.html_content, + 'text': self.template.text_content + } + return { + 'html': self.html_content, + 'text': self.text_content + } + + def get_subject(self): + return self.subject or (self.template.subject if self.template else "") + + +class EmailLog(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE, related_name='email_logs') + subscriber = models.ForeignKey(Subscriber, on_delete=models.CASCADE, related_name='email_logs') + + tracking_id = models.UUIDField(default=uuid.uuid4, unique=True) + + # Email status + sent_at = models.DateTimeField(blank=True, null=True) + delivered_at = models.DateTimeField(blank=True, null=True) + opened_at = models.DateTimeField(blank=True, null=True) + clicked_at = models.DateTimeField(blank=True, null=True) + bounced_at = models.DateTimeField(blank=True, null=True) + + # Error tracking + error_message = models.TextField(blank=True, null=True) + + class Meta: + unique_together = ['campaign', 'subscriber'] + + def delete_dependencies(self): + pass + + def __str__(self): + return f"{self.campaign.name} - {self.subscriber.email}" + + @property + def is_sent(self): + return self.sent_at is not None + + @property + def is_delivered(self): + return self.delivered_at is not None + + @property + def is_opened(self): + return self.opened_at is not None + + @property + def is_clicked(self): + return self.clicked_at is not None + + @property + def is_bounced(self): + return self.bounced_at is not None diff --git a/mailing/templates/admin/mailing/campaign/send_campaign.html b/mailing/templates/admin/mailing/campaign/send_campaign.html new file mode 100644 index 0000000..2b25bcd --- /dev/null +++ b/mailing/templates/admin/mailing/campaign/send_campaign.html @@ -0,0 +1,73 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

{{ title }}

+ +
+

Campaign Details

+ + + + + + + + + + + + + + + + + +
Name:{{ campaign.name }}
Subject:{{ campaign.subject }}
Mailing Lists: + {% for ml in campaign.mailing_lists.all %} + {{ ml.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
Subscribers to Receive:{{ subscriber_count }} active subscribers
+
+ +
+

Email Preview

+ {% with content=campaign.get_content %} + {% if content.html %} +

HTML Content:

+
+ {{ content.html|safe }} +
+ {% endif %} + + {% if content.text %} +

Text Content:

+
{{ content.text }}
+ {% endif %} + {% endwith %} +
+ +
+
+ {% csrf_token %} + + Cancel +
+
+ +
+

Warning: Once sent, a campaign cannot be sent again or modified. Make sure you have reviewed the content and recipient list carefully.

+

Each email will include an unsubscribe link automatically.

+
+{% endblock %} \ No newline at end of file diff --git a/mailing/templates/admin/mailing/subscriber/import_prospects.html b/mailing/templates/admin/mailing/subscriber/import_prospects.html new file mode 100644 index 0000000..7a9c880 --- /dev/null +++ b/mailing/templates/admin/mailing/subscriber/import_prospects.html @@ -0,0 +1,42 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

{{ title }}

+ +
+ {% csrf_token %} + +
+
+ + +
+
+ +
+ + Cancel +
+
+ +
+

This will import all prospects with email addresses from the CRM into the selected mailing list. Existing subscribers will not be duplicated.

+
+{% endblock %} \ No newline at end of file diff --git a/mailing/templates/admin/mailing/subscriber/import_users.html b/mailing/templates/admin/mailing/subscriber/import_users.html new file mode 100644 index 0000000..c24f479 --- /dev/null +++ b/mailing/templates/admin/mailing/subscriber/import_users.html @@ -0,0 +1,42 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

{{ title }}

+ +
+ {% csrf_token %} + +
+
+ + +
+
+ +
+ + Cancel +
+
+ +
+

This will import all users from the system into the selected mailing list. Existing subscribers will not be duplicated.

+
+{% endblock %} \ No newline at end of file diff --git a/mailing/templates/mailing/unsubscribe_confirm.html b/mailing/templates/mailing/unsubscribe_confirm.html new file mode 100644 index 0000000..60387df --- /dev/null +++ b/mailing/templates/mailing/unsubscribe_confirm.html @@ -0,0 +1,54 @@ + + + + Unsubscribe Confirmation + + + +
+

Unsubscribe Confirmation

+ +

Are you sure you want to unsubscribe {{ subscriber.email }} from all mailing lists?

+ +

You will no longer receive any emails from our mailing system.

+ +
+ {% csrf_token %} + + Cancel +
+
+ + \ No newline at end of file diff --git a/mailing/templates/mailing/unsubscribe_error.html b/mailing/templates/mailing/unsubscribe_error.html new file mode 100644 index 0000000..d043cff --- /dev/null +++ b/mailing/templates/mailing/unsubscribe_error.html @@ -0,0 +1,37 @@ + + + + Unsubscribe Error + + + +
+
+

Unsubscribe Error

+ +

The unsubscribe link is invalid or has expired.

+ +

If you are trying to unsubscribe from our mailing list, please contact us directly for assistance.

+
+ + \ No newline at end of file diff --git a/mailing/templates/mailing/unsubscribe_success.html b/mailing/templates/mailing/unsubscribe_success.html new file mode 100644 index 0000000..24dc502 --- /dev/null +++ b/mailing/templates/mailing/unsubscribe_success.html @@ -0,0 +1,39 @@ + + + + Unsubscribe Successful + + + +
+
+

Unsubscribe Successful

+ +

The email address {{ email }} has been successfully unsubscribed from all mailing lists.

+ +

You will no longer receive emails from our mailing system.

+ +

If you unsubscribed by mistake, please contact us to re-subscribe.

+
+ + \ No newline at end of file diff --git a/mailing/tests.py b/mailing/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/mailing/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/mailing/urls.py b/mailing/urls.py new file mode 100644 index 0000000..d9fb8d9 --- /dev/null +++ b/mailing/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +app_name = 'mailing' + +urlpatterns = [ + path('unsubscribe//', views.unsubscribe, name='unsubscribe'), + path('track/open//', views.track_email_open, name='track_open'), + path('track/click//', views.track_email_click, name='track_click'), +] \ No newline at end of file diff --git a/mailing/views.py b/mailing/views.py new file mode 100644 index 0000000..84448e9 --- /dev/null +++ b/mailing/views.py @@ -0,0 +1,86 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.http import HttpResponse, Http404 +from django.utils import timezone +from django.contrib import messages +from .models import Subscriber, EmailLog +import logging + +logger = logging.getLogger(__name__) + + +def unsubscribe(request, token): + """Handle unsubscribe requests using the unique token""" + try: + subscriber = get_object_or_404(Subscriber, unsubscribe_token=token) + + if request.method == 'POST': + subscriber.is_active = False + subscriber.save() + + context = { + 'success': True, + 'email': subscriber.email + } + return render(request, 'mailing/unsubscribe_success.html', context) + + context = { + 'subscriber': subscriber + } + return render(request, 'mailing/unsubscribe_confirm.html', context) + + except Subscriber.DoesNotExist: + context = { + 'error': True + } + return render(request, 'mailing/unsubscribe_error.html', context) + + +def track_email_open(request, tracking_id): + """Track email opens using a 1x1 pixel image""" + try: + email_log = EmailLog.objects.get(tracking_id=tracking_id) + + # Only record the first open + if not email_log.opened_at: + email_log.opened_at = timezone.now() + email_log.save() + + # Update campaign statistics + campaign = email_log.campaign + campaign.total_opened += 1 + campaign.save() + + logger.info(f"Email opened: {email_log.subscriber.email} for campaign {email_log.campaign.name}") + + except EmailLog.DoesNotExist: + logger.warning(f"Tracking ID not found: {tracking_id}") + + # Return a 1x1 transparent pixel + pixel_data = b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x21\xF9\x04\x01\x00\x00\x00\x00\x2C\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x04\x01\x00\x3B' + return HttpResponse(pixel_data, content_type='image/gif') + + +def track_email_click(request, tracking_id): + """Track email clicks and redirect to the original URL""" + try: + email_log = EmailLog.objects.get(tracking_id=tracking_id) + + # Only record the first click + if not email_log.clicked_at: + email_log.clicked_at = timezone.now() + email_log.save() + + # Update campaign statistics + campaign = email_log.campaign + campaign.total_clicked += 1 + campaign.save() + + logger.info(f"Email clicked: {email_log.subscriber.email} for campaign {email_log.campaign.name}") + + # Get the original URL from the query parameter + original_url = request.GET.get('url', '/') + return redirect(original_url) + + except EmailLog.DoesNotExist: + logger.warning(f"Tracking ID not found: {tracking_id}") + return redirect('/') # Redirect to home if tracking fails diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index 38b52cc..5efa834 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -37,6 +37,7 @@ INSTALLED_APPS = [ 'tournaments', 'shop', 'biz', + 'mailing', 'api', 'django.contrib.admin', 'django.contrib.auth', diff --git a/padelclub_backend/urls.py b/padelclub_backend/urls.py index bf418f8..53d12c2 100644 --- a/padelclub_backend/urls.py +++ b/padelclub_backend/urls.py @@ -24,6 +24,7 @@ urlpatterns = [ path("", include("tournaments.urls")), path('shop/', include('shop.urls')), + path('mailing/', include('mailing.urls')), # path("crm/", include("crm.urls")), path('roads/', include("api.urls")), path('kingdom/debug/', debug_tools_page, name='debug_tools'),