v4.1 - Admin/onboarding
This commit is contained in:
+1
-1
@@ -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.
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"emails": [
|
||||
"mattcohen0@gmail.com",
|
||||
"test@test.com"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ fastapi>=0.115
|
||||
uvicorn[standard]>=0.32
|
||||
resend>=2.0
|
||||
pydantic[email]>=2.10
|
||||
asyncpg>=0.30
|
||||
|
||||
Reference in New Issue
Block a user