05/07 Phase 3: initial codes

This commit is contained in:
2026-05-07 12:17:19 -04:00
parent 6e5518c103
commit ce462c57b2
107 changed files with 6365 additions and 208 deletions
+69 -102
View File
@@ -1,135 +1,102 @@
"""
app/tenant/checkin/routes.py — Customer self check-in kiosk.
Route: GET/POST /checkin/<tenant_slug>
app/tenant/checkin/routes.py
Customer self check-in kiosk — /checkin/<tenant_slug>
No authentication required. CSRF-exempt. Rate-limited.
Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting.
Receptionist alert delivered via 5-second polling: 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 flask import Blueprint, render_template, request, redirect, url_for, g
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
from app.models.salon import CheckinQueue, Customer, Service, Location
logger = logging.getLogger(__name__)
checkin_bp = Blueprint("checkin", __name__)
@checkin_bp.route("/checkin/<tenant_slug>", 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)
@csrf.exempt
def kiosk(tenant_slug):
# Validate slug against known tenants — prevents enumeration
tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
Tenant.status.in_(["active", "trial"])
).first()
if not tenant:
return render_template("tenant/checkin/not_found.html"), 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
# Resolve location: session-stored or primary
location = Location.query.filter_by(
tenant_id=tenant.id,
is_primary=True,
is_active=True,
).filter(Location.deleted_at.is_(None)).first()
tenant_id=tenant.id, is_primary=True, is_active=True
).first()
if not location:
location = Location.query.filter_by(
tenant_id=tenant.id, is_active=True
).first()
if not location:
return render_template("tenant/checkin/not_found.html"), 404
if location is None:
abort(404)
services = Service.query.filter_by(
tenant_id=tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
confirmed = False
error = None
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", "")
# Accept ONLY these three fields — all others ignored
customer_name = request.form.get("customer_name", "").strip()[:255]
customer_phone = request.form.get("customer_phone", "").strip()[:30]
service_requested = request.form.get("service_requested", "").strip()[:255] or None
name = sanitise_string(raw_name, max_length=150)
phone = sanitise_string(raw_phone, max_length=30)
service = sanitise_string(raw_service, max_length=150)
if not customer_name or not customer_phone:
error = "Please enter your name and phone number."
else:
# Look up or create customer profile
customer = Customer.query.filter_by(
tenant_id=tenant.id, phone=customer_phone
).filter(Customer.deleted_at.is_(None)).first()
customer_id = None
if customer:
customer_id = customer.id
else:
# Auto-create profile
new_cust = Customer(
tenant_id=tenant.id,
name=customer_name,
phone=customer_phone,
is_active=True,
loyalty_points=0,
no_show_count=0,
)
db.session.add(new_cust)
db.session.flush()
customer_id = new_cust.id
logger.info("Kiosk: new customer profile created tenant=%s phone=%s",
tenant.id, customer_phone)
# 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(
entry = CheckinQueue(
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
location_id=location.id,
customer_id=customer_id,
customer_name=customer_name,
customer_phone=customer_phone,
service_requested=service_requested,
checked_in_at=datetime.now(timezone.utc),
status="waiting",
)
db.session.add(entry)
db.session.commit()
# 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()
logger.info("Kiosk check-in: tenant=%s location=%s customer=%s service=%s",
tenant.id, location.id, customer_id, service_requested)
confirmed = True
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
services=services,
confirmed=confirmed,
error=None,
error=error,
)