Files
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

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)