""" app/tenant/appointments/routes.py Appointment management: calendar view, create, edit, status workflow, cancellation reason, no-show capture. """ import logging from datetime import datetime, timezone, timedelta, date from flask import Blueprint, render_template, redirect, url_for, flash, request, g, jsonify from flask_login import login_required from app.extensions import db from app.models.salon import Appointment, Customer, Staff, Service, Location from app.decorators import require_role, demo_readonly from app.tenant.utils import log_tenant_action logger = logging.getLogger(__name__) appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments") VALID_STATUSES = {"pending", "confirmed", "in_progress", "completed", "cancelled", "no_show"} @appointments_bp.route("/") @login_required @require_role("tenant_admin", "tenant_manager") def index(): # Default to today date_str = request.args.get("date", date.today().isoformat()) try: view_date = date.fromisoformat(date_str) except ValueError: view_date = date.today() day_start = datetime.combine(view_date, datetime.min.time()).replace(tzinfo=timezone.utc) day_end = day_start + timedelta(days=1) appts = Appointment.query.filter_by( tenant_id=g.tenant.id, location_id=g.location.id ).filter( Appointment.start_time >= day_start, Appointment.start_time < day_end, ).order_by(Appointment.start_time).all() staff_list = Staff.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Staff.deleted_at.is_(None)).order_by(Staff.name).all() services = Service.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Service.deleted_at.is_(None)).order_by(Service.name).all() prev_date = view_date - timedelta(days=1) next_date = view_date + timedelta(days=1) return render_template("tenant/appointments/index.html", appointments=appts, view_date=view_date, prev_date=prev_date, next_date=next_date, staff_list=staff_list, services=services) @appointments_bp.route("/new", methods=["GET", "POST"]) @login_required @require_role("tenant_admin", "tenant_manager") @demo_readonly def create(): staff_list = Staff.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Staff.deleted_at.is_(None)).order_by(Staff.name).all() services = Service.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Service.deleted_at.is_(None)).order_by(Service.name).all() customers = Customer.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all() if request.method == "POST": start_raw = request.form.get("start_time", "") if not start_raw: return render_template("tenant/appointments/form.html", mode="create", staff_list=staff_list, services=services, customers=customers, error="Start time is required.") try: start_time = datetime.fromisoformat(start_raw) if start_time.tzinfo is None: start_time = start_time.replace(tzinfo=timezone.utc) except ValueError: return render_template("tenant/appointments/form.html", mode="create", staff_list=staff_list, services=services, customers=customers, error="Invalid date/time format.") service_id = request.form.get("service_id", type=int) svc = Service.query.get(service_id) if service_id else None duration = svc.duration_min if svc else 30 end_time = start_time + timedelta(minutes=duration) customer_id = request.form.get("customer_id", type=int) or None staff_id = request.form.get("staff_id", type=int) or None is_walk_in = request.form.get("is_walk_in") == "1" appt = Appointment( tenant_id=g.tenant.id, location_id=g.location.id, customer_id=customer_id, staff_id=staff_id, service_id=service_id, start_time=start_time, end_time=end_time, is_walk_in=is_walk_in, status="confirmed" if not is_walk_in else "in_progress", notes=request.form.get("notes", "").strip() or None, rebook_source="manual", created_by=_user_db_id(), ) db.session.add(appt) db.session.flush() log_tenant_action("appointment.create", "appointment", appt.id, {"customer": customer_id, "staff": staff_id, "start": start_raw}) # Schedule 24h and 2h email reminders from app.models.salon import AppointmentReminder as AR for hours, rtype in [(24, "24h"), (2, "2h")]: remind_at = start_time - timedelta(hours=hours) if remind_at > datetime.now(timezone.utc): db.session.add(AR( tenant_id=g.tenant.id, location_id=g.location.id, appointment_id=appt.id, reminder_type=rtype, scheduled_for=remind_at, channel="email", status="pending", )) db.session.commit() flash("Appointment created.", "success") return redirect(url_for("appointments.index", date=start_time.date().isoformat())) return render_template("tenant/appointments/form.html", mode="create", staff_list=staff_list, services=services, customers=customers) @appointments_bp.route("/") @login_required @require_role("tenant_admin", "tenant_manager") def view(appt_id): appt = Appointment.query.filter_by( id=appt_id, tenant_id=g.tenant.id).first_or_404() return render_template("tenant/appointments/view.html", appointment=appt) @appointments_bp.route("//status", methods=["POST"]) @login_required @require_role("tenant_admin", "tenant_manager") @demo_readonly def set_status(appt_id): appt = Appointment.query.filter_by( id=appt_id, tenant_id=g.tenant.id).first_or_404() new_status = request.form.get("status", "").strip() if new_status not in VALID_STATUSES: flash("Invalid status.", "danger") return redirect(url_for("appointments.view", appt_id=appt_id)) old_status = appt.status appt.status = new_status if new_status == "cancelled": appt.cancellation_reason = request.form.get("reason", "").strip() or None appt.cancelled_at = datetime.now(timezone.utc) # Cancel pending reminders from app.models.salon import AppointmentReminder AppointmentReminder.query.filter_by( appointment_id=appt_id, status="pending" ).update({"status": "cancelled"}) elif new_status == "no_show" and appt.customer_id: # Increment no-show counter on customer record Customer.query.filter_by(id=appt.customer_id).update( {"no_show_count": Customer.no_show_count + 1} ) log_tenant_action("appointment.set_status", "appointment", appt.id, {"old": old_status, "new": new_status}) db.session.commit() flash(f"Appointment status updated to '{new_status}'.", "success") return redirect(url_for("appointments.view", appt_id=appt_id)) @appointments_bp.route("//edit", methods=["GET", "POST"]) @login_required @require_role("tenant_admin", "tenant_manager") @demo_readonly def edit(appt_id): appt = Appointment.query.filter_by( id=appt_id, tenant_id=g.tenant.id).first_or_404() staff_list = Staff.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Staff.deleted_at.is_(None)).order_by(Staff.name).all() services = Service.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Service.deleted_at.is_(None)).order_by(Service.name).all() customers = Customer.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all() if request.method == "POST": start_raw = request.form.get("start_time", "") try: start_time = datetime.fromisoformat(start_raw) if start_time.tzinfo is None: start_time = start_time.replace(tzinfo=timezone.utc) except ValueError: return render_template("tenant/appointments/form.html", mode="edit", appointment=appt, staff_list=staff_list, services=services, customers=customers, error="Invalid date/time.") service_id = request.form.get("service_id", type=int) svc = Service.query.get(service_id) if service_id else None duration = svc.duration_min if svc else 30 appt.customer_id = request.form.get("customer_id", type=int) or None appt.staff_id = request.form.get("staff_id", type=int) or None appt.service_id = service_id appt.start_time = start_time appt.end_time = start_time + timedelta(minutes=duration) appt.notes = request.form.get("notes", "").strip() or None log_tenant_action("appointment.edit", "appointment", appt.id, {"start": start_raw}) db.session.commit() flash("Appointment updated.", "success") return redirect(url_for("appointments.view", appt_id=appt_id)) return render_template("tenant/appointments/form.html", mode="edit", appointment=appt, staff_list=staff_list, services=services, customers=customers) def _user_db_id(): from flask_login import current_user try: uid_str = current_user.get_id() return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None except Exception: return None