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 @@

Sie sind angemeldet.

Zum Kalender

-

Einstellungen folgen hier.

+

Einstellungen

Abmelden

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 @@ + + + + + + + Einstellungen — {{ client.business_name if client else '' }} + + + +

{{ 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 %} + + + + {% for s in services %} + + + + + + + + + + {% endfor %} + +
NameDauer (Min.)PreisAktiv
+ {% else %} +

Noch keine Leistungen angelegt.

+ {% endif %} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {% for r in resources %} +
+

Verfügbarkeit — {{ r.name }}

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ {% 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) %} +
+ {{ day_names[weekday] }} + + + + + +
+ {% endfor %} + +
+
+ {% endfor %} + +
+

Buchungen & Benachrichtigung

+
+
+ + +
+
+ + +
+ +
+
+ +

Zurück

+ + diff --git a/backoffice/app/tests/test_owner_settings.py b/backoffice/app/tests/test_owner_settings.py new file mode 100644 index 0000000..15924d8 --- /dev/null +++ b/backoffice/app/tests/test_owner_settings.py @@ -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"