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:
@@ -26,6 +26,7 @@ from public_booking import bp as public_booking_bp
|
|||||||
from manage_booking import bp as manage_booking_bp
|
from manage_booking import bp as manage_booking_bp
|
||||||
from owner_auth import bp as owner_auth_bp
|
from owner_auth import bp as owner_auth_bp
|
||||||
from owner_booking import bp as owner_booking_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 = Flask(__name__, static_folder="static", static_url_path="")
|
||||||
app.register_blueprint(booking_bp)
|
app.register_blueprint(booking_bp)
|
||||||
@@ -33,6 +34,7 @@ app.register_blueprint(public_booking_bp)
|
|||||||
app.register_blueprint(manage_booking_bp)
|
app.register_blueprint(manage_booking_bp)
|
||||||
app.register_blueprint(owner_auth_bp)
|
app.register_blueprint(owner_auth_bp)
|
||||||
app.register_blueprint(owner_booking_bp)
|
app.register_blueprint(owner_booking_bp)
|
||||||
|
app.register_blueprint(owner_settings_bp)
|
||||||
|
|
||||||
# Dedicated secret for the owner-login session cookie -- deliberately not
|
# Dedicated secret for the owner-login session cookie -- deliberately not
|
||||||
# shared with CRM_API_TOKEN or BOOKING_TOKEN_SECRET (#19), same reasoning as
|
# shared with CRM_API_TOKEN or BOOKING_TOKEN_SECRET (#19), same reasoning as
|
||||||
|
|||||||
@@ -65,10 +65,18 @@ def get_resource(client_id, resource_id):
|
|||||||
|
|
||||||
def set_resource_hours(client_id, resource_id, weekday, opens_at, closes_at):
|
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
|
"""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."""
|
client_id's."""
|
||||||
if get_resource(client_id, resource_id) is None:
|
if get_resource(client_id, resource_id) is None:
|
||||||
raise UnknownResource(f"no resource {resource_id} for client {client_id}")
|
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:
|
with db.connect() as conn, conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO resource_hours (resource_id, weekday, opens_at, closes_at) "
|
"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
|
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):
|
def get_resource_hours(client_id, resource_id):
|
||||||
"""Return {weekday: (opens_at, closes_at)} for client_id's own resource
|
"""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
|
(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)
|
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):
|
def list_active_resources(client_id):
|
||||||
return _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
|
return row
|
||||||
|
|
||||||
|
|
||||||
# ---- clients (read-only; the clients table itself is db.py's, this is just
|
# ---- clients (the clients table itself is db.py's; these are just the
|
||||||
# the booking flow's read of its own client's config) ----
|
# booking flow's own read/write of its own client's config) ----
|
||||||
|
|
||||||
def get_client(client_id):
|
def get_client(client_id):
|
||||||
with db.connect() as conn, conn.cursor() as cur:
|
with db.connect() as conn, conn.cursor() as cur:
|
||||||
@@ -275,6 +340,30 @@ def get_client_by_slug(slug):
|
|||||||
return cur.fetchone()
|
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) ----
|
# ---- users (owner login) ----
|
||||||
|
|
||||||
def create_user(client_id, email, password):
|
def create_user(client_id, email, password):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<p>Sie sind angemeldet.</p>
|
<p>Sie sind angemeldet.</p>
|
||||||
<p class="muted"><a href="{{ url_for('owner_booking.agenda') }}">Zum Kalender</a></p>
|
<p class="muted"><a href="{{ url_for('owner_booking.agenda') }}">Zum Kalender</a></p>
|
||||||
<p class="muted">Einstellungen folgen hier.</p>
|
<p class="muted"><a href="{{ url_for('owner_settings.settings') }}">Einstellungen</a></p>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted"><a href="{{ url_for('owner_auth.logout') }}">Abmelden</a></p>
|
<p class="muted"><a href="{{ url_for('owner_auth.logout') }}">Abmelden</a></p>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<title>Einstellungen — {{ client.business_name if client else '' }}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #0f8a7e;
|
||||||
|
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
|
||||||
|
--danger: #b3261e; --danger-bg: #fdecea;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--ink); padding: 20px; max-width: 760px; }
|
||||||
|
h1 { font-size: 1.2rem; margin: 0 0 6px; }
|
||||||
|
h2 { font-size: .95rem; margin: 0 0 8px; color: var(--muted); }
|
||||||
|
a { color: var(--brand); }
|
||||||
|
.muted { color: var(--muted); font-size: .85rem; }
|
||||||
|
section { border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin-bottom: 16px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: .88rem; margin-bottom: 10px; }
|
||||||
|
td, th { text-align: left; padding: 6px 4px; border-bottom: 1px solid var(--line); }
|
||||||
|
form.inline { display: inline; }
|
||||||
|
form.row { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; margin-bottom: 6px; }
|
||||||
|
input[type=text], input[type=number], input[type=time], select {
|
||||||
|
padding: 7px 9px; border: 1px solid var(--line); border-radius: 7px;
|
||||||
|
font: inherit; font-size: .85rem; }
|
||||||
|
input[type=text] { width: 140px; }
|
||||||
|
input[type=number] { width: 90px; }
|
||||||
|
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 7px;
|
||||||
|
padding: 6px 12px; font: inherit; font-size: .82rem; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn-small { padding: 4px 9px; font-size: .78rem; }
|
||||||
|
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
|
||||||
|
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; }
|
||||||
|
.field { display: inline-block; }
|
||||||
|
label { display: block; font-size: .75rem; color: var(--muted); margin-bottom: 3px; }
|
||||||
|
.weekday-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: .85rem; }
|
||||||
|
.weekday-row .day-name { width: 90px; }
|
||||||
|
.weekday-row label { display: inline; margin: 0 0 0 4px; font-size: .8rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ client.business_name if client else 'Einstellungen' }}</h1>
|
||||||
|
|
||||||
|
{% if error == "invalid_service" %}
|
||||||
|
<div class="error">Bitte Name und eine gültige Dauer angeben.</div>
|
||||||
|
{% elif error == "invalid_resource" %}
|
||||||
|
<div class="error">Vorlaufzeit, Vorausbuchung und Puffer müssen 0 oder größer sein.</div>
|
||||||
|
{% elif error == "invalid_hours" %}
|
||||||
|
<div class="error">Bitte gültige Öffnungszeiten angeben (Ende nach Beginn).</div>
|
||||||
|
{% elif error == "invalid_notify_channel" %}
|
||||||
|
<div class="error">Nur Telegram ist derzeit als Benachrichtigungskanal verfügbar.</div>
|
||||||
|
{% elif error == "not_found" %}
|
||||||
|
<div class="error">Nicht gefunden.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Leistungen</h2>
|
||||||
|
{% if services %}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Dauer (Min.)</th><th>Preis</th><th>Aktiv</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in services %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="{{ url_for('owner_settings.update_service', service_id=s.service_id) }}">
|
||||||
|
<td><input type="text" name="name" value="{{ s.name }}" required /></td>
|
||||||
|
<td><input type="number" name="duration_minutes" value="{{ s.duration_minutes }}" min="1" required /></td>
|
||||||
|
<td><input type="number" name="price" value="{{ s.price if s.price is not none else '' }}" step="0.01" min="0" /></td>
|
||||||
|
<td><input type="checkbox" name="active" {{ 'checked' if s.active }} /></td>
|
||||||
|
<td><button type="submit" class="btn btn-small">Speichern</button></td>
|
||||||
|
</form>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Noch keine Leistungen angelegt.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="row" method="post" action="{{ url_for('owner_settings.create_service') }}">
|
||||||
|
<div class="field">
|
||||||
|
<label>Neue Leistung</label>
|
||||||
|
<input type="text" name="name" placeholder="Name" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Dauer (Min.)</label>
|
||||||
|
<input type="number" name="duration_minutes" min="1" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Preis</label>
|
||||||
|
<input type="number" name="price" step="0.01" min="0" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% for r in resources %}
|
||||||
|
<section>
|
||||||
|
<h2>Verfügbarkeit — {{ r.name }}</h2>
|
||||||
|
<form class="row" method="post" action="{{ url_for('owner_settings.update_resource', resource_id=r.resource_id) }}">
|
||||||
|
<div class="field">
|
||||||
|
<label>Vorlaufzeit (Min.)</label>
|
||||||
|
<input type="number" name="min_notice_minutes" value="{{ r.min_notice_minutes }}" min="0" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Vorausbuchung (Tage)</label>
|
||||||
|
<input type="number" name="max_advance_days" value="{{ r.max_advance_days }}" min="0" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Puffer (Min.)</label>
|
||||||
|
<input type="number" name="buffer_minutes" value="{{ r.buffer_minutes }}" min="0" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-small">Speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('owner_settings.update_resource_hours', resource_id=r.resource_id) }}">
|
||||||
|
{% set hours = hours_by_resource.get(r.resource_id, {}) %}
|
||||||
|
{% set day_names = {0: 'Montag', 1: 'Dienstag', 2: 'Mittwoch', 3: 'Donnerstag',
|
||||||
|
4: 'Freitag', 5: 'Samstag', 6: 'Sonntag'} %}
|
||||||
|
{% for weekday in weekdays %}
|
||||||
|
{% set day_hours = hours.get(weekday) %}
|
||||||
|
<div class="weekday-row">
|
||||||
|
<span class="day-name">{{ day_names[weekday] }}</span>
|
||||||
|
<input type="time" name="opens_at_{{ weekday }}"
|
||||||
|
value="{{ day_hours[0].strftime('%H:%M') if day_hours else '' }}" />
|
||||||
|
<span>–</span>
|
||||||
|
<input type="time" name="closes_at_{{ weekday }}"
|
||||||
|
value="{{ day_hours[1].strftime('%H:%M') if day_hours else '' }}" />
|
||||||
|
<input type="checkbox" name="closed_{{ weekday }}" id="closed_{{ r.resource_id }}_{{ weekday }}"
|
||||||
|
{{ 'checked' if not day_hours }} />
|
||||||
|
<label for="closed_{{ r.resource_id }}_{{ weekday }}">Geschlossen</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-small">Öffnungszeiten speichern</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Buchungen & Benachrichtigung</h2>
|
||||||
|
<form class="row" method="post" action="{{ url_for('owner_settings.update_client') }}">
|
||||||
|
<div class="field">
|
||||||
|
<label>Automatisch bestätigen</label>
|
||||||
|
<input type="checkbox" name="auto_confirm" {{ 'checked' if client and client.auto_confirm }} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Benachrichtigungskanal</label>
|
||||||
|
<select name="notify_channel">
|
||||||
|
<option value="" {{ 'selected' if not client or not client.notify_channel }}>Kein</option>
|
||||||
|
<option value="telegram" {{ 'selected' if client and client.notify_channel == 'telegram' }}>Telegram</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="muted"><a href="{{ url_for('owner_auth.dashboard') }}">Zurück</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Flask test client / real-DB integration tests for the owner settings
|
||||||
|
page: services CRUD, per-resource hours/notice/buffer, auto_confirm, and
|
||||||
|
notify_channel (#21). Same testing decision as #20: assert on HTTP response
|
||||||
|
+ resulting DB state, real Postgres.
|
||||||
|
"""
|
||||||
|
from datetime import date, time, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import booking_db as bdb
|
||||||
|
from app import app as flask_app
|
||||||
|
|
||||||
|
CLIENT_A = "C-TEST-OWNER-SETTINGS-A"
|
||||||
|
CLIENT_B = "C-TEST-OWNER-SETTINGS-B"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
flask_app.config["TESTING"] = True
|
||||||
|
flask_app.secret_key = "test-secret"
|
||||||
|
return flask_app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(client_id=CLIENT_A, **resource_kwargs):
|
||||||
|
with bdb.db.connect() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO clients (client_id, timezone, auto_confirm, notify_channel) "
|
||||||
|
"VALUES (%s, %s, %s, %s) ON CONFLICT (client_id) DO UPDATE SET "
|
||||||
|
"timezone = EXCLUDED.timezone, auto_confirm = EXCLUDED.auto_confirm, "
|
||||||
|
"notify_channel = EXCLUDED.notify_channel",
|
||||||
|
(client_id, "Europe/Berlin", True, None))
|
||||||
|
conn.commit()
|
||||||
|
resource = bdb.create_resource(client_id, "Chair 1", **resource_kwargs)
|
||||||
|
service = bdb.create_service(client_id, "Haircut", 60, price=25)
|
||||||
|
return resource, service
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, client_id, email="owner@example.com", password="correct horse"):
|
||||||
|
bdb.create_user(client_id, email, password)
|
||||||
|
client.post("/owner/login", data={"email": email, "password": password})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- auth gate ----
|
||||||
|
|
||||||
|
def test_settings_requires_login(client):
|
||||||
|
resp = client.get("/owner/settings")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert "/owner/login" in resp.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- services ----
|
||||||
|
|
||||||
|
def test_owner_can_create_service(client):
|
||||||
|
_setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post("/owner/settings/services", data={
|
||||||
|
"name": "Coloring", "duration_minutes": "90", "price": "60"})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert "error" not in resp.headers["Location"]
|
||||||
|
names = {s["name"] for s in bdb.list_services(CLIENT_A)}
|
||||||
|
assert "Coloring" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_service_rejects_missing_fields(client):
|
||||||
|
_setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post("/owner/settings/services", data={"name": "", "duration_minutes": "90"})
|
||||||
|
assert "error=invalid_service" in resp.headers["Location"]
|
||||||
|
assert len(bdb.list_services(CLIENT_A)) == 1 # only the seed service from _setup
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_update_and_deactivate_service(client):
|
||||||
|
resource, service = _setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post(f"/owner/settings/services/{service['service_id']}", data={
|
||||||
|
"name": "Haircut Deluxe", "duration_minutes": "45", "price": "30"})
|
||||||
|
assert "error" not in resp.headers["Location"]
|
||||||
|
updated = bdb.get_service(CLIENT_A, service["service_id"])
|
||||||
|
assert updated["name"] == "Haircut Deluxe"
|
||||||
|
assert updated["duration_minutes"] == 45
|
||||||
|
assert updated["active"] is False # checkbox omitted from form data == unchecked
|
||||||
|
|
||||||
|
resp = client.post(f"/owner/settings/services/{service['service_id']}", data={
|
||||||
|
"name": "Haircut Deluxe", "duration_minutes": "45", "price": "30", "active": "on"})
|
||||||
|
assert bdb.get_service(CLIENT_A, service["service_id"])["active"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_cannot_update_another_tenants_service(client):
|
||||||
|
resource_b, service_b = _setup(CLIENT_B)
|
||||||
|
_setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post(f"/owner/settings/services/{service_b['service_id']}", data={
|
||||||
|
"name": "Hijacked", "duration_minutes": "30", "active": "on"})
|
||||||
|
assert "error=not_found" in resp.headers["Location"]
|
||||||
|
assert bdb.get_service(CLIENT_B, service_b["service_id"])["name"] == "Haircut"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deactivated_service_disappears_from_public_picker(client):
|
||||||
|
resource, service = _setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
client.post(f"/owner/settings/services/{service['service_id']}", data={
|
||||||
|
"name": "Haircut", "duration_minutes": "60", "price": "25"})
|
||||||
|
assert bdb.list_active_services(CLIENT_A) == []
|
||||||
|
assert len(bdb.list_services(CLIENT_A)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---- resource availability config ----
|
||||||
|
|
||||||
|
def test_owner_can_update_resource_notice_and_buffer(client):
|
||||||
|
resource, service = _setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post(f"/owner/settings/resources/{resource['resource_id']}", data={
|
||||||
|
"min_notice_minutes": "120", "max_advance_days": "14", "buffer_minutes": "15"})
|
||||||
|
assert "error" not in resp.headers["Location"]
|
||||||
|
updated = bdb.get_resource(CLIENT_A, resource["resource_id"])
|
||||||
|
assert updated["min_notice_minutes"] == 120
|
||||||
|
assert updated["max_advance_days"] == 14
|
||||||
|
assert updated["buffer_minutes"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_resource_rejects_negative_values(client):
|
||||||
|
resource, service = _setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post(f"/owner/settings/resources/{resource['resource_id']}", data={
|
||||||
|
"min_notice_minutes": "-1", "max_advance_days": "14", "buffer_minutes": "15"})
|
||||||
|
assert "error=invalid_resource" in resp.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_cannot_update_another_tenants_resource(client):
|
||||||
|
resource_b, service_b = _setup(CLIENT_B)
|
||||||
|
_setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post(f"/owner/settings/resources/{resource_b['resource_id']}", data={
|
||||||
|
"min_notice_minutes": "0", "max_advance_days": "1", "buffer_minutes": "0"})
|
||||||
|
assert "error=not_found" in resp.headers["Location"]
|
||||||
|
assert bdb.get_resource(CLIENT_B, resource_b["resource_id"])["max_advance_days"] != 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_set_opening_hours(client):
|
||||||
|
resource, service = _setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
form = {"opens_at_0": "09:00", "closes_at_0": "17:00", "closed_1": "on"}
|
||||||
|
for weekday in range(2, 7):
|
||||||
|
form[f"closed_{weekday}"] = "on"
|
||||||
|
resp = client.post(
|
||||||
|
f"/owner/settings/resources/{resource['resource_id']}/hours", data=form)
|
||||||
|
assert "error" not in resp.headers["Location"]
|
||||||
|
hours = bdb.get_resource_hours(CLIENT_A, resource["resource_id"])
|
||||||
|
assert hours[0] == (time(9, 0), time(17, 0))
|
||||||
|
assert 1 not in hours
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_opening_hours_rejects_end_before_start(client):
|
||||||
|
resource, service = _setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
form = {"opens_at_0": "17:00", "closes_at_0": "09:00"}
|
||||||
|
for weekday in range(1, 7):
|
||||||
|
form[f"closed_{weekday}"] = "on"
|
||||||
|
resp = client.post(
|
||||||
|
f"/owner/settings/resources/{resource['resource_id']}/hours", data=form)
|
||||||
|
assert "error=invalid_hours" in resp.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_cannot_set_hours_for_another_tenants_resource(client):
|
||||||
|
resource_b, service_b = _setup(CLIENT_B)
|
||||||
|
_setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
form = {"opens_at_0": "09:00", "closes_at_0": "17:00"}
|
||||||
|
for weekday in range(1, 7):
|
||||||
|
form[f"closed_{weekday}"] = "on"
|
||||||
|
resp = client.post(
|
||||||
|
f"/owner/settings/resources/{resource_b['resource_id']}/hours", data=form)
|
||||||
|
assert "error=not_found" in resp.headers["Location"]
|
||||||
|
assert bdb.get_resource_hours(CLIENT_B, resource_b["resource_id"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_availability_change_reflected_in_slot_generation(client):
|
||||||
|
resource, service = _setup(CLIENT_A, min_notice_minutes=0, max_advance_days=365)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
form = {"opens_at_0": "09:00", "closes_at_0": "10:00"}
|
||||||
|
for weekday in range(1, 7):
|
||||||
|
form[f"closed_{weekday}"] = "on"
|
||||||
|
client.post(f"/owner/settings/resources/{resource['resource_id']}/hours", data=form)
|
||||||
|
updated_hours = bdb.get_resource_hours(CLIENT_A, resource["resource_id"])
|
||||||
|
assert updated_hours[0] == (time(9, 0), time(10, 0))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- client-level booking settings ----
|
||||||
|
|
||||||
|
def test_owner_can_toggle_auto_confirm(client):
|
||||||
|
_setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post("/owner/settings/client", data={"notify_channel": ""})
|
||||||
|
assert "error" not in resp.headers["Location"]
|
||||||
|
assert bdb.get_client(CLIENT_A)["auto_confirm"] is False
|
||||||
|
|
||||||
|
resp = client.post("/owner/settings/client", data={
|
||||||
|
"auto_confirm": "on", "notify_channel": ""})
|
||||||
|
assert bdb.get_client(CLIENT_A)["auto_confirm"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_set_notify_channel_to_telegram(client):
|
||||||
|
_setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post("/owner/settings/client", data={
|
||||||
|
"auto_confirm": "on", "notify_channel": "telegram"})
|
||||||
|
assert "error" not in resp.headers["Location"]
|
||||||
|
assert bdb.get_client(CLIENT_A)["notify_channel"] == "telegram"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_notify_channel_is_rejected(client):
|
||||||
|
_setup(CLIENT_A)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
resp = client.post("/owner/settings/client", data={
|
||||||
|
"auto_confirm": "on", "notify_channel": "sms"})
|
||||||
|
assert "error=invalid_notify_channel" in resp.headers["Location"]
|
||||||
|
assert bdb.get_client(CLIENT_A)["notify_channel"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def _next_monday(after):
|
||||||
|
d = after + timedelta(days=1)
|
||||||
|
while d.weekday() != 0:
|
||||||
|
d += timedelta(days=1)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_confirm_toggle_drives_new_public_booking_status(client):
|
||||||
|
resource, service = _setup(CLIENT_A, min_notice_minutes=0, max_advance_days=365)
|
||||||
|
_login(client, CLIENT_A)
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
form = {"opens_at_0": "09:00", "closes_at_0": "17:00"}
|
||||||
|
for weekday in range(1, 7):
|
||||||
|
form[f"closed_{weekday}"] = "on"
|
||||||
|
client.post(f"/owner/settings/resources/{resource['resource_id']}/hours", data=form)
|
||||||
|
client.post("/owner/settings/client", data={"notify_channel": ""}) # auto_confirm off
|
||||||
|
assert bdb.get_client(CLIENT_A)["auto_confirm"] is False
|
||||||
|
|
||||||
|
slots = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
|
||||||
|
resp = client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slots[0],
|
||||||
|
"customer_name": "Ivy", "customer_contact": "ivy@example.com"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["status"] == "pending"
|
||||||
Reference in New Issue
Block a user