Improve campaign personalization and contact duplicate handling.
Add merge tags for email/SMS, paginated removable recipients, PCM event panels for postcard campaigns, and a portal modal when phone/address matches an existing contact.
This commit is contained in:
+77
-40
@@ -60,6 +60,9 @@ def find_matching_contact(
|
|||||||
email: str = "",
|
email: str = "",
|
||||||
phone: str = "",
|
phone: str = "",
|
||||||
postal_address: dict | None = None,
|
postal_address: dict | None = None,
|
||||||
|
match_email: bool = True,
|
||||||
|
match_phone: bool = True,
|
||||||
|
match_address: bool = True,
|
||||||
) -> tuple[Contact | None, str]:
|
) -> tuple[Contact | None, str]:
|
||||||
"""
|
"""
|
||||||
Resolve an existing contact.
|
Resolve an existing contact.
|
||||||
@@ -68,11 +71,12 @@ def find_matching_contact(
|
|||||||
Returns (contact, reason) where reason is email|phone|address|"".
|
Returns (contact, reason) where reason is email|phone|address|"".
|
||||||
"""
|
"""
|
||||||
email_norm = (email or "").strip().lower()
|
email_norm = (email or "").strip().lower()
|
||||||
if email_norm:
|
if match_email and email_norm:
|
||||||
hit = Contact.objects.filter(email__iexact=email_norm).first()
|
hit = Contact.objects.filter(email__iexact=email_norm).first()
|
||||||
if hit:
|
if hit:
|
||||||
return hit, "email"
|
return hit, "email"
|
||||||
|
|
||||||
|
if match_phone:
|
||||||
phone_digits = normalize_phone_digits(phone)
|
phone_digits = normalize_phone_digits(phone)
|
||||||
if len(phone_digits) >= _PHONE_MIN_DIGITS:
|
if len(phone_digits) >= _PHONE_MIN_DIGITS:
|
||||||
tail = phone_digits[-10:]
|
tail = phone_digits[-10:]
|
||||||
@@ -81,7 +85,7 @@ def find_matching_contact(
|
|||||||
if len(other) >= _PHONE_MIN_DIGITS and other[-10:] == tail:
|
if len(other) >= _PHONE_MIN_DIGITS and other[-10:] == tail:
|
||||||
return Contact.objects.get(pk=row.pk), "phone"
|
return Contact.objects.get(pk=row.pk), "phone"
|
||||||
|
|
||||||
if Contact.postal_address_has_content(postal_address):
|
if match_address and Contact.postal_address_has_content(postal_address):
|
||||||
line1 = (postal_address.get("line1") or "").strip()
|
line1 = (postal_address.get("line1") or "").strip()
|
||||||
zip_code = (postal_address.get("zip") or "").strip()
|
zip_code = (postal_address.get("zip") or "").strip()
|
||||||
candidates = Contact.objects.exclude(postal_address={})
|
candidates = Contact.objects.exclude(postal_address={})
|
||||||
@@ -110,45 +114,18 @@ def _merge_notes(existing: str, addition: str) -> str:
|
|||||||
return f"{existing}\n{addition}".strip()
|
return f"{existing}\n{addition}".strip()
|
||||||
|
|
||||||
|
|
||||||
def upsert_contact(
|
def _apply_contact_fields(
|
||||||
|
existing: Contact,
|
||||||
*,
|
*,
|
||||||
email: str,
|
email_norm: str,
|
||||||
first_name: str = "",
|
first_name: str,
|
||||||
last_name: str = "",
|
last_name: str,
|
||||||
phone: str = "",
|
phone: str,
|
||||||
postal_address: dict | None = None,
|
postal: dict,
|
||||||
source: str = Contact.Source.CONTACT_FORM,
|
has_postal: bool,
|
||||||
notes_append: str = "",
|
source: str,
|
||||||
) -> tuple[Contact, bool, str]:
|
notes_append: str,
|
||||||
"""
|
) -> None:
|
||||||
Find or create a contact, merging on email / phone / address.
|
|
||||||
|
|
||||||
Returns (contact, created, match_reason).
|
|
||||||
When merged onto a different email, the submitted email is noted in ``notes``.
|
|
||||||
"""
|
|
||||||
email_norm = (email or "").strip().lower()
|
|
||||||
phone = (phone or "").strip()
|
|
||||||
postal = postal_address if isinstance(postal_address, dict) else {}
|
|
||||||
has_postal = Contact.postal_address_has_content(postal)
|
|
||||||
|
|
||||||
existing, reason = find_matching_contact(
|
|
||||||
email=email_norm,
|
|
||||||
phone=phone,
|
|
||||||
postal_address=postal if has_postal else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing is None:
|
|
||||||
contact = Contact.objects.create(
|
|
||||||
email=email_norm or None,
|
|
||||||
first_name=(first_name or "").strip(),
|
|
||||||
last_name=(last_name or "").strip(),
|
|
||||||
phone=phone,
|
|
||||||
postal_address=postal if has_postal else {},
|
|
||||||
source=source,
|
|
||||||
notes=(notes_append or "").strip(),
|
|
||||||
)
|
|
||||||
return contact, True, ""
|
|
||||||
|
|
||||||
changed: list[str] = []
|
changed: list[str] = []
|
||||||
if first_name and first_name.strip():
|
if first_name and first_name.strip():
|
||||||
existing.first_name = first_name.strip()
|
existing.first_name = first_name.strip()
|
||||||
@@ -199,4 +176,64 @@ def upsert_contact(
|
|||||||
fields = sorted(set(changed) | {"updated_at"})
|
fields = sorted(set(changed) | {"updated_at"})
|
||||||
existing.save(update_fields=fields)
|
existing.save(update_fields=fields)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_contact(
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
first_name: str = "",
|
||||||
|
last_name: str = "",
|
||||||
|
phone: str = "",
|
||||||
|
postal_address: dict | None = None,
|
||||||
|
source: str = Contact.Source.CONTACT_FORM,
|
||||||
|
notes_append: str = "",
|
||||||
|
merge_phone_address: bool = True,
|
||||||
|
merge_into: Contact | None = None,
|
||||||
|
) -> tuple[Contact, bool, str]:
|
||||||
|
"""
|
||||||
|
Find or create a contact, merging on email / phone / address.
|
||||||
|
|
||||||
|
Returns (contact, created, match_reason).
|
||||||
|
|
||||||
|
``merge_phone_address=False`` only merges on exact email.
|
||||||
|
``merge_into`` forces merge into that contact row.
|
||||||
|
"""
|
||||||
|
email_norm = (email or "").strip().lower()
|
||||||
|
phone = (phone or "").strip()
|
||||||
|
postal = postal_address if isinstance(postal_address, dict) else {}
|
||||||
|
has_postal = Contact.postal_address_has_content(postal)
|
||||||
|
|
||||||
|
if merge_into is not None:
|
||||||
|
existing, reason = merge_into, "manual"
|
||||||
|
else:
|
||||||
|
existing, reason = find_matching_contact(
|
||||||
|
email=email_norm,
|
||||||
|
phone=phone,
|
||||||
|
postal_address=postal if has_postal else None,
|
||||||
|
match_phone=merge_phone_address,
|
||||||
|
match_address=merge_phone_address,
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
contact = Contact.objects.create(
|
||||||
|
email=email_norm or None,
|
||||||
|
first_name=(first_name or "").strip(),
|
||||||
|
last_name=(last_name or "").strip(),
|
||||||
|
phone=phone,
|
||||||
|
postal_address=postal if has_postal else {},
|
||||||
|
source=source,
|
||||||
|
notes=(notes_append or "").strip(),
|
||||||
|
)
|
||||||
|
return contact, True, ""
|
||||||
|
|
||||||
|
_apply_contact_fields(
|
||||||
|
existing,
|
||||||
|
email_norm=email_norm,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
phone=phone,
|
||||||
|
postal=postal,
|
||||||
|
has_postal=has_postal,
|
||||||
|
source=source,
|
||||||
|
notes_append=notes_append,
|
||||||
|
)
|
||||||
return existing, False, reason
|
return existing, False, reason
|
||||||
|
|||||||
@@ -4,10 +4,32 @@
|
|||||||
{% block topbar_title %}New contact{% endblock %}
|
{% block topbar_title %}New contact{% endblock %}
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
|
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
|
||||||
|
<style>
|
||||||
|
.portal-modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(17, 24, 39, 0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000; padding: 16px;
|
||||||
|
}
|
||||||
|
.portal-modal {
|
||||||
|
background: #fff; border: 1px solid var(--monica-border);
|
||||||
|
max-width: 480px; width: 100%; padding: 20px 22px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.portal-modal h3 { margin: 0 0 8px; font-size: 18px; }
|
||||||
|
.portal-modal p { margin: 0 0 12px; font-size: 14px; color: var(--monica-muted); line-height: 1.45; }
|
||||||
|
.portal-modal .match-card {
|
||||||
|
background: #f8fafc; border: 1px solid var(--monica-border);
|
||||||
|
padding: 12px; margin: 0 0 16px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.portal-modal .match-card strong { display: block; margin-bottom: 4px; }
|
||||||
|
.portal-modal-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block portal_content %}
|
{% block portal_content %}
|
||||||
<form method="post">
|
<form method="post" id="contact-create-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="resolve_match" id="id_resolve_match" value="">
|
||||||
|
<input type="hidden" name="match_id" id="id_match_id" value="{% if match_prompt %}{{ match_prompt.contact.pk }}{% endif %}">
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-h"><h2>Profile</h2></div>
|
<div class="panel-h"><h2>Profile</h2></div>
|
||||||
@@ -87,14 +109,67 @@
|
|||||||
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if form.consent_postcard %}checked{% endif %}> Postcard mailings</label>
|
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if form.consent_postcard %}checked{% endif %}> Postcard mailings</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint-block" style="margin-top:16px">
|
<p class="hint-block" style="margin-top:16px">
|
||||||
Matches existing contacts by email, phone, or address when possible.
|
Same email always updates that contact. Same phone or address asks whether to update or create new.
|
||||||
Postcard defaults on when an address is saved.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if match_prompt %}
|
||||||
|
<div class="portal-modal-backdrop" id="match-modal" role="dialog" aria-modal="true" aria-labelledby="match-modal-title">
|
||||||
|
<div class="portal-modal">
|
||||||
|
<h3 id="match-modal-title">Possible duplicate</h3>
|
||||||
|
<p>
|
||||||
|
An existing contact shares this {{ match_prompt.reason_label }}.
|
||||||
|
Update that record, or create a separate contact anyway?
|
||||||
|
</p>
|
||||||
|
<div class="match-card">
|
||||||
|
<strong>{{ match_prompt.contact }}</strong>
|
||||||
|
{% if match_prompt.contact.email %}<div>{{ match_prompt.contact.email }}</div>{% endif %}
|
||||||
|
{% if match_prompt.contact.phone %}<div>{{ match_prompt.contact.phone }}</div>{% endif %}
|
||||||
|
{% if match_prompt.contact.postal_address.line1 %}
|
||||||
|
<div class="muted">
|
||||||
|
{{ match_prompt.contact.postal_address.line1 }}{% if match_prompt.contact.postal_address.city %}, {{ match_prompt.contact.postal_address.city }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<a href="{% url 'contacts:detail' match_prompt.contact.pk %}" target="_blank" rel="noopener">Open existing contact</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="portal-modal-actions">
|
||||||
|
<button class="btn btn-primary" type="button" id="match-update">Update existing</button>
|
||||||
|
<button class="btn btn-ghost" type="button" id="match-create">Create new contact</button>
|
||||||
|
<button class="btn btn-ghost" type="button" id="match-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="{% static 'js/address-autocomplete.js' %}"></script>
|
<script src="{% static 'js/address-autocomplete.js' %}"></script>
|
||||||
|
{% if match_prompt %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById('contact-create-form');
|
||||||
|
var resolve = document.getElementById('id_resolve_match');
|
||||||
|
var modal = document.getElementById('match-modal');
|
||||||
|
function submitWith(choice) {
|
||||||
|
if (resolve) resolve.value = choice;
|
||||||
|
if (modal) modal.hidden = true;
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
document.getElementById('match-update')?.addEventListener('click', function () {
|
||||||
|
submitWith('update');
|
||||||
|
});
|
||||||
|
document.getElementById('match-create')?.addEventListener('click', function () {
|
||||||
|
submitWith('create');
|
||||||
|
});
|
||||||
|
document.getElementById('match-cancel')?.addEventListener('click', function () {
|
||||||
|
if (resolve) resolve.value = '';
|
||||||
|
if (modal) modal.remove();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,12 +13,24 @@
|
|||||||
<div class="panel-h"><h2>Profile</h2></div>
|
<div class="panel-h"><h2>Profile</h2></div>
|
||||||
<div class="panel-b form-grid">
|
<div class="panel-b form-grid">
|
||||||
<div class="form-grid cols-2">
|
<div class="form-grid cols-2">
|
||||||
<div class="field"><label>First name</label><input value="{{ contact.first_name }}" readonly></div>
|
<div class="field">
|
||||||
<div class="field"><label>Last name</label><input value="{{ contact.last_name }}" readonly></div>
|
<label for="id_first_name">First name</label>
|
||||||
|
<input id="id_first_name" name="first_name" value="{{ contact.first_name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_last_name">Last name</label>
|
||||||
|
<input id="id_last_name" name="last_name" value="{{ contact.last_name }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid cols-2">
|
<div class="form-grid cols-2">
|
||||||
<div class="field"><label>Email</label><input value="{{ contact.email }}" readonly></div>
|
<div class="field">
|
||||||
<div class="field"><label>Phone</label><input value="{{ contact.phone }}" readonly></div>
|
<label for="id_email">Email</label>
|
||||||
|
<input id="id_email" name="email" type="email" value="{{ contact.email }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_phone">Phone</label>
|
||||||
|
<input id="id_phone" name="phone" value="{{ contact.phone }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Source</label><input value="{{ contact.get_source_display }}" readonly></div>
|
<div class="field"><label>Source</label><input value="{{ contact.get_source_display }}" readonly></div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@@ -152,3 +153,69 @@ class ContactFormMergeTests(TestCase):
|
|||||||
self.assertEqual(lead.contact_id, self.existing.pk)
|
self.assertEqual(lead.contact_id, self.existing.pk)
|
||||||
self.assertIn("alt@example.com", lead.message)
|
self.assertIn("alt@example.com", lead.message)
|
||||||
self.assertIn("merged by phone", lead.message)
|
self.assertIn("merged by phone", lead.message)
|
||||||
|
|
||||||
|
|
||||||
|
class PortalCreateMatchPromptTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="adder", password="test-pass-123"
|
||||||
|
)
|
||||||
|
self.client = Client()
|
||||||
|
self.client.login(username="adder", password="test-pass-123")
|
||||||
|
self.existing = Contact.objects.create(
|
||||||
|
email="primary@example.com",
|
||||||
|
phone="6301112222",
|
||||||
|
first_name="Alex",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_phone_match_shows_prompt(self):
|
||||||
|
url = reverse("contacts:create")
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"first_name": "Alex",
|
||||||
|
"email": "alt@example.com",
|
||||||
|
"phone": "(630) 111-2222",
|
||||||
|
"consent_email": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Possible duplicate")
|
||||||
|
self.assertContains(response, "Update existing")
|
||||||
|
self.assertEqual(Contact.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_choose_create_new_keeps_both(self):
|
||||||
|
url = reverse("contacts:create")
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"first_name": "Alex",
|
||||||
|
"email": "alt@example.com",
|
||||||
|
"phone": "(630) 111-2222",
|
||||||
|
"consent_email": "1",
|
||||||
|
"resolve_match": "create",
|
||||||
|
"match_id": str(self.existing.pk),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(Contact.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_choose_update_merges(self):
|
||||||
|
url = reverse("contacts:create")
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"first_name": "Alexander",
|
||||||
|
"email": "alt@example.com",
|
||||||
|
"phone": "(630) 111-2222",
|
||||||
|
"consent_email": "1",
|
||||||
|
"resolve_match": "update",
|
||||||
|
"match_id": str(self.existing.pk),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(Contact.objects.count(), 1)
|
||||||
|
self.existing.refresh_from_db()
|
||||||
|
self.assertEqual(self.existing.first_name, "Alexander")
|
||||||
|
self.assertIn("alt@example.com", self.existing.notes)
|
||||||
|
|||||||
+106
-7
@@ -9,7 +9,7 @@ from django.views.decorators.http import require_GET, require_http_methods
|
|||||||
|
|
||||||
from contacts.models import Channel, ConsentRecord, Contact
|
from contacts.models import Channel, ConsentRecord, Contact
|
||||||
from contacts.nominatim import NominatimError, suggest_addresses
|
from contacts.nominatim import NominatimError, suggest_addresses
|
||||||
from contacts.services import upsert_contact
|
from contacts.services import find_matching_contact, upsert_contact
|
||||||
from messaging.services import channel_preferences, set_channel_preferences
|
from messaging.services import channel_preferences, set_channel_preferences
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ def contact_create(request):
|
|||||||
"consent_sms": False,
|
"consent_sms": False,
|
||||||
"consent_postcard": True,
|
"consent_postcard": True,
|
||||||
}
|
}
|
||||||
|
match_prompt = None
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
for key in list(form.keys()):
|
for key in list(form.keys()):
|
||||||
if key.startswith("consent_"):
|
if key.startswith("consent_"):
|
||||||
@@ -77,6 +78,8 @@ def contact_create(request):
|
|||||||
else:
|
else:
|
||||||
form[key] = (request.POST.get(key) or "").strip()
|
form[key] = (request.POST.get(key) or "").strip()
|
||||||
email = form["email"].lower()
|
email = form["email"].lower()
|
||||||
|
resolve = (request.POST.get("resolve_match") or "").strip()
|
||||||
|
match_id = (request.POST.get("match_id") or "").strip()
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
if not form["first_name"]:
|
if not form["first_name"]:
|
||||||
errors.append("First name is required.")
|
errors.append("First name is required.")
|
||||||
@@ -92,10 +95,41 @@ def contact_create(request):
|
|||||||
if form["consent_sms"] and not form["phone"]:
|
if form["consent_sms"] and not form["phone"]:
|
||||||
errors.append("Phone is required for SMS consent.")
|
errors.append("Phone is required for SMS consent.")
|
||||||
if form["consent_postcard"] and not has_postal:
|
if form["consent_postcard"] and not has_postal:
|
||||||
# Soft: allow save but clear postcard consent if no address
|
|
||||||
form["consent_postcard"] = False
|
form["consent_postcard"] = False
|
||||||
|
|
||||||
if not errors:
|
if not errors:
|
||||||
contact, created, reason = upsert_contact(
|
existing, reason = find_matching_contact(
|
||||||
|
email=email,
|
||||||
|
phone=form["phone"],
|
||||||
|
postal_address=postal if has_postal else None,
|
||||||
|
)
|
||||||
|
# Phone/address collision (different email): ask user unless they chose.
|
||||||
|
if (
|
||||||
|
existing
|
||||||
|
and reason in {"phone", "address"}
|
||||||
|
and resolve not in {"update", "create"}
|
||||||
|
):
|
||||||
|
match_prompt = {
|
||||||
|
"contact": existing,
|
||||||
|
"reason": reason,
|
||||||
|
"reason_label": "phone number"
|
||||||
|
if reason == "phone"
|
||||||
|
else "mailing address",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
merge_into = None
|
||||||
|
merge_phone_address = True
|
||||||
|
if resolve == "update" and match_id:
|
||||||
|
merge_into = Contact.objects.filter(pk=match_id).first()
|
||||||
|
if merge_into is None:
|
||||||
|
errors.append("Matched contact no longer exists.")
|
||||||
|
elif resolve == "create":
|
||||||
|
merge_phone_address = False
|
||||||
|
elif reason == "email" and existing:
|
||||||
|
merge_into = existing
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
contact, created, used_reason = upsert_contact(
|
||||||
email=email,
|
email=email,
|
||||||
first_name=form["first_name"],
|
first_name=form["first_name"],
|
||||||
last_name=form["last_name"],
|
last_name=form["last_name"],
|
||||||
@@ -103,6 +137,8 @@ def contact_create(request):
|
|||||||
postal_address=postal if has_postal else None,
|
postal_address=postal if has_postal else None,
|
||||||
source=Contact.Source.MANUAL,
|
source=Contact.Source.MANUAL,
|
||||||
notes_append=form["notes"],
|
notes_append=form["notes"],
|
||||||
|
merge_phone_address=merge_phone_address,
|
||||||
|
merge_into=merge_into,
|
||||||
)
|
)
|
||||||
set_channel_preferences(
|
set_channel_preferences(
|
||||||
contact,
|
contact,
|
||||||
@@ -113,13 +149,23 @@ def contact_create(request):
|
|||||||
},
|
},
|
||||||
reason="portal_manual",
|
reason="portal_manual",
|
||||||
)
|
)
|
||||||
verb = "Added" if created else f"Updated (matched by {reason or 'email'})"
|
if created:
|
||||||
|
verb = "Added"
|
||||||
|
elif used_reason == "email":
|
||||||
|
verb = "Updated (same email)"
|
||||||
|
elif resolve == "update":
|
||||||
|
verb = f"Updated (matched by {reason or used_reason})"
|
||||||
|
else:
|
||||||
|
verb = f"Updated (matched by {used_reason or 'email'})"
|
||||||
messages.success(request, f"{verb} {contact}.")
|
messages.success(request, f"{verb} {contact}.")
|
||||||
return redirect("contacts:detail", pk=contact.pk)
|
return redirect("contacts:detail", pk=contact.pk)
|
||||||
for err in errors:
|
for err in errors:
|
||||||
messages.error(request, err)
|
messages.error(request, err)
|
||||||
return render(request, "contacts/create.html", {"form": form})
|
return render(
|
||||||
|
request,
|
||||||
|
"contacts/create.html",
|
||||||
|
{"form": form, "match_prompt": match_prompt},
|
||||||
|
)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
@@ -128,9 +174,62 @@ def contact_detail(request, pk):
|
|||||||
Contact.objects.prefetch_related("consents"), pk=pk
|
Contact.objects.prefetch_related("consents"), pk=pk
|
||||||
)
|
)
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
first_name = (request.POST.get("first_name") or "").strip()
|
||||||
|
last_name = (request.POST.get("last_name") or "").strip()
|
||||||
|
email = (request.POST.get("email") or "").strip().lower()
|
||||||
|
phone = (request.POST.get("phone") or "").strip()
|
||||||
|
errors: list[str] = []
|
||||||
|
if not first_name:
|
||||||
|
errors.append("First name is required.")
|
||||||
|
if not email:
|
||||||
|
errors.append("Email is required.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
validate_email(email)
|
||||||
|
except ValidationError:
|
||||||
|
errors.append("Enter a valid email address.")
|
||||||
|
else:
|
||||||
|
taken = (
|
||||||
|
Contact.objects.filter(email__iexact=email)
|
||||||
|
.exclude(pk=contact.pk)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
if taken:
|
||||||
|
errors.append("Another contact already uses that email.")
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
messages.error(request, err)
|
||||||
|
prefs = _consent_flags(contact)
|
||||||
|
# Reflect submitted values so the user can fix them.
|
||||||
|
contact.first_name = first_name
|
||||||
|
contact.last_name = last_name
|
||||||
|
contact.email = email
|
||||||
|
contact.phone = phone
|
||||||
contact.postal_address = _postal_from_post(request.POST)
|
contact.postal_address = _postal_from_post(request.POST)
|
||||||
contact.notes = (request.POST.get("notes") or "").strip()
|
contact.notes = (request.POST.get("notes") or "").strip()
|
||||||
contact.save(update_fields=["postal_address", "notes", "updated_at"])
|
return render(
|
||||||
|
request,
|
||||||
|
"contacts/detail.html",
|
||||||
|
{"contact": contact, "prefs": prefs},
|
||||||
|
)
|
||||||
|
|
||||||
|
contact.first_name = first_name
|
||||||
|
contact.last_name = last_name
|
||||||
|
contact.email = email
|
||||||
|
contact.phone = phone
|
||||||
|
contact.postal_address = _postal_from_post(request.POST)
|
||||||
|
contact.notes = (request.POST.get("notes") or "").strip()
|
||||||
|
contact.save(
|
||||||
|
update_fields=[
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"postal_address",
|
||||||
|
"notes",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
set_channel_preferences(
|
set_channel_preferences(
|
||||||
contact,
|
contact,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.core.mail import EmailMultiAlternatives
|
|||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
|
|
||||||
from contacts.models import Channel
|
from contacts.models import Channel
|
||||||
from messaging.services import one_click_unsubscribe_url, preferences_url
|
from messaging.services import one_click_unsubscribe_url, preferences_url, render_merge_tags
|
||||||
from public.email_branding import (
|
from public.email_branding import (
|
||||||
campaign_body_to_email_html,
|
campaign_body_to_email_html,
|
||||||
campaign_body_to_plain_text,
|
campaign_body_to_plain_text,
|
||||||
@@ -28,6 +28,8 @@ def send_email(message) -> str:
|
|||||||
body = message.body_snapshot or campaign.body_override or (
|
body = message.body_snapshot or campaign.body_override or (
|
||||||
campaign.template.body if campaign.template else ""
|
campaign.template.body if campaign.template else ""
|
||||||
)
|
)
|
||||||
|
subject = render_merge_tags(subject, contact)
|
||||||
|
body = render_merge_tags(body, contact)
|
||||||
|
|
||||||
site = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
site = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||||
prefs_path = preferences_url(str(contact.pk), Channel.EMAIL)
|
prefs_path = preferences_url(str(contact.pk), Channel.EMAIL)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
from messaging.services import render_merge_tags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ def send_sms(message) -> str:
|
|||||||
body = message.body_snapshot or campaign.body_override or (
|
body = message.body_snapshot or campaign.body_override or (
|
||||||
campaign.template.body if campaign.template else ""
|
campaign.template.body if campaign.template else ""
|
||||||
)
|
)
|
||||||
|
body = render_merge_tags(body, contact)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -25,6 +26,49 @@ AUDIENCE_CHANNEL = {
|
|||||||
UNSUB_SALT = "monica-site-unsubscribe"
|
UNSUB_SALT = "monica-site-unsubscribe"
|
||||||
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
|
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
|
||||||
|
|
||||||
|
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
|
||||||
|
_MERGE_TAG_RE = re.compile(
|
||||||
|
r"\{\{\s*(first_name|last_name|email|phone|full_name)\s*\}\}"
|
||||||
|
r"|\{\s*(first_name|last_name|email|phone|full_name)\s*\}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
REMOVABLE_MESSAGE_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
Message.Status.DRAFT,
|
||||||
|
Message.Status.SCHEDULED,
|
||||||
|
Message.Status.FAILED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_merge_tags(text: str, contact: Contact | None) -> str:
|
||||||
|
"""Replace personalization tags with contact field values."""
|
||||||
|
if not text:
|
||||||
|
return text or ""
|
||||||
|
first = (getattr(contact, "first_name", None) or "").strip() if contact else ""
|
||||||
|
last = (getattr(contact, "last_name", None) or "").strip() if contact else ""
|
||||||
|
email = (getattr(contact, "email", None) or "").strip() if contact else ""
|
||||||
|
phone = (getattr(contact, "phone", None) or "").strip() if contact else ""
|
||||||
|
full = f"{first} {last}".strip()
|
||||||
|
values = {
|
||||||
|
"first_name": first,
|
||||||
|
"last_name": last,
|
||||||
|
"email": email,
|
||||||
|
"phone": phone,
|
||||||
|
"full_name": full,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _replace(match: re.Match[str]) -> str:
|
||||||
|
key = (match.group(1) or match.group(2) or "").lower()
|
||||||
|
return values.get(key, "")
|
||||||
|
|
||||||
|
return _MERGE_TAG_RE.sub(_replace, text)
|
||||||
|
|
||||||
|
|
||||||
|
def message_is_removable(message: Message) -> bool:
|
||||||
|
return message.status in REMOVABLE_MESSAGE_STATUSES
|
||||||
|
|
||||||
|
|
||||||
def contact_may_receive(contact: Contact, channel: str) -> bool:
|
def contact_may_receive(contact: Contact, channel: str) -> bool:
|
||||||
if Suppression.objects.filter(
|
if Suppression.objects.filter(
|
||||||
@@ -443,6 +487,16 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
|
|||||||
if not body.strip():
|
if not body.strip():
|
||||||
raise ValueError("Campaign has no body.")
|
raise ValueError("Campaign has no body.")
|
||||||
|
|
||||||
|
# Preview merge tags using first recipient when available.
|
||||||
|
sample = (
|
||||||
|
campaign.messages.select_related("contact")
|
||||||
|
.order_by("created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
sample_contact = sample.contact if sample else None
|
||||||
|
subject = render_merge_tags(subject, sample_contact)
|
||||||
|
body = render_merge_tags(body, sample_contact)
|
||||||
|
|
||||||
notice = (
|
notice = (
|
||||||
"This is a test send from the Monica portal. "
|
"This is a test send from the Monica portal. "
|
||||||
"Recipient list was not notified."
|
"Recipient list was not notified."
|
||||||
|
|||||||
@@ -39,7 +39,11 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="btn btn-primary" type="submit">Send now to recipients</button>
|
<button class="btn btn-primary" type="submit">Send now to recipients</button>
|
||||||
<p class="hint-block" style="margin-top:8px">
|
<p class="hint-block" style="margin-top:8px">
|
||||||
|
{% if campaign.channel == "postcard" %}
|
||||||
|
Enqueues draft / scheduled / failed messages via PCM Integrations.
|
||||||
|
{% else %}
|
||||||
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
|
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -60,6 +64,7 @@
|
|||||||
<div class="label">Delivered</div>
|
<div class="label">Delivered</div>
|
||||||
<div class="value" data-stat="delivered">{{ stats.delivered }}</div>
|
<div class="value" data-stat="delivered">{{ stats.delivered }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if campaign.channel == "email" %}
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="label">Opens</div>
|
<div class="label">Opens</div>
|
||||||
<div class="value" data-stat="opens">{{ stats.opens }}</div>
|
<div class="value" data-stat="opens">{{ stats.opens }}</div>
|
||||||
@@ -68,6 +73,7 @@
|
|||||||
<div class="label">Clicks</div>
|
<div class="label">Clicks</div>
|
||||||
<div class="value" data-stat="clicks">{{ stats.clicks }}</div>
|
<div class="value" data-stat="clicks">{{ stats.clicks }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="label">Bounced / failed</div>
|
<div class="label">Bounced / failed</div>
|
||||||
<div class="value" data-stat="failed">{{ stats.failed }}</div>
|
<div class="value" data-stat="failed">{{ stats.failed }}</div>
|
||||||
@@ -82,21 +88,32 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-h"><h2>Engagement</h2></div>
|
<div class="panel-h"><h2>Engagement</h2></div>
|
||||||
<div class="panel-b">
|
<div class="panel-b">
|
||||||
|
{% if campaign.channel == "email" %}
|
||||||
<p class="hint-block" style="margin-top:0">
|
<p class="hint-block" style="margin-top:0">
|
||||||
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
|
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
|
||||||
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
|
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
|
||||||
({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO).
|
({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO).
|
||||||
</p>
|
</p>
|
||||||
|
{% elif campaign.channel == "sms" %}
|
||||||
|
<p class="hint-block" style="margin-top:0">
|
||||||
|
Delivery status updates from SMTP2GO SMS webhooks.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint-block" style="margin-top:0">
|
||||||
|
Postcard status updates from PCM Integrations webhooks.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
<div class="chart-placeholder" aria-hidden="true">
|
<div class="chart-placeholder" aria-hidden="true">
|
||||||
<div class="bar" style="height:55%"></div>
|
<div class="bar" style="height:55%"></div>
|
||||||
<div class="bar" style="height:70%"></div>
|
<div class="bar" style="height:70%"></div>
|
||||||
<div class="bar" style="height:40%"></div>
|
<div class="bar" style="height:40%"></div>
|
||||||
<div class="bar" style="height:30%"></div>
|
<div class="bar" style="height:30%"></div>
|
||||||
<div class="bar" style="height:20%"></div>
|
<div class="bar" style="height:20%"></div>
|
||||||
</div> </div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-h"><h2>Recent SMTP2GO events</h2></div>
|
<div class="panel-h"><h2>{{ events_title }}</h2></div>
|
||||||
<div class="panel-b" style="padding:0">
|
<div class="panel-b" style="padding:0">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>When</th><th>Event</th><th>Contact</th></tr></thead>
|
<thead><tr><th>When</th><th>Event</th><th>Contact</th></tr></thead>
|
||||||
@@ -108,7 +125,7 @@
|
|||||||
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
|
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="3" class="empty-state">No webhook events yet. SMTP2GO must POST opens/clicks to <code>/portal/messaging/webhooks/email/</code> (see messaging README). SMTP2GO’s own “Clicked” feed does not fill this table by itself.</td></tr>
|
<tr><td colspan="3" class="empty-state">{{ events_empty|safe }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -116,15 +133,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel" id="recipients-panel" data-page="{{ page_obj.number }}">
|
||||||
<div class="panel-h"><h2>Recipients</h2></div>
|
<div class="panel-h" style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||||
|
<h2 style="margin:0">Recipients</h2>
|
||||||
|
<span class="muted" style="font-size:13px">
|
||||||
|
{{ page_obj.paginator.count }} total
|
||||||
|
{% if page_obj.paginator.num_pages > 1 %}
|
||||||
|
· page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="panel-b" style="padding:0">
|
<div class="panel-b" style="padding:0">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Contact</th><th>Status</th><th>Provider id</th><th>Error</th></tr>
|
<tr><th>Contact</th><th>Status</th><th>Provider id</th><th>Error</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="recipients-body">
|
<tbody id="recipients-body">
|
||||||
{% for message in messages %}
|
{% for message in recipient_messages %}
|
||||||
<tr data-message-id="{{ message.pk }}">
|
<tr data-message-id="{{ message.pk }}">
|
||||||
<td>
|
<td>
|
||||||
<div>{{ message.contact }}</div>
|
<div>{{ message.contact }}</div>
|
||||||
@@ -135,24 +160,56 @@
|
|||||||
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
|
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
|
||||||
<td class="muted">{{ message.provider_message_id|default:"—" }}</td>
|
<td class="muted">{{ message.provider_message_id|default:"—" }}</td>
|
||||||
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
|
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
|
||||||
|
<td style="white-space:nowrap;text-align:right">
|
||||||
|
{% if message.can_remove %}
|
||||||
|
<form method="post"
|
||||||
|
action="{% url 'messaging:campaign_message_remove' campaign.pk message.pk %}"
|
||||||
|
style="display:inline"
|
||||||
|
onsubmit="return confirm('Remove this recipient from the campaign?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="page" value="{{ page_obj.number }}">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Remove</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>
|
<tr><td colspan="5" class="empty-state">No messages on this campaign.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{% if page_obj.paginator.num_pages > 1 %}
|
||||||
|
<div class="panel-b" style="display:flex;gap:8px;align-items:center;justify-content:flex-end;border-top:1px solid var(--monica-border)">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a class="btn btn-ghost btn-sm" href="?page={{ page_obj.previous_page_number }}">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="muted" style="font-size:13px">Page {{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a class="btn btn-ghost btn-sm" href="?page={{ page_obj.next_page_number }}">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var url = "{% url 'messaging:campaign_status_json' campaign.pk %}";
|
var panel = document.getElementById("recipients-panel");
|
||||||
|
var page = (panel && panel.getAttribute("data-page")) || "1";
|
||||||
|
var removeBase = "{% url 'messaging:campaign_message_remove' campaign.pk '00000000-0000-0000-0000-000000000000' %}";
|
||||||
|
var csrfToken = "{{ csrf_token }}";
|
||||||
|
var url = "{% url 'messaging:campaign_status_json' campaign.pk %}?page=" + encodeURIComponent(page);
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s || "").replace(/[&<>"']/g, function (c) {
|
return String(s || "").replace(/[&<>"']/g, function (c) {
|
||||||
return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c];
|
return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function removeUrl(id) {
|
||||||
|
return removeBase.replace("00000000-0000-0000-0000-000000000000", id);
|
||||||
|
}
|
||||||
function apply(data) {
|
function apply(data) {
|
||||||
var badge = document.getElementById("campaign-status-badge");
|
var badge = document.getElementById("campaign-status-badge");
|
||||||
if (badge) {
|
if (badge) {
|
||||||
@@ -167,17 +224,25 @@
|
|||||||
var body = document.getElementById("recipients-body");
|
var body = document.getElementById("recipients-body");
|
||||||
if (body && data.messages) {
|
if (body && data.messages) {
|
||||||
if (!data.messages.length) {
|
if (!data.messages.length) {
|
||||||
body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>';
|
body.innerHTML = '<tr><td colspan="5" class="empty-state">No messages on this campaign.</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
body.innerHTML = data.messages.map(function (m) {
|
body.innerHTML = data.messages.map(function (m) {
|
||||||
var dest = m.destination
|
var dest = m.destination
|
||||||
? '<div class="muted" style="font-size:12px;margin-top:2px">' + esc(m.destination) + '</div>'
|
? '<div class="muted" style="font-size:12px;margin-top:2px">' + esc(m.destination) + '</div>'
|
||||||
: '';
|
: '';
|
||||||
|
var action = m.can_remove
|
||||||
|
? '<form method="post" action="' + esc(removeUrl(m.id)) + '" style="display:inline" ' +
|
||||||
|
'onsubmit="return confirm(\'Remove this recipient from the campaign?\');">' +
|
||||||
|
'<input type="hidden" name="csrfmiddlewaretoken" value="' + esc(csrfToken) + '">' +
|
||||||
|
'<input type="hidden" name="page" value="' + esc(page) + '">' +
|
||||||
|
'<button class="btn btn-ghost btn-sm" type="submit">Remove</button></form>'
|
||||||
|
: '<span class="muted">—</span>';
|
||||||
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
|
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
|
||||||
"<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" +
|
"<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" +
|
||||||
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
|
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
|
||||||
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
|
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
|
||||||
"<td class=\"muted\">" + esc(m.error || "—") + "</td></tr>";
|
"<td class=\"muted\">" + esc(m.error || "—") + "</td>" +
|
||||||
|
"<td style=\"white-space:nowrap;text-align:right\">" + action + "</td></tr>";
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,14 +77,14 @@
|
|||||||
<label>Body</label>
|
<label>Body</label>
|
||||||
<div id="email-editor"></div>
|
<div id="email-editor"></div>
|
||||||
<textarea id="id_body" name="body" hidden>{{ form_data.body }}</textarea>
|
<textarea id="id_body" name="body" hidden>{{ form_data.body }}</textarea>
|
||||||
<div class="hint">Bold, fonts, sizes, links, images · merge tags: {first_name}, {last_name}</div>
|
<div class="hint">Bold, fonts, sizes, links, images · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" id="sms-body-wrap" hidden>
|
<div class="field" id="sms-body-wrap" hidden>
|
||||||
<label for="id_body_sms">Body</label>
|
<label for="id_body_sms">Body</label>
|
||||||
<textarea id="id_body_sms" style="min-height:140px"
|
<textarea id="id_body_sms" style="min-height:140px"
|
||||||
placeholder="Hi {first_name}, …"
|
placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …"
|
||||||
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
|
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
|
||||||
<div class="hint">Plain text for SMS · keep it short</div>
|
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" id="postcard-body-hint" hidden>
|
<div class="field" id="postcard-body-hint" hidden>
|
||||||
<p class="hint-block" style="margin:0">
|
<p class="hint-block" style="margin:0">
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ class PortalConsentToggleTests(TestCase):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
|
"first_name": "Sam",
|
||||||
|
"last_name": "",
|
||||||
|
"email": "sam@example.com",
|
||||||
|
"phone": "",
|
||||||
"notes": "updated",
|
"notes": "updated",
|
||||||
"consent_sms": "1",
|
"consent_sms": "1",
|
||||||
"consent_postcard": "1",
|
"consent_postcard": "1",
|
||||||
@@ -155,6 +159,32 @@ class PortalConsentToggleTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(email_consent.reason, "portal_manual")
|
self.assertEqual(email_consent.reason, "portal_manual")
|
||||||
|
|
||||||
|
def test_portal_can_update_profile(self):
|
||||||
|
url = reverse("contacts:detail", kwargs={"pk": self.contact.pk})
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"first_name": "Samantha",
|
||||||
|
"last_name": "Lee",
|
||||||
|
"email": "sam.lee@example.com",
|
||||||
|
"phone": "6305550100",
|
||||||
|
"address_line1": "10 Main St",
|
||||||
|
"address_city": "Naperville",
|
||||||
|
"address_state": "IL",
|
||||||
|
"address_zip": "60540",
|
||||||
|
"notes": "VIP",
|
||||||
|
"consent_email": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.contact.refresh_from_db()
|
||||||
|
self.assertEqual(self.contact.first_name, "Samantha")
|
||||||
|
self.assertEqual(self.contact.last_name, "Lee")
|
||||||
|
self.assertEqual(self.contact.email, "sam.lee@example.com")
|
||||||
|
self.assertEqual(self.contact.phone, "6305550100")
|
||||||
|
self.assertEqual(self.contact.postal_address.get("line1"), "10 Main St")
|
||||||
|
self.assertEqual(self.contact.notes, "VIP")
|
||||||
|
|
||||||
|
|
||||||
class CampaignDraftSaveTests(TestCase):
|
class CampaignDraftSaveTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -899,3 +929,114 @@ class StoredFileUploadTests(TestCase):
|
|||||||
self.assertEqual(fetch.status_code, 200)
|
self.assertEqual(fetch.status_code, 200)
|
||||||
self.assertEqual(fetch["Content-Type"], "image/png")
|
self.assertEqual(fetch["Content-Type"], "image/png")
|
||||||
self.assertEqual(b"".join(fetch.streaming_content), png)
|
self.assertEqual(b"".join(fetch.streaming_content), png)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class MergeTagTests(TestCase):
|
||||||
|
def test_render_merge_tags_double_and_single_braces(self):
|
||||||
|
from messaging.services import render_merge_tags
|
||||||
|
|
||||||
|
contact = Contact.objects.create(
|
||||||
|
email="pat@example.com",
|
||||||
|
first_name="Pat",
|
||||||
|
last_name="Lee",
|
||||||
|
phone="6305551212",
|
||||||
|
)
|
||||||
|
text = "Hi {{first_name}} {{last_name}} / {first_name} {email}"
|
||||||
|
out = render_merge_tags(text, contact)
|
||||||
|
self.assertEqual(out, "Hi Pat Lee / Pat pat@example.com")
|
||||||
|
|
||||||
|
def test_email_send_substitutes_merge_tags(self):
|
||||||
|
from django.core import mail
|
||||||
|
|
||||||
|
from messaging.providers.email.smtp2go import send_email
|
||||||
|
from messaging.services import create_campaign_draft, set_channel_consent
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(username="merge", password="x")
|
||||||
|
contact = Contact.objects.create(
|
||||||
|
email="pat@example.com", first_name="Pat", last_name="Lee"
|
||||||
|
)
|
||||||
|
set_channel_consent(contact, Channel.EMAIL, opted_in=True, reason="test")
|
||||||
|
campaign = create_campaign_draft(
|
||||||
|
name="Merge",
|
||||||
|
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||||
|
subject="Hello {{first_name}}",
|
||||||
|
body="<p>Dear {{first_name}} {{last_name}}</p>",
|
||||||
|
created_by=user,
|
||||||
|
)
|
||||||
|
send_email(campaign.messages.get())
|
||||||
|
self.assertEqual(mail.outbox[0].subject, "Hello Pat")
|
||||||
|
html = mail.outbox[0].alternatives[0][0]
|
||||||
|
self.assertIn("Dear Pat Lee", html)
|
||||||
|
self.assertNotIn("{{first_name}}", html)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRecipientTableTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from messaging.services import create_campaign_draft, set_channel_consent
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="recip", password="test-pass-123"
|
||||||
|
)
|
||||||
|
self.client = Client()
|
||||||
|
self.client.login(username="recip", password="test-pass-123")
|
||||||
|
self.contacts = []
|
||||||
|
for i in range(3):
|
||||||
|
c = Contact.objects.create(
|
||||||
|
email=f"c{i}@example.com", first_name=f"Name{i}"
|
||||||
|
)
|
||||||
|
set_channel_consent(c, Channel.EMAIL, opted_in=True, reason="test")
|
||||||
|
self.contacts.append(c)
|
||||||
|
self.campaign = create_campaign_draft(
|
||||||
|
name="Recipients",
|
||||||
|
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||||
|
subject="Hi",
|
||||||
|
body="Body",
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_detail_does_not_flash_message_objects(self):
|
||||||
|
url = reverse(
|
||||||
|
"messaging:campaign_detail", kwargs={"pk": self.campaign.pk}
|
||||||
|
)
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotContains(response, "email →")
|
||||||
|
self.assertContains(response, "Recipients")
|
||||||
|
self.assertContains(response, "Remove")
|
||||||
|
|
||||||
|
def test_remove_recipient(self):
|
||||||
|
msg = self.campaign.messages.first()
|
||||||
|
url = reverse(
|
||||||
|
"messaging:campaign_message_remove",
|
||||||
|
kwargs={"pk": self.campaign.pk, "message_id": msg.pk},
|
||||||
|
)
|
||||||
|
response = self.client.post(url)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(self.campaign.messages.count(), 2)
|
||||||
|
|
||||||
|
def test_postcard_shows_pcm_events_title(self):
|
||||||
|
from messaging.services import create_campaign_draft
|
||||||
|
|
||||||
|
Contact.objects.create(
|
||||||
|
email="pc@example.com",
|
||||||
|
postal_address=Contact.make_postal_address(line1="1 Main"),
|
||||||
|
)
|
||||||
|
campaign = create_campaign_draft(
|
||||||
|
name="Cards",
|
||||||
|
audience=Campaign.Audience.POSTCARD_OPT_IN,
|
||||||
|
body="Postcard mailing",
|
||||||
|
created_by=self.user,
|
||||||
|
template=MessageTemplate.objects.create(
|
||||||
|
name="Design",
|
||||||
|
channel=Channel.POSTCARD,
|
||||||
|
body="",
|
||||||
|
postcard_front={"design_id": "99"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
url = reverse("messaging:campaign_detail", kwargs={"pk": campaign.pk})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertContains(response, "Recent PCM Integrations events")
|
||||||
|
self.assertNotContains(response, "Recent SMTP2GO events")
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ urlpatterns = [
|
|||||||
views.campaign_test_send,
|
views.campaign_test_send,
|
||||||
name="campaign_test_send",
|
name="campaign_test_send",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"campaigns/<uuid:pk>/messages/<uuid:message_id>/remove/",
|
||||||
|
views.campaign_message_remove,
|
||||||
|
name="campaign_message_remove",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"campaigns/upload-image/",
|
"campaigns/upload-image/",
|
||||||
views.campaign_image_upload,
|
views.campaign_image_upload,
|
||||||
|
|||||||
+96
-10
@@ -7,6 +7,7 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.paginator import Paginator
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.http import FileResponse, HttpResponseForbidden, JsonResponse
|
from django.http import FileResponse, HttpResponseForbidden, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
@@ -15,7 +16,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
|
|
||||||
from contacts.models import Channel
|
from contacts.models import Channel
|
||||||
from messaging.models import Campaign, MessageTemplate, ProviderEvent, StoredFile
|
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent, StoredFile
|
||||||
from messaging.providers.postcard.pcm import (
|
from messaging.providers.postcard.pcm import (
|
||||||
PCM_SIZE_CHOICES,
|
PCM_SIZE_CHOICES,
|
||||||
PcmApiError,
|
PcmApiError,
|
||||||
@@ -27,12 +28,16 @@ from messaging.providers.postcard.pcm import (
|
|||||||
from messaging.services import (
|
from messaging.services import (
|
||||||
create_campaign_draft,
|
create_campaign_draft,
|
||||||
enqueue_campaign_send,
|
enqueue_campaign_send,
|
||||||
|
message_is_removable,
|
||||||
opted_in_contacts,
|
opted_in_contacts,
|
||||||
parse_scheduled_for,
|
parse_scheduled_for,
|
||||||
record_sms_stop,
|
record_sms_stop,
|
||||||
send_campaign_test_email,
|
send_campaign_test_email,
|
||||||
)
|
)
|
||||||
from messaging.webhooks import (
|
from messaging.webhooks import (
|
||||||
|
PROVIDER_EMAIL,
|
||||||
|
PROVIDER_PCM,
|
||||||
|
PROVIDER_SMS,
|
||||||
campaign_engagement_stats,
|
campaign_engagement_stats,
|
||||||
is_inbound_sms_stop,
|
is_inbound_sms_stop,
|
||||||
parse_webhook_payload,
|
parse_webhook_payload,
|
||||||
@@ -43,6 +48,8 @@ from messaging.webhooks import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RECIPIENTS_PER_PAGE = 50
|
||||||
|
|
||||||
_ALLOWED_IMAGE_TYPES = frozenset(
|
_ALLOWED_IMAGE_TYPES = frozenset(
|
||||||
{"image/jpeg", "image/png", "image/gif", "image/webp"}
|
{"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
)
|
)
|
||||||
@@ -227,20 +234,59 @@ def _message_destination(message) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _campaign_report(campaign: Campaign) -> dict:
|
def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]:
|
||||||
messages_qs = list(campaign.messages.select_related("contact").all()[:200])
|
"""Return (provider codes, panel title, empty-state hint) for campaign channel."""
|
||||||
for msg in messages_qs:
|
if campaign.channel == Channel.POSTCARD:
|
||||||
|
return (
|
||||||
|
[PROVIDER_PCM],
|
||||||
|
"Recent PCM Integrations events",
|
||||||
|
"No PCM webhook events yet. PCM must POST to "
|
||||||
|
"<code>/portal/messaging/webhooks/postcard/</code>.",
|
||||||
|
)
|
||||||
|
if campaign.channel == Channel.SMS:
|
||||||
|
return (
|
||||||
|
[PROVIDER_SMS],
|
||||||
|
"Recent SMTP2GO events",
|
||||||
|
"No webhook events yet. SMTP2GO must POST SMS events to "
|
||||||
|
"<code>/portal/messaging/webhooks/sms/</code>.",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
[PROVIDER_EMAIL],
|
||||||
|
"Recent SMTP2GO events",
|
||||||
|
"No webhook events yet. SMTP2GO must POST opens/clicks to "
|
||||||
|
"<code>/portal/messaging/webhooks/email/</code> "
|
||||||
|
"(see messaging README). SMTP2GO’s own “Clicked” feed does not fill "
|
||||||
|
"this table by itself.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict:
|
||||||
|
qs = campaign.messages.select_related("contact").order_by(
|
||||||
|
"contact__first_name", "contact__last_name", "created_at"
|
||||||
|
)
|
||||||
|
paginator = Paginator(qs, RECIPIENTS_PER_PAGE)
|
||||||
|
page_obj = paginator.get_page(page)
|
||||||
|
recipient_messages = list(page_obj.object_list)
|
||||||
|
for msg in recipient_messages:
|
||||||
msg.destination = _message_destination(msg)
|
msg.destination = _message_destination(msg)
|
||||||
|
msg.can_remove = message_is_removable(msg)
|
||||||
stats = campaign_engagement_stats(campaign)
|
stats = campaign_engagement_stats(campaign)
|
||||||
|
providers, events_title, events_empty = _events_provider_filter(campaign)
|
||||||
recent_events = (
|
recent_events = (
|
||||||
ProviderEvent.objects.filter(message__campaign=campaign)
|
ProviderEvent.objects.filter(
|
||||||
|
message__campaign=campaign,
|
||||||
|
provider__in=providers,
|
||||||
|
)
|
||||||
.select_related("message", "message__contact")
|
.select_related("message", "message__contact")
|
||||||
.order_by("-created_at")[:25]
|
.order_by("-created_at")[:25]
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"messages": messages_qs,
|
"recipient_messages": recipient_messages,
|
||||||
|
"page_obj": page_obj,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"recent_events": recent_events,
|
"recent_events": recent_events,
|
||||||
|
"events_title": events_title,
|
||||||
|
"events_empty": events_empty,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -467,15 +513,22 @@ def campaign_list(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def campaign_detail(request, pk):
|
def campaign_detail(request, pk):
|
||||||
campaign = get_object_or_404(Campaign, pk=pk)
|
campaign = get_object_or_404(Campaign, pk=pk)
|
||||||
ctx = _campaign_report(campaign)
|
try:
|
||||||
|
page = max(1, int(request.GET.get("page") or 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
page = 1
|
||||||
|
ctx = _campaign_report(campaign, page=page)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"messaging/campaign_detail.html",
|
"messaging/campaign_detail.html",
|
||||||
{
|
{
|
||||||
"campaign": campaign,
|
"campaign": campaign,
|
||||||
"messages": ctx["messages"],
|
"recipient_messages": ctx["recipient_messages"],
|
||||||
|
"page_obj": ctx["page_obj"],
|
||||||
"stats": ctx["stats"],
|
"stats": ctx["stats"],
|
||||||
"recent_events": ctx["recent_events"],
|
"recent_events": ctx["recent_events"],
|
||||||
|
"events_title": ctx["events_title"],
|
||||||
|
"events_empty": ctx["events_empty"],
|
||||||
"can_send": campaign.status
|
"can_send": campaign.status
|
||||||
in {
|
in {
|
||||||
Campaign.Status.DRAFT,
|
Campaign.Status.DRAFT,
|
||||||
@@ -499,12 +552,19 @@ def campaign_status_json(request, pk):
|
|||||||
|
|
||||||
refresh_campaign_status(campaign)
|
refresh_campaign_status(campaign)
|
||||||
campaign.refresh_from_db()
|
campaign.refresh_from_db()
|
||||||
ctx = _campaign_report(campaign)
|
try:
|
||||||
|
page = max(1, int(request.GET.get("page") or 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
page = 1
|
||||||
|
ctx = _campaign_report(campaign, page=page)
|
||||||
|
page_obj = ctx["page_obj"]
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"status": campaign.status,
|
"status": campaign.status,
|
||||||
"status_display": campaign.get_status_display(),
|
"status_display": campaign.get_status_display(),
|
||||||
"stats": ctx["stats"],
|
"stats": ctx["stats"],
|
||||||
|
"page": page_obj.number,
|
||||||
|
"num_pages": page_obj.paginator.num_pages,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"id": str(m.pk),
|
"id": str(m.pk),
|
||||||
@@ -514,8 +574,9 @@ def campaign_status_json(request, pk):
|
|||||||
"status_display": m.get_status_display(),
|
"status_display": m.get_status_display(),
|
||||||
"provider_message_id": m.provider_message_id or "",
|
"provider_message_id": m.provider_message_id or "",
|
||||||
"error": (m.error or "")[:120],
|
"error": (m.error or "")[:120],
|
||||||
|
"can_remove": bool(getattr(m, "can_remove", False)),
|
||||||
}
|
}
|
||||||
for m in ctx["messages"]
|
for m in ctx["recipient_messages"]
|
||||||
],
|
],
|
||||||
"events": [
|
"events": [
|
||||||
{
|
{
|
||||||
@@ -529,6 +590,31 @@ def campaign_status_json(request, pk):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def campaign_message_remove(request, pk, message_id):
|
||||||
|
"""Drop a draft/scheduled/failed recipient from the campaign."""
|
||||||
|
campaign = get_object_or_404(Campaign, pk=pk)
|
||||||
|
message = get_object_or_404(Message, pk=message_id, campaign=campaign)
|
||||||
|
if not message_is_removable(message):
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
"Only draft, scheduled, or failed recipients can be removed.",
|
||||||
|
)
|
||||||
|
return redirect("messaging:campaign_detail", pk=campaign.pk)
|
||||||
|
|
||||||
|
label = str(message.contact)
|
||||||
|
message.delete()
|
||||||
|
messages.success(request, f"Removed {label} from this campaign.")
|
||||||
|
page = (request.POST.get("page") or request.GET.get("page") or "").strip()
|
||||||
|
if page and page.isdigit() and int(page) > 1:
|
||||||
|
return redirect(
|
||||||
|
f"{reverse('messaging:campaign_detail', kwargs={'pk': campaign.pk})}"
|
||||||
|
f"?page={page}"
|
||||||
|
)
|
||||||
|
return redirect("messaging:campaign_detail", pk=campaign.pk)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def campaign_send(request, pk):
|
def campaign_send(request, pk):
|
||||||
|
|||||||
Reference in New Issue
Block a user