This commit is contained in:
ponzischeme89
2026-04-18 07:23:55 +12:00
parent f210020772
commit 6d44e05de4
396 changed files with 75296 additions and 0 deletions
View File
+393
View File
@@ -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,
}
+56
View File
@@ -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)
+166
View File
@@ -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 &mdash; 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 &rarr;</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)
+251
View File
@@ -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
+614
View File
@@ -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)
+73
View File
@@ -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
+99
View File
@@ -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
+80
View File
@@ -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
+26
View File
@@ -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()]
+173
View File
@@ -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)