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/
| Name | Dauer (Min.) | Preis | Aktiv | |
|---|---|---|---|---|
Noch keine Leistungen angelegt.
+ {% endif %} + + +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 @@ + + +
+ + + +
+ + +
+