v4.1 - Admin/onboarding

This commit is contained in:
2026-05-18 22:25:43 +12:00
parent 6ff970015f
commit 541ae2eeec
79 changed files with 11544 additions and 1007 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.2.3
ARG APP_VERSION=4.0.0
FROM python:3.12-slim
ARG APP_VERSION
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
{
"emails": [
"mattcohen0@gmail.com",
"test@test.com"
]
}
+19 -2
View File
@@ -2,11 +2,28 @@
"mattcohen0@gmail.com": {
"fullName": "Matt Test",
"phone": "02124347477",
"dogName": "Geoffrey"
"dogName": "Geoffrey",
"welcomePackSentAt": "2026-05-18T20:37:14",
"welcomePackOffer": {
"serviceType": "test",
"priceDetails": "45",
"startDate": "2026-05-18",
"sentAt": "2026-05-18T20:37:14"
}
},
"natalie@desseinparke.com": {
"fullName":"Natalie Parke",
"fullName": "Natalie Parke",
"phone": "021616200",
"dogName": "Ziggy"
},
"test@test.com": {
"fullName": "test test",
"phone": "test@test.com",
"address": "test@test.com",
"dogName": "X",
"dogBreed": "H",
"dogAge": "2026-05-18",
"onboardingCompleted": true,
"birthdayAutoSend": false
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"mattcohen0@gmail.com": {
"onboarding": {
"currentStep": 5,
"ownerFirstName": "test",
"ownerLastName": "test",
"email": "test@test.com",
"phone": "test@test.com",
"address": "test@test.com",
"dogName": "test@test.com",
"dogLastName": "test@test.com",
"dogBreed": "test@test.com",
"dogDateOfBirth": "2026-05-18",
"servicesNeeded": [
"Tiny Gang Pack Walks"
],
"temperament": "test",
"accessInstructions": "01",
"vetName": "test",
"vetAddress": "test",
"vetPhone": "test",
"emergencyContactName": "test",
"emergencyContactPhone": "test",
"isVaccinated": "no",
"hasFoodAllergies": "no",
"foodAllergiesDetail": "",
"hasEnvAllergies": "no",
"envAllergiesDetail": "",
"onSpecialDiet": "no",
"specialDietDetail": "",
"onMedication": "no",
"medicationDetail": "",
"wellSocialised": "",
"dogsInteractedWeekly": "",
"visitsBeach": "yes",
"visitsDogParks": "no",
"dogParksFrequency": "",
"biteHistory": "no",
"reactiveToDogs": "no",
"reactiveToAnimals": "no",
"reactiveToChildren": "no",
"reactiveToPeople": "no",
"isDesexed": "no",
"isRegistered": "yes",
"leashTrained": "yes",
"recallRating": 5,
"ranAwayBefore": "yes",
"carBehaviour": "test",
"knownCommands": "test",
"additionalNotes": "",
"socialMediaAccount": "",
"howDidYouHear": "",
"emergencyVetConsent": false,
"termsAccepted": false
}
}
}
+138
View File
@@ -0,0 +1,138 @@
"""Postgres-backed key/value persistence for mail-api admin state.
The mail-api historically stored client_profiles / allowed_emails / drafts
as JSON files on a Docker volume. This module lets the same data live in
the shared Goodwalk postgres database so the admin dashboard at
admin.goodwalk.co.nz reads from a real database instead of a per-container
JSON file. JSON files remain as a development/local fallback and as the
seed source for the initial postgres migration.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
try:
import asyncpg
except Exception: # pragma: no cover - asyncpg is optional in dev
asyncpg = None # type: ignore[assignment]
logger = logging.getLogger("mail-api.db")
_pool: Any = None
_pool_lock = asyncio.Lock()
_schema_lock = asyncio.Lock()
_schema_ensured = False
def database_url() -> str:
return (os.environ.get("DATABASE_URL", "") or "").strip()
def is_enabled() -> bool:
return bool(database_url()) and asyncpg is not None
async def get_pool() -> Any:
"""Return a lazily-initialised asyncpg pool, or None when DB is disabled."""
global _pool
if not is_enabled():
return None
if _pool is not None:
return _pool
async with _pool_lock:
if _pool is None:
try:
_pool = await asyncpg.create_pool(
dsn=database_url(),
min_size=1,
max_size=4,
command_timeout=10,
)
logger.info("Postgres pool ready for admin_kv persistence")
except Exception as exc:
logger.warning("Postgres pool init failed (%s); falling back to JSON only", exc)
return None
return _pool
async def _ensure_schema() -> None:
global _schema_ensured
if _schema_ensured:
return
pool = await get_pool()
if pool is None:
return
async with _schema_lock:
if _schema_ensured:
return
async with pool.acquire() as conn:
await conn.execute(
"""
create table if not exists admin_kv (
key text primary key,
value jsonb not null,
updated_at timestamptz not null default now()
);
"""
)
_schema_ensured = True
async def get_kv(key: str) -> Any | None:
pool = await get_pool()
if pool is None:
return None
await _ensure_schema()
async with pool.acquire() as conn:
row = await conn.fetchrow("select value from admin_kv where key = $1", key)
if not row:
return None
raw = row["value"]
# asyncpg returns jsonb as a Python str; parse to native value.
if isinstance(raw, (dict, list)):
return raw
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8")
try:
return json.loads(raw)
except Exception:
return None
async def set_kv(key: str, value: Any) -> bool:
pool = await get_pool()
if pool is None:
return False
await _ensure_schema()
payload = json.dumps(value)
async with pool.acquire() as conn:
await conn.execute(
"""
insert into admin_kv (key, value, updated_at)
values ($1, $2::jsonb, now())
on conflict (key) do update
set value = excluded.value,
updated_at = excluded.updated_at
""",
key,
payload,
)
return True
async def has_any_value() -> bool:
"""Return True if admin_kv already has any rows. Used to decide whether
to seed from JSON files on first boot."""
pool = await get_pool()
if pool is None:
return False
await _ensure_schema()
async with pool.acquire() as conn:
row = await conn.fetchrow("select 1 from admin_kv limit 1")
return row is not None
File diff suppressed because one or more lines are too long
+1505 -40
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.115
uvicorn[standard]>=0.32
resend>=2.0
pydantic[email]>=2.10
asyncpg>=0.30