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
+161
View File
@@ -0,0 +1,161 @@
"""Owner settings: services CRUD, per-resource opening hours/notice/buffer,
auto_confirm, and notify_channel (#21).
All routes are session-authenticated via owner_auth.login_required and read
client_id from the session only (never a request param), so every write goes
through booking_db.py's own tenant-scoped UPDATE ... WHERE client_id = ...
guard on top of that. Because booking_api.py's slot generation and booking
creation always re-read resources/services/clients fresh on every request
(#16, #18), a settings change here is live for the public booking page and
availability engine on the very next request -- no cache to invalidate.
"""
from datetime import time
from flask import Blueprint, redirect, render_template, request, session, url_for
import booking_db as bdb
from owner_auth import login_required
bp = Blueprint("owner_settings", __name__, url_prefix="/owner/settings")
WEEKDAYS = [0, 1, 2, 3, 4, 5, 6] # 0=Monday..6=Sunday, matching resource_hours
def _redirect(error=None):
return redirect(url_for("owner_settings.settings", error=error))
def _parse_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def _parse_price(value):
value = (value or "").strip()
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _parse_time(value):
value = (value or "").strip()
if not value:
return None
try:
return time.fromisoformat(value)
except ValueError:
return None
@bp.get("")
@login_required
def settings():
client_id = session["client_id"]
client = bdb.get_client(client_id)
resources = bdb.list_resources(client_id)
hours_by_resource = {
r["resource_id"]: bdb.get_resource_hours(client_id, r["resource_id"])
for r in resources}
return render_template(
"owner/settings.html", client=client, services=bdb.list_services(client_id),
resources=resources, hours_by_resource=hours_by_resource, weekdays=WEEKDAYS,
error=request.args.get("error"))
@bp.post("/services")
@login_required
def create_service():
client_id = session["client_id"]
name = (request.form.get("name") or "").strip()
duration_minutes = _parse_int(request.form.get("duration_minutes"))
price = _parse_price(request.form.get("price"))
if not name or not duration_minutes or duration_minutes <= 0:
return _redirect(error="invalid_service")
bdb.create_service(client_id, name, duration_minutes, price=price)
return _redirect()
@bp.post("/services/<service_id>")
@login_required
def update_service(service_id):
client_id = session["client_id"]
name = (request.form.get("name") or "").strip()
duration_minutes = _parse_int(request.form.get("duration_minutes"))
price = _parse_price(request.form.get("price"))
active = request.form.get("active") == "on"
if not name or not duration_minutes or duration_minutes <= 0:
return _redirect(error="invalid_service")
row = bdb.update_service(
client_id, service_id, name=name, duration_minutes=duration_minutes,
price=price, active=active)
if row is None:
return _redirect(error="not_found")
return _redirect()
@bp.post("/resources/<resource_id>")
@login_required
def update_resource(resource_id):
client_id = session["client_id"]
min_notice_minutes = _parse_int(request.form.get("min_notice_minutes"))
max_advance_days = _parse_int(request.form.get("max_advance_days"))
buffer_minutes = _parse_int(request.form.get("buffer_minutes"))
if min_notice_minutes is None or min_notice_minutes < 0:
return _redirect(error="invalid_resource")
if max_advance_days is None or max_advance_days < 0:
return _redirect(error="invalid_resource")
if buffer_minutes is None or buffer_minutes < 0:
return _redirect(error="invalid_resource")
row = bdb.update_resource(
client_id, resource_id, min_notice_minutes=min_notice_minutes,
max_advance_days=max_advance_days, buffer_minutes=buffer_minutes)
if row is None:
return _redirect(error="not_found")
return _redirect()
@bp.post("/resources/<resource_id>/hours")
@login_required
def update_resource_hours(resource_id):
client_id = session["client_id"]
if bdb.get_resource(client_id, resource_id) is None:
return _redirect(error="not_found")
# Parse and validate every weekday up front so a bad entry later in the
# form (e.g. Wednesday) can't leave earlier weekdays (Monday, Tuesday)
# already written -- either the whole week's hours update or none of it
# does, matching update_resource/update_client's all-or-nothing shape.
parsed = {}
for weekday in WEEKDAYS:
if request.form.get(f"closed_{weekday}") == "on":
parsed[weekday] = None
continue
opens_at = _parse_time(request.form.get(f"opens_at_{weekday}"))
closes_at = _parse_time(request.form.get(f"closes_at_{weekday}"))
if opens_at is None or closes_at is None or closes_at <= opens_at:
return _redirect(error="invalid_hours")
parsed[weekday] = (opens_at, closes_at)
for weekday, hours in parsed.items():
if hours is None:
bdb.set_resource_hours(client_id, resource_id, weekday, None, None)
else:
bdb.set_resource_hours(client_id, resource_id, weekday, *hours)
return _redirect()
@bp.post("/client")
@login_required
def update_client():
client_id = session["client_id"]
auto_confirm = request.form.get("auto_confirm") == "on"
notify_channel = (request.form.get("notify_channel") or "").strip() or None
if notify_channel is not None and notify_channel != "telegram":
return _redirect(error="invalid_notify_channel")
bdb.update_client(client_id, auto_confirm=auto_confirm, notify_channel=notify_channel)
return _redirect()