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
+2
View File
@@ -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
+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):
+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()
@@ -24,7 +24,7 @@
<div class="card">
<p>Sie sind angemeldet.</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>
<p class="muted"><a href="{{ url_for('owner_auth.logout') }}">Abmelden</a></p>
</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>&ndash;</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 &amp; 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>
+247
View File
@@ -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"