Owner settings: services, hours/buffer, auto-confirm, notify channel (#21)

Adds a session-authenticated /owner/settings blueprint for services CRUD
(create/edit/deactivate), per-resource opening hours + min-notice/max-advance/
buffer, and client-level auto_confirm/notify_channel — all scoped to the
logged-in owner's own client_id. Extends booking_db.py with the missing
tenant-scoped update_service/update_resource/update_client writes, mirroring
the existing update_booking allowlist pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 09:00:38 +02:00
parent 3a20cb5d2b
commit 16500c4392
6 changed files with 663 additions and 4 deletions
+92 -3
View File
@@ -65,10 +65,18 @@ def get_resource(client_id, resource_id):
def set_resource_hours(client_id, resource_id, weekday, opens_at, closes_at):
"""Upsert the opening hours for one weekday (0=Monday..6=Sunday) of a
client's own resource. Raises UnknownResource if resource_id isn't
client's own resource, or clear that weekday (closed all day) if either
opens_at/closes_at is None. Raises UnknownResource if resource_id isn't
client_id's."""
if get_resource(client_id, resource_id) is None:
raise UnknownResource(f"no resource {resource_id} for client {client_id}")
if opens_at is None or closes_at is None:
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"DELETE FROM resource_hours WHERE resource_id = %s AND weekday = %s",
(resource_id, weekday))
conn.commit()
return None
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO resource_hours (resource_id, weekday, opens_at, closes_at) "
@@ -82,6 +90,30 @@ def set_resource_hours(client_id, resource_id, weekday, opens_at, closes_at):
return row
_RESOURCE_UPDATABLE = {"name", "active", "min_notice_minutes", "max_advance_days",
"buffer_minutes"}
def update_resource(client_id, resource_id, **fields):
"""Update a resource's own settings (min_notice/max_advance/buffer/etc.),
scoped to client_id. Returns the updated row, or None if no such resource
exists for this client."""
bad = set(fields) - _RESOURCE_UPDATABLE
if bad:
raise ValueError(f"not updatable: {', '.join(sorted(bad))}")
if not fields:
return get_resource(client_id, resource_id)
setsql = ", ".join(f"{c} = %s" for c in fields)
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
f"UPDATE resources SET {setsql} WHERE client_id = %s AND resource_id = %s "
"RETURNING *",
[*fields.values(), client_id, resource_id])
row = cur.fetchone()
conn.commit()
return row
def get_resource_hours(client_id, resource_id):
"""Return {weekday: (opens_at, closes_at)} for client_id's own resource
(empty for a resource with no hours configured yet, or one that isn't
@@ -121,6 +153,39 @@ def list_active_services(client_id):
return _list_active("services", client_id)
def list_services(client_id):
"""All of client_id's services, active or not -- for the owner settings
page (#21), which must show (and let the owner reactivate) deactivated
services too, unlike the public/manual-booking pickers."""
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT * FROM services WHERE client_id = %s ORDER BY name",
(client_id,))
return cur.fetchall()
_SERVICE_UPDATABLE = {"name", "duration_minutes", "price", "active"}
def update_service(client_id, service_id, **fields):
"""Update a service's own fields, scoped to client_id. Returns the
updated row, or None if no such service exists for this client."""
bad = set(fields) - _SERVICE_UPDATABLE
if bad:
raise ValueError(f"not updatable: {', '.join(sorted(bad))}")
if not fields:
return get_service(client_id, service_id)
setsql = ", ".join(f"{c} = %s" for c in fields)
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
f"UPDATE services SET {setsql} WHERE client_id = %s AND service_id = %s "
"RETURNING *",
[*fields.values(), client_id, service_id])
row = cur.fetchone()
conn.commit()
return row
def list_active_resources(client_id):
return _list_active("resources", client_id)
@@ -256,8 +321,8 @@ def update_booking(client_id, booking_id, **fields):
return row
# ---- clients (read-only; the clients table itself is db.py's, this is just
# the booking flow's read of its own client's config) ----
# ---- clients (the clients table itself is db.py's; these are just the
# booking flow's own read/write of its own client's config) ----
def get_client(client_id):
with db.connect() as conn, conn.cursor() as cur:
@@ -275,6 +340,30 @@ def get_client_by_slug(slug):
return cur.fetchone()
_CLIENT_UPDATABLE = {"auto_confirm", "notify_channel"}
def update_client(client_id, **fields):
"""Update a client's own booking settings (auto_confirm/notify_channel),
for the owner settings page (#21). The only booking-flow write to
clients -- everything else about a client is the CRM operator's via
db.py/app.py. Returns the updated row, or None if client_id is
unknown."""
bad = set(fields) - _CLIENT_UPDATABLE
if bad:
raise ValueError(f"not updatable: {', '.join(sorted(bad))}")
if not fields:
return get_client(client_id)
setsql = ", ".join(f"{c} = %s" for c in fields)
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
f"UPDATE clients SET {setsql} WHERE client_id = %s RETURNING *",
[*fields.values(), client_id])
row = cur.fetchone()
conn.commit()
return row
# ---- users (owner login) ----
def create_user(client_id, email, password):