From 16500c439261ef4e5beddd828883ba02ca02cc04 Mon Sep 17 00:00:00 2001
From: rogalik27
Date: Tue, 4 Aug 2026 09:00:38 +0200
Subject: [PATCH] Owner settings: services, hours/buffer, auto-confirm, notify
channel (#21)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
backoffice/app/app.py | 2 +
backoffice/app/booking_db.py | 95 ++++++-
backoffice/app/owner_settings.py | 161 ++++++++++++
backoffice/app/templates/owner/dashboard.html | 2 +-
backoffice/app/templates/owner/settings.html | 160 ++++++++++++
backoffice/app/tests/test_owner_settings.py | 247 ++++++++++++++++++
6 files changed, 663 insertions(+), 4 deletions(-)
create mode 100644 backoffice/app/owner_settings.py
create mode 100644 backoffice/app/templates/owner/settings.html
create mode 100644 backoffice/app/tests/test_owner_settings.py
diff --git a/backoffice/app/app.py b/backoffice/app/app.py
index ab49ece..1cd5a9a 100644
--- a/backoffice/app/app.py
+++ b/backoffice/app/app.py
@@ -26,6 +26,7 @@ from public_booking import bp as public_booking_bp
from manage_booking import bp as manage_booking_bp
from owner_auth import bp as owner_auth_bp
from owner_booking import bp as owner_booking_bp
+from owner_settings import bp as owner_settings_bp
app = Flask(__name__, static_folder="static", static_url_path="")
app.register_blueprint(booking_bp)
@@ -33,6 +34,7 @@ app.register_blueprint(public_booking_bp)
app.register_blueprint(manage_booking_bp)
app.register_blueprint(owner_auth_bp)
app.register_blueprint(owner_booking_bp)
+app.register_blueprint(owner_settings_bp)
# Dedicated secret for the owner-login session cookie -- deliberately not
# shared with CRM_API_TOKEN or BOOKING_TOKEN_SECRET (#19), same reasoning as
diff --git a/backoffice/app/booking_db.py b/backoffice/app/booking_db.py
index 5379c27..5407f43 100644
--- a/backoffice/app/booking_db.py
+++ b/backoffice/app/booking_db.py
@@ -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):
diff --git a/backoffice/app/owner_settings.py b/backoffice/app/owner_settings.py
new file mode 100644
index 0000000..1246924
--- /dev/null
+++ b/backoffice/app/owner_settings.py
@@ -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/")
+@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/")
+@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//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()
diff --git a/backoffice/app/templates/owner/dashboard.html b/backoffice/app/templates/owner/dashboard.html
index b3455af..294676a 100644
--- a/backoffice/app/templates/owner/dashboard.html
+++ b/backoffice/app/templates/owner/dashboard.html
@@ -24,7 +24,7 @@
Abmelden
+ {{ client.business_name if client else 'Einstellungen' }}
+
+ {% if error == "invalid_service" %}
+ Bitte Name und eine gültige Dauer angeben.
+ {% elif error == "invalid_resource" %}
+ Vorlaufzeit, Vorausbuchung und Puffer müssen 0 oder größer sein.
+ {% elif error == "invalid_hours" %}
+ Bitte gültige Öffnungszeiten angeben (Ende nach Beginn).
+ {% elif error == "invalid_notify_channel" %}
+ Nur Telegram ist derzeit als Benachrichtigungskanal verfügbar.
+ {% elif error == "not_found" %}
+ Nicht gefunden.
+ {% endif %}
+
+
+ Leistungen
+ {% if services %}
+
+ | Name | Dauer (Min.) | Preis | Aktiv | |
+
+ {% for s in services %}
+
+
+
+ {% endfor %}
+
+
+ {% else %}
+ Noch keine Leistungen angelegt.
+ {% endif %}
+
+
+
+
+ {% for r in resources %}
+
+ Verfügbarkeit — {{ r.name }}
+
+
+
+
+ {% endfor %}
+
+
+ Buchungen & Benachrichtigung
+
+
+
+ Zurück
+
diff --git a/backoffice/app/templates/owner/settings.html b/backoffice/app/templates/owner/settings.html
new file mode 100644
index 0000000..b449288
--- /dev/null
+++ b/backoffice/app/templates/owner/settings.html
@@ -0,0 +1,160 @@
+
+
+