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