v1
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import Date, case, cast, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.analytics import AnalyticsEvent
|
||||
from app.models.member import Booking, Member
|
||||
from app.schemas.analytics import EventCreate
|
||||
|
||||
|
||||
async def record_event(
|
||||
db: AsyncSession,
|
||||
data: EventCreate,
|
||||
ip_hash: str | None,
|
||||
ip_partial: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
browser: str | None = None,
|
||||
os_name: str | None = None,
|
||||
country: str | None = None,
|
||||
city: str | None = None,
|
||||
) -> AnalyticsEvent:
|
||||
"""Insert a new analytics event and return it."""
|
||||
event = AnalyticsEvent(
|
||||
event_type=data.event_type,
|
||||
page=data.page,
|
||||
element=data.element,
|
||||
metadata_=data.metadata,
|
||||
session_id=data.session_id,
|
||||
ip_hash=ip_hash,
|
||||
ip_partial=ip_partial,
|
||||
user_agent=user_agent,
|
||||
browser=browser,
|
||||
os_name=os_name,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
async def get_summary(db: AsyncSession) -> dict:
|
||||
"""Return all summary data needed for AnalyticsSummary."""
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1)
|
||||
week_ago = today - timedelta(days=6)
|
||||
|
||||
date_col = cast(AnalyticsEvent.created_at, Date)
|
||||
|
||||
# Total events today
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(AnalyticsEvent).where(date_col == today)
|
||||
)
|
||||
total_events_today = result.scalar_one()
|
||||
|
||||
# Total events yesterday
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(AnalyticsEvent).where(date_col == yesterday)
|
||||
)
|
||||
total_events_yesterday = result.scalar_one()
|
||||
|
||||
# Page views today
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(AnalyticsEvent)
|
||||
.where(date_col == today)
|
||||
.where(AnalyticsEvent.event_type == "page_view")
|
||||
)
|
||||
page_views_today = result.scalar_one()
|
||||
|
||||
# Unique sessions today
|
||||
result = await db.execute(
|
||||
select(func.count(AnalyticsEvent.session_id.distinct()))
|
||||
.select_from(AnalyticsEvent)
|
||||
.where(date_col == today)
|
||||
)
|
||||
unique_sessions_today = result.scalar_one()
|
||||
|
||||
# Unique sessions total
|
||||
result = await db.execute(
|
||||
select(func.count(AnalyticsEvent.session_id.distinct())).select_from(AnalyticsEvent)
|
||||
)
|
||||
unique_sessions_total = result.scalar_one()
|
||||
|
||||
# Total events all time
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(AnalyticsEvent)
|
||||
)
|
||||
total_events_all_time = result.scalar_one()
|
||||
|
||||
# Events by type (top 10, all time)
|
||||
result = await db.execute(
|
||||
select(AnalyticsEvent.event_type, func.count().label("cnt"))
|
||||
.group_by(AnalyticsEvent.event_type)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
)
|
||||
events_by_type = [{"label": r.event_type, "count": r.cnt} for r in result.all()]
|
||||
|
||||
# Top pages (page_view events, top 10)
|
||||
result = await db.execute(
|
||||
select(AnalyticsEvent.page, func.count().label("cnt"))
|
||||
.where(AnalyticsEvent.event_type == "page_view")
|
||||
.group_by(AnalyticsEvent.page)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
)
|
||||
top_pages = [{"label": r.page, "count": r.cnt} for r in result.all()]
|
||||
|
||||
# Top elements (non page_view, top 10)
|
||||
result = await db.execute(
|
||||
select(AnalyticsEvent.element, func.count().label("cnt"))
|
||||
.where(AnalyticsEvent.event_type != "page_view")
|
||||
.where(AnalyticsEvent.element.isnot(None))
|
||||
.group_by(AnalyticsEvent.element)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
)
|
||||
top_elements = [{"label": r.element, "count": r.cnt} for r in result.all()]
|
||||
|
||||
# Top journeys (page-to-page flows derived from page_view events per session)
|
||||
result = await db.execute(
|
||||
select(
|
||||
AnalyticsEvent.session_id,
|
||||
AnalyticsEvent.page,
|
||||
)
|
||||
.where(AnalyticsEvent.event_type == "page_view")
|
||||
.order_by(AnalyticsEvent.session_id, AnalyticsEvent.created_at, AnalyticsEvent.id)
|
||||
)
|
||||
|
||||
journey_counts: dict[str, int] = {}
|
||||
current_session = None
|
||||
previous_page = None
|
||||
|
||||
for row in result.all():
|
||||
if row.session_id != current_session:
|
||||
current_session = row.session_id
|
||||
previous_page = None
|
||||
|
||||
if row.page == previous_page:
|
||||
continue
|
||||
|
||||
if previous_page is not None:
|
||||
journey = f"{previous_page} -> {row.page}"
|
||||
journey_counts[journey] = journey_counts.get(journey, 0) + 1
|
||||
|
||||
previous_page = row.page
|
||||
|
||||
top_journeys = [
|
||||
{"label": label, "count": count}
|
||||
for label, count in sorted(
|
||||
journey_counts.items(),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)[:10]
|
||||
]
|
||||
|
||||
# Top browsers
|
||||
result = await db.execute(
|
||||
select(AnalyticsEvent.browser, func.count().label("cnt"))
|
||||
.where(AnalyticsEvent.browser.isnot(None))
|
||||
.group_by(AnalyticsEvent.browser)
|
||||
.order_by(func.count().desc())
|
||||
.limit(8)
|
||||
)
|
||||
top_browsers = [{"label": r.browser, "count": r.cnt} for r in result.all()]
|
||||
|
||||
# Top OS
|
||||
result = await db.execute(
|
||||
select(AnalyticsEvent.os_name, func.count().label("cnt"))
|
||||
.where(AnalyticsEvent.os_name.isnot(None))
|
||||
.group_by(AnalyticsEvent.os_name)
|
||||
.order_by(func.count().desc())
|
||||
.limit(8)
|
||||
)
|
||||
top_os = [{"label": r.os_name, "count": r.cnt} for r in result.all()]
|
||||
|
||||
# Top countries
|
||||
result = await db.execute(
|
||||
select(AnalyticsEvent.country, func.count().label("cnt"))
|
||||
.where(AnalyticsEvent.country.isnot(None))
|
||||
.group_by(AnalyticsEvent.country)
|
||||
.order_by(func.count().desc())
|
||||
.limit(8)
|
||||
)
|
||||
top_countries = [{"label": r.country, "count": r.cnt} for r in result.all()]
|
||||
|
||||
# Last 7 days counts
|
||||
result = await db.execute(
|
||||
select(date_col.label("day"), func.count().label("cnt"))
|
||||
.where(date_col >= week_ago)
|
||||
.group_by(date_col)
|
||||
.order_by(date_col)
|
||||
)
|
||||
days = {str(r.day): r.cnt for r in result.all()}
|
||||
|
||||
last_7 = []
|
||||
for i in range(6, -1, -1):
|
||||
d = str(today - timedelta(days=i))
|
||||
last_7.append({"date": d, "count": days.get(d, 0)})
|
||||
|
||||
# Recent events (last 30)
|
||||
result = await db.execute(
|
||||
select(AnalyticsEvent)
|
||||
.order_by(AnalyticsEvent.created_at.desc())
|
||||
.limit(30)
|
||||
)
|
||||
recent = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"total_events_today": total_events_today,
|
||||
"total_events_yesterday": total_events_yesterday,
|
||||
"page_views_today": page_views_today,
|
||||
"unique_sessions_today": unique_sessions_today,
|
||||
"unique_sessions_total": unique_sessions_total,
|
||||
"total_events_all_time": total_events_all_time,
|
||||
"events_by_type": events_by_type,
|
||||
"top_pages": top_pages,
|
||||
"top_elements": top_elements,
|
||||
"top_journeys": top_journeys,
|
||||
"top_browsers": top_browsers,
|
||||
"top_os": top_os,
|
||||
"top_countries": top_countries,
|
||||
"events_last_7_days": last_7,
|
||||
"recent_events": recent,
|
||||
}
|
||||
|
||||
|
||||
async def get_booking_operations_summary(db: AsyncSession) -> dict:
|
||||
"""Return booking operations reporting for the admin Reporting page."""
|
||||
today = date.today()
|
||||
activity_start = today - timedelta(days=29)
|
||||
forward_load_end = today + timedelta(days=13)
|
||||
|
||||
created_date_col = cast(Booking.created_at, Date)
|
||||
updated_date_col = cast(Booking.updated_at, Date)
|
||||
requested_date_col = cast(Booking.requested_date, Date)
|
||||
|
||||
active_statuses = ("pending", "confirmed", "completed")
|
||||
forward_statuses = ("pending", "confirmed")
|
||||
|
||||
active_total_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Booking)
|
||||
.where(Booking.status.in_(active_statuses))
|
||||
)
|
||||
active_bookings_total = active_total_result.scalar_one()
|
||||
|
||||
forward_load_total_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Booking)
|
||||
.where(Booking.status.in_(forward_statuses))
|
||||
.where(Booking.requested_date.is_not(None))
|
||||
.where(requested_date_col >= today)
|
||||
.where(requested_date_col <= forward_load_end)
|
||||
)
|
||||
forward_load_total = forward_load_total_result.scalar_one()
|
||||
|
||||
booked_last_30_days_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Booking)
|
||||
.where(created_date_col >= activity_start)
|
||||
.where(created_date_col <= today)
|
||||
)
|
||||
booked_last_30_days = booked_last_30_days_result.scalar_one()
|
||||
|
||||
cancellations_last_30_days_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Booking)
|
||||
.where(Booking.status == "cancelled")
|
||||
.where(updated_date_col >= activity_start)
|
||||
.where(updated_date_col <= today)
|
||||
)
|
||||
cancellations_last_30_days = cancellations_last_30_days_result.scalar_one()
|
||||
|
||||
high_volume_result = await db.execute(
|
||||
select(func.count().label("booking_count"))
|
||||
.select_from(Booking)
|
||||
.where(Booking.status.in_(forward_statuses))
|
||||
.where(Booking.requested_date.is_not(None))
|
||||
.where(requested_date_col >= today)
|
||||
.group_by(Booking.member_id)
|
||||
.having(func.count() >= 3)
|
||||
)
|
||||
high_volume_customer_count = len(high_volume_result.all())
|
||||
|
||||
forward_load_result = await db.execute(
|
||||
select(
|
||||
requested_date_col.label("day"),
|
||||
func.count().label("total"),
|
||||
func.sum(
|
||||
case(
|
||||
(func.extract("hour", Booking.requested_date) < 12, 1),
|
||||
else_=0,
|
||||
)
|
||||
).label("am"),
|
||||
func.sum(
|
||||
case(
|
||||
(func.extract("hour", Booking.requested_date) >= 12, 1),
|
||||
else_=0,
|
||||
)
|
||||
).label("pm"),
|
||||
)
|
||||
.where(Booking.status.in_(forward_statuses))
|
||||
.where(Booking.requested_date.is_not(None))
|
||||
.where(requested_date_col >= today)
|
||||
.where(requested_date_col <= forward_load_end)
|
||||
.group_by(requested_date_col)
|
||||
.order_by(requested_date_col)
|
||||
)
|
||||
forward_load_by_day = {
|
||||
str(row.day): {
|
||||
"total": int(row.total or 0),
|
||||
"am": int(row.am or 0),
|
||||
"pm": int(row.pm or 0),
|
||||
}
|
||||
for row in forward_load_result.all()
|
||||
}
|
||||
|
||||
forward_load_next_14_days = []
|
||||
for offset in range(14):
|
||||
current_day = str(today + timedelta(days=offset))
|
||||
values = forward_load_by_day.get(current_day, {"total": 0, "am": 0, "pm": 0})
|
||||
forward_load_next_14_days.append({
|
||||
"date": current_day,
|
||||
"total": values["total"],
|
||||
"am": values["am"],
|
||||
"pm": values["pm"],
|
||||
})
|
||||
|
||||
booked_activity_result = await db.execute(
|
||||
select(created_date_col.label("day"), func.count().label("count"))
|
||||
.where(created_date_col >= activity_start)
|
||||
.where(created_date_col <= today)
|
||||
.group_by(created_date_col)
|
||||
.order_by(created_date_col)
|
||||
)
|
||||
booked_by_day = {str(row.day): int(row.count or 0) for row in booked_activity_result.all()}
|
||||
|
||||
cancellation_activity_result = await db.execute(
|
||||
select(updated_date_col.label("day"), func.count().label("count"))
|
||||
.where(Booking.status == "cancelled")
|
||||
.where(updated_date_col >= activity_start)
|
||||
.where(updated_date_col <= today)
|
||||
.group_by(updated_date_col)
|
||||
.order_by(updated_date_col)
|
||||
)
|
||||
cancellations_by_day = {
|
||||
str(row.day): int(row.count or 0)
|
||||
for row in cancellation_activity_result.all()
|
||||
}
|
||||
|
||||
activity_last_30_days = []
|
||||
for offset in range(30):
|
||||
current_day = str(activity_start + timedelta(days=offset))
|
||||
activity_last_30_days.append({
|
||||
"date": current_day,
|
||||
"booked": booked_by_day.get(current_day, 0),
|
||||
"cancellations": cancellations_by_day.get(current_day, 0),
|
||||
})
|
||||
|
||||
volume_result = await db.execute(
|
||||
select(
|
||||
Member.first_name,
|
||||
Member.last_name,
|
||||
func.count(Booking.id).label("count"),
|
||||
)
|
||||
.join(Member, Booking.member_id == Member.id)
|
||||
.where(Booking.status.in_(forward_statuses))
|
||||
.where(Booking.requested_date.is_not(None))
|
||||
.where(requested_date_col >= today)
|
||||
.group_by(Member.id, Member.first_name, Member.last_name)
|
||||
.order_by(func.count(Booking.id).desc(), Member.first_name.asc(), Member.last_name.asc())
|
||||
.limit(8)
|
||||
)
|
||||
top_high_volume_customers = [
|
||||
{
|
||||
"label": " ".join(part for part in [row.first_name, row.last_name] if part).strip() or "Client",
|
||||
"count": int(row.count or 0),
|
||||
}
|
||||
for row in volume_result.all()
|
||||
]
|
||||
|
||||
return {
|
||||
"active_bookings_total": int(active_bookings_total or 0),
|
||||
"forward_load_total": int(forward_load_total or 0),
|
||||
"booked_last_30_days": int(booked_last_30_days or 0),
|
||||
"cancellations_last_30_days": int(cancellations_last_30_days or 0),
|
||||
"high_volume_customer_count": int(high_volume_customer_count or 0),
|
||||
"forward_load_next_14_days": forward_load_next_14_days,
|
||||
"activity_last_30_days": activity_last_30_days,
|
||||
"top_high_volume_customers": top_high_volume_customers,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Audit logging service.
|
||||
|
||||
Call `log_audit(db, ...)` from within any request handler that already holds
|
||||
an open AsyncSession. The entry is added to the session — it will be
|
||||
committed with the surrounding transaction.
|
||||
|
||||
For error logging outside a request session (e.g. exception middleware), open
|
||||
a fresh session via `AsyncSessionLocal`, call `log_audit`, then `commit`.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.services.settings import get_feature_settings_snapshot
|
||||
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
action_type: str,
|
||||
area: str,
|
||||
description: str,
|
||||
member_id: Optional[uuid.UUID] = None,
|
||||
member_email: Optional[str] = None,
|
||||
status: str = "success",
|
||||
booking_id: Optional[uuid.UUID] = None,
|
||||
error_message: Optional[str] = None,
|
||||
error_detail: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
extra: Optional[dict] = None,
|
||||
) -> None:
|
||||
feature_settings = await get_feature_settings_snapshot(db)
|
||||
if not feature_settings.audit_history_enabled:
|
||||
return
|
||||
|
||||
entry = AuditLog(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
member_id=member_id,
|
||||
member_email=member_email,
|
||||
action_type=action_type,
|
||||
area=area,
|
||||
description=description,
|
||||
status=status,
|
||||
booking_id=booking_id,
|
||||
error_message=error_message,
|
||||
error_detail=error_detail,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
extra=extra,
|
||||
)
|
||||
db.add(entry)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Email sending service.
|
||||
|
||||
In development (SMTP_HOST unset or EMAIL_BACKEND=console), codes are printed to
|
||||
stdout instead of being sent. In production set:
|
||||
|
||||
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, EMAIL_FROM
|
||||
"""
|
||||
import asyncio
|
||||
import smtplib
|
||||
import ssl
|
||||
import logging
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from functools import partial
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send_smtp_blocking(to_address: str, subject: str, html_body: str, text_body: str) -> None:
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
if settings.SMTP_USE_TLS:
|
||||
server.starttls(context=context)
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.EMAIL_FROM
|
||||
msg["To"] = to_address
|
||||
msg.attach(MIMEText(text_body, "plain"))
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
server.sendmail(settings.EMAIL_FROM, to_address, msg.as_string())
|
||||
|
||||
|
||||
async def send_email(to_address: str, subject: str, html_body: str, text_body: str) -> None:
|
||||
if settings.EMAIL_BACKEND == "console" or not settings.SMTP_HOST:
|
||||
logger.info(
|
||||
"\n%s\nTO: %s\nSUBJECT: %s\n%s\n%s",
|
||||
"=" * 60,
|
||||
to_address,
|
||||
subject,
|
||||
text_body,
|
||||
"=" * 60,
|
||||
)
|
||||
print(f"\n{'='*60}\nEMAIL TO: {to_address}\nSUBJECT: {subject}\n{text_body}\n{'='*60}\n")
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
fn = partial(_send_smtp_blocking, to_address, subject, html_body, text_body)
|
||||
await loop.run_in_executor(None, fn)
|
||||
|
||||
|
||||
# ── Template helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _base_html(content: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {{ font-family: 'Readex Pro', Arial, sans-serif; background: #FBFBFB; margin: 0; padding: 0; }}
|
||||
.container {{ max-width: 560px; margin: 40px auto; background: #fff; border-radius: 16px;
|
||||
overflow: hidden; box-shadow: 0 4px 24px rgba(0,40,66,.10); }}
|
||||
.header {{ background: #002842; padding: 32px 40px; text-align: center; }}
|
||||
.header h1 {{ color: #FFD100; font-family: 'Fredoka One', Arial, sans-serif;
|
||||
font-size: 28px; margin: 0; letter-spacing: .5px; }}
|
||||
.header p {{ color: #E5EEFF; margin: 6px 0 0; font-size: 14px; }}
|
||||
.body {{ padding: 36px 40px; color: #2E3031; }}
|
||||
.body p {{ line-height: 1.6; margin: 0 0 16px; }}
|
||||
.code-box {{ background: #E5EEFF; border-radius: 12px; padding: 20px;
|
||||
text-align: center; margin: 24px 0; }}
|
||||
.code {{ font-size: 36px; font-weight: 700; letter-spacing: 10px; color: #002842;
|
||||
font-family: 'Fredoka One', monospace; }}
|
||||
.footer {{ background: #F4F6FB; padding: 20px 40px; text-align: center;
|
||||
font-size: 12px; color: #888; border-top: 1px solid #E5EEFF; }}
|
||||
.expiry {{ color: #888; font-size: 13px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🐾 Goodwalk</h1>
|
||||
<p>Auckland's favourite dog walking service</p>
|
||||
</div>
|
||||
{content}
|
||||
<div class="footer">
|
||||
<p>Goodwalk — Auckland, New Zealand<br>
|
||||
<a href="mailto:info@goodwalk.co.nz" style="color:#FFD100;">info@goodwalk.co.nz</a>
|
||||
</p>
|
||||
<p>If you didn't request this email, you can safely ignore it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
async def send_claim_code(to_address: str, first_name: str, code: str) -> None:
|
||||
subject = "Claim your Goodwalk Members Account"
|
||||
html_body = _base_html(f"""
|
||||
<div class="body">
|
||||
<p>Hi {first_name}!</p>
|
||||
<p>Welcome to the Goodwalk Members Area. Use the code below to claim your account.
|
||||
It expires in <strong>15 minutes</strong>.</p>
|
||||
<div class="code-box">
|
||||
<div class="code">{code}</div>
|
||||
</div>
|
||||
<p class="expiry">This code is valid for 15 minutes and can only be used once.</p>
|
||||
<p>If you didn't request this, please contact us at
|
||||
<a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>.</p>
|
||||
</div>""")
|
||||
text_body = (
|
||||
f"Hi {first_name},\n\n"
|
||||
f"Your Goodwalk account claim code is: {code}\n\n"
|
||||
"This code expires in 15 minutes.\n\n"
|
||||
"If you didn't request this, please ignore this email."
|
||||
)
|
||||
await send_email(to_address, subject, html_body, text_body)
|
||||
|
||||
|
||||
async def send_login_2fa(to_address: str, first_name: str, code: str) -> None:
|
||||
subject = "Your Goodwalk login code"
|
||||
html_body = _base_html(f"""
|
||||
<div class="body">
|
||||
<p>Hi {first_name}!</p>
|
||||
<p>Here's your one-time login code for the Goodwalk Members Area.
|
||||
It expires in <strong>10 minutes</strong>.</p>
|
||||
<div class="code-box">
|
||||
<div class="code">{code}</div>
|
||||
</div>
|
||||
<p class="expiry">This code is valid for 10 minutes and can only be used once.</p>
|
||||
<p>If you didn't try to log in, please contact us immediately at
|
||||
<a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>.</p>
|
||||
</div>""")
|
||||
text_body = (
|
||||
f"Hi {first_name},\n\n"
|
||||
f"Your Goodwalk login code is: {code}\n\n"
|
||||
"This code expires in 10 minutes.\n\n"
|
||||
"If you didn't request this, please contact us immediately."
|
||||
)
|
||||
await send_email(to_address, subject, html_body, text_body)
|
||||
|
||||
|
||||
async def send_onboarding_invite(to_address: str, first_name: str, magic_url: str) -> None:
|
||||
subject = "You're invited to complete your Goodwalk onboarding"
|
||||
html_body = _base_html(f"""
|
||||
<div class="body">
|
||||
<p>Hi {first_name}!</p>
|
||||
<p>Thanks for getting in touch with Goodwalk. We've opened your onboarding invitation so you can complete your details and sign your service agreement.</p>
|
||||
<p><a href="{magic_url}" style="display:inline-block;padding:12px 18px;border-radius:12px;background:#FFD100;color:#002842;text-decoration:none;font-weight:700;">Start onboarding →</a></p>
|
||||
<p class="expiry">This link is valid for 7 days and can only be used once.</p>
|
||||
<p>Once your onboarding is complete and your contract is signed, we'll activate your members account.</p>
|
||||
<p>If you have any questions, reach us at <a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>.</p>
|
||||
</div>""")
|
||||
text_body = (
|
||||
f"Hi {first_name},\n\n"
|
||||
"We've opened your Goodwalk onboarding invitation.\n\n"
|
||||
f"Click this link to get started (valid for 7 days):\n{magic_url}\n\n"
|
||||
"Once your onboarding is complete and your contract is signed, we'll activate your members account.\n\n"
|
||||
"Questions? Email info@goodwalk.co.nz"
|
||||
)
|
||||
await send_email(to_address, subject, html_body, text_body)
|
||||
@@ -0,0 +1,251 @@
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.experiments.registry import EXPERIMENT_REGISTRY
|
||||
from app.models.experiment import Experiment, ExperimentEvent, ExperimentVariant
|
||||
from app.schemas.experiments import (
|
||||
ExperimentConversionCreate,
|
||||
ExperimentDefinitionResponse,
|
||||
ExperimentDefinitionUpdate,
|
||||
ExperimentEventCreate,
|
||||
ExperimentImpressionCreate,
|
||||
ExperimentResult,
|
||||
ExperimentVariantResult,
|
||||
)
|
||||
|
||||
|
||||
def experiment_exists(experiment_key: str, variant_key: str) -> bool:
|
||||
definition = EXPERIMENT_REGISTRY.get(experiment_key)
|
||||
if not definition:
|
||||
return False
|
||||
return any(variant["variant_key"] == variant_key for variant in definition["variants"])
|
||||
|
||||
|
||||
async def sync_experiment_registry(db: AsyncSession) -> None:
|
||||
result = await db.execute(
|
||||
select(Experiment).options(selectinload(Experiment.variants))
|
||||
)
|
||||
existing = {experiment.experiment_key: experiment for experiment in result.scalars().all()}
|
||||
|
||||
for definition in EXPERIMENT_REGISTRY.values():
|
||||
experiment = existing.get(definition["experiment_key"])
|
||||
existing_variants: dict[str, ExperimentVariant] = {}
|
||||
|
||||
if experiment is None:
|
||||
experiment = Experiment(
|
||||
experiment_key=definition["experiment_key"],
|
||||
cookie_name=definition["cookie_name"],
|
||||
name=definition["name"],
|
||||
description=definition.get("description"),
|
||||
enabled=definition["enabled"],
|
||||
eligible_routes=definition["eligible_routes"],
|
||||
)
|
||||
db.add(experiment)
|
||||
await db.flush()
|
||||
else:
|
||||
existing_variants = {variant.variant_key: variant for variant in experiment.variants}
|
||||
|
||||
for variant_definition in definition["variants"]:
|
||||
variant = existing_variants.get(variant_definition["variant_key"])
|
||||
if variant is None:
|
||||
db.add(
|
||||
ExperimentVariant(
|
||||
experiment_id=experiment.id,
|
||||
variant_key=variant_definition["variant_key"],
|
||||
label=variant_definition["label"],
|
||||
allocation=variant_definition["allocation"],
|
||||
is_control=variant_definition["is_control"],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
variant.label = variant_definition["label"]
|
||||
variant.allocation = variant_definition["allocation"]
|
||||
variant.is_control = variant_definition["is_control"]
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def list_experiment_definitions(db: AsyncSession) -> list[ExperimentDefinitionResponse]:
|
||||
result = await db.execute(
|
||||
select(Experiment).options(selectinload(Experiment.variants)).order_by(Experiment.experiment_key)
|
||||
)
|
||||
experiments = result.scalars().all()
|
||||
|
||||
return [
|
||||
ExperimentDefinitionResponse(
|
||||
experiment_key=experiment.experiment_key,
|
||||
cookie_name=experiment.cookie_name,
|
||||
name=experiment.name,
|
||||
description=experiment.description,
|
||||
enabled=experiment.enabled,
|
||||
eligible_routes=experiment.eligible_routes,
|
||||
variants=[
|
||||
{
|
||||
"variant_key": variant.variant_key,
|
||||
"label": variant.label,
|
||||
"allocation": variant.allocation,
|
||||
"is_control": variant.is_control,
|
||||
}
|
||||
for variant in experiment.variants
|
||||
],
|
||||
)
|
||||
for experiment in experiments
|
||||
]
|
||||
|
||||
|
||||
async def record_experiment_event(
|
||||
db: AsyncSession,
|
||||
payload: ExperimentImpressionCreate | ExperimentEventCreate | ExperimentConversionCreate,
|
||||
) -> ExperimentEvent:
|
||||
conversion_value = getattr(payload, "conversion_value", None)
|
||||
timestamp = payload.timestamp
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
event = ExperimentEvent(
|
||||
experiment_key=payload.experiment_key,
|
||||
variant_key=payload.variant_key,
|
||||
session_id=payload.session_id,
|
||||
user_id=payload.user_id,
|
||||
path=payload.path,
|
||||
event_type=payload.event_name,
|
||||
conversion_value=conversion_value,
|
||||
metadata_=payload.metadata,
|
||||
created_at=timestamp.astimezone(timezone.utc).replace(tzinfo=None),
|
||||
)
|
||||
db.add(event)
|
||||
await db.flush()
|
||||
await db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
async def get_experiment_results(db: AsyncSession, experiment_key: str | None = None) -> list[ExperimentResult]:
|
||||
stmt = select(
|
||||
ExperimentEvent.experiment_key,
|
||||
ExperimentEvent.variant_key,
|
||||
func.sum(case((ExperimentEvent.event_type == "impression", 1), else_=0)).label("impressions"),
|
||||
func.sum(case((ExperimentEvent.event_type == "cta_click", 1), else_=0)).label("cta_clicks"),
|
||||
func.sum(case((ExperimentEvent.event_type == "form_start", 1), else_=0)).label("form_starts"),
|
||||
func.sum(case((ExperimentEvent.event_type == "form_submit", 1), else_=0)).label("form_submits"),
|
||||
func.sum(case((ExperimentEvent.event_type == "conversion", 1), else_=0)).label("conversions"),
|
||||
func.count(func.distinct(ExperimentEvent.session_id)).label("unique_sessions"),
|
||||
func.coalesce(func.sum(ExperimentEvent.conversion_value), Decimal("0")).label("conversion_value_total"),
|
||||
).group_by(ExperimentEvent.experiment_key, ExperimentEvent.variant_key).order_by(
|
||||
ExperimentEvent.experiment_key,
|
||||
ExperimentEvent.variant_key,
|
||||
)
|
||||
|
||||
if experiment_key:
|
||||
stmt = stmt.where(ExperimentEvent.experiment_key == experiment_key)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
grouped: dict[str, list[ExperimentVariantResult]] = {}
|
||||
for row in rows:
|
||||
impressions = int(row.impressions or 0)
|
||||
conversions = int(row.conversions or 0)
|
||||
conversion_rate = conversions / impressions if impressions else 0.0
|
||||
|
||||
grouped.setdefault(row.experiment_key, []).append(
|
||||
ExperimentVariantResult(
|
||||
variant_key=row.variant_key,
|
||||
impressions=impressions,
|
||||
cta_clicks=int(row.cta_clicks or 0),
|
||||
form_starts=int(row.form_starts or 0),
|
||||
form_submits=int(row.form_submits or 0),
|
||||
conversions=conversions,
|
||||
unique_sessions=int(row.unique_sessions or 0),
|
||||
conversion_rate=round(conversion_rate, 4),
|
||||
conversion_value_total=float(row.conversion_value_total or 0),
|
||||
)
|
||||
)
|
||||
|
||||
generated_at = datetime.now(timezone.utc)
|
||||
return [
|
||||
ExperimentResult(
|
||||
experiment_key=key,
|
||||
generated_at=generated_at,
|
||||
variants=variants,
|
||||
)
|
||||
for key, variants in grouped.items()
|
||||
]
|
||||
|
||||
|
||||
async def get_experiment_definition(db: AsyncSession, experiment_key: str) -> Experiment | None:
|
||||
result = await db.execute(
|
||||
select(Experiment)
|
||||
.options(selectinload(Experiment.variants))
|
||||
.where(Experiment.experiment_key == experiment_key)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def upsert_experiment_definition(
|
||||
db: AsyncSession,
|
||||
experiment_key: str,
|
||||
payload: ExperimentDefinitionUpdate,
|
||||
) -> Experiment:
|
||||
experiment = await get_experiment_definition(db, experiment_key)
|
||||
|
||||
duplicate_cookie = await db.execute(
|
||||
select(Experiment).where(
|
||||
Experiment.cookie_name == payload.cookie_name,
|
||||
Experiment.experiment_key != experiment_key,
|
||||
)
|
||||
)
|
||||
if duplicate_cookie.scalars().first():
|
||||
raise ValueError("cookie_name is already used by another experiment")
|
||||
|
||||
if experiment is None:
|
||||
experiment = Experiment(
|
||||
experiment_key=experiment_key,
|
||||
cookie_name=payload.cookie_name,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
enabled=payload.enabled,
|
||||
eligible_routes=payload.eligible_routes,
|
||||
)
|
||||
db.add(experiment)
|
||||
await db.flush()
|
||||
existing_variants: dict[str, ExperimentVariant] = {}
|
||||
else:
|
||||
experiment.cookie_name = payload.cookie_name
|
||||
experiment.name = payload.name
|
||||
experiment.description = payload.description
|
||||
experiment.enabled = payload.enabled
|
||||
experiment.eligible_routes = payload.eligible_routes
|
||||
existing_variants = {variant.variant_key: variant for variant in experiment.variants}
|
||||
|
||||
incoming_keys = {variant.variant_key for variant in payload.variants}
|
||||
for variant in list(existing_variants.values()):
|
||||
if variant.variant_key not in incoming_keys:
|
||||
await db.delete(variant)
|
||||
|
||||
for variant_payload in payload.variants:
|
||||
variant = existing_variants.get(variant_payload.variant_key)
|
||||
if variant is None:
|
||||
db.add(
|
||||
ExperimentVariant(
|
||||
experiment_id=experiment.id,
|
||||
variant_key=variant_payload.variant_key,
|
||||
label=variant_payload.label,
|
||||
allocation=variant_payload.allocation,
|
||||
is_control=variant_payload.is_control,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
variant.label = variant_payload.label
|
||||
variant.allocation = variant_payload.allocation
|
||||
variant.is_control = variant_payload.is_control
|
||||
|
||||
await db.flush()
|
||||
refreshed = await get_experiment_definition(db, experiment_key)
|
||||
assert refreshed is not None
|
||||
return refreshed
|
||||
@@ -0,0 +1,614 @@
|
||||
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)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Service layer for Page CRUD operations.
|
||||
All DB queries are async; HTML body is sanitized on write.
|
||||
"""
|
||||
import nh3
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.page import Page
|
||||
from app.schemas.page import PageCreate, PageUpdate
|
||||
|
||||
|
||||
def _sanitize_body(body: str) -> str:
|
||||
"""Strip dangerous HTML tags/attributes using nh3."""
|
||||
return nh3.clean(body)
|
||||
|
||||
|
||||
async def get_published_pages(db: AsyncSession) -> list[Page]:
|
||||
result = await db.execute(
|
||||
select(Page).where(Page.published == True).order_by(Page.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_page_by_slug(db: AsyncSession, slug: str, published_only: bool = True) -> Optional[Page]:
|
||||
stmt = select(Page).where(Page.slug == slug)
|
||||
if published_only:
|
||||
stmt = stmt.where(Page.published == True)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create_page(db: AsyncSession, data: PageCreate) -> Page:
|
||||
page = Page(
|
||||
title=data.title,
|
||||
slug=data.slug,
|
||||
body=_sanitize_body(data.body),
|
||||
meta_title=data.meta_title,
|
||||
meta_description=data.meta_description,
|
||||
og_image_url=data.og_image_url,
|
||||
published=data.published,
|
||||
)
|
||||
db.add(page)
|
||||
await db.flush()
|
||||
await db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
async def update_page(db: AsyncSession, slug: str, data: PageUpdate) -> Optional[Page]:
|
||||
page = await get_page_by_slug(db, slug, published_only=False)
|
||||
if page is None:
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "body" in update_data and update_data["body"] is not None:
|
||||
update_data["body"] = _sanitize_body(update_data["body"])
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(page, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
async def delete_page(db: AsyncSession, slug: str) -> bool:
|
||||
page = await get_page_by_slug(db, slug, published_only=False)
|
||||
if page is None:
|
||||
return False
|
||||
await db.delete(page)
|
||||
await db.flush()
|
||||
return True
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Service layer for BlogPost CRUD operations.
|
||||
"""
|
||||
import math
|
||||
import nh3
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.post import BlogPost
|
||||
from app.schemas.post import PostCreate, PostUpdate, PaginatedPostsResponse, PostResponse
|
||||
|
||||
|
||||
def _sanitize_body(body: str) -> str:
|
||||
"""Strip dangerous HTML tags/attributes using nh3."""
|
||||
return nh3.clean(body)
|
||||
|
||||
|
||||
async def get_published_posts(
|
||||
db: AsyncSession, page: int = 1, per_page: int = 10
|
||||
) -> PaginatedPostsResponse:
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(BlogPost).where(BlogPost.published == True)
|
||||
)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
result = await db.execute(
|
||||
select(BlogPost)
|
||||
.where(BlogPost.published == True)
|
||||
.order_by(BlogPost.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
total_pages = math.ceil(total / per_page) if per_page > 0 else 0
|
||||
|
||||
return PaginatedPostsResponse(
|
||||
items=[PostResponse.model_validate(p) for p in items],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
async def get_post_by_slug(
|
||||
db: AsyncSession, slug: str, published_only: bool = True
|
||||
) -> Optional[BlogPost]:
|
||||
stmt = select(BlogPost).where(BlogPost.slug == slug)
|
||||
if published_only:
|
||||
stmt = stmt.where(BlogPost.published == True)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create_post(db: AsyncSession, data: PostCreate) -> BlogPost:
|
||||
post = BlogPost(
|
||||
title=data.title,
|
||||
slug=data.slug,
|
||||
excerpt=data.excerpt,
|
||||
body=_sanitize_body(data.body),
|
||||
author=data.author,
|
||||
featured_image_url=data.featured_image_url,
|
||||
tags=data.tags,
|
||||
published=data.published,
|
||||
)
|
||||
db.add(post)
|
||||
await db.flush()
|
||||
await db.refresh(post)
|
||||
return post
|
||||
|
||||
|
||||
async def update_post(db: AsyncSession, slug: str, data: PostUpdate) -> Optional[BlogPost]:
|
||||
post = await get_post_by_slug(db, slug, published_only=False)
|
||||
if post is None:
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "body" in update_data and update_data["body"] is not None:
|
||||
update_data["body"] = _sanitize_body(update_data["body"])
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(post, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(post)
|
||||
return post
|
||||
|
||||
|
||||
async def delete_post(db: AsyncSession, slug: str) -> bool:
|
||||
post = await get_post_by_slug(db, slug, published_only=False)
|
||||
if post is None:
|
||||
return False
|
||||
await db.delete(post)
|
||||
await db.flush()
|
||||
return True
|
||||
@@ -0,0 +1,80 @@
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
SERVICE_PRICING_DEFAULTS = {
|
||||
"pack_walk": {
|
||||
"label": "Pack Walk",
|
||||
"amount": 58.0,
|
||||
"unit": "per walk",
|
||||
},
|
||||
"1_1_walk": {
|
||||
"label": "1-1 Walk",
|
||||
"amount": 45.0,
|
||||
"unit": "per walk",
|
||||
},
|
||||
"puppy_visit": {
|
||||
"label": "Puppy Visit",
|
||||
"amount": 39.0,
|
||||
"unit": "per visit",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def default_service_pricing() -> dict[str, dict[str, float | str]]:
|
||||
return deepcopy(SERVICE_PRICING_DEFAULTS)
|
||||
|
||||
|
||||
def normalize_service_pricing(data: object | None) -> dict[str, dict[str, float | str]]:
|
||||
normalized = default_service_pricing()
|
||||
source = data if isinstance(data, dict) else {}
|
||||
|
||||
for service_type, defaults in normalized.items():
|
||||
candidate = source.get(service_type) if isinstance(source, dict) else None
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
|
||||
amount = candidate.get("amount")
|
||||
try:
|
||||
parsed_amount = round(float(amount), 2)
|
||||
except (TypeError, ValueError):
|
||||
parsed_amount = defaults["amount"]
|
||||
|
||||
if parsed_amount < 0:
|
||||
parsed_amount = defaults["amount"]
|
||||
|
||||
unit = candidate.get("unit")
|
||||
label = candidate.get("label")
|
||||
|
||||
normalized[service_type] = {
|
||||
"label": label.strip() if isinstance(label, str) and label.strip() else defaults["label"],
|
||||
"amount": parsed_amount,
|
||||
"unit": unit.strip() if isinstance(unit, str) and unit.strip() else defaults["unit"],
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_service_pricing_overrides(data: object | None) -> dict[str, float]:
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
|
||||
normalized: dict[str, float] = {}
|
||||
for service_type in SERVICE_PRICING_DEFAULTS:
|
||||
if service_type not in data:
|
||||
continue
|
||||
|
||||
value = data.get(service_type)
|
||||
if value in (None, ""):
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed = round(float(value), 2)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if parsed < 0:
|
||||
continue
|
||||
|
||||
normalized[service_type] = parsed
|
||||
|
||||
return normalized
|
||||
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.section import ContentSection
|
||||
|
||||
|
||||
async def get_section(db: AsyncSession, key: str) -> dict | None:
|
||||
result = await db.execute(select(ContentSection).where(ContentSection.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
return row.data if row else None
|
||||
|
||||
|
||||
async def upsert_section(db: AsyncSession, key: str, data: dict) -> ContentSection:
|
||||
result = await db.execute(select(ContentSection).where(ContentSection.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.data = data
|
||||
else:
|
||||
row = ContentSection(key=key, data=data)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def list_sections(db: AsyncSession) -> list[dict]:
|
||||
result = await db.execute(select(ContentSection).order_by(ContentSection.key))
|
||||
return [{"key": r.key, "updated_at": r.updated_at.isoformat()} for r in result.scalars()]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Service layer for SiteSettings singleton.
|
||||
Uses get-or-create pattern; only one row should ever exist.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from sqlalchemy import inspect as sa_inspect, select
|
||||
from sqlalchemy.orm import load_only
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.settings import SiteSettings
|
||||
from app.schemas.settings import FeatureSettingsUpdate, SiteSettingsUpdate
|
||||
from app.services.pricing import default_service_pricing, normalize_service_pricing
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureSettingsSnapshot:
|
||||
bookings_enabled: bool = True
|
||||
walks_enabled: bool = True
|
||||
messages_enabled: bool = True
|
||||
two_factor_enabled: bool = True
|
||||
audit_history_enabled: bool = True
|
||||
experiments_enabled: bool = True
|
||||
|
||||
|
||||
class FeatureSettingsSchemaOutdatedError(RuntimeError):
|
||||
"""Raised when feature settings are requested against an older schema."""
|
||||
|
||||
|
||||
class ServicePricingSchemaOutdatedError(RuntimeError):
|
||||
"""Raised when service pricing is requested against an older schema."""
|
||||
|
||||
|
||||
async def _get_site_settings_column_names(db: AsyncSession) -> set[str]:
|
||||
connection = await db.connection()
|
||||
|
||||
def _load_columns(sync_connection):
|
||||
inspector = sa_inspect(sync_connection)
|
||||
return {column["name"] for column in inspector.get_columns("site_settings")}
|
||||
|
||||
return await connection.run_sync(_load_columns)
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession, *, existing_columns: set[str] | None = None) -> Optional[SiteSettings]:
|
||||
column_names = existing_columns or await _get_site_settings_column_names(db)
|
||||
loadable_fields = [
|
||||
getattr(SiteSettings, column_name)
|
||||
for column_name in column_names
|
||||
if hasattr(SiteSettings, column_name)
|
||||
]
|
||||
|
||||
statement = select(SiteSettings).limit(1)
|
||||
if loadable_fields:
|
||||
statement = statement.options(load_only(*loadable_fields))
|
||||
|
||||
result = await db.execute(statement)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def upsert_settings(db: AsyncSession, data: SiteSettingsUpdate) -> SiteSettings:
|
||||
settings_row = await get_settings(db)
|
||||
|
||||
if settings_row is None:
|
||||
# Create with defaults + provided values
|
||||
init_data = {
|
||||
"site_name": "",
|
||||
"tagline": None,
|
||||
"logo_url": None,
|
||||
"footer_text": None,
|
||||
"social_links": {},
|
||||
"automatic_member_notifications_enabled": True,
|
||||
"nz_public_holiday_notifications_enabled": True,
|
||||
"invoice_reminder_notifications_enabled": True,
|
||||
"invoice_day_of_week": 1,
|
||||
"admin_notifications_cleared_before": None,
|
||||
"bookings_enabled": True,
|
||||
"walks_enabled": True,
|
||||
"messages_enabled": True,
|
||||
"two_factor_enabled": True,
|
||||
"audit_history_enabled": True,
|
||||
"experiments_enabled": True,
|
||||
"service_pricing": default_service_pricing(),
|
||||
}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
init_data.update(update_data)
|
||||
settings_row = SiteSettings(**init_data)
|
||||
db.add(settings_row)
|
||||
else:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(settings_row, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(settings_row)
|
||||
return settings_row
|
||||
|
||||
|
||||
async def get_feature_settings_snapshot(db: AsyncSession) -> FeatureSettingsSnapshot:
|
||||
existing_columns = await _get_site_settings_column_names(db)
|
||||
row = await get_settings(db, existing_columns=existing_columns)
|
||||
if row is None:
|
||||
return FeatureSettingsSnapshot()
|
||||
|
||||
return FeatureSettingsSnapshot(
|
||||
bookings_enabled=getattr(row, "bookings_enabled", True) if "bookings_enabled" in existing_columns else True,
|
||||
walks_enabled=getattr(row, "walks_enabled", True) if "walks_enabled" in existing_columns else True,
|
||||
messages_enabled=getattr(row, "messages_enabled", True) if "messages_enabled" in existing_columns else True,
|
||||
two_factor_enabled=getattr(row, "two_factor_enabled", True) if "two_factor_enabled" in existing_columns else True,
|
||||
audit_history_enabled=getattr(row, "audit_history_enabled", True) if "audit_history_enabled" in existing_columns else True,
|
||||
experiments_enabled=getattr(row, "experiments_enabled", True) if "experiments_enabled" in existing_columns else True,
|
||||
)
|
||||
|
||||
|
||||
async def update_feature_settings_snapshot(
|
||||
db: AsyncSession,
|
||||
data: FeatureSettingsUpdate,
|
||||
) -> FeatureSettingsSnapshot:
|
||||
existing_columns = await _get_site_settings_column_names(db)
|
||||
requested_fields = set(data.model_dump(exclude_unset=True).keys())
|
||||
missing_columns = sorted(field for field in requested_fields if field not in existing_columns)
|
||||
if missing_columns:
|
||||
raise FeatureSettingsSchemaOutdatedError(
|
||||
"Feature toggle columns are missing from site_settings. Run alembic upgrade head."
|
||||
)
|
||||
|
||||
row = await get_settings(db, existing_columns=existing_columns)
|
||||
if row is None:
|
||||
row = SiteSettings(site_name="")
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(row, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(row)
|
||||
return await get_feature_settings_snapshot(db)
|
||||
|
||||
|
||||
async def get_service_pricing_snapshot(db: AsyncSession) -> dict[str, dict[str, float | str]]:
|
||||
existing_columns = await _get_site_settings_column_names(db)
|
||||
row = await get_settings(db, existing_columns=existing_columns)
|
||||
if row is None:
|
||||
return default_service_pricing()
|
||||
|
||||
if "service_pricing" not in existing_columns:
|
||||
return default_service_pricing()
|
||||
|
||||
return normalize_service_pricing(getattr(row, "service_pricing", None))
|
||||
|
||||
|
||||
async def update_service_pricing_snapshot(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
service_pricing: dict,
|
||||
) -> dict[str, dict[str, float | str]]:
|
||||
existing_columns = await _get_site_settings_column_names(db)
|
||||
if "service_pricing" not in existing_columns:
|
||||
raise ServicePricingSchemaOutdatedError(
|
||||
"Service pricing columns are missing from site_settings. Run alembic upgrade head."
|
||||
)
|
||||
|
||||
row = await get_settings(db, existing_columns=existing_columns)
|
||||
if row is None:
|
||||
row = SiteSettings(site_name="", service_pricing=default_service_pricing())
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
|
||||
row.service_pricing = normalize_service_pricing(service_pricing)
|
||||
await db.flush()
|
||||
await db.refresh(row)
|
||||
return await get_service_pricing_snapshot(db)
|
||||
Reference in New Issue
Block a user