Add Django site, Docker packaging, and beta/prod Gitea deploys.
Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
"""Consent checks and unsubscribe helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.core import signing
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from contacts.models import Channel, ConsentRecord, Contact, Suppression
|
||||
from messaging.models import Campaign, Message, MessageTemplate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
|
||||
AUDIENCE_CHANNEL = {
|
||||
Campaign.Audience.EMAIL_OPT_IN: Channel.EMAIL,
|
||||
Campaign.Audience.SMS_OPT_IN: Channel.SMS,
|
||||
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
||||
}
|
||||
|
||||
UNSUB_SALT = "monica-site-unsubscribe"
|
||||
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
|
||||
|
||||
|
||||
def contact_may_receive(contact: Contact, channel: str) -> bool:
|
||||
if Suppression.objects.filter(
|
||||
contact=contact, channel=channel, active=True
|
||||
).exists():
|
||||
return False
|
||||
consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first()
|
||||
return bool(consent and consent.opted_in)
|
||||
|
||||
|
||||
def channel_preferences(contact: Contact) -> dict[str, bool]:
|
||||
"""Current opt-in flags for every channel (missing record = False)."""
|
||||
flags = {c.value: False for c in Channel}
|
||||
for record in contact.consents.all():
|
||||
flags[record.channel] = record.opted_in
|
||||
return flags
|
||||
|
||||
|
||||
def set_channel_consent(
|
||||
contact: Contact,
|
||||
channel: str,
|
||||
*,
|
||||
opted_in: bool,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""Write ConsentRecord + Suppression for one channel."""
|
||||
if channel not in Channel.values:
|
||||
raise ValueError(f"Unknown channel: {channel}")
|
||||
ConsentRecord.objects.update_or_create(
|
||||
contact=contact,
|
||||
channel=channel,
|
||||
defaults={"opted_in": opted_in, "reason": reason},
|
||||
)
|
||||
Suppression.objects.update_or_create(
|
||||
contact=contact,
|
||||
channel=channel,
|
||||
defaults={
|
||||
"active": not opted_in,
|
||||
"reason": reason if not opted_in else "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def set_channel_preferences(
|
||||
contact: Contact,
|
||||
preferences: dict[str, bool],
|
||||
*,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""Update consent for each provided channel key."""
|
||||
for channel, opted_in in preferences.items():
|
||||
if channel not in Channel.values:
|
||||
continue
|
||||
set_channel_consent(
|
||||
contact, channel, opted_in=bool(opted_in), reason=reason
|
||||
)
|
||||
|
||||
|
||||
def unsubscribe_all(contact: Contact, *, reason: str = "unsubscribe_all") -> None:
|
||||
for channel in Channel:
|
||||
set_channel_consent(
|
||||
contact, channel.value, opted_in=False, reason=reason
|
||||
)
|
||||
|
||||
|
||||
def make_unsubscribe_token(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
||||
return signing.dumps({"c": str(contact_id), "ch": channel}, salt=UNSUB_SALT)
|
||||
|
||||
|
||||
def parse_unsubscribe_token(token: str) -> tuple[Contact | None, str]:
|
||||
"""Return (contact, channel) or (None, '') on bad/expired token."""
|
||||
try:
|
||||
data = signing.loads(token, salt=UNSUB_SALT, max_age=UNSUB_MAX_AGE)
|
||||
except signing.BadSignature:
|
||||
return None, ""
|
||||
contact = (
|
||||
Contact.objects.filter(pk=data.get("c"))
|
||||
.prefetch_related("consents")
|
||||
.first()
|
||||
)
|
||||
if not contact:
|
||||
return None, ""
|
||||
channel = data.get("ch") or Channel.EMAIL
|
||||
if channel not in Channel.values:
|
||||
channel = Channel.EMAIL
|
||||
return contact, channel
|
||||
|
||||
|
||||
def process_unsubscribe_token(token: str) -> bool:
|
||||
"""One-click opt-out for the channel encoded in the token."""
|
||||
contact, channel = parse_unsubscribe_token(token)
|
||||
if not contact:
|
||||
return False
|
||||
set_channel_consent(
|
||||
contact, channel, opted_in=False, reason="unsubscribe_link"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def preferences_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
||||
token = make_unsubscribe_token(contact_id, channel)
|
||||
return reverse("public:unsubscribe", kwargs={"token": token})
|
||||
|
||||
|
||||
def one_click_unsubscribe_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
||||
token = make_unsubscribe_token(contact_id, channel)
|
||||
return reverse("public:unsubscribe_one_click", kwargs={"token": token})
|
||||
|
||||
|
||||
def record_sms_stop(phone: str) -> bool:
|
||||
digits = "".join(ch for ch in (phone or "") if ch.isdigit())
|
||||
if len(digits) < 7:
|
||||
return False
|
||||
tail = digits[-10:]
|
||||
contact = None
|
||||
for row in Contact.objects.exclude(phone="").iterator():
|
||||
stored = "".join(ch for ch in row.phone if ch.isdigit())
|
||||
if stored.endswith(tail) or tail.endswith(stored[-10:]):
|
||||
contact = row
|
||||
break
|
||||
if not contact:
|
||||
return False
|
||||
set_channel_consent(
|
||||
contact, Channel.SMS, opted_in=False, reason="sms_stop"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def channel_for_audience(audience: str) -> str:
|
||||
try:
|
||||
return AUDIENCE_CHANNEL[audience]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown audience: {audience}") from exc
|
||||
|
||||
|
||||
def opted_in_contacts(channel: str) -> QuerySet[Contact]:
|
||||
"""Contacts opted in for channel and not actively suppressed."""
|
||||
suppressed = Suppression.objects.filter(
|
||||
channel=channel, active=True
|
||||
).values_list("contact_id", flat=True)
|
||||
qs = (
|
||||
Contact.objects.filter(
|
||||
consents__channel=channel,
|
||||
consents__opted_in=True,
|
||||
)
|
||||
.exclude(pk__in=suppressed)
|
||||
.distinct()
|
||||
.order_by("first_name", "last_name", "email")
|
||||
)
|
||||
if channel == Channel.POSTCARD:
|
||||
qs = qs.filter(postal_address__has_key="line1").exclude(
|
||||
postal_address__line1=""
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
def parse_scheduled_for(raw: str | None):
|
||||
"""Parse optional ``datetime-local`` value into an aware datetime."""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid schedule datetime.") from exc
|
||||
if timezone.is_naive(parsed):
|
||||
return timezone.make_aware(parsed, timezone.get_current_timezone())
|
||||
return parsed
|
||||
|
||||
|
||||
def create_campaign_draft(
|
||||
*,
|
||||
name: str,
|
||||
audience: str,
|
||||
subject: str = "",
|
||||
body: str = "",
|
||||
scheduled_for=None,
|
||||
created_by: AbstractBaseUser | None = None,
|
||||
template: MessageTemplate | None = None,
|
||||
) -> Campaign:
|
||||
"""Persist a draft campaign and per-recipient Message stubs."""
|
||||
channel = channel_for_audience(audience)
|
||||
campaign = Campaign.objects.create(
|
||||
name=name,
|
||||
channel=channel,
|
||||
audience=audience,
|
||||
status=Campaign.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
subject_override=subject,
|
||||
body_override=body,
|
||||
created_by=created_by,
|
||||
template=template,
|
||||
)
|
||||
contacts = list(opted_in_contacts(channel))
|
||||
Message.objects.bulk_create(
|
||||
[
|
||||
Message(
|
||||
campaign=campaign,
|
||||
contact=contact,
|
||||
channel=channel,
|
||||
status=Message.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
body_snapshot=body,
|
||||
)
|
||||
for contact in contacts
|
||||
]
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
def campaign_notify_recipient(campaign: Campaign) -> str:
|
||||
"""Email address for the realtor summary (created_by, else CONTACT_EMAIL)."""
|
||||
from django.conf import settings
|
||||
|
||||
user = campaign.created_by
|
||||
if user is not None:
|
||||
email = (getattr(user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
return (settings.CONTACT_EMAIL or "").strip()
|
||||
|
||||
|
||||
def send_campaign_completion_notify(campaign: Campaign) -> bool:
|
||||
"""
|
||||
One-shot summary email when a campaign finishes sending.
|
||||
|
||||
Returns True if mail was sent (or already sent earlier).
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.db.models import Count, Q
|
||||
from django.urls import reverse
|
||||
|
||||
if campaign.notify_sent_at:
|
||||
return True
|
||||
if campaign.status != Campaign.Status.COMPLETED:
|
||||
return False
|
||||
|
||||
to_email = campaign_notify_recipient(campaign)
|
||||
if not to_email:
|
||||
return False
|
||||
|
||||
counts = campaign.messages.aggregate(
|
||||
sent=Count("id", filter=Q(status=Message.Status.SENT)),
|
||||
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)),
|
||||
failed=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
status__in=[
|
||||
Message.Status.FAILED,
|
||||
Message.Status.BOUNCED,
|
||||
]
|
||||
),
|
||||
),
|
||||
suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)),
|
||||
total=Count("id"),
|
||||
)
|
||||
report_path = reverse("messaging:campaign_detail", kwargs={"pk": campaign.pk})
|
||||
public = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
report_url = f"{public}{report_path}" if public else report_path
|
||||
|
||||
subject = f"Campaign sent: {campaign.name}"
|
||||
body = (
|
||||
f"Your {campaign.get_channel_display()} campaign “{campaign.name}” "
|
||||
f"has finished sending.\n\n"
|
||||
f"Recipients: {counts['total']}\n"
|
||||
f"Sent: {counts['sent']}\n"
|
||||
f"Delivered: {counts['delivered']}\n"
|
||||
f"Failed / bounced: {counts['failed']}\n"
|
||||
f"Suppressed: {counts['suppressed']}\n\n"
|
||||
f"Report: {report_url}\n"
|
||||
)
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=body,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
try:
|
||||
email.send(fail_silently=False)
|
||||
except Exception: # noqa: BLE001 — don't block completion on mail errors
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"Campaign completion notify failed for %s", campaign.pk
|
||||
)
|
||||
return False
|
||||
|
||||
campaign.notify_sent_at = timezone.now()
|
||||
campaign.save(update_fields=["notify_sent_at", "updated_at"])
|
||||
return True
|
||||
|
||||
|
||||
def refresh_campaign_status(campaign: Campaign) -> Campaign:
|
||||
"""Set campaign to completed when no messages remain pending."""
|
||||
pending = campaign.messages.filter(
|
||||
status__in=[
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.QUEUED,
|
||||
]
|
||||
).exists()
|
||||
if pending:
|
||||
return campaign
|
||||
if campaign.status == Campaign.Status.SENDING:
|
||||
campaign.status = Campaign.Status.COMPLETED
|
||||
campaign.save(update_fields=["status", "updated_at"])
|
||||
send_campaign_completion_notify(campaign)
|
||||
return campaign
|
||||
|
||||
|
||||
def enqueue_campaign_send(campaign: Campaign) -> int:
|
||||
"""
|
||||
Queue draft/scheduled/failed messages for send.
|
||||
|
||||
Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console.
|
||||
"""
|
||||
from messaging.tasks import send_campaign_message
|
||||
|
||||
sendable = list(
|
||||
campaign.messages.filter(
|
||||
status__in=[
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.FAILED,
|
||||
]
|
||||
)
|
||||
)
|
||||
if not sendable:
|
||||
return 0
|
||||
|
||||
campaign.status = Campaign.Status.SENDING
|
||||
campaign.save(update_fields=["status", "updated_at"])
|
||||
|
||||
enqueued = 0
|
||||
for message in sendable:
|
||||
message.status = Message.Status.QUEUED
|
||||
message.save(update_fields=["status", "updated_at"])
|
||||
try:
|
||||
send_campaign_message.enqueue(message_id=str(message.pk))
|
||||
except Exception: # noqa: BLE001 — task already persisted FAILED
|
||||
pass
|
||||
enqueued += 1
|
||||
|
||||
refresh_campaign_status(campaign)
|
||||
return enqueued
|
||||
|
||||
|
||||
def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
|
||||
"""Send one preview copy to ``to_email`` without touching recipient rows."""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
if campaign.channel != Channel.EMAIL:
|
||||
raise ValueError("Test send is only available for email campaigns.")
|
||||
subject = campaign.subject_override or (
|
||||
campaign.template.subject if campaign.template_id else "Message from Monica"
|
||||
)
|
||||
body = campaign.body_override or (
|
||||
campaign.template.body if campaign.template_id else ""
|
||||
)
|
||||
if not subject.strip():
|
||||
raise ValueError("Campaign has no subject.")
|
||||
if not body.strip():
|
||||
raise ValueError("Campaign has no body.")
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=f"[TEST] {subject}",
|
||||
body=(
|
||||
f"{body}\n\n---\n"
|
||||
"This is a test send from the Monica portal. "
|
||||
"Recipient list was not notified."
|
||||
),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
email.send(fail_silently=False)
|
||||
Reference in New Issue
Block a user