"""Owner agenda: server-rendered week view, manual walk-in/phone bookings, and owner-initiated cancel/reschedule (#20). All routes are session-authenticated via owner_auth.login_required and reuse booking_api.py's create/cancel/reschedule helpers, so the EXCLUDE constraint (#16) and confirmation email (#18) stay on the single code path those tickets already established -- this module never calls booking_db.py directly for a mutation, only for the read-side agenda listing. Manual creation passes skip_availability_check=True (the "override flag" #20 asks for): it bypasses opening-hours/min-notice/max-advance/buffer, but booking_api.create_booking_row still always goes through booking_db.create_booking, so the Postgres EXCLUDE constraint -- the actual double-booking guard -- is never bypassable, owner included. """ from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo from flask import Blueprint, redirect, render_template, request, session, url_for import booking_api as bapi import booking_db as bdb from owner_auth import login_required bp = Blueprint("owner_booking", __name__, url_prefix="/owner") def _tz(client): return ZoneInfo((client or {}).get("timezone") or "Europe/Berlin") def _week_start(value): """Monday (a date) of the week containing value (an ISO date string), or of the current week if value is missing/unparseable.""" try: d = datetime.fromisoformat(value).date() if value else datetime.now().date() except ValueError: d = datetime.now().date() return d - timedelta(days=d.weekday()) def _parse_local_start(value, tz): """value is a string (e.g. "2026-08-10T14:30"), naive and meant in the client's own timezone -- never UTC, since that's what an owner typing a time means.""" try: naive = datetime.fromisoformat(value) except (TypeError, ValueError): return None return naive.replace(tzinfo=tz).astimezone(timezone.utc) def _agenda_redirect(monday, error=None): return redirect(url_for("owner_booking.agenda", week=monday.isoformat(), error=error)) def _agenda_days(client_id, client, monday): """[(date, [booking, ...]), ...] for the 7 days starting at monday, each booking augmented with resource_name/start_local for display.""" tz = _tz(client) start_utc = datetime.combine(monday, datetime.min.time(), tzinfo=tz).astimezone(timezone.utc) 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)} by_date = {monday + timedelta(days=i): [] for i in range(7)} for row in rows: local_date = row["start_time"].astimezone(tz).date() if local_date not in by_date: 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["start_local"] = row["start_time"].astimezone(tz) by_date[local_date].append(row) return sorted(by_date.items()) @bp.get("/agenda") @login_required def agenda(): client_id = session["client_id"] client = bdb.get_client(client_id) monday = _week_start(request.args.get("week")) today = datetime.now(_tz(client)).date() 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), services=bdb.list_active_services(client_id), error=request.args.get("error")) @bp.post("/bookings") @login_required def create_manual_booking(): client_id = session["client_id"] client = bdb.get_client(client_id) monday = _week_start(request.form.get("week")) start_time = _parse_local_start(request.form.get("start_time"), _tz(client)) resource_id = request.form.get("resource_id") service_id = request.form.get("service_id") customer_name = (request.form.get("customer_name") or "").strip() customer_contact = (request.form.get("customer_contact") or "").strip() if not (start_time and resource_id and service_id and customer_name and customer_contact): return _agenda_redirect(monday, error="missing_fields") try: bapi.create_booking_row( client_id, resource_id, service_id, start_time, customer_name, customer_contact, source="owner", skip_availability_check=True) except bapi.NotFound: return _agenda_redirect(monday, error="not_found") except bapi.SlotTaken: return _agenda_redirect(monday, error="slot_taken") return _agenda_redirect(monday) @bp.post("/bookings//cancel") @login_required def cancel_manual_booking(booking_id): client_id = session["client_id"] monday = _week_start(request.form.get("week")) try: bapi.cancel_booking_row(client_id, booking_id) except bapi.NotFound: return _agenda_redirect(monday, error="not_found") except bapi.AlreadyCancelled: return _agenda_redirect(monday, error="already_cancelled") return _agenda_redirect(monday) @bp.post("/bookings//reschedule") @login_required def reschedule_manual_booking(booking_id): client_id = session["client_id"] client = bdb.get_client(client_id) monday = _week_start(request.form.get("week")) new_start = _parse_local_start(request.form.get("start_time"), _tz(client)) if new_start is None: return _agenda_redirect(monday, error="bad_time") try: bapi.reschedule_booking_row(client_id, booking_id, new_start) except bapi.NotFound: return _agenda_redirect(monday, error="not_found") except bapi.AlreadyCancelled: return _agenda_redirect(monday, error="already_cancelled") except bapi.SlotUnavailable: return _agenda_redirect(monday, error="unavailable") except bapi.SlotTaken: return _agenda_redirect(monday, error="slot_taken") return _agenda_redirect(monday)