diff --git a/backoffice/app/app.py b/backoffice/app/app.py index b7a516e..69e13ca 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -254,6 +254,25 @@ def _parse_hours_time(value): return None +@app.post("/api/locations") +def create_location_route(): + """Onboarding provisioning: a default location (Filiale) for a new + client, created before its first resource -- create_resource_route + requires a location_id.""" + if not authed(): + return jsonify({"error": "forbidden"}), 403 + body = request.get_json(force=True, silent=True) or {} + client_id = body.get("client_id") + name = (body.get("name") or "").strip() + if not client_id or not name: + return jsonify({"error": "client_id and name required"}), 400 + row = bdb.create_location(client_id, name) + with db.connect() as conn, conn.cursor() as cur: + log_activity(cur, client_id, "add location", f"location_id={row['location_id']}") + conn.commit() + return jsonify({"location_id": row["location_id"]}), 201 + + @app.post("/api/resources") def create_resource_route(): """Onboarding provisioning (#24): a default bookable resource for a new @@ -264,10 +283,14 @@ def create_resource_route(): return jsonify({"error": "forbidden"}), 403 body = request.get_json(force=True, silent=True) or {} client_id = body.get("client_id") + location_id = body.get("location_id") name = (body.get("name") or "").strip() - if not client_id or not name: - return jsonify({"error": "client_id and name required"}), 400 - row = bdb.create_resource(client_id, name) + if not client_id or not location_id or not name: + return jsonify({"error": "client_id, location_id and name required"}), 400 + try: + row = bdb.create_resource(client_id, location_id, name) + except bdb.UnknownLocation: + return jsonify({"error": "location not found"}), 404 for h in body.get("hours") or []: opens_at = _parse_hours_time(h.get("opens_at")) closes_at = _parse_hours_time(h.get("closes_at")) diff --git a/backoffice/app/booking_db.py b/backoffice/app/booking_db.py index 8b1beac..a37fe37 100644 --- a/backoffice/app/booking_db.py +++ b/backoffice/app/booking_db.py @@ -26,6 +26,12 @@ class UnknownResource(Exception): guards against a booking write smuggling in another tenant's resource.""" +class UnknownLocation(Exception): + """Raised when a location_id doesn't belong to the given client_id -- + guards create_resource against a location_id smuggled in from another + tenant.""" + + def new_id(prefix): return f"{prefix}-{int(time.time() * 1000)}-{secrets.token_hex(3)}" @@ -38,17 +44,80 @@ def _list_active(table, client_id): return cur.fetchall() +# ---- locations ---- + +def create_location(client_id, name, active=True): + location_id = new_id("LOC") + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + "INSERT INTO locations (location_id, client_id, name, active) " + "VALUES (%s, %s, %s, %s) RETURNING *", + (location_id, client_id, name, active)) + row = cur.fetchone() + conn.commit() + return row + + +def get_location(client_id, location_id): + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + "SELECT * FROM locations WHERE client_id = %s AND location_id = %s", + (client_id, location_id)) + return cur.fetchone() + + +def list_active_locations(client_id): + return _list_active("locations", client_id) + + +def list_locations(client_id): + """All of client_id's locations, active or not -- for the owner settings + page, same reasoning as list_resources/list_services.""" + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + "SELECT * FROM locations WHERE client_id = %s ORDER BY name", + (client_id,)) + return cur.fetchall() + + +_LOCATION_UPDATABLE = {"name", "active"} + + +def update_location(client_id, location_id, **fields): + """Update a location's own fields, scoped to client_id. Returns the + updated row, or None if no such location exists for this client.""" + bad = set(fields) - _LOCATION_UPDATABLE + if bad: + raise ValueError(f"not updatable: {', '.join(sorted(bad))}") + if not fields: + return get_location(client_id, location_id) + setsql = ", ".join(f"{c} = %s" for c in fields) + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + f"UPDATE locations SET {setsql} WHERE client_id = %s AND location_id = %s " + "RETURNING *", + [*fields.values(), client_id, location_id]) + row = cur.fetchone() + conn.commit() + return row + + # ---- resources ---- -def create_resource(client_id, name, active=True, min_notice_minutes=60, +def create_resource(client_id, location_id, name, active=True, min_notice_minutes=60, max_advance_days=30, buffer_minutes=0): + """location_id must be one of client_id's own locations -- resources + (individual bookable staff) always belong to a Filiale. Raises + UnknownLocation if it isn't.""" + if get_location(client_id, location_id) is None: + raise UnknownLocation(f"no location {location_id} for client {client_id}") resource_id = new_id("RS") with db.connect() as conn, conn.cursor() as cur: cur.execute( - "INSERT INTO resources (resource_id, client_id, name, active, " + "INSERT INTO resources (resource_id, client_id, location_id, name, active, " "min_notice_minutes, max_advance_days, buffer_minutes) " - "VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *", - (resource_id, client_id, name, active, min_notice_minutes, + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING *", + (resource_id, client_id, location_id, name, active, min_notice_minutes, max_advance_days, buffer_minutes)) row = cur.fetchone() conn.commit() diff --git a/backoffice/app/owner_booking.py b/backoffice/app/owner_booking.py index b9a8766..7b63210 100644 --- a/backoffice/app/owner_booking.py +++ b/backoffice/app/owner_booking.py @@ -62,7 +62,9 @@ def _agenda_days(client_id, client, monday): end_utc = datetime.combine(monday + timedelta(days=7), datetime.min.time(), tzinfo=tz).astimezone(timezone.utc) rows = bdb.list_bookings_between(client_id, start_utc, end_utc) - resource_names = {r["resource_id"]: r["name"] for r in bdb.list_resources(client_id)} + all_resources = bdb.list_resources(client_id) + resource_names = {r["resource_id"]: r["name"] for r in all_resources} + resource_locations = {r["resource_id"]: r["location_id"] for r in all_resources} by_date = {monday + timedelta(days=i): [] for i in range(7)} for row in rows: @@ -71,6 +73,7 @@ def _agenda_days(client_id, client, monday): continue # a booking straddling the window edge in another tz row = dict(row) row["resource_name"] = resource_names.get(row["resource_id"], row["resource_id"]) + row["location_id"] = resource_locations.get(row["resource_id"]) row["start_local"] = row["start_time"].astimezone(tz) by_date[local_date].append(row) return sorted(by_date.items()) @@ -83,13 +86,18 @@ def agenda(): client = bdb.get_client(client_id) monday = _week_start(request.args.get("week")) today = datetime.now(_tz(client)).date() + locations = bdb.list_active_locations(client_id) + resources = bdb.list_active_resources(client_id) + resources_by_location = {} + for r in resources: + resources_by_location.setdefault(r["location_id"], []).append(r) return render_template( "owner/agenda.html", client=client, days=_agenda_days(client_id, client, monday), week_start=monday, today=today, prev_week=(monday - timedelta(days=7)).isoformat(), next_week=(monday + timedelta(days=7)).isoformat(), this_week=_week_start(None).isoformat(), - resources=bdb.list_active_resources(client_id), + locations=locations, resources_by_location=resources_by_location, services=bdb.list_active_services(client_id), error=request.args.get("error")) diff --git a/backoffice/app/owner_settings.py b/backoffice/app/owner_settings.py index fa5f03d..5f24832 100644 --- a/backoffice/app/owner_settings.py +++ b/backoffice/app/owner_settings.py @@ -57,18 +57,60 @@ def _parse_time(value): def settings(): client_id = session["client_id"] client = bdb.get_client(client_id) + locations = bdb.list_locations(client_id) resources = bdb.list_resources(client_id) + resources_by_location = {} + for r in resources: + resources_by_location.setdefault(r["location_id"], []).append(r) hours_by_resource = { r["resource_id"]: bdb.get_resource_hours(client_id, r["resource_id"]) for r in resources} ics_token = bdb.ensure_ics_token(client_id) return render_template( "owner/settings.html", client=client, services=bdb.list_services(client_id), - resources=resources, hours_by_resource=hours_by_resource, weekdays=WEEKDAYS, + locations=locations, resources_by_location=resources_by_location, + hours_by_resource=hours_by_resource, weekdays=WEEKDAYS, ics_url=url_for("bookings_ics", token=ics_token, _external=True), error=request.args.get("error")) +@bp.post("/locations") +@login_required +def create_location(): + client_id = session["client_id"] + name = (request.form.get("name") or "").strip() + if not name: + return _redirect(error="invalid_location") + bdb.create_location(client_id, name) + return _redirect() + + +@bp.post("/locations/") +@login_required +def update_location(location_id): + client_id = session["client_id"] + name = (request.form.get("name") or "").strip() + active = request.form.get("active") == "on" + if not name: + return _redirect(error="invalid_location") + row = bdb.update_location(client_id, location_id, name=name, active=active) + if row is None: + return _redirect(error="not_found") + return _redirect() + + +@bp.post("/resources") +@login_required +def create_resource(): + client_id = session["client_id"] + location_id = request.form.get("location_id") + name = (request.form.get("name") or "").strip() + if not name or bdb.get_location(client_id, location_id) is None: + return _redirect(error="invalid_resource") + bdb.create_resource(client_id, location_id, name) + return _redirect() + + @bp.post("/services") @login_required def create_service(): @@ -104,9 +146,13 @@ def update_service(service_id): @login_required def update_resource(resource_id): client_id = session["client_id"] + name = (request.form.get("name") or "").strip() + active = request.form.get("active") == "on" 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 not name: + return _redirect(error="invalid_resource") 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: @@ -114,7 +160,8 @@ def update_resource(resource_id): 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, + client_id, resource_id, name=name, active=active, + 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") diff --git a/backoffice/app/public_booking.py b/backoffice/app/public_booking.py index 767fa07..3f329d2 100644 --- a/backoffice/app/public_booking.py +++ b/backoffice/app/public_booking.py @@ -22,17 +22,25 @@ def book_page(slug): client = bdb.get_client_by_slug(slug) if client is None: abort(404) - resources = bdb.list_active_resources(client["client_id"]) - services = bdb.list_active_services(client["client_id"]) - if not resources or not services: + client_id = client["client_id"] + locations = bdb.list_active_locations(client_id) + active_location_ids = {l["location_id"] for l in locations} + # A resource whose Filiale was deactivated shouldn't stay bookable even if + # the resource row itself is still active. + resources = [r for r in bdb.list_active_resources(client_id) + if r["location_id"] in active_location_ids] + services = bdb.list_active_services(client_id) + if not locations or not resources or not services: # No bookable services/resources configured yet -- nothing to show a # customer rather than a broken/empty booking form. abort(404) return render_template( "book.html", - client_id=client["client_id"], + client_id=client_id, business_name=client.get("business_name") or slug, - resources=[{"resource_id": r["resource_id"], "name": r["name"]} for r in resources], + locations=[{"location_id": l["location_id"], "name": l["name"]} for l in locations], + resources=[{"resource_id": r["resource_id"], "name": r["name"], + "location_id": r["location_id"]} for r in resources], services=[{"service_id": s["service_id"], "name": s["name"], "duration_minutes": s["duration_minutes"], "price": float(s["price"]) if s["price"] is not None else None} diff --git a/backoffice/app/templates/book.html b/backoffice/app/templates/book.html index ad8752b..4ab6529 100644 --- a/backoffice/app/templates/book.html +++ b/backoffice/app/templates/book.html @@ -48,6 +48,17 @@
+ {% if locations|length > 1 %} +
+

Filiale

+
+ {% for l in locations %} + + {% endfor %} +
+
+ {% endif %} +

Leistung

@@ -61,11 +72,12 @@
{% if resources|length > 1 %} -
+

Mitarbeiter

{% for r in resources %} - + {% endfor %}
@@ -108,12 +120,45 @@