05/06/2026 Initial commit

This commit is contained in:
2026-05-06 14:19:07 -04:00
parent 9da1dffd9a
commit dde18a2cd2
116 changed files with 4276 additions and 7 deletions
+105
View File
@@ -0,0 +1,105 @@
"""
app/tenant/__init__.py — Tenant app factory (mydomain.com).
"""
import logging
from datetime import timedelta
import os
from flask import Flask
from app.extensions import (
db, migrate, csrf, mail, scheduler,
tenant_login_manager, tenant_jwt, limiter,
)
from app.security import apply_security_headers
from app.context import load_tenant_context, load_location_context
from config import get_config
logger = logging.getLogger(__name__)
def create_tenant_app(config_override=None):
flask_app = Flask(
__name__,
template_folder="../templates",
static_folder="../static",
static_url_path="/static/tenant",
)
# ── Config ────────────────────────────────────────────────
cfg = config_override or get_config()
flask_app.config.from_object(cfg)
flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_tenant_session"
flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
seconds=int(os.environ.get("SESSION_TIMEOUT_TENANT", 1800))
)
flask_app.config["SESSION_COOKIE_HTTPONLY"] = True
flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# ── Extensions ────────────────────────────────────────────
db.init_app(flask_app)
migrate.init_app(flask_app, db)
csrf.init_app(flask_app)
mail.init_app(flask_app)
limiter.init_app(flask_app)
# Tenant login manager
tenant_login_manager.login_view = "tenant_auth.login"
tenant_login_manager.login_message_category = "warning"
tenant_login_manager.session_protection = "strong"
tenant_login_manager.init_app(flask_app)
# Tenant JWT
tenant_jwt.init_app(flask_app)
# ── User loaders ──────────────────────────────────────────
from app.models.salon import User, Staff
@tenant_login_manager.user_loader
def load_tenant_user(user_id: str):
if user_id.startswith("user:"):
try:
uid = int(user_id.split(":")[1])
except (ValueError, IndexError):
return None
return User.query.filter_by(id=uid, is_active=True)\
.filter(User.deleted_at.is_(None)).first()
if user_id.startswith("staff:"):
try:
sid = int(user_id.split(":")[1])
except (ValueError, IndexError):
return None
return Staff.query.filter_by(id=sid, is_active=True)\
.filter(Staff.deleted_at.is_(None)).first()
return None
# ── Context hooks ─────────────────────────────────────────
flask_app.before_request(load_tenant_context)
flask_app.before_request(load_location_context)
# ── Security ──────────────────────────────────────────────
apply_security_headers(flask_app)
# ── Blueprints ────────────────────────────────────────────
from app.tenant.auth.routes import tenant_auth_bp
from app.tenant.staff_auth.routes import staff_auth_bp
from app.tenant.checkin.routes import checkin_bp
from app.tenant.dashboard.routes import dashboard_bp
flask_app.register_blueprint(tenant_auth_bp)
flask_app.register_blueprint(staff_auth_bp)
flask_app.register_blueprint(checkin_bp)
flask_app.register_blueprint(dashboard_bp)
# Remaining blueprints registered in Phases 36:
# locations, customers, appointments, services, pos, staff,
# staff_portal, booking, waitlist, gift_cards, reviews,
# reconciliation, inventory, marketing, reports, settings
# ── Import all models for Migrate ─────────────────────────
import app.models # noqa: F401
logger.info("Tenant app created (env=%s)", flask_app.config.get("FLASK_ENV"))
return flask_app
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/appointments/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
View File
+198
View File
@@ -0,0 +1,198 @@
"""
app/tenant/auth/routes.py — Tenant portal authentication (email + password).
Routes: /login, /logout, /password-reset, /suspended, /cancelled
Roles: tenant_admin, tenant_manager
"""
import logging
import secrets
from datetime import datetime, timezone, timedelta
from flask import (
Blueprint, render_template, redirect, url_for,
flash, request, current_app, session, g,
)
from flask_login import login_user, logout_user, login_required, current_user
import bcrypt
from app.extensions import db, limiter, mail
from app.models.platform import Tenant
from app.models.salon import User
from flask_mail import Message
logger = logging.getLogger(__name__)
tenant_auth_bp = Blueprint("tenant_auth", __name__)
@tenant_auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def login():
if current_user.is_authenticated:
return redirect(url_for("dashboard.index"))
error = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
user = User.query.filter_by(email=email, is_active=True)\
.filter(User.deleted_at.is_(None)).first()
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
if user and user.is_locked():
logger.warning("Tenant login blocked — account locked: %s", email)
error = f"Account locked. Try again in {lockout_minutes} minutes."
elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()):
# Verify tenant is accessible
tenant = Tenant.query.get(user.tenant_id)
if not tenant or tenant.status == "cancelled":
error = "This account is no longer active."
else:
user.failed_login_attempts = 0
user.locked_until = None
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
login_user(user, remember=False)
session.permanent = True
logger.info(
"Tenant login success: user=%s tenant=%s",
user.id, user.tenant_id,
)
return redirect(url_for("dashboard.index"))
else:
if user:
user.failed_login_attempts += 1
if user.failed_login_attempts >= max_attempts:
user.locked_until = datetime.now(timezone.utc) + timedelta(
minutes=lockout_minutes
)
logger.warning(
"Tenant account locked after %d failures: %s",
max_attempts, email,
)
db.session.commit()
logger.warning("Tenant login failed: %s", email)
error = "Invalid email or password."
return render_template("tenant/auth/login.html", error=error)
@tenant_auth_bp.route("/logout")
@login_required
def logout():
logout_user()
session.clear()
logger.info("Tenant logout")
return redirect(url_for("tenant_auth.login"))
@tenant_auth_bp.route("/password-reset", methods=["GET", "POST"])
@limiter.limit("5 per hour")
def password_reset_request():
"""Step 1: submit email → send reset link."""
message = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
user = User.query.filter_by(email=email)\
.filter(User.deleted_at.is_(None)).first()
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
db.session.commit()
reset_url = url_for(
"tenant_auth.password_reset_confirm",
token=token,
_external=True,
)
try:
msg = Message(
subject="Reset your password",
recipients=[email],
body=f"Click the link below to reset your password (valid 1 hour):\n\n{reset_url}",
)
mail.send(msg)
logger.info("Password reset email sent to %s", email)
except Exception as exc:
logger.error("Failed to send reset email to %s: %s", email, exc)
# Always show the same message to prevent email enumeration
message = "If that email is registered, a reset link has been sent."
return render_template(
"tenant/auth/password_reset_request.html", message=message
)
@tenant_auth_bp.route("/password-reset/<token>", methods=["GET", "POST"])
def password_reset_confirm(token):
"""Step 2: confirm token → set new password."""
user = User.query.filter_by(password_reset_token=token)\
.filter(User.deleted_at.is_(None)).first()
now = datetime.now(timezone.utc)
if not user or not user.password_reset_expires_at:
flash("Invalid or expired reset link.", "danger")
return redirect(url_for("tenant_auth.login"))
expires = user.password_reset_expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if now > expires:
flash("This reset link has expired. Please request a new one.", "danger")
return redirect(url_for("tenant_auth.password_reset_request"))
error = None
if request.method == "POST":
new_password = request.form.get("password", "")
confirm = request.form.get("confirm_password", "")
if new_password != confirm:
error = "Passwords do not match."
elif len(new_password) < 10:
error = "Password must be at least 10 characters."
elif not any(c.isupper() for c in new_password):
error = "Password must contain at least one uppercase letter."
elif not any(c.islower() for c in new_password):
error = "Password must contain at least one lowercase letter."
elif not any(c.isdigit() for c in new_password):
error = "Password must contain at least one digit."
else:
hashed = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt(rounds=12))
user.password_hash = hashed.decode()
user.password_reset_token = None
user.password_reset_expires_at = None
user.failed_login_attempts = 0
user.locked_until = None
db.session.commit()
logger.info("Password reset completed for user %s", user.id)
flash("Password updated. Please log in.", "success")
return redirect(url_for("tenant_auth.login"))
return render_template(
"tenant/auth/password_reset_confirm.html", error=error, token=token
)
@tenant_auth_bp.route("/suspended")
def suspended():
return render_template("tenant/auth/suspended.html"), 403
@tenant_auth_bp.route("/cancelled")
def cancelled():
return render_template("tenant/auth/cancelled.html"), 403
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/booking/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
booking_bp = Blueprint("booking", __name__, url_prefix="")
View File
+135
View File
@@ -0,0 +1,135 @@
"""
app/tenant/checkin/routes.py — Customer self check-in kiosk.
Route: GET/POST /checkin/<tenant_slug>
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/<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)
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,
)
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/customers/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
View File
+23
View File
@@ -0,0 +1,23 @@
"""
app/tenant/dashboard/routes.py — Tenant dashboard (placeholder for Phase 3 KPIs).
"""
import logging
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
logger = logging.getLogger(__name__)
dashboard_bp = Blueprint("dashboard", __name__)
@dashboard_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template(
"tenant/dashboard/index.html",
tenant=g.tenant,
location=g.location,
)
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/gift_cards/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/inventory/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/locations/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/marketing/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/pos/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/reconciliation/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/reports/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/reviews/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/services/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
services_bp = Blueprint("services", __name__, url_prefix="/services")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/settings/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/staff/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
staff_bp = Blueprint("staff", __name__, url_prefix="/staff")
View File
+113
View File
@@ -0,0 +1,113 @@
"""
app/tenant/staff_auth/routes.py Staff portal authentication (phone + passcode).
Routes: /staff-login, /staff-logout
Brute-force: 5 failures passcode_locked_until for LOGIN_LOCKOUT_MINUTES.
"""
import logging
from datetime import datetime, timezone, timedelta
from flask import (
Blueprint, render_template, redirect, url_for,
request, current_app, session, flash,
)
from flask_login import login_user, logout_user, login_required, current_user
import bcrypt
from app.extensions import db, limiter
from app.models.salon import Staff
from app.models.platform import Tenant
logger = logging.getLogger(__name__)
staff_auth_bp = Blueprint("staff_auth", __name__)
@staff_auth_bp.route("/staff-login", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def staff_login():
if current_user.is_authenticated:
return redirect(url_for("staff_portal.index"))
error = None
if request.method == "POST":
phone = request.form.get("phone", "").strip()
passcode = request.form.get("passcode", "").strip()
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
min_len = current_app.config.get("STAFF_PASSCODE_MIN_LENGTH", 4)
max_len = current_app.config.get("STAFF_PASSCODE_MAX_LENGTH", 6)
# Basic input validation
if not phone or not passcode:
error = "Phone number and passcode are required."
elif not passcode.isdigit() or not (min_len <= len(passcode) <= max_len):
error = f"Passcode must be {min_len}{max_len} digits."
else:
# Look up staff by phone (phone is unique within tenant)
# Platform-wide: find first matching phone, then validate tenant
staff = Staff.query.filter_by(phone=phone, is_active=True)\
.filter(Staff.deleted_at.is_(None)).first()
if staff and staff.is_passcode_locked():
logger.warning(
"Staff passcode login blocked — locked: staff=%s", staff.id
)
error = f"Account locked. Try again in {lockout_minutes} minutes."
elif staff and bcrypt.checkpw(
passcode.encode(), staff.passcode_hash.encode()
):
# Verify the tenant is active
tenant = Tenant.query.get(staff.tenant_id)
if not tenant or not tenant.is_active_status():
error = "Your salon account is not active."
else:
# Success
staff.passcode_failed_attempts = 0
staff.passcode_locked_until = None
db.session.commit()
login_user(staff, remember=False)
session.permanent = True
# Set active location to staff's primary assigned location
from app.models.salon import StaffLocation
assignment = StaffLocation.query.filter_by(
staff_id=staff.id,
tenant_id=staff.tenant_id,
).first()
if assignment:
session["active_location_id"] = assignment.location_id
logger.info(
"Staff login success: staff=%s tenant=%s",
staff.id, staff.tenant_id,
)
return redirect(url_for("staff_portal.index"))
else:
if staff:
staff.passcode_failed_attempts += 1
if staff.passcode_failed_attempts >= max_attempts:
staff.passcode_locked_until = datetime.now(timezone.utc) + \
timedelta(minutes=lockout_minutes)
logger.warning(
"Staff passcode locked after %d failures: staff=%s",
max_attempts, staff.id,
)
db.session.commit()
logger.warning("Staff login failed for phone: %s", phone)
error = "Invalid phone number or passcode."
return render_template("tenant/staff_auth/login.html", error=error)
@staff_auth_bp.route("/staff-logout")
@login_required
def staff_logout():
logout_user()
session.clear()
logger.info("Staff logout")
return redirect(url_for("staff_auth.staff_login"))
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/staff_portal/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/waitlist/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")