Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/community/_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class User:
CANNOT_PAUSE_ADMIN = "user.cannot_pause_admin"
ROLE_IDS_NOT_FOUND = "user.role_ids_not_found"
INVALID_BIRTHDAY = "user.invalid_birthday"
INVALID_VEGANNIVERSARY = "user.invalid_veganniversary"

class Survey:
NOT_FOUND = "survey.not_found"
Expand Down
62 changes: 49 additions & 13 deletions backend/community/management/commands/send_weekly_digest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from collections import defaultdict
from datetime import timedelta

from django.conf import settings
Expand All @@ -19,24 +20,46 @@ def _format_event_when(event: Event) -> str:
return format_eastern_datetime(event.start_datetime)


def veganniversary_groups(*, now, users) -> list[dict]:
today = timezone.localtime(now).date()
if today.day > 7:
return []
grouped: dict[int, list[dict]] = defaultdict(list)
for user in users:
if user.veganniversary_month != today.month:
continue
years = today.year - user.veganniversary_year
if years < 1:
continue
grouped[years].append(
{
"name": user.first_name,
"url": f"{settings.FRONTEND_BASE_URL}/members/{user.pk}",
}
)
return [
{"label": "1 year" if y == 1 else f"{y} years", "people": people}
for y, people in sorted(grouped.items(), reverse=True)
]


class Command(BaseCommand):
help = "Email active members a digest of events starting in the next 7 days."
help = "Email active members a digest of upcoming events and this month's veganniversaries."

def add_arguments(self, parser):
parser.add_argument(
"--to",
help="send one digest to this address instead of the membership (provider smoke test)",
)

def _send_test(self, sender, to: str, events: list[dict], urls: dict[str, str]) -> None:
def _send_test(self, sender, to: str, payload: dict) -> None:
"""Send one real digest to an explicit address so a provider swap can be verified live."""
user = User.objects.filter(email__iexact=to).first()
result = send_weekly_digest_email(
sender=sender,
to=to,
display_name=user.first_name if user else "",
events=events,
urls=urls,
**payload,
)
if not result.success:
raise CommandError(f"test digest failed: {result.error}")
Expand Down Expand Up @@ -64,18 +87,32 @@ def handle(self, *args, **options):
}
for event in upcoming
]
if not events:
logger.info("send_weekly_digest: no upcoming events, skipped")
self.stdout.write(self.style.SUCCESS("No upcoming events; sent 0 digest(s)."))
groups = veganniversary_groups(
now=now,
users=User.objects.active_members()
.filter(
veganniversary_month__isnull=False,
veganniversary_year__isnull=False,
veganniversary_shoutout_opt_in=True,
)
.exclude(first_name=""),
)
if not events and not groups:
logger.info("send_weekly_digest: no upcoming events or veganniversaries, skipped")
self.stdout.write(self.style.SUCCESS("Nothing to send; sent 0 digest(s)."))
return

urls = {
"calendar_url": f"{settings.FRONTEND_BASE_URL}/calendar",
"settings_url": f"{settings.FRONTEND_BASE_URL}/settings",
payload = {
"events": events,
"urls": {
"calendar_url": f"{settings.FRONTEND_BASE_URL}/calendar",
"settings_url": f"{settings.FRONTEND_BASE_URL}/settings",
},
"veganniversaries": groups,
}
sender = get_email_sender(EmailStream.BULK)
if options["to"]:
self._send_test(sender, options["to"], events, urls)
self._send_test(sender, options["to"], payload)
return

sent_count = 0
Expand All @@ -91,8 +128,7 @@ def handle(self, *args, **options):
sender=sender,
to=user.email,
display_name=user.first_name,
events=events,
urls=urls,
**payload,
)
if result.success:
sent_count += 1
Expand Down
5 changes: 5 additions & 0 deletions backend/community/validation_codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,11 @@
"name": "INVALID_BIRTHDAY",
"value": "user.invalid_birthday",
"params": []
},
{
"name": "INVALID_VEGANNIVERSARY",
"value": "user.invalid_veganniversary",
"params": []
}
],
"Survey": [
Expand Down
7 changes: 5 additions & 2 deletions backend/notifications/_email_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,22 +288,25 @@ def send_checkin_reminder_email(
)


def send_weekly_digest_email(
def send_weekly_digest_email( # noqa: PLR0913
*,
sender: EmailSender,
to: str,
display_name: str,
events: list[dict],
urls: dict[str, str],
veganniversaries: list[dict] | None = None,
) -> SendResult:
"""Render and send the weekly "what's coming up" digest to one member.

param events(list[dict]): upcoming events, each with title/when/location/url keys
param urls(dict[str, str]): calendar_url and settings_url for the email footer/CTA
param urls: calendar_url and settings_url
param veganniversaries: year-grouped shout-out people, or empty
"""
context = {
"display_name": display_name or "",
"events": events,
"veganniversaries": veganniversaries or [],
**urls,
}
html = render_to_string("emails/weekly_digest.html", context)
Expand Down
142 changes: 142 additions & 0 deletions backend/openapi_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4452,6 +4452,17 @@
],
"title": "First Name"
},
"has_seen_veganniversary": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Has Seen Veganniversary"
},
"hide_last_name": {
"anyOf": [
{
Expand Down Expand Up @@ -4543,6 +4554,38 @@
],
"title": "Show Phone"
},
"show_veganniversary": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Show Veganniversary"
},
"veganniversary": {
"anyOf": [
{
"$ref": "#/components/schemas/VeganniversaryIn"
},
{
"type": "null"
}
]
},
"veganniversary_shoutout_opt_in": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Veganniversary Shoutout Opt In"
},
"week_start": {
"anyOf": [
{
Expand Down Expand Up @@ -4776,6 +4819,16 @@
"default": "",
"title": "Pronouns",
"type": "string"
},
"veganniversary": {
"anyOf": [
{
"$ref": "#/components/schemas/VeganniversaryOut"
},
{
"type": "null"
}
]
}
},
"required": [
Expand Down Expand Up @@ -6514,6 +6567,11 @@
"title": "Has Logged In",
"type": "boolean"
},
"has_seen_veganniversary": {
"default": false,
"title": "Has Seen Veganniversary",
"type": "boolean"
},
"hide_last_name": {
"default": false,
"title": "Hide Last Name",
Expand Down Expand Up @@ -6637,6 +6695,26 @@
"title": "Show Phone",
"type": "boolean"
},
"show_veganniversary": {
"default": true,
"title": "Show Veganniversary",
"type": "boolean"
},
"veganniversary": {
"anyOf": [
{
"$ref": "#/components/schemas/VeganniversaryOut"
},
{
"type": "null"
}
]
},
"veganniversary_shoutout_opt_in": {
"default": false,
"title": "Veganniversary Shoutout Opt In",
"type": "boolean"
},
"week_start": {
"default": "sunday",
"title": "Week Start",
Expand Down Expand Up @@ -6771,6 +6849,70 @@
"title": "UserSearchOut",
"type": "object"
},
"VeganniversaryIn": {
"properties": {
"day": {
"anyOf": [
{
"maximum": 31,
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Day"
},
"month": {
"maximum": 12,
"minimum": 1,
"title": "Month",
"type": "integer"
},
"year": {
"maximum": 2026,
"minimum": 1900,
"title": "Year",
"type": "integer"
}
},
"required": [
"month",
"year"
],
"title": "VeganniversaryIn",
"type": "object"
},
"VeganniversaryOut": {
"properties": {
"day": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Day"
},
"month": {
"title": "Month",
"type": "integer"
},
"year": {
"title": "Year",
"type": "integer"
}
},
"required": [
"month",
"year"
],
"title": "VeganniversaryOut",
"type": "object"
},
"VersionOut": {
"properties": {
"commit_sha": {
Expand Down
9 changes: 9 additions & 0 deletions backend/templates/emails/weekly_digest.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<body style="margin: 0; padding: 24px; background: #f5f5f3; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #222;">
<div style="max-width: 480px; margin: 0 auto; background: #ffffff; border-radius: 8px; padding: 32px;">
<p style="margin: 0 0 16px;">hi{% if display_name %} {{ display_name|lower }}{% endif %} —</p>
{% if events %}
<p style="margin: 0 0 24px;">here's what's coming up this week:</p>
{% for event in events %}
<div style="margin: 0 0 24px; padding: 0 0 24px; border-bottom: 1px solid #eee;">
Expand All @@ -12,6 +13,14 @@
<p style="margin: 0; font-size: 14px;"><a href="{{ event.url }}" style="color: #3c6939;">view event</a></p>
</div>
{% endfor %}
{% endif %}
{% if veganniversaries %}
<p style="margin: 0 0 16px;">Happy veganniversary!</p>
{% for group in veganniversaries %}
<p style="margin: 0 0 4px;"><strong>{{ group.label }}</strong></p>
<p style="margin: 0 0 16px; font-size: 14px; color: #666;">{% for person in group.people %}<a href="{{ person.url }}" style="color: #3c6939;">{{ person.name|lower }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
{% endfor %}
{% endif %}
<p style="margin: 0; text-align: center;">
<a href="{{ calendar_url }}" style="display: inline-block; background: #3c6939; color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 500;">see the full calendar</a>
</p>
Expand Down
9 changes: 8 additions & 1 deletion backend/templates/emails/weekly_digest.txt
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
hi{% if display_name %} {{ display_name|lower }}{% endif %} —

{% if events %}
here's what's coming up this week:
{% for event in events %}
{{ event.title|lower }}{% if event.when %}
{{ event.when|lower }}{% endif %}{% if event.location %}
{{ event.location|lower }}{% endif %}
{{ event.url }}
{% endfor %}{% endif %}{% if veganniversaries %}
Happy veganniversary!
{% for group in veganniversaries %}
{{ group.label }}
{% for person in group.people %}{{ person.name|lower }}
{{ person.url }}
{% endfor %}
{% endfor %}{% endif %}
see the full calendar:

{{ calendar_url }}
Expand Down
Loading
Loading