parent
169bc465c5
commit
d6477c781a
@ -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('<uuid:campaign_id>/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'<img src="{tracking_pixel_url}" width="1" height="1" style="display:none;" />' |
||||||
|
html_content += f'<br><br><a href="{unsubscribe_url}">Unsubscribe</a>' |
||||||
|
|
||||||
|
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') |
||||||
|
}), |
||||||
|
) |
||||||
@ -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' |
||||||
@ -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')}, |
||||||
|
}, |
||||||
|
), |
||||||
|
] |
||||||
@ -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 |
||||||
@ -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 %} |
||||||
|
<div class="breadcrumbs"> |
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||||
|
› <a href="{% url 'admin:app_list' app_label='mailing' %}">{% trans 'Mailing' %}</a> |
||||||
|
› <a href="{% url 'admin:mailing_campaign_changelist' %}">{% trans 'Campaigns' %}</a> |
||||||
|
› {% trans 'Send Campaign' %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<h1>{{ title }}</h1> |
||||||
|
|
||||||
|
<div class="module"> |
||||||
|
<h2>Campaign Details</h2> |
||||||
|
<table> |
||||||
|
<tr> |
||||||
|
<th>Name:</th> |
||||||
|
<td>{{ campaign.name }}</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<th>Subject:</th> |
||||||
|
<td>{{ campaign.subject }}</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<th>Mailing Lists:</th> |
||||||
|
<td> |
||||||
|
{% for ml in campaign.mailing_lists.all %} |
||||||
|
{{ ml.name }}{% if not forloop.last %}, {% endif %} |
||||||
|
{% endfor %} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<th>Subscribers to Receive:</th> |
||||||
|
<td><strong>{{ subscriber_count }}</strong> active subscribers</td> |
||||||
|
</tr> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="module"> |
||||||
|
<h2>Email Preview</h2> |
||||||
|
{% with content=campaign.get_content %} |
||||||
|
{% if content.html %} |
||||||
|
<h3>HTML Content:</h3> |
||||||
|
<div style="border: 1px solid #ccc; padding: 10px; max-height: 300px; overflow-y: auto;"> |
||||||
|
{{ content.html|safe }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if content.text %} |
||||||
|
<h3>Text Content:</h3> |
||||||
|
<pre style="border: 1px solid #ccc; padding: 10px; max-height: 200px; overflow-y: auto;">{{ content.text }}</pre> |
||||||
|
{% endif %} |
||||||
|
{% endwith %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="submit-row"> |
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
<input type="submit" value="Send Campaign to {{ subscriber_count }} subscribers" class="default" onclick="return confirm('Are you sure you want to send this campaign? This action cannot be undone.');" /> |
||||||
|
<a href="{% url 'admin:mailing_campaign_changelist' %}" class="button cancel-link">Cancel</a> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="help"> |
||||||
|
<p><strong>Warning:</strong> Once sent, a campaign cannot be sent again or modified. Make sure you have reviewed the content and recipient list carefully.</p> |
||||||
|
<p>Each email will include an unsubscribe link automatically.</p> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -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 %} |
||||||
|
<div class="breadcrumbs"> |
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||||
|
› <a href="{% url 'admin:app_list' app_label='mailing' %}">{% trans 'Mailing' %}</a> |
||||||
|
› <a href="{% url 'admin:mailing_subscriber_changelist' %}">{% trans 'Subscribers' %}</a> |
||||||
|
› {% trans 'Import Prospects' %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<h1>{{ title }}</h1> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<div> |
||||||
|
<label for="mailing_list">Select Mailing List:</label> |
||||||
|
<select name="mailing_list" id="mailing_list" required> |
||||||
|
<option value="">---------</option> |
||||||
|
{% for ml in mailing_lists %} |
||||||
|
<option value="{{ ml.id }}">{{ ml.name }}</option> |
||||||
|
{% endfor %} |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="submit-row"> |
||||||
|
<input type="submit" value="Import Prospects" class="default" /> |
||||||
|
<a href="{% url 'admin:mailing_subscriber_changelist' %}" class="button cancel-link">Cancel</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div class="help"> |
||||||
|
<p>This will import all prospects with email addresses from the CRM into the selected mailing list. Existing subscribers will not be duplicated.</p> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -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 %} |
||||||
|
<div class="breadcrumbs"> |
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||||
|
› <a href="{% url 'admin:app_list' app_label='mailing' %}">{% trans 'Mailing' %}</a> |
||||||
|
› <a href="{% url 'admin:mailing_subscriber_changelist' %}">{% trans 'Subscribers' %}</a> |
||||||
|
› {% trans 'Import Users' %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<h1>{{ title }}</h1> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<div> |
||||||
|
<label for="mailing_list">Select Mailing List:</label> |
||||||
|
<select name="mailing_list" id="mailing_list" required> |
||||||
|
<option value="">---------</option> |
||||||
|
{% for ml in mailing_lists %} |
||||||
|
<option value="{{ ml.id }}">{{ ml.name }}</option> |
||||||
|
{% endfor %} |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="submit-row"> |
||||||
|
<input type="submit" value="Import Users" class="default" /> |
||||||
|
<a href="{% url 'admin:mailing_subscriber_changelist' %}" class="button cancel-link">Cancel</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div class="help"> |
||||||
|
<p>This will import all users from the system into the selected mailing list. Existing subscribers will not be duplicated.</p> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html> |
||||||
|
<head> |
||||||
|
<title>Unsubscribe Confirmation</title> |
||||||
|
<style> |
||||||
|
body { |
||||||
|
font-family: Arial, sans-serif; |
||||||
|
max-width: 600px; |
||||||
|
margin: 50px auto; |
||||||
|
padding: 20px; |
||||||
|
background-color: #f5f5f5; |
||||||
|
} |
||||||
|
.container { |
||||||
|
background-color: white; |
||||||
|
padding: 30px; |
||||||
|
border-radius: 8px; |
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
||||||
|
} |
||||||
|
.button { |
||||||
|
background-color: #dc3545; |
||||||
|
color: white; |
||||||
|
padding: 12px 24px; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 16px; |
||||||
|
text-decoration: none; |
||||||
|
display: inline-block; |
||||||
|
margin: 10px 5px; |
||||||
|
} |
||||||
|
.cancel-button { |
||||||
|
background-color: #6c757d; |
||||||
|
} |
||||||
|
.button:hover { |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="container"> |
||||||
|
<h1>Unsubscribe Confirmation</h1> |
||||||
|
|
||||||
|
<p>Are you sure you want to unsubscribe <strong>{{ subscriber.email }}</strong> from all mailing lists?</p> |
||||||
|
|
||||||
|
<p>You will no longer receive any emails from our mailing system.</p> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
<button type="submit" class="button">Yes, Unsubscribe Me</button> |
||||||
|
<a href="javascript:history.back()" class="button cancel-button">Cancel</a> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html> |
||||||
|
<head> |
||||||
|
<title>Unsubscribe Error</title> |
||||||
|
<style> |
||||||
|
body { |
||||||
|
font-family: Arial, sans-serif; |
||||||
|
max-width: 600px; |
||||||
|
margin: 50px auto; |
||||||
|
padding: 20px; |
||||||
|
background-color: #f5f5f5; |
||||||
|
} |
||||||
|
.container { |
||||||
|
background-color: white; |
||||||
|
padding: 30px; |
||||||
|
border-radius: 8px; |
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
.error-icon { |
||||||
|
color: #dc3545; |
||||||
|
font-size: 48px; |
||||||
|
margin-bottom: 20px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="container"> |
||||||
|
<div class="error-icon">✗</div> |
||||||
|
<h1>Unsubscribe Error</h1> |
||||||
|
|
||||||
|
<p>The unsubscribe link is invalid or has expired.</p> |
||||||
|
|
||||||
|
<p>If you are trying to unsubscribe from our mailing list, please contact us directly for assistance.</p> |
||||||
|
</div> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html> |
||||||
|
<head> |
||||||
|
<title>Unsubscribe Successful</title> |
||||||
|
<style> |
||||||
|
body { |
||||||
|
font-family: Arial, sans-serif; |
||||||
|
max-width: 600px; |
||||||
|
margin: 50px auto; |
||||||
|
padding: 20px; |
||||||
|
background-color: #f5f5f5; |
||||||
|
} |
||||||
|
.container { |
||||||
|
background-color: white; |
||||||
|
padding: 30px; |
||||||
|
border-radius: 8px; |
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
.success-icon { |
||||||
|
color: #28a745; |
||||||
|
font-size: 48px; |
||||||
|
margin-bottom: 20px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="container"> |
||||||
|
<div class="success-icon">✓</div> |
||||||
|
<h1>Unsubscribe Successful</h1> |
||||||
|
|
||||||
|
<p>The email address <strong>{{ email }}</strong> has been successfully unsubscribed from all mailing lists.</p> |
||||||
|
|
||||||
|
<p>You will no longer receive emails from our mailing system.</p> |
||||||
|
|
||||||
|
<p>If you unsubscribed by mistake, please contact us to re-subscribe.</p> |
||||||
|
</div> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
from django.urls import path |
||||||
|
from . import views |
||||||
|
|
||||||
|
app_name = 'mailing' |
||||||
|
|
||||||
|
urlpatterns = [ |
||||||
|
path('unsubscribe/<uuid:token>/', views.unsubscribe, name='unsubscribe'), |
||||||
|
path('track/open/<uuid:tracking_id>/', views.track_email_open, name='track_open'), |
||||||
|
path('track/click/<uuid:tracking_id>/', views.track_email_click, name='track_click'), |
||||||
|
] |
||||||
@ -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 |
||||||
Loading…
Reference in new issue