""" app/tenant/checkin/routes.py — Customer self check-in kiosk. Route: GET/POST /checkin/ No authentication required. CSRF-exempt. Rate-limited. Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting. """ import logging from datetime import datetime, timezone from flask import ( Blueprint, render_template, request, jsonify, current_app, abort, ) from app.extensions import db, limiter, csrf from app.models.platform import Tenant from app.models.salon import Customer, CheckinQueue, Location from app.security import sanitise_string, validate_slug logger = logging.getLogger(__name__) checkin_bp = Blueprint("checkin", __name__) @checkin_bp.route("/checkin/", methods=["GET", "POST"]) @csrf.exempt @limiter.limit("20 per minute") def kiosk(tenant_slug: str): """ Public kiosk page. No auth required. Tenant slug validated against known slugs (not guessable). Accepts: name, phone, service_requested — all other fields ignored. Phone is sanitised and validated before profile lookup. Page auto-resets after 10 seconds (configurable per tenant) via JS. """ # Validate slug format before hitting the DB if not validate_slug(tenant_slug): logger.warning("Kiosk: invalid slug format: %s", tenant_slug) abort(404) tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first() if not tenant or not tenant.is_active_status(): logger.warning("Kiosk: tenant not found or inactive: %s", tenant_slug) abort(404) # Resolve the primary location for this tenant location = Location.query.filter_by( tenant_id=tenant.id, is_primary=True, is_active=True, ).filter(Location.deleted_at.is_(None)).first() if location is None: abort(404) confirmed = False if request.method == "POST": raw_name = request.form.get("customer_name", "") raw_phone = request.form.get("customer_phone", "") raw_service = request.form.get("service_requested", "") name = sanitise_string(raw_name, max_length=150) phone = sanitise_string(raw_phone, max_length=30) service = sanitise_string(raw_service, max_length=150) # Validate required fields if not name or not phone: return render_template( "tenant/checkin/kiosk.html", tenant=tenant, error="Name and phone number are required.", confirmed=False, ) # Strip non-digit characters from phone for lookup consistency phone_digits = "".join(filter(str.isdigit, phone)) if len(phone_digits) < 7: return render_template( "tenant/checkin/kiosk.html", tenant=tenant, error="Please enter a valid phone number.", confirmed=False, ) # Look up or create customer profile customer = Customer.query.filter_by( tenant_id=tenant.id, phone=phone_digits, ).filter(Customer.deleted_at.is_(None)).first() if customer is None: customer = Customer( tenant_id=tenant.id, name=name, phone=phone_digits, ) db.session.add(customer) db.session.flush() # get customer.id before committing logger.info( "Kiosk: new customer profile created for tenant %s", tenant.slug ) # Queue the walk-in entry entry = CheckinQueue( tenant_id=tenant.id, location_id=location.id, customer_id=customer.id, customer_name=name, customer_phone=phone_digits, service_requested=service or None, status="waiting", ) db.session.add(entry) db.session.commit() logger.info( "Kiosk: check-in queued for tenant=%s location=%s customer=%s", tenant.slug, location.id, customer.id, ) confirmed = True # Fetch services for the kiosk selector (if configured) from app.models.salon import Service services = Service.query.filter_by( tenant_id=tenant.id, is_active=True, ).filter(Service.deleted_at.is_(None)).order_by(Service.name).all() return render_template( "tenant/checkin/kiosk.html", tenant=tenant, services=services, confirmed=confirmed, error=None, )