Add Django site, Docker packaging, and beta/prod Gitea deploys.
Deploy Beta / unit-tests (push) Successful in 9s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 2m31s

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:
2026-08-08 07:32:55 -05:00
parent 7dca98bbf6
commit 1f7d78de64
204 changed files with 21662 additions and 70 deletions
+404
View File
@@ -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)