05/07 Phase 3: initial codes
This commit is contained in:
+69
-102
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user