adds mailing app

mailing
Laurent 1 month ago
parent 169bc465c5
commit d6477c781a
  1. 0
      mailing/__init__.py
  2. 317
      mailing/admin.py
  3. 7
      mailing/apps.py
  4. 126
      mailing/migrations/0001_initial.py
  5. 0
      mailing/migrations/__init__.py
  6. 158
      mailing/models.py
  7. 73
      mailing/templates/admin/mailing/campaign/send_campaign.html
  8. 42
      mailing/templates/admin/mailing/subscriber/import_prospects.html
  9. 42
      mailing/templates/admin/mailing/subscriber/import_users.html
  10. 54
      mailing/templates/mailing/unsubscribe_confirm.html
  11. 37
      mailing/templates/mailing/unsubscribe_error.html
  12. 39
      mailing/templates/mailing/unsubscribe_success.html
  13. 3
      mailing/tests.py
  14. 10
      mailing/urls.py
  15. 86
      mailing/views.py
  16. 1
      padelclub_backend/settings.py
  17. 1
      padelclub_backend/urls.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('<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>
&rsaquo; <a href="{% url 'admin:app_list' app_label='mailing' %}">{% trans 'Mailing' %}</a>
&rsaquo; <a href="{% url 'admin:mailing_campaign_changelist' %}">{% trans 'Campaigns' %}</a>
&rsaquo; {% 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>
&rsaquo; <a href="{% url 'admin:app_list' app_label='mailing' %}">{% trans 'Mailing' %}</a>
&rsaquo; <a href="{% url 'admin:mailing_subscriber_changelist' %}">{% trans 'Subscribers' %}</a>
&rsaquo; {% 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>
&rsaquo; <a href="{% url 'admin:app_list' app_label='mailing' %}">{% trans 'Mailing' %}</a>
&rsaquo; <a href="{% url 'admin:mailing_subscriber_changelist' %}">{% trans 'Subscribers' %}</a>
&rsaquo; {% 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

@ -37,6 +37,7 @@ INSTALLED_APPS = [
'tournaments',
'shop',
'biz',
'mailing',
'api',
'django.contrib.admin',
'django.contrib.auth',

@ -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'),

Loading…
Cancel
Save