615 lines
21 KiB
Python
615 lines
21 KiB
Python
|
|
import asyncio
|
||
|
|
import logging
|
||
|
|
import re
|
||
|
|
from copy import deepcopy
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from datetime import date, datetime, timedelta, timezone
|
||
|
|
from zoneinfo import ZoneInfo
|
||
|
|
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.database import AsyncSessionLocal
|
||
|
|
from app.models.member import AdminMessage, Booking, Member, MemberNotificationDispatch, Walk
|
||
|
|
from app.models.settings import SiteSettings
|
||
|
|
from app.services.sections import get_section
|
||
|
|
from app.services.settings import get_feature_settings_snapshot, get_settings
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
NZ_TZ = ZoneInfo("Pacific/Auckland")
|
||
|
|
AUTOMATION_INTERVAL_SECONDS = 3600
|
||
|
|
SERVICE_LABELS = {
|
||
|
|
"pack_walk": "Pack Walk",
|
||
|
|
"1_1_walk": "1-1 Walk",
|
||
|
|
"puppy_visit": "Puppy Visit",
|
||
|
|
}
|
||
|
|
DEFAULT_SENT_BY = "Goodwalk"
|
||
|
|
DEFAULT_INVOICE_DAY = 1
|
||
|
|
MATARIKI_DATES = {
|
||
|
|
2025: date(2025, 6, 20),
|
||
|
|
2026: date(2026, 7, 10),
|
||
|
|
2027: date(2027, 6, 25),
|
||
|
|
2028: date(2028, 7, 14),
|
||
|
|
2029: date(2029, 7, 6),
|
||
|
|
2030: date(2030, 6, 21),
|
||
|
|
2031: date(2031, 7, 11),
|
||
|
|
2032: date(2032, 7, 2),
|
||
|
|
2033: date(2033, 6, 24),
|
||
|
|
2034: date(2034, 7, 7),
|
||
|
|
2035: date(2035, 6, 29),
|
||
|
|
}
|
||
|
|
AUTOMATIC_NOTIFICATION_SECTION_KEY = "notifications.automaticMessages"
|
||
|
|
PUBLIC_HOLIDAY_NOTIFICATION_SECTION_KEY = "notifications.publicHolidays"
|
||
|
|
INVOICE_REMINDER_NOTIFICATION_SECTION_KEY = "notifications.invoiceReminders"
|
||
|
|
TEMPLATE_TOKEN_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
|
||
|
|
DEFAULT_AUTOMATIC_NOTIFICATION_TEMPLATES = {
|
||
|
|
"member_activated": {
|
||
|
|
"subject": "Your Goodwalk members account is now active",
|
||
|
|
"body": "Your onboarding is complete and your full members area is now ready. You can log in to view bookings, messages, walks, and your contract.",
|
||
|
|
},
|
||
|
|
"booking_confirmed": {
|
||
|
|
"subject": "Your Goodwalk booking has been confirmed",
|
||
|
|
"body": "Your {{service_label}} booking for {{requested_date_label}} has been confirmed. If anything changes, we will message you here.",
|
||
|
|
},
|
||
|
|
"booking_rescheduled": {
|
||
|
|
"subject": "Your Goodwalk booking has been rescheduled",
|
||
|
|
"body": "Your {{service_label}} has been moved to {{requested_date_label}}. If you have any questions, please get in touch.",
|
||
|
|
},
|
||
|
|
"booking_cancelled": {
|
||
|
|
"subject": "Your Goodwalk booking has been cancelled",
|
||
|
|
"body": "Your {{service_label}} booking for {{requested_date_label}} has been cancelled. Please contact us if you would like to arrange another time.",
|
||
|
|
},
|
||
|
|
"walk_completed": {
|
||
|
|
"subject": "Your Goodwalk walk is complete",
|
||
|
|
"body": "{{member_first_name}}, your {{service_label}} on {{walked_on_label}} has been marked as complete.{{walk_notes_sentence}}",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
DEFAULT_PUBLIC_HOLIDAY_NOTIFICATION_TEMPLATE = {
|
||
|
|
"subject": "Goodwalk public holiday update: {{holiday_name}}",
|
||
|
|
"body": "Today is {{holiday_name}} in New Zealand. If you were expecting service changes or slower replies today, this is why. We will confirm any booking adjustments directly in your messages.",
|
||
|
|
}
|
||
|
|
DEFAULT_INVOICE_REMINDER_NOTIFICATION_TEMPLATE = {
|
||
|
|
"subject": "Invoice reminder from Goodwalk",
|
||
|
|
"body": "A quick reminder that invoices are scheduled to go out on {{weekday_label}}. This week that falls on {{invoice_date_label}}.",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class NotificationSettingsSnapshot:
|
||
|
|
automatic_member_notifications_enabled: bool = True
|
||
|
|
nz_public_holiday_notifications_enabled: bool = True
|
||
|
|
invoice_reminder_notifications_enabled: bool = True
|
||
|
|
invoice_day_of_week: int = DEFAULT_INVOICE_DAY
|
||
|
|
admin_notifications_cleared_before: datetime | None = None
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class NotificationRunSummary:
|
||
|
|
automatic_member_notifications_enabled: bool
|
||
|
|
public_holiday_messages_sent: int = 0
|
||
|
|
invoice_reminders_sent: int = 0
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class NotificationTemplateSnapshot:
|
||
|
|
automatic_messages: dict[str, dict[str, str]]
|
||
|
|
public_holidays: dict[str, str]
|
||
|
|
invoice_reminders: dict[str, str]
|
||
|
|
|
||
|
|
|
||
|
|
def _service_label(service_type: str | None) -> str:
|
||
|
|
return SERVICE_LABELS.get(service_type or "", service_type or "service")
|
||
|
|
|
||
|
|
|
||
|
|
def _weekday_label(weekday: int) -> str:
|
||
|
|
labels = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||
|
|
return labels[weekday] if 0 <= weekday < len(labels) else labels[DEFAULT_INVOICE_DAY]
|
||
|
|
|
||
|
|
|
||
|
|
def _format_nz_date(day: datetime | date, *, include_year: bool = False) -> str:
|
||
|
|
if isinstance(day, datetime):
|
||
|
|
local_day = day.astimezone(NZ_TZ)
|
||
|
|
base = local_day.date()
|
||
|
|
else:
|
||
|
|
base = day
|
||
|
|
|
||
|
|
month = base.strftime("%B")
|
||
|
|
weekday = base.strftime("%A")
|
||
|
|
if include_year:
|
||
|
|
return f"{weekday} {base.day} {month} {base.year}"
|
||
|
|
return f"{weekday} {base.day} {month}"
|
||
|
|
|
||
|
|
|
||
|
|
def _shift_single_holiday(day: date) -> date:
|
||
|
|
if day.weekday() == 5:
|
||
|
|
return day + timedelta(days=2)
|
||
|
|
if day.weekday() == 6:
|
||
|
|
return day + timedelta(days=1)
|
||
|
|
return day
|
||
|
|
|
||
|
|
|
||
|
|
def _observed_pair(day_one: date, day_two: date) -> list[date]:
|
||
|
|
observed: list[date] = []
|
||
|
|
|
||
|
|
for actual in [day_one, day_two]:
|
||
|
|
candidate = actual
|
||
|
|
if candidate.weekday() >= 5:
|
||
|
|
candidate += timedelta(days=7 - candidate.weekday())
|
||
|
|
while candidate in observed:
|
||
|
|
candidate += timedelta(days=1)
|
||
|
|
observed.append(candidate)
|
||
|
|
|
||
|
|
return observed
|
||
|
|
|
||
|
|
|
||
|
|
def _nth_weekday(year: int, month: int, weekday: int, occurrence: int) -> date:
|
||
|
|
first = date(year, month, 1)
|
||
|
|
offset = (weekday - first.weekday()) % 7
|
||
|
|
return first + timedelta(days=offset + (occurrence - 1) * 7)
|
||
|
|
|
||
|
|
|
||
|
|
def _easter_sunday(year: int) -> date:
|
||
|
|
a = year % 19
|
||
|
|
b = year // 100
|
||
|
|
c = year % 100
|
||
|
|
d = b // 4
|
||
|
|
e = b % 4
|
||
|
|
f = (b + 8) // 25
|
||
|
|
g = (b - f + 1) // 3
|
||
|
|
h = (19 * a + b - d - g + 15) % 30
|
||
|
|
i = c // 4
|
||
|
|
k = c % 4
|
||
|
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||
|
|
m = (a + 11 * h + 22 * l) // 451
|
||
|
|
month = (h + l - 7 * m + 114) // 31
|
||
|
|
day = ((h + l - 7 * m + 114) % 31) + 1
|
||
|
|
return date(year, month, day)
|
||
|
|
|
||
|
|
|
||
|
|
def nz_public_holidays_for_year(year: int) -> dict[date, str]:
|
||
|
|
easter = _easter_sunday(year)
|
||
|
|
new_year, day_after = _observed_pair(date(year, 1, 1), date(year, 1, 2))
|
||
|
|
christmas, boxing = _observed_pair(date(year, 12, 25), date(year, 12, 26))
|
||
|
|
|
||
|
|
holidays = {
|
||
|
|
new_year: "New Year's Day",
|
||
|
|
day_after: "Day after New Year's Day",
|
||
|
|
_shift_single_holiday(date(year, 2, 6)): "Waitangi Day",
|
||
|
|
easter - timedelta(days=2): "Good Friday",
|
||
|
|
easter + timedelta(days=1): "Easter Monday",
|
||
|
|
_shift_single_holiday(date(year, 4, 25)): "ANZAC Day",
|
||
|
|
_nth_weekday(year, 6, 0, 1): "King's Birthday",
|
||
|
|
_nth_weekday(year, 10, 0, 4): "Labour Day",
|
||
|
|
christmas: "Christmas Day",
|
||
|
|
boxing: "Boxing Day",
|
||
|
|
}
|
||
|
|
|
||
|
|
matariki = MATARIKI_DATES.get(year)
|
||
|
|
if matariki is not None:
|
||
|
|
holidays[matariki] = "Matariki"
|
||
|
|
|
||
|
|
return holidays
|
||
|
|
|
||
|
|
|
||
|
|
def nz_public_holiday_name(day: date) -> str | None:
|
||
|
|
return nz_public_holidays_for_year(day.year).get(day)
|
||
|
|
|
||
|
|
|
||
|
|
def _normalize_template_text(value: object, fallback: str) -> str:
|
||
|
|
if isinstance(value, str):
|
||
|
|
trimmed = value.strip()
|
||
|
|
if trimmed:
|
||
|
|
return trimmed
|
||
|
|
return fallback
|
||
|
|
|
||
|
|
|
||
|
|
def _normalize_subject_body_template(data: object, fallback: dict[str, str]) -> dict[str, str]:
|
||
|
|
source = data if isinstance(data, dict) else {}
|
||
|
|
return {
|
||
|
|
"subject": _normalize_template_text(source.get("subject"), fallback["subject"]),
|
||
|
|
"body": _normalize_template_text(source.get("body"), fallback["body"]),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def get_notification_template_snapshot(db: AsyncSession) -> NotificationTemplateSnapshot:
|
||
|
|
automatic_section = await get_section(db, AUTOMATIC_NOTIFICATION_SECTION_KEY)
|
||
|
|
automatic_templates = deepcopy(DEFAULT_AUTOMATIC_NOTIFICATION_TEMPLATES)
|
||
|
|
automatic_source = automatic_section.get("templates") if isinstance(automatic_section, dict) else {}
|
||
|
|
if isinstance(automatic_source, dict):
|
||
|
|
for template_key, fallback in DEFAULT_AUTOMATIC_NOTIFICATION_TEMPLATES.items():
|
||
|
|
automatic_templates[template_key] = _normalize_subject_body_template(
|
||
|
|
automatic_source.get(template_key),
|
||
|
|
fallback,
|
||
|
|
)
|
||
|
|
|
||
|
|
public_holiday_section = await get_section(db, PUBLIC_HOLIDAY_NOTIFICATION_SECTION_KEY)
|
||
|
|
invoice_reminder_section = await get_section(db, INVOICE_REMINDER_NOTIFICATION_SECTION_KEY)
|
||
|
|
|
||
|
|
return NotificationTemplateSnapshot(
|
||
|
|
automatic_messages=automatic_templates,
|
||
|
|
public_holidays=_normalize_subject_body_template(
|
||
|
|
public_holiday_section,
|
||
|
|
DEFAULT_PUBLIC_HOLIDAY_NOTIFICATION_TEMPLATE,
|
||
|
|
),
|
||
|
|
invoice_reminders=_normalize_subject_body_template(
|
||
|
|
invoice_reminder_section,
|
||
|
|
DEFAULT_INVOICE_REMINDER_NOTIFICATION_TEMPLATE,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _render_template_text(template: str, context: dict[str, object]) -> str:
|
||
|
|
def replace(match: re.Match[str]) -> str:
|
||
|
|
return str(context.get(match.group(1), ""))
|
||
|
|
|
||
|
|
return TEMPLATE_TOKEN_PATTERN.sub(replace, template).strip()
|
||
|
|
|
||
|
|
|
||
|
|
def _render_subject_body_template(template: dict[str, str], context: dict[str, object]) -> tuple[str, str]:
|
||
|
|
return (
|
||
|
|
_render_template_text(template["subject"], context),
|
||
|
|
_render_template_text(template["body"], context),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def get_notification_settings_snapshot(db: AsyncSession) -> NotificationSettingsSnapshot:
|
||
|
|
row = await get_settings(db)
|
||
|
|
if row is None:
|
||
|
|
return NotificationSettingsSnapshot()
|
||
|
|
|
||
|
|
return NotificationSettingsSnapshot(
|
||
|
|
automatic_member_notifications_enabled=row.automatic_member_notifications_enabled,
|
||
|
|
nz_public_holiday_notifications_enabled=row.nz_public_holiday_notifications_enabled,
|
||
|
|
invoice_reminder_notifications_enabled=row.invoice_reminder_notifications_enabled,
|
||
|
|
invoice_day_of_week=row.invoice_day_of_week,
|
||
|
|
admin_notifications_cleared_before=row.admin_notifications_cleared_before,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def update_notification_settings_snapshot(
|
||
|
|
db: AsyncSession,
|
||
|
|
*,
|
||
|
|
automatic_member_notifications_enabled: bool | None = None,
|
||
|
|
nz_public_holiday_notifications_enabled: bool | None = None,
|
||
|
|
invoice_reminder_notifications_enabled: bool | None = None,
|
||
|
|
invoice_day_of_week: int | None = None,
|
||
|
|
admin_notifications_cleared_before: datetime | None = None,
|
||
|
|
) -> NotificationSettingsSnapshot:
|
||
|
|
row = await get_settings(db)
|
||
|
|
if row is None:
|
||
|
|
row = SiteSettings(site_name="")
|
||
|
|
db.add(row)
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
if automatic_member_notifications_enabled is not None:
|
||
|
|
row.automatic_member_notifications_enabled = automatic_member_notifications_enabled
|
||
|
|
if nz_public_holiday_notifications_enabled is not None:
|
||
|
|
row.nz_public_holiday_notifications_enabled = nz_public_holiday_notifications_enabled
|
||
|
|
if invoice_reminder_notifications_enabled is not None:
|
||
|
|
row.invoice_reminder_notifications_enabled = invoice_reminder_notifications_enabled
|
||
|
|
if invoice_day_of_week is not None:
|
||
|
|
if invoice_day_of_week < 0 or invoice_day_of_week > 6:
|
||
|
|
raise ValueError("invoice_day_of_week must be between 0 and 6")
|
||
|
|
row.invoice_day_of_week = invoice_day_of_week
|
||
|
|
if admin_notifications_cleared_before is not None:
|
||
|
|
row.admin_notifications_cleared_before = admin_notifications_cleared_before
|
||
|
|
|
||
|
|
await db.flush()
|
||
|
|
await db.refresh(row)
|
||
|
|
return await get_notification_settings_snapshot(db)
|
||
|
|
|
||
|
|
|
||
|
|
async def create_member_message(
|
||
|
|
db: AsyncSession,
|
||
|
|
*,
|
||
|
|
member: Member,
|
||
|
|
subject: str,
|
||
|
|
body: str,
|
||
|
|
sent_by: str = DEFAULT_SENT_BY,
|
||
|
|
automatic: bool = False,
|
||
|
|
dispatch_key: str | None = None,
|
||
|
|
notification_type: str | None = None,
|
||
|
|
respect_preferences: bool = True,
|
||
|
|
) -> AdminMessage | None:
|
||
|
|
feature_settings = await get_feature_settings_snapshot(db)
|
||
|
|
if not feature_settings.messages_enabled:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if respect_preferences and not member.notifications_enabled:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if automatic:
|
||
|
|
settings = await get_notification_settings_snapshot(db)
|
||
|
|
if not settings.automatic_member_notifications_enabled:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if dispatch_key is not None:
|
||
|
|
existing = await db.execute(
|
||
|
|
select(MemberNotificationDispatch).where(
|
||
|
|
MemberNotificationDispatch.member_id == member.id,
|
||
|
|
MemberNotificationDispatch.dispatch_key == dispatch_key,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if existing.scalars().first() is not None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
db.add(
|
||
|
|
MemberNotificationDispatch(
|
||
|
|
member_id=member.id,
|
||
|
|
notification_type=notification_type or "notification",
|
||
|
|
dispatch_key=dispatch_key,
|
||
|
|
metadata_json={"automatic": automatic},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
message = AdminMessage(
|
||
|
|
member_id=member.id,
|
||
|
|
subject=subject,
|
||
|
|
body=body,
|
||
|
|
sent_by=sent_by,
|
||
|
|
)
|
||
|
|
db.add(message)
|
||
|
|
await db.flush()
|
||
|
|
return message
|
||
|
|
|
||
|
|
|
||
|
|
async def send_account_activated_notification(
|
||
|
|
db: AsyncSession,
|
||
|
|
member: Member,
|
||
|
|
*,
|
||
|
|
templates: NotificationTemplateSnapshot | None = None,
|
||
|
|
) -> AdminMessage | None:
|
||
|
|
templates = templates or await get_notification_template_snapshot(db)
|
||
|
|
subject, body = _render_subject_body_template(
|
||
|
|
templates.automatic_messages["member_activated"],
|
||
|
|
{
|
||
|
|
"member_first_name": member.first_name,
|
||
|
|
"member_last_name": member.last_name,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return await create_member_message(
|
||
|
|
db,
|
||
|
|
member=member,
|
||
|
|
subject=subject,
|
||
|
|
body=body,
|
||
|
|
automatic=True,
|
||
|
|
dispatch_key=f"member_activated:{member.id}:{member.activated_at.isoformat() if member.activated_at else 'pending'}",
|
||
|
|
notification_type="member_activated",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def send_booking_status_notification(
|
||
|
|
db: AsyncSession,
|
||
|
|
member: Member,
|
||
|
|
booking: Booking,
|
||
|
|
*,
|
||
|
|
templates: NotificationTemplateSnapshot | None = None,
|
||
|
|
) -> AdminMessage | None:
|
||
|
|
if booking.status not in {"confirmed", "cancelled"}:
|
||
|
|
return None
|
||
|
|
|
||
|
|
templates = templates or await get_notification_template_snapshot(db)
|
||
|
|
requested = _format_nz_date(booking.requested_date, include_year=True) if booking.requested_date else "the requested date"
|
||
|
|
service_name = _service_label(booking.service_type)
|
||
|
|
|
||
|
|
if booking.status == "confirmed":
|
||
|
|
template = templates.automatic_messages["booking_confirmed"]
|
||
|
|
else:
|
||
|
|
template = templates.automatic_messages["booking_cancelled"]
|
||
|
|
|
||
|
|
subject, body = _render_subject_body_template(
|
||
|
|
template,
|
||
|
|
{
|
||
|
|
"member_first_name": member.first_name,
|
||
|
|
"member_last_name": member.last_name,
|
||
|
|
"service_label": service_name,
|
||
|
|
"requested_date_label": requested,
|
||
|
|
"booking_status": booking.status,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
return await create_member_message(
|
||
|
|
db,
|
||
|
|
member=member,
|
||
|
|
subject=subject,
|
||
|
|
body=body,
|
||
|
|
automatic=True,
|
||
|
|
dispatch_key=f"booking_status:{booking.id}:{booking.status}",
|
||
|
|
notification_type="booking_status",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def send_booking_rescheduled_notification(
|
||
|
|
db: AsyncSession,
|
||
|
|
member: Member,
|
||
|
|
booking: Booking,
|
||
|
|
*,
|
||
|
|
templates: NotificationTemplateSnapshot | None = None,
|
||
|
|
) -> AdminMessage | None:
|
||
|
|
templates = templates or await get_notification_template_snapshot(db)
|
||
|
|
requested = _format_nz_date(booking.requested_date, include_year=True) if booking.requested_date else "a new date"
|
||
|
|
service_name = _service_label(booking.service_type)
|
||
|
|
|
||
|
|
subject, body = _render_subject_body_template(
|
||
|
|
templates.automatic_messages["booking_rescheduled"],
|
||
|
|
{
|
||
|
|
"member_first_name": member.first_name,
|
||
|
|
"member_last_name": member.last_name,
|
||
|
|
"service_label": service_name,
|
||
|
|
"requested_date_label": requested,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
return await create_member_message(
|
||
|
|
db,
|
||
|
|
member=member,
|
||
|
|
subject=subject,
|
||
|
|
body=body,
|
||
|
|
automatic=True,
|
||
|
|
dispatch_key=f"booking_rescheduled:{booking.id}:{booking.requested_date.isoformat() if booking.requested_date else 'none'}",
|
||
|
|
notification_type="booking_rescheduled",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def send_walk_completed_notification(
|
||
|
|
db: AsyncSession,
|
||
|
|
member: Member,
|
||
|
|
walk: Walk,
|
||
|
|
*,
|
||
|
|
templates: NotificationTemplateSnapshot | None = None,
|
||
|
|
) -> AdminMessage | None:
|
||
|
|
templates = templates or await get_notification_template_snapshot(db)
|
||
|
|
walked_on = _format_nz_date(walk.walked_at)
|
||
|
|
service_name = _service_label(walk.service_type)
|
||
|
|
subject, body = _render_subject_body_template(
|
||
|
|
templates.automatic_messages["walk_completed"],
|
||
|
|
{
|
||
|
|
"member_first_name": member.first_name,
|
||
|
|
"member_last_name": member.last_name,
|
||
|
|
"service_label": service_name,
|
||
|
|
"walked_on_label": walked_on,
|
||
|
|
"walk_notes": walk.notes or "",
|
||
|
|
"walk_notes_sentence": f" Notes from the team: {walk.notes}" if walk.notes else "",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
return await create_member_message(
|
||
|
|
db,
|
||
|
|
member=member,
|
||
|
|
subject=subject,
|
||
|
|
body=body,
|
||
|
|
automatic=True,
|
||
|
|
dispatch_key=f"walk_completed:{walk.id}",
|
||
|
|
notification_type="walk_completed",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def send_public_holiday_notification(
|
||
|
|
db: AsyncSession,
|
||
|
|
member: Member,
|
||
|
|
holiday_date: date,
|
||
|
|
holiday_name: str,
|
||
|
|
*,
|
||
|
|
templates: NotificationTemplateSnapshot | None = None,
|
||
|
|
) -> AdminMessage | None:
|
||
|
|
templates = templates or await get_notification_template_snapshot(db)
|
||
|
|
subject, body = _render_subject_body_template(
|
||
|
|
templates.public_holidays,
|
||
|
|
{
|
||
|
|
"member_first_name": member.first_name,
|
||
|
|
"member_last_name": member.last_name,
|
||
|
|
"holiday_name": holiday_name,
|
||
|
|
"holiday_date_label": _format_nz_date(holiday_date, include_year=True),
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return await create_member_message(
|
||
|
|
db,
|
||
|
|
member=member,
|
||
|
|
subject=subject,
|
||
|
|
body=body,
|
||
|
|
automatic=True,
|
||
|
|
dispatch_key=f"public_holiday:{holiday_date.isoformat()}",
|
||
|
|
notification_type="public_holiday",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def send_invoice_day_notification(
|
||
|
|
db: AsyncSession,
|
||
|
|
member: Member,
|
||
|
|
invoice_date: date,
|
||
|
|
weekday_label: str,
|
||
|
|
*,
|
||
|
|
templates: NotificationTemplateSnapshot | None = None,
|
||
|
|
) -> AdminMessage | None:
|
||
|
|
templates = templates or await get_notification_template_snapshot(db)
|
||
|
|
subject, body = _render_subject_body_template(
|
||
|
|
templates.invoice_reminders,
|
||
|
|
{
|
||
|
|
"member_first_name": member.first_name,
|
||
|
|
"member_last_name": member.last_name,
|
||
|
|
"weekday_label": weekday_label,
|
||
|
|
"invoice_date_label": _format_nz_date(invoice_date),
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return await create_member_message(
|
||
|
|
db,
|
||
|
|
member=member,
|
||
|
|
subject=subject,
|
||
|
|
body=body,
|
||
|
|
automatic=True,
|
||
|
|
dispatch_key=f"invoice_reminder:{invoice_date.isoformat()}",
|
||
|
|
notification_type="invoice_reminder",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def run_automatic_notifications(
|
||
|
|
db: AsyncSession,
|
||
|
|
*,
|
||
|
|
now: datetime | None = None,
|
||
|
|
) -> NotificationRunSummary:
|
||
|
|
settings = await get_notification_settings_snapshot(db)
|
||
|
|
summary = NotificationRunSummary(
|
||
|
|
automatic_member_notifications_enabled=settings.automatic_member_notifications_enabled,
|
||
|
|
)
|
||
|
|
|
||
|
|
if not settings.automatic_member_notifications_enabled:
|
||
|
|
return summary
|
||
|
|
|
||
|
|
templates = await get_notification_template_snapshot(db)
|
||
|
|
|
||
|
|
local_now = (now or datetime.now(timezone.utc)).astimezone(NZ_TZ)
|
||
|
|
local_date = local_now.date()
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(Member).where(
|
||
|
|
Member.is_active == True, # noqa: E712
|
||
|
|
Member.member_status == "active",
|
||
|
|
Member.notifications_enabled == True, # noqa: E712
|
||
|
|
)
|
||
|
|
)
|
||
|
|
members = result.scalars().all()
|
||
|
|
|
||
|
|
if settings.nz_public_holiday_notifications_enabled:
|
||
|
|
holiday_name = nz_public_holiday_name(local_date)
|
||
|
|
if holiday_name:
|
||
|
|
for member in members:
|
||
|
|
created = await send_public_holiday_notification(
|
||
|
|
db,
|
||
|
|
member,
|
||
|
|
local_date,
|
||
|
|
holiday_name,
|
||
|
|
templates=templates,
|
||
|
|
)
|
||
|
|
if created is not None:
|
||
|
|
summary.public_holiday_messages_sent += 1
|
||
|
|
|
||
|
|
if settings.invoice_reminder_notifications_enabled and local_date.weekday() == settings.invoice_day_of_week:
|
||
|
|
weekday_label = _weekday_label(settings.invoice_day_of_week)
|
||
|
|
for member in members:
|
||
|
|
created = await send_invoice_day_notification(
|
||
|
|
db,
|
||
|
|
member,
|
||
|
|
local_date,
|
||
|
|
weekday_label,
|
||
|
|
templates=templates,
|
||
|
|
)
|
||
|
|
if created is not None:
|
||
|
|
summary.invoice_reminders_sent += 1
|
||
|
|
|
||
|
|
return summary
|
||
|
|
|
||
|
|
|
||
|
|
async def notification_automation_loop() -> None:
|
||
|
|
while True:
|
||
|
|
try:
|
||
|
|
async with AsyncSessionLocal() as session:
|
||
|
|
await run_automatic_notifications(session)
|
||
|
|
await session.commit()
|
||
|
|
except asyncio.CancelledError:
|
||
|
|
raise
|
||
|
|
except Exception:
|
||
|
|
logger.exception("Automatic member notification loop failed.")
|
||
|
|
|
||
|
|
await asyncio.sleep(AUTOMATION_INTERVAL_SECONDS)
|