Compare commits
67 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
3ce30cf5f7 | 3 days ago |
|
|
cf28db9fd0 | 3 days ago |
|
|
08fd01e119 | 6 days ago |
|
|
f97dbd79cc | 7 days ago |
|
|
240bb3fc25 | 7 days ago |
|
|
5102e4c295 | 7 days ago |
|
|
6be947706e | 1 week ago |
|
|
eb25d0e609 | 1 week ago |
|
|
efdb414345 | 2 weeks ago |
|
|
85c56981a6 | 2 weeks ago |
|
|
f01a681e93 | 2 weeks ago |
|
|
3522ee87f5 | 2 weeks ago |
|
|
e215ca7e1d | 2 weeks ago |
|
|
174c2988b2 | 2 weeks ago |
|
|
ec079e1a7a | 2 weeks ago |
|
|
a31796aad0 | 3 weeks ago |
|
|
7c31c511dd | 3 weeks ago |
|
|
441815d9a8 | 3 weeks ago |
|
|
521acaf747 | 3 weeks ago |
|
|
93a27f9583 | 3 weeks ago |
|
|
d9130b0fdf | 3 weeks ago |
|
|
1218c74d26 | 3 weeks ago |
|
|
49d497d48f | 3 weeks ago |
|
|
0d330f3dcf | 3 weeks ago |
|
|
34924db360 | 3 weeks ago |
|
|
11d6913807 | 3 weeks ago |
|
|
59a39ffd49 | 3 weeks ago |
|
|
7bf560a6a2 | 3 weeks ago |
|
|
77b999fbb3 | 3 weeks ago |
|
|
8de8a9ac49 | 4 weeks ago |
|
|
80b6bc1136 | 4 weeks ago |
|
|
1339b65731 | 4 weeks ago |
|
|
0158ce150d | 4 weeks ago |
|
|
fcb2ef9549 | 4 weeks ago |
|
|
005d8877e7 | 4 weeks ago |
|
|
093015dac6 | 4 weeks ago |
|
|
f152a441d4 | 4 weeks ago |
|
|
ef0e7b6326 | 4 weeks ago |
|
|
1572bed50d | 4 weeks ago |
|
|
4fb9460572 | 4 weeks ago |
|
|
015934c663 | 4 weeks ago |
|
|
16bc3e4428 | 4 weeks ago |
|
|
3db14c6180 | 4 weeks ago |
|
|
6918677009 | 4 weeks ago |
|
|
3b56d59321 | 4 weeks ago |
|
|
7c1c37746c | 1 month ago |
|
|
908c0b7dc8 | 1 month ago |
|
|
00228e4c8a | 1 month ago |
|
|
808d65a5a3 | 1 month ago |
|
|
0f7516a617 | 1 month ago |
|
|
1bf31744b5 | 1 month ago |
|
|
4df1ccba28 | 1 month ago |
|
|
108fd9cafa | 1 month ago |
|
|
76b0b02933 | 1 month ago |
|
|
6a8b5c4d97 | 1 month ago |
|
|
69ad1bef02 | 1 month ago |
|
|
632651a5ef | 1 month ago |
|
|
b6de2f5653 | 1 month ago |
|
|
8583523a0e | 1 month ago |
|
|
c6d5af345f | 1 month ago |
|
|
5c36eb8781 | 1 month ago |
|
|
35884b728f | 1 month ago |
|
|
511066ccc8 | 1 month ago |
|
|
c8c54b5ac8 | 1 month ago |
|
|
12ddbb2c29 | 1 month ago |
|
|
2d0dfd1b8f | 1 month ago |
|
|
ff7718d044 | 1 month ago |
@ -0,0 +1,23 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-10-15 07:46 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('biz', '0007_prospectgroup_delete_campaign'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterField( |
||||||
|
model_name='activity', |
||||||
|
name='declination_reason', |
||||||
|
field=models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('TOO_FEW_TOURNAMENTS', 'Too few tournaments'), ('NOT_INTERESTED', 'Not interested'), ('UNKNOWN', 'Unknown')], max_length=50, null=True), |
||||||
|
), |
||||||
|
migrations.AlterField( |
||||||
|
model_name='activity', |
||||||
|
name='type', |
||||||
|
field=models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth'), ('WHATS_APP', 'WhatsApp')], max_length=20, null=True), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,448 @@ |
|||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load static %} |
||||||
|
|
||||||
|
{% block extrahead %} |
||||||
|
{{ block.super }} |
||||||
|
<style> |
||||||
|
.dashboard-container { |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.filter-switch { |
||||||
|
margin-bottom: 20px; |
||||||
|
padding: 15px; |
||||||
|
background: #f8f8f8; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
.filter-switch label { |
||||||
|
font-weight: bold; |
||||||
|
margin-right: 10px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.filter-switch input[type="checkbox"] { |
||||||
|
cursor: pointer; |
||||||
|
width: 18px; |
||||||
|
height: 18px; |
||||||
|
vertical-align: middle; |
||||||
|
} |
||||||
|
|
||||||
|
.status-section { |
||||||
|
margin-bottom: 30px; |
||||||
|
background: white; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.status-header { |
||||||
|
background: #417690; |
||||||
|
color: white; |
||||||
|
padding: 12px 15px; |
||||||
|
font-weight: bold; |
||||||
|
font-size: 14px; |
||||||
|
} |
||||||
|
|
||||||
|
.status-header .status-name { |
||||||
|
font-size: 16px; |
||||||
|
margin-right: 10px; |
||||||
|
} |
||||||
|
|
||||||
|
.status-header .count { |
||||||
|
font-size: 13px; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
table-layout: fixed; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table thead { |
||||||
|
background: #f9f9f9; |
||||||
|
border-bottom: 2px solid #ddd; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table thead th { |
||||||
|
padding: 10px 12px; |
||||||
|
text-align: left; |
||||||
|
font-weight: 600; |
||||||
|
font-size: 13px; |
||||||
|
color: #666; |
||||||
|
border-bottom: 1px solid #ddd; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(1), |
||||||
|
.prospect-table td:nth-child(1) { |
||||||
|
width: 225px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(2), |
||||||
|
.prospect-table td:nth-child(2) { |
||||||
|
width: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(3), |
||||||
|
.prospect-table td:nth-child(3) { |
||||||
|
width: 120px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(4), |
||||||
|
.prospect-table td:nth-child(4) { |
||||||
|
width: 140px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(5), |
||||||
|
.prospect-table td:nth-child(5) { |
||||||
|
width: 130px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(6), |
||||||
|
.prospect-table td:nth-child(6) { |
||||||
|
width: 130px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th.actions-col, |
||||||
|
.prospect-table td.actions-col { |
||||||
|
width: 80px; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.add-activity-btn { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
width: 24px; |
||||||
|
height: 24px; |
||||||
|
background: #70bf2b; |
||||||
|
color: white !important; |
||||||
|
text-decoration: none !important; |
||||||
|
border-radius: 50%; |
||||||
|
font-size: 18px; |
||||||
|
font-weight: bold; |
||||||
|
transition: background-color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.add-activity-btn:hover { |
||||||
|
background: #5fa624; |
||||||
|
color: white !important; |
||||||
|
text-decoration: none !important; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody tr { |
||||||
|
border-bottom: 1px solid #eee; |
||||||
|
transition: background-color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody tr:hover { |
||||||
|
background: #f5f5f5; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody td { |
||||||
|
padding: 10px 12px; |
||||||
|
font-size: 13px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody td a { |
||||||
|
color: #417690; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody td a:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-name { |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-entity { |
||||||
|
color: #666; |
||||||
|
font-style: italic; |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-date { |
||||||
|
color: #666; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-status { |
||||||
|
display: inline-block; |
||||||
|
padding: 3px 8px; |
||||||
|
background: #e8f4f8; |
||||||
|
border-radius: 3px; |
||||||
|
font-size: 11px; |
||||||
|
color: #417690; |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.empty-state { |
||||||
|
padding: 40px 15px; |
||||||
|
text-align: center; |
||||||
|
color: #999; |
||||||
|
font-style: italic; |
||||||
|
} |
||||||
|
</style> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="dashboard-container"> |
||||||
|
|
||||||
|
<!-- Quick Actions --> |
||||||
|
<div class="quick-actions" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 12px; padding: 25px; margin-bottom: 20px;"> |
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 15px;"> |
||||||
|
<a href="{% url 'admin:biz_prospect_changelist' %}" |
||||||
|
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||||
|
Prospects |
||||||
|
</a> |
||||||
|
<a href="{% url 'admin:biz_activity_changelist' %}" |
||||||
|
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||||
|
Activities |
||||||
|
</a> |
||||||
|
<a href="{% url 'admin:biz_entity_changelist' %}" |
||||||
|
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||||
|
Entities |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="filter-switch"> |
||||||
|
<label for="my-prospects-toggle"> |
||||||
|
<input type="checkbox" id="my-prospects-toggle" {% if filter_my %}checked{% endif %}> |
||||||
|
Show only my prospects |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- CONTACT AGAIN Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">CONTACT AGAIN</span> |
||||||
|
<span class="count">({{ contact_again_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if contact_again_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Contact Again</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in contact_again_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td><span class="prospect-status">{{ prospect.current_status }}</span></td> |
||||||
|
<td class="prospect-date">{{ prospect.contact_again|date:"d/m/Y" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- SHOULD_TEST Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">SHOULD TEST</span> |
||||||
|
<span class="count">({{ should_test_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if should_test_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Activity Type</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in should_test_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- TESTING Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">TESTING</span> |
||||||
|
<span class="count">({{ testing_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if testing_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Activity Type</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in testing_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- OTHERS Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">OTHERS</span> |
||||||
|
<span class="count">({{ others_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if others_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Activity Type</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in others_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td><span class="prospect-status">{{ prospect.current_status }}</span></td> |
||||||
|
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- RESPONDED Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">RESPONDED</span> |
||||||
|
<span class="count">({{ responded_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if responded_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Activity Type</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in responded_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
document.getElementById('my-prospects-toggle').addEventListener('change', function(e) { |
||||||
|
const url = new URL(window.location); |
||||||
|
if (e.target.checked) { |
||||||
|
url.searchParams.set('my', 'true'); |
||||||
|
} else { |
||||||
|
url.searchParams.delete('my'); |
||||||
|
} |
||||||
|
window.location.href = url.toString(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
@ -1,317 +0,0 @@ |
|||||||
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') |
|
||||||
}), |
|
||||||
) |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
from django.apps import AppConfig |
|
||||||
|
|
||||||
|
|
||||||
class MailingConfig(AppConfig): |
|
||||||
default_auto_field = 'django.db.models.BigAutoField' |
|
||||||
name = 'mailing' |
|
||||||
verbose_name = 'Mailing System' |
|
||||||
@ -1,126 +0,0 @@ |
|||||||
# 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')}, |
|
||||||
}, |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
# Generated by Django 5.1 on 2025-10-08 12:40 |
|
||||||
|
|
||||||
from django.db import migrations |
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration): |
|
||||||
|
|
||||||
dependencies = [ |
|
||||||
('mailing', '0001_initial'), |
|
||||||
] |
|
||||||
|
|
||||||
operations = [ |
|
||||||
migrations.RemoveField( |
|
||||||
model_name='subscriber', |
|
||||||
name='prospect', |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,158 +0,0 @@ |
|||||||
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 |
|
||||||
@ -1,73 +0,0 @@ |
|||||||
{% 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 %} |
|
||||||
@ -1,42 +0,0 @@ |
|||||||
{% 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 %} |
|
||||||
@ -1,42 +0,0 @@ |
|||||||
{% 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 %} |
|
||||||
@ -1,54 +0,0 @@ |
|||||||
<!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> |
|
||||||
@ -1,37 +0,0 @@ |
|||||||
<!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> |
|
||||||
@ -1,39 +0,0 @@ |
|||||||
<!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> |
|
||||||
@ -1,3 +0,0 @@ |
|||||||
from django.test import TestCase |
|
||||||
|
|
||||||
# Create your tests here. |
|
||||||
@ -1,10 +0,0 @@ |
|||||||
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'), |
|
||||||
] |
|
||||||
@ -1,86 +0,0 @@ |
|||||||
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 |
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-10-15 08:02 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('tournaments', '0139_customuser_organizers'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AddField( |
||||||
|
model_name='tournament', |
||||||
|
name='custom_club_name', |
||||||
|
field=models.CharField(blank=True, max_length=100, null=True), |
||||||
|
), |
||||||
|
] |
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,81 @@ |
|||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load static %} |
||||||
|
|
||||||
|
{% block extrahead %} |
||||||
|
{{ block.super }} |
||||||
|
{{ media }} |
||||||
|
<script type="text/javascript" src="{% url 'admin:jsi18n' %}"></script> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block extrastyle %} |
||||||
|
{{ block.super }} |
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/forms.css' %}"> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block breadcrumbs %} |
||||||
|
<div class="breadcrumbs"> |
||||||
|
<a href="{% url 'admin:index' %}">Home</a> |
||||||
|
› <a href="{% url 'admin:tournaments_event_changelist' %}">Events</a> |
||||||
|
› Set Club |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<h1>{{ title }}</h1> |
||||||
|
|
||||||
|
<p>You are about to set the club for the following {{ events|length }} event(s):</p> |
||||||
|
|
||||||
|
<div style="margin: 20px 0; padding: 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px;"> |
||||||
|
<ul style="margin: 0; padding-left: 20px;"> |
||||||
|
{% for event in events %} |
||||||
|
<li> |
||||||
|
<strong>{{ event.name }}</strong> |
||||||
|
{% if event.club %} |
||||||
|
(currently: {{ event.club.name }}) |
||||||
|
{% else %} |
||||||
|
(currently: No club assigned) |
||||||
|
{% endif %} |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<form method="post" id="club-form" action=""> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
{# Hidden fields to preserve the selection #} |
||||||
|
{% for hidden in form.hidden_fields %} |
||||||
|
{{ hidden }} |
||||||
|
{% endfor %} |
||||||
|
|
||||||
|
{% if form.non_field_errors %} |
||||||
|
<div class="errors"> |
||||||
|
{{ form.non_field_errors }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div style="margin: 20px 0;"> |
||||||
|
<fieldset class="module aligned"> |
||||||
|
<div class="form-row field-club_id"> |
||||||
|
<div> |
||||||
|
<label for="{{ form.club_id.id_for_label }}" class="required">{{ form.club_id.label }}:</label> |
||||||
|
<div class="related-widget-wrapper"> |
||||||
|
{{ form.club_id }} |
||||||
|
</div> |
||||||
|
{% if form.club_id.help_text %} |
||||||
|
<div class="help">{{ form.club_id.help_text }}</div> |
||||||
|
{% endif %} |
||||||
|
{% if form.club_id.errors %} |
||||||
|
<div class="errors">{{ form.club_id.errors }}</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</fieldset> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="submit-row" style="margin-top: 20px; padding: 10px; text-align: right;"> |
||||||
|
<input type="submit" name="apply" value="Set Club" class="default" style="margin-right: 10px;"/> |
||||||
|
<a href="{% url 'admin:tournaments_event_changelist' %}" class="button cancel-link">Cancel</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
{% endblock %} |
||||||
Loading…
Reference in new issue