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
+2
View File
@@ -0,0 +1,2 @@
# Shared package marker — do not instantiate apps here.
# Use create_admin_app() or create_tenant_app() instead.
+83
View File
@@ -0,0 +1,83 @@
"""
app/admin/__init__.py — Admin app factory (admin.mydomain.com).
"""
import logging
from datetime import timedelta
from flask import Flask
from app.extensions import db, migrate, csrf, mail, scheduler, admin_login_manager, admin_jwt, limiter
from app.security import apply_security_headers, check_admin_ip
from config import get_config
logger = logging.getLogger(__name__)
def create_admin_app(config_override=None):
flask_app = Flask(
__name__,
template_folder="../templates",
static_folder="../static",
static_url_path="/static/admin",
)
# ── Config ────────────────────────────────────────────────
cfg = config_override or get_config()
flask_app.config.from_object(cfg)
# Admin portal uses a separate secret key and session cookie name
import os
flask_app.config["SECRET_KEY"] = os.environ.get(
"ADMIN_SECRET_KEY", flask_app.config["SECRET_KEY"]
)
flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_admin_session"
flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
seconds=int(os.environ.get("SESSION_TIMEOUT_ADMIN", 3600))
)
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)
# Admin login manager
admin_login_manager.login_view = "admin_auth.login"
admin_login_manager.login_message_category = "warning"
admin_login_manager.session_protection = "strong"
admin_login_manager.init_app(flask_app)
# Admin JWT
admin_jwt.init_app(flask_app)
# ── User loader ───────────────────────────────────────────
from app.models.platform import SystemUser
@admin_login_manager.user_loader
def load_admin_user(user_id: str):
if not user_id.startswith("system:"):
return None
try:
uid = int(user_id.split(":")[1])
except (ValueError, IndexError):
return None
return SystemUser.query.get(uid)
# ── Security ──────────────────────────────────────────────
apply_security_headers(flask_app)
check_admin_ip(flask_app)
# ── Blueprints ────────────────────────────────────────────
from app.admin.auth.routes import admin_auth_bp
flask_app.register_blueprint(admin_auth_bp)
# Placeholder blueprints registered in later phases:
# system_users, tenants, plans, billing, settings_override, audit_log, analytics
# ── Import all models for Migrate ─────────────────────────
import app.models # noqa: F401
logger.info("Admin app created (env=%s)", flask_app.config.get("FLASK_ENV"))
return flask_app
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/analytics/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
analytics_bp = Blueprint("analytics", __name__, url_prefix="/analytics")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/audit_log/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
audit_log_bp = Blueprint("audit_log", __name__, url_prefix="/audit-log")
View File
+107
View File
@@ -0,0 +1,107 @@
"""
app/admin/auth/routes.py — Admin portal authentication.
Routes: /admin/login, /admin/logout
Brute-force lockout: 5 failures → locked for LOGIN_LOCKOUT_MINUTES.
"""
import logging
from datetime import datetime, timezone, timedelta
from flask import (
Blueprint, render_template, redirect, url_for,
flash, request, current_app, session,
)
from flask_login import login_user, logout_user, login_required, current_user
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import bcrypt
from app.extensions import db, limiter
from app.models.platform import SystemUser, AuditLog
logger = logging.getLogger(__name__)
admin_auth_bp = Blueprint("admin_auth", __name__, url_prefix="/admin")
@admin_auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def login():
if current_user.is_authenticated:
return redirect(url_for("admin_auth.dashboard_redirect"))
error = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
user = SystemUser.query.filter_by(email=email, is_active=True).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("Admin 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()):
# Success — reset lockout counters
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
AuditLog.log(
actor_id=user.id,
actor_type="system_user",
action="auth.login",
ip_address=request.remote_addr,
)
db.session.commit()
logger.info("Admin login success: %s", email)
next_url = request.args.get("next") or url_for("admin_auth.dashboard_redirect")
return redirect(next_url)
else:
# Failed attempt
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(
"Admin account locked after %d failures: %s",
max_attempts, email,
)
db.session.commit()
logger.warning("Admin login failed: %s", email)
error = "Invalid email or password."
return render_template("admin/auth/login.html", error=error)
@admin_auth_bp.route("/logout")
@login_required
def logout():
AuditLog.log(
actor_id=current_user.id,
actor_type="system_user",
action="auth.logout",
ip_address=request.remote_addr,
)
db.session.commit()
logout_user()
session.clear()
logger.info("Admin logout")
return redirect(url_for("admin_auth.login"))
@admin_auth_bp.route("/")
@login_required
def dashboard_redirect():
# Will route to the analytics dashboard in Phase 2
return redirect(url_for("admin_auth.login"))
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/billing/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
billing_bp = Blueprint("billing", __name__, url_prefix="/billing")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/plans/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
plans_bp = Blueprint("plans", __name__, url_prefix="/plans")
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/settings_override/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
settings_override_bp = Blueprint("settings_override", __name__, url_prefix="/settings-override")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/system_users/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
system_users_bp = Blueprint("system_users", __name__, url_prefix="/system-users")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/tenants/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants")
View File
View File
View File
+126
View File
@@ -0,0 +1,126 @@
"""
context.py — Tenant and location context resolution.
load_tenant_context() and load_location_context() are registered
as before_request hooks in the tenant app factory.
"""
import logging
from flask import g, session, redirect, url_for, request, abort
from flask_login import current_user
from app.models.platform import Tenant
from app.models.salon import Location, StaffLocation, Staff
logger = logging.getLogger(__name__)
def load_tenant_context():
"""
Resolve g.tenant from the authenticated user's session.
Called on every request in the tenant portal.
Redirects to a locked page if the tenant is suspended or cancelled.
Skips public routes (checkin, booking, staff-login, auth).
"""
# Skip context resolution for public / auth endpoints
public_blueprints = {"tenant_auth", "staff_auth", "checkin", "booking", "static"}
if request.blueprint in public_blueprints:
g.tenant = None
return
if not current_user.is_authenticated:
g.tenant = None
return
# Resolve tenant_id from the authenticated principal
if hasattr(current_user, "tenant_id"):
tenant_id = current_user.tenant_id
else:
g.tenant = None
return
tenant = Tenant.query.get(tenant_id)
if tenant is None:
logger.warning("Tenant %s not found for user %s", tenant_id, current_user)
abort(403)
g.tenant = tenant
# Enforce subscription status
if tenant.status == "suspended":
if request.endpoint != "tenant_auth.suspended":
return redirect(url_for("tenant_auth.suspended"))
if tenant.status == "cancelled":
if request.endpoint != "tenant_auth.cancelled":
return redirect(url_for("tenant_auth.cancelled"))
logger.debug("Tenant context loaded: %s", tenant.slug)
def load_location_context():
"""
Resolve g.location from session or default to the tenant's primary location.
For tenant_staff, validates that the staff member is assigned to the location.
Must be called after load_tenant_context().
"""
if not hasattr(g, "tenant") or g.tenant is None:
g.location = None
return
if not current_user.is_authenticated:
g.location = None
return
tenant_id = g.tenant.id
# Try to load from session
location_id = session.get("active_location_id")
if location_id:
location = Location.query.filter_by(
id=location_id,
tenant_id=tenant_id,
is_active=True,
).filter(Location.deleted_at.is_(None)).first()
else:
location = None
# Fall back to primary location
if location is None:
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:
# Last resort: first active location
location = Location.query.filter_by(
tenant_id=tenant_id,
is_active=True,
).filter(Location.deleted_at.is_(None)).first()
if location:
session["active_location_id"] = location.id
# For tenant_staff: enforce location assignment
if location and hasattr(current_user, "get_id"):
user_id_str = current_user.get_id()
if user_id_str and user_id_str.startswith("staff:"):
staff_id = int(user_id_str.split(":")[1])
assigned = StaffLocation.query.filter_by(
staff_id=staff_id,
location_id=location.id,
tenant_id=tenant_id,
).first()
if not assigned:
logger.warning(
"Staff %s attempted access to unassigned location %s",
staff_id, location.id,
)
abort(403)
g.location = location
logger.debug(
"Location context loaded: %s",
location.name if location else "None",
)
+126
View File
@@ -0,0 +1,126 @@
"""
decorators.py — Shared route decorators.
@require_role(*roles) — Enforce system role on a route.
@tenant_feature_required(flag) — Gate a route behind a plan feature flag.
@demo_readonly — Block write operations on the demo tenant.
"""
import logging
import functools
from flask import g, abort, jsonify, request
from flask_login import current_user
logger = logging.getLogger(__name__)
def require_role(*roles):
"""
Decorator that enforces the current user holds one of the specified roles.
Works for both system_users (superadmin) and tenant users / staff.
Usage:
@require_role("tenant_admin")
@require_role("tenant_admin", "tenant_manager")
@require_role("superadmin")
"""
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
user_role = getattr(current_user, "role", None)
if user_role not in roles:
logger.warning(
"Role check failed: user %s has role '%s', required one of %s",
getattr(current_user, "id", "?"), user_role, roles,
)
abort(403)
return fn(*args, **kwargs)
return wrapper
return decorator
def tenant_feature_required(flag: str):
"""
Decorator that gates a route behind a plan feature flag.
Checks g.tenant.plan.features_json for the given flag.
Also respects tenant_setting_overrides for force-enable/disable.
Usage:
@tenant_feature_required("marketing")
@tenant_feature_required("inventory")
"""
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
tenant = getattr(g, "tenant", None)
if tenant is None:
abort(403)
# Check for superadmin override (force-enable / force-disable)
from app.models.platform import TenantSettingOverride
override = TenantSettingOverride.query.filter_by(
tenant_id=tenant.id,
setting_key=f"feature.{flag}",
lifted_at=None,
).first()
if override is not None:
enabled = override.setting_value in ("1", "true", "True")
if not enabled:
logger.info(
"Feature '%s' force-disabled by admin override for tenant %s",
flag, tenant.slug,
)
abort(403)
# force-enabled: proceed regardless of plan
return fn(*args, **kwargs)
# Check plan feature flags
if tenant.plan is None or not tenant.plan.has_feature(flag):
logger.info(
"Feature '%s' not available on plan '%s' for tenant %s",
flag,
tenant.plan.name if tenant.plan else "unknown",
tenant.slug,
)
abort(403)
return fn(*args, **kwargs)
return wrapper
return decorator
def demo_readonly(fn):
"""
Decorator that blocks all write operations (POST, PUT, PATCH, DELETE)
when the active tenant is the demo tenant.
Returns 403 with a JSON or HTML response depending on the request type.
Usage:
@demo_readonly
def create_customer():
...
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
tenant = getattr(g, "tenant", None)
if tenant and tenant.is_demo:
logger.info(
"Demo write blocked: %s %s", request.method, request.path
)
if request.is_json or request.path.startswith("/api/"):
return jsonify(
error="Demo account is read-only. "
"Sign up for a full account to make changes."
), 403
from flask import flash, redirect, request as req
flash(
"This is a demo account. Sign up for a full account to make changes.",
"warning",
)
return redirect(req.referrer or "/")
return fn(*args, **kwargs)
return wrapper
+36
View File
@@ -0,0 +1,36 @@
"""
extensions.py — Shared Flask extension instances.
Imported by both app factories to avoid circular imports.
"""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_jwt_extended import JWTManager
from flask_wtf.csrf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_mail import Mail
from flask_apscheduler import APScheduler
from flask_bcrypt import Bcrypt
db = SQLAlchemy()
migrate = Migrate()
csrf = CSRFProtect()
mail = Mail()
scheduler = APScheduler()
bcrypt = Bcrypt()
# Two separate LoginManager instances — one per app factory.
admin_login_manager = LoginManager()
tenant_login_manager = LoginManager()
# Two separate JWTManager instances — one per app factory.
admin_jwt = JWTManager()
tenant_jwt = JWTManager()
# Rate limiter — shared, keyed by remote address.
limiter = Limiter(key_func=get_remote_address)
jwt = admin_jwt # shared alias used by both app factories
+39
View File
@@ -0,0 +1,39 @@
"""
forms.py
Shared form utilities and password strength validator.
Per-module WTForms classes live in their respective blueprint directories.
"""
import re
_PASSWORD_MIN_LENGTH = 10
_HAS_UPPERCASE = re.compile(r"[A-Z]")
_HAS_LOWERCASE = re.compile(r"[a-z]")
_HAS_DIGIT = re.compile(r"\d")
def validate_password_strength(password: str, confirm: str = None) -> str | None:
"""
Validate password strength per the security policy:
- Minimum 10 characters
- Must include at least one uppercase letter
- Must include at least one lowercase letter
- Must include at least one digit
Returns an error string, or None if the password is acceptable.
If confirm is supplied, also checks they match.
"""
if not password:
return "Password is required."
if len(password) < _PASSWORD_MIN_LENGTH:
return f"Password must be at least {_PASSWORD_MIN_LENGTH} characters."
if not _HAS_UPPERCASE.search(password):
return "Password must include at least one uppercase letter."
if not _HAS_LOWERCASE.search(password):
return "Password must include at least one lowercase letter."
if not _HAS_DIGIT.search(password):
return "Password must include at least one digit."
if confirm is not None and password != confirm:
return "Passwords do not match."
return None
+44
View File
@@ -0,0 +1,44 @@
"""
models/__init__.py
Import all models so Flask-Migrate can discover them.
JWTBlocklist lives in platform.py (platform-level table).
"""
from app.models.platform import ( # noqa: F401
SystemUser,
Plan,
Tenant,
TenantBillingHistory,
TenantSettingOverride,
AuditLog,
JWTBlocklist,
)
from app.models.salon import ( # noqa: F401
TenantSetting,
Location,
LocationSetting,
User,
Customer,
Service,
Product,
Promotion,
Staff,
StaffLocation,
StaffSchedule,
Appointment,
Transaction,
TransactionItem,
Inventory,
InventoryLog,
CommissionLog,
StaffPayPeriod,
StaffClocking,
MarketingCampaign,
GiftCard,
CheckinQueue,
Waitlist,
CheckoutReview,
DailyReconciliation,
AppointmentReminder,
)
+275
View File
@@ -0,0 +1,275 @@
"""
models/platform.py — Platform-level models (superadmin scope).
Tables: system_users, plans, tenants, tenant_billing_history,
tenant_setting_overrides, audit_log, jwt_blocklist
"""
import logging
from datetime import datetime, timezone
from app.extensions import db
logger = logging.getLogger(__name__)
class SystemUser(db.Model):
__tablename__ = "system_users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
name = db.Column(db.String(100), nullable=False)
role = db.Column(db.String(50), nullable=False, default="superadmin")
is_active = db.Column(db.Boolean, nullable=False, default=True)
failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
password_reset_token = db.Column(db.String(255), nullable=True)
password_reset_expires_at = db.Column(db.DateTime, nullable=True)
last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
# Flask-Login interface
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return f"system:{self.id}"
def is_locked(self):
if self.locked_until is None:
return False
return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc)
def record_login(self):
from datetime import datetime as _dt, timezone as _tz
self.failed_login_attempts = 0
self.locked_until = None
self.last_login_at = _dt.now(_tz.utc)
def record_failed_login(self, max_attempts: int, lockout_minutes: int):
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
if self.failed_login_attempts >= max_attempts:
self.locked_until = _dt.now(_tz.utc) + _td(minutes=lockout_minutes)
def __repr__(self):
return f"<SystemUser {self.email}>"
class Plan(db.Model):
__tablename__ = "plans"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
price_monthly = db.Column(db.Numeric(8, 2), nullable=False)
max_staff = db.Column(db.Integer, nullable=True) # None = unlimited
max_locations = db.Column(db.Integer, nullable=True) # None = unlimited
features_json = db.Column(db.JSON, nullable=False, default=dict)
is_active = db.Column(db.Boolean, nullable=False, default=True)
tenants = db.relationship("Tenant", back_populates="plan", lazy="dynamic")
def has_feature(self, flag: str) -> bool:
return bool(self.features_json.get(flag, False))
def __repr__(self):
return f"<Plan {self.name}>"
class Tenant(db.Model):
__tablename__ = "tenants"
id = db.Column(db.Integer, primary_key=True)
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
owner_email = db.Column(db.String(255), nullable=False)
plan_id = db.Column(db.Integer, db.ForeignKey("plans.id"), nullable=False)
status = db.Column(db.String(20), nullable=False, default="trial")
# status: 'active' | 'trial' | 'suspended' | 'cancelled'
trial_ends_at = db.Column(db.DateTime, nullable=True)
subscription_expires_at = db.Column(db.DateTime, nullable=True)
is_demo = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
plan = db.relationship("Plan", back_populates="tenants")
billing_history = db.relationship(
"TenantBillingHistory", back_populates="tenant", lazy="dynamic"
)
setting_overrides = db.relationship(
"TenantSettingOverride", back_populates="tenant", lazy="dynamic"
)
def is_suspended(self):
return self.status in ("suspended", "cancelled")
def is_active_status(self):
return self.status in ("active", "trial")
def get_setting(self, key: str, default=None):
"""
Resolve a tenant setting, honouring superadmin overrides.
Override takes precedence if lifted_at is NULL.
Falls back to tenant_settings, then the supplied default.
"""
override = (
TenantSettingOverride.query
.filter_by(tenant_id=self.id, setting_key=key)
.filter(TenantSettingOverride.lifted_at.is_(None))
.first()
)
if override:
return override.setting_value
from app.models.salon import TenantSetting
setting = TenantSetting.query.filter_by(
tenant_id=self.id, setting_key=key
).first()
return setting.setting_value if setting else default
def has_feature(self, flag: str) -> bool:
"""Check plan feature flag, allowing active overrides to force-enable/disable."""
override = (
TenantSettingOverride.query
.filter_by(tenant_id=self.id, setting_key=f"feature_{flag}")
.filter(TenantSettingOverride.lifted_at.is_(None))
.first()
)
if override:
return override.setting_value.lower() in ("true", "1", "yes")
return self.plan.has_feature(flag) if self.plan else False
def __repr__(self):
return f"<Tenant {self.slug}>"
class TenantBillingHistory(db.Model):
__tablename__ = "tenant_billing_history"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
amount = db.Column(db.Numeric(10, 2), nullable=False)
description = db.Column(db.String(255), nullable=False)
paid_at = db.Column(db.DateTime, nullable=True)
invoice_ref = db.Column(db.String(100), nullable=True)
recorded_by = db.Column(db.Integer, db.ForeignKey("system_users.id"),
nullable=True)
tenant = db.relationship("Tenant", back_populates="billing_history")
recorder = db.relationship("SystemUser")
def __repr__(self):
return f"<BillingHistory tenant={self.tenant_id} amount={self.amount}>"
class TenantSettingOverride(db.Model):
__tablename__ = "tenant_setting_overrides"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
setting_key = db.Column(db.String(100), nullable=False)
setting_value = db.Column(db.Text, nullable=True)
overridden_by = db.Column(db.Integer, db.ForeignKey("system_users.id"),
nullable=False)
overridden_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
lifted_at = db.Column(db.DateTime, nullable=True)
note = db.Column(db.Text, nullable=True)
tenant = db.relationship("Tenant", back_populates="setting_overrides")
admin = db.relationship("SystemUser")
@property
def is_active(self):
return self.lifted_at is None
def __repr__(self):
return f"<SettingOverride tenant={self.tenant_id} key={self.setting_key}>"
class AuditLog(db.Model):
"""
Immutable, append-only audit log.
Never issue UPDATE or DELETE against this table from application code.
Retention: records older than 365 days purged monthly via APScheduler.
"""
__tablename__ = "audit_log"
id = db.Column(db.Integer, primary_key=True)
actor_id = db.Column(db.Integer, nullable=False)
actor_type = db.Column(db.String(50), nullable=False)
# actor_type: 'system_user' | 'tenant_user'
action = db.Column(db.String(100), nullable=False)
target_type = db.Column(db.String(100), nullable=True)
target_id = db.Column(db.Integer, nullable=True)
before_json = db.Column(db.JSON, nullable=True)
after_json = db.Column(db.JSON, nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc),
index=True)
@classmethod
def log(cls, actor_id, actor_type, action,
target_type=None, target_id=None,
before=None, after=None, ip_address=None):
"""
Helper to append an audit entry and flush to DB.
Usage:
AuditLog.log(
actor_id=current_user.id,
actor_type='system_user',
action='tenant.suspend',
target_type='tenant',
target_id=tenant.id,
before={'status': 'active'},
after={'status': 'suspended'},
ip_address=request.remote_addr,
)
"""
entry = cls(
actor_id=actor_id,
actor_type=actor_type,
action=action,
target_type=target_type,
target_id=target_id,
before_json=before,
after_json=after,
ip_address=ip_address,
)
db.session.add(entry)
logger.info(
"AUDIT | actor=%s(%s) action=%s target=%s/%s",
actor_type, actor_id, action, target_type, target_id,
)
return entry
def __repr__(self):
return f"<AuditLog {self.action} by {self.actor_type}:{self.actor_id}>"
class JWTBlocklist(db.Model):
"""
Stores revoked JWT refresh token JTIs.
Checked on every token refresh request.
"""
__tablename__ = "jwt_blocklist"
id = db.Column(db.Integer, primary_key=True)
jti = db.Column(db.String(36), nullable=False, unique=True, index=True)
token_type = db.Column(db.String(20), nullable=False, default="refresh")
revoked_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f"<JWTBlocklist jti={self.jti}>"
+744
View File
@@ -0,0 +1,744 @@
"""
models/salon.py — Tenant-level models (all carry tenant_id).
Soft delete convention: all tenant models include deleted_at.
Queries always filter WHERE deleted_at IS NULL.
"""
import logging
from datetime import datetime, timezone
from app.extensions import db
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────
# User & Location
# ─────────────────────────────────────────────────────────────
class User(db.Model):
"""Tenant admin and manager accounts (email + password login)."""
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
email = db.Column(db.String(255), nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(30), nullable=False)
# role: 'tenant_admin' | 'tenant_manager'
is_active = db.Column(db.Boolean, nullable=False, default=True)
failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
password_reset_token = db.Column(db.String(255), nullable=True)
password_reset_expires_at = db.Column(db.DateTime, nullable=True)
last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
)
# Flask-Login interface
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return f"user:{self.id}"
def is_locked(self):
if self.locked_until is None:
return False
return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc)
def record_login(self):
self.failed_login_attempts = 0
self.locked_until = None
self.last_login_at = datetime.now(timezone.utc)
def record_failed_login(self, max_attempts: int, lockout_minutes: int):
from datetime import timedelta
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
if self.failed_login_attempts >= max_attempts:
self.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
def __repr__(self):
return f"<User {self.email} tenant={self.tenant_id}>"
class TenantSetting(db.Model):
__tablename__ = "tenant_settings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
setting_key = db.Column(db.String(100), nullable=False)
setting_value = db.Column(db.Text, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "setting_key",
name="uq_tenant_settings_key"),
)
def __repr__(self):
return f"<TenantSetting {self.setting_key}={self.setting_value}>"
class Location(db.Model):
__tablename__ = "locations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
address = db.Column(db.String(255), nullable=True)
phone = db.Column(db.String(30), nullable=True)
email = db.Column(db.String(255), nullable=True)
timezone = db.Column(db.String(60), nullable=False, default="America/New_York")
is_active = db.Column(db.Boolean, nullable=False, default=True)
is_primary = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
settings = db.relationship("LocationSetting", back_populates="location",
lazy="dynamic")
def __repr__(self):
return f"<Location {self.name} tenant={self.tenant_id}>"
class LocationSetting(db.Model):
__tablename__ = "location_settings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
setting_key = db.Column(db.String(100), nullable=False)
setting_value = db.Column(db.Text, nullable=True)
location = db.relationship("Location", back_populates="settings")
__table_args__ = (
db.UniqueConstraint("location_id", "setting_key",
name="uq_location_settings_key"),
)
# ─────────────────────────────────────────────────────────────
# Customers
# ─────────────────────────────────────────────────────────────
class Customer(db.Model):
__tablename__ = "customers"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
phone = db.Column(db.String(30), nullable=True)
email = db.Column(db.String(255), nullable=True)
date_of_birth = db.Column(db.Date, nullable=True)
preferred_staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=True)
notes = db.Column(db.Text, nullable=True)
loyalty_points = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True)
no_show_count = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
def soft_delete(self):
self.deleted_at = datetime.now(timezone.utc)
def __repr__(self):
return f"<Customer {self.name} tenant={self.tenant_id}>"
# ─────────────────────────────────────────────────────────────
# Services, Products & Promotions
# ─────────────────────────────────────────────────────────────
class Service(db.Model):
__tablename__ = "services"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
category = db.Column(db.String(100), nullable=True)
duration_min = db.Column(db.Integer, nullable=False, default=30)
price = db.Column(db.Numeric(8, 2), nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
deleted_at = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f"<Service {self.name}>"
class Product(db.Model):
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
sku = db.Column(db.String(100), nullable=True)
category = db.Column(db.String(100), nullable=True)
sale_price = db.Column(db.Numeric(8, 2), nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
deleted_at = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f"<Product {self.name}>"
class Promotion(db.Model):
"""
applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all'
target_ids_json: list of IDs when applies_to is 'service' or 'product'; else null.
discount_percent: 1100 integer.
Active window: starts_at <= NOW() <= ends_at AND is_active = true.
"""
__tablename__ = "promotions"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
discount_percent = db.Column(db.Integer, nullable=False)
applies_to = db.Column(db.String(30), nullable=False)
target_ids_json = db.Column(db.JSON, nullable=True)
starts_at = db.Column(db.DateTime, nullable=False)
ends_at = db.Column(db.DateTime, nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f"<Promotion {self.name} {self.discount_percent}%>"
# ─────────────────────────────────────────────────────────────
# Staff
# ─────────────────────────────────────────────────────────────
class Staff(db.Model):
"""
staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist'
pay_type: 'hourly' | 'salary' | 'guarantee'
pay_period: 'weekly' | 'biweekly' | 'monthly'
passcode_hash: bcrypt-hashed 46 digit PIN; set by tenant_admin at creation.
phone: unique within tenant; used for staff-login.
"""
__tablename__ = "staff"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
name = db.Column(db.String(150), nullable=False)
phone = db.Column(db.String(30), nullable=False)
passcode_hash = db.Column(db.String(255), nullable=False)
staff_type = db.Column(db.String(30), nullable=False)
pay_type = db.Column(db.String(20), nullable=False, default="hourly")
hourly_rate = db.Column(db.Numeric(8, 2), nullable=True)
salary_amount = db.Column(db.Numeric(10, 2), nullable=True)
guarantee_amount = db.Column(db.Numeric(10, 2), nullable=True)
pay_period = db.Column(db.String(20), nullable=False, default="biweekly")
commission_rate = db.Column(db.Numeric(5, 2), nullable=True)
commission_enabled = db.Column(db.Boolean, nullable=False, default=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
passcode_failed_attempts = db.Column(db.Integer, nullable=False, default=0)
passcode_locked_until = db.Column(db.DateTime, nullable=True)
deleted_at = db.Column(db.DateTime, nullable=True)
locations = db.relationship("StaffLocation", back_populates="staff",
lazy="dynamic")
schedules = db.relationship("StaffSchedule", back_populates="staff",
lazy="dynamic")
__table_args__ = (
db.UniqueConstraint("tenant_id", "phone", name="uq_staff_tenant_phone"),
)
# Flask-Login interface (for staff portal session)
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return f"staff:{self.id}"
def is_passcode_locked(self):
if self.passcode_locked_until is None:
return False
return datetime.now(timezone.utc) < self.passcode_locked_until.replace(
tzinfo=timezone.utc
)
def record_passcode_success(self):
self.passcode_failed_attempts = 0
self.passcode_locked_until = None
def record_passcode_failure(self, max_attempts: int, lockout_minutes: int):
from datetime import timedelta
self.passcode_failed_attempts = (self.passcode_failed_attempts or 0) + 1
if self.passcode_failed_attempts >= max_attempts:
self.passcode_locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
def soft_delete(self):
self.deleted_at = datetime.now(timezone.utc)
def is_assigned_to_location(self, location_id: int) -> bool:
return any(loc.id == location_id for loc in self.locations)
def __repr__(self):
return f"<Staff {self.name} tenant={self.tenant_id}>"
class StaffLocation(db.Model):
__tablename__ = "staff_locations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff = db.relationship("Staff", back_populates="locations")
location = db.relationship("Location")
__table_args__ = (
db.UniqueConstraint("staff_id", "location_id",
name="uq_staff_location"),
)
class StaffSchedule(db.Model):
__tablename__ = "staff_schedules"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
day_of_week = db.Column(db.Integer, nullable=False) # 0=Mon … 6=Sun
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
staff = db.relationship("Staff", back_populates="schedules")
# ─────────────────────────────────────────────────────────────
# Appointments
# ─────────────────────────────────────────────────────────────
class Appointment(db.Model):
"""
status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show'
rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk' | null
"""
__tablename__ = "appointments"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=True)
is_walk_in = db.Column(db.Boolean, nullable=False, default=False)
status = db.Column(db.String(20), nullable=False, default="pending")
notes = db.Column(db.Text, nullable=True)
cancellation_reason = db.Column(db.Text, nullable=True)
cancelled_at = db.Column(db.DateTime, nullable=True)
rebook_source = db.Column(db.String(20), nullable=True)
rebooked_from_transaction_id = db.Column(
db.Integer, db.ForeignKey("transactions.id"), nullable=True
)
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
customer = db.relationship("Customer")
staff = db.relationship("Staff")
service = db.relationship("Service")
def __repr__(self):
return f"<Appointment {self.id} status={self.status}>"
# ─────────────────────────────────────────────────────────────
# Transactions & POS
# ─────────────────────────────────────────────────────────────
class Transaction(db.Model):
"""
payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other'
"""
__tablename__ = "transactions"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
nullable=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
subtotal = db.Column(db.Numeric(10, 2), nullable=False)
discount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
tip_amount = db.Column(db.Numeric(8, 2), nullable=False, default=0)
gift_card_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total = db.Column(db.Numeric(10, 2), nullable=False)
payment_method = db.Column(db.String(20), nullable=False)
payment_reference = db.Column(db.String(255), nullable=True)
gift_card_id = db.Column(db.Integer, db.ForeignKey("gift_cards.id"),
nullable=True)
review_request_sent_at = db.Column(db.DateTime, nullable=True)
voided_at = db.Column(db.DateTime, nullable=True)
voided_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
void_reason = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
items = db.relationship("TransactionItem", back_populates="transaction",
lazy="dynamic")
def __repr__(self):
return f"<Transaction {self.id} total={self.total}>"
class TransactionItem(db.Model):
__tablename__ = "transaction_items"
id = db.Column(db.Integer, primary_key=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False, index=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=True)
qty = db.Column(db.Integer, nullable=False, default=1)
unit_price = db.Column(db.Numeric(8, 2), nullable=False)
# unit_price: final price after promotion
original_price = db.Column(db.Numeric(8, 2), nullable=False)
# original_price: price before promotion
discount_percent = db.Column(db.Numeric(5, 2), nullable=False, default=0)
promotion_id = db.Column(db.Integer, db.ForeignKey("promotions.id"),
nullable=True)
transaction = db.relationship("Transaction", back_populates="items")
def __repr__(self):
return f"<TransactionItem tx={self.transaction_id}>"
# ─────────────────────────────────────────────────────────────
# Inventory
# ─────────────────────────────────────────────────────────────
class Inventory(db.Model):
__tablename__ = "inventory"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
sku = db.Column(db.String(100), nullable=True)
category = db.Column(db.String(100), nullable=True)
qty_on_hand = db.Column(db.Integer, nullable=False, default=0)
reorder_level = db.Column(db.Integer, nullable=False, default=5)
cost_price = db.Column(db.Numeric(8, 2), nullable=True)
sale_price = db.Column(db.Numeric(8, 2), nullable=True)
def __repr__(self):
return f"<Inventory {self.name} qty={self.qty_on_hand}>"
class InventoryLog(db.Model):
__tablename__ = "inventory_log"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
inventory_id = db.Column(db.Integer, db.ForeignKey("inventory.id"),
nullable=False)
delta = db.Column(db.Integer, nullable=False)
reason = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
# ─────────────────────────────────────────────────────────────
# Commission & Pay
# ─────────────────────────────────────────────────────────────
class CommissionLog(db.Model):
__tablename__ = "commission_log"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False)
amount = db.Column(db.Numeric(10, 2), nullable=False)
period = db.Column(db.String(20), nullable=True)
class StaffPayPeriod(db.Model):
"""
status: 'draft' | 'approved' | 'paid'
pay_type: mirrors staff.pay_type at the time of the period.
guarantee_topup: max(0, guarantee_amount - commission_amount) for 'guarantee' pay_type.
"""
__tablename__ = "staff_pay_periods"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
period_start = db.Column(db.Date, nullable=False)
period_end = db.Column(db.Date, nullable=False)
pay_type = db.Column(db.String(20), nullable=False)
base_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
commission_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
guarantee_topup = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
status = db.Column(db.String(20), nullable=False, default="draft")
notes = db.Column(db.Text, nullable=True)
class StaffClocking(db.Model):
"""One row per shift. clocked_out_at NULL = currently clocked in."""
__tablename__ = "staff_clockings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
clocked_in_at = db.Column(db.DateTime, nullable=False)
clocked_out_at = db.Column(db.DateTime, nullable=True)
total_minutes = db.Column(db.Integer, nullable=True)
notes = db.Column(db.Text, nullable=True)
# ─────────────────────────────────────────────────────────────
# Marketing
# ─────────────────────────────────────────────────────────────
class MarketingCampaign(db.Model):
"""
channel: 'email' (SMS deferred)
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled'
"""
__tablename__ = "marketing_campaigns"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
channel = db.Column(db.String(20), nullable=False, default="email")
status = db.Column(db.String(20), nullable=False, default="draft")
audience_filter_json = db.Column(db.JSON, nullable=True)
subject = db.Column(db.String(255), nullable=True)
message_body = db.Column(db.Text, nullable=True)
scheduled_at = db.Column(db.DateTime, nullable=True)
sent_at = db.Column(db.DateTime, nullable=True)
sent_count = db.Column(db.Integer, nullable=False, default=0)
open_count = db.Column(db.Integer, nullable=False, default=0)
# ─────────────────────────────────────────────────────────────
# Gift Cards
# ─────────────────────────────────────────────────────────────
class GiftCard(db.Model):
__tablename__ = "gift_cards"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
code = db.Column(db.String(50), nullable=False)
original_value = db.Column(db.Numeric(10, 2), nullable=False)
remaining_balance = db.Column(db.Numeric(10, 2), nullable=False)
issued_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
issued_to_customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
expires_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint("tenant_id", "code", name="uq_gift_card_tenant_code"),
)
# ─────────────────────────────────────────────────────────────
# Check-in Kiosk & Waitlist
# ─────────────────────────────────────────────────────────────
class CheckinQueue(db.Model):
"""
status: 'waiting' | 'acknowledged' | 'seated' | 'expired'
Surfaced to receptionist dashboard via 5-second polling.
"""
__tablename__ = "checkin_queue"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
customer_name = db.Column(db.String(150), nullable=False)
customer_phone = db.Column(db.String(30), nullable=False)
service_requested = db.Column(db.String(150), nullable=True)
checked_in_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
status = db.Column(db.String(20), nullable=False, default="waiting")
acknowledged_by = db.Column(db.Integer, db.ForeignKey("users.id"),
nullable=True)
acknowledged_at = db.Column(db.DateTime, nullable=True)
class Waitlist(db.Model):
"""status: 'waiting' | 'notified' | 'booked' | 'expired'"""
__tablename__ = "waitlist"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_name = db.Column(db.String(150), nullable=False)
customer_phone = db.Column(db.String(30), nullable=True)
customer_email = db.Column(db.String(255), nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
requested_date = db.Column(db.Date, nullable=True)
status = db.Column(db.String(20), nullable=False, default="waiting")
notified_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
# ─────────────────────────────────────────────────────────────
# Reviews & Reconciliation
# ─────────────────────────────────────────────────────────────
class CheckoutReview(db.Model):
"""
rating: 15 integer.
is_public_suggested: True if rating >= 4 (platform links shown).
"""
__tablename__ = "checkout_reviews"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
rating = db.Column(db.Integer, nullable=False)
comment = db.Column(db.Text, nullable=True)
is_public_suggested = db.Column(db.Boolean, nullable=False, default=False)
google_clicked = db.Column(db.Boolean, nullable=False, default=False)
facebook_clicked = db.Column(db.Boolean, nullable=False, default=False)
yelp_clicked = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
class DailyReconciliation(db.Model):
"""variance = actual_cash_counted - expected_cash_in_drawer (negative = shortage)"""
__tablename__ = "daily_reconciliations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
date = db.Column(db.Date, nullable=False)
total_cash = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_app_payments = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_tips = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_gift_card_redemptions = db.Column(db.Numeric(10, 2), nullable=False,
default=0)
expected_cash_in_drawer = db.Column(db.Numeric(10, 2), nullable=False, default=0)
actual_cash_counted = db.Column(db.Numeric(10, 2), nullable=True)
variance = db.Column(db.Numeric(10, 2), nullable=True)
closed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
closed_at = db.Column(db.DateTime, nullable=True)
notes = db.Column(db.Text, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "location_id", "date",
name="uq_reconciliation_location_date"),
)
class AppointmentReminder(db.Model):
"""
reminder_type: '24h' | '2h'
channel: 'email' (SMS deferred)
status: 'pending' | 'sent' | 'failed' | 'cancelled'
"""
__tablename__ = "appointment_reminders"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
nullable=False, index=True)
reminder_type = db.Column(db.String(10), nullable=False)
scheduled_for = db.Column(db.DateTime, nullable=False)
sent_at = db.Column(db.DateTime, nullable=True)
channel = db.Column(db.String(20), nullable=False, default="email")
status = db.Column(db.String(20), nullable=False, default="pending")
+99
View File
@@ -0,0 +1,99 @@
"""
security.py — Security middleware and input sanitisation helpers.
- apply_security_headers(app) — Attach security headers to every response.
- check_admin_ip(app) — Enforce ADMIN_IP_ALLOWLIST at the Flask layer.
- sanitise_string(value) — Strip control characters from user input.
- validate_slug(slug) — Validate tenant slug format.
"""
import re
import logging
import ipaddress
from flask import request, abort, current_app
logger = logging.getLogger(__name__)
# Regex: slugs must be lowercase alphanumeric + hyphens, 280 chars
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{1,78}[a-z0-9]$")
# Strip ASCII control characters (0x000x1F, 0x7F) and Unicode C0/C1 blocks
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f\x80-\x9f]")
def apply_security_headers(app):
"""
Register an after_request hook that attaches security headers to every
response. Nginx adds HSTS and the more restrictive CSP for production;
this layer provides defence-in-depth and covers the development server.
"""
@app.after_request
def _add_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Basic CSP — tightened further in Nginx for production
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none';"
)
return response
def check_admin_ip(app):
"""
Register a before_request hook on the admin app that validates the
client IP against ADMIN_IP_ALLOWLIST. Provides a Flask-layer double-check
behind Nginx's allow/deny directives.
"""
@app.before_request
def _check_ip():
allowlist_raw = app.config.get("ADMIN_IP_ALLOWLIST", "")
if not allowlist_raw.strip():
return # No allowlist configured — skip check (dev mode)
networks = []
for cidr in allowlist_raw.split(","):
cidr = cidr.strip()
if cidr:
try:
networks.append(ipaddress.ip_network(cidr, strict=False))
except ValueError:
logger.error("Invalid CIDR in ADMIN_IP_ALLOWLIST: %s", cidr)
if not networks:
return
client_ip_str = request.headers.get("X-Real-IP") or request.remote_addr
try:
client_ip = ipaddress.ip_address(client_ip_str)
except ValueError:
logger.warning("Unparseable client IP: %s", client_ip_str)
abort(403)
return
if not any(client_ip in net for net in networks):
logger.warning(
"Admin IP blocked: %s not in allowlist", client_ip_str
)
abort(403)
def sanitise_string(value: str, max_length: int = None) -> str:
"""
Strip control characters from a user-supplied string.
Optionally truncate to max_length.
"""
if not isinstance(value, str):
return value
cleaned = _CONTROL_CHAR_RE.sub("", value).strip()
if max_length:
cleaned = cleaned[:max_length]
return cleaned
def validate_slug(slug: str) -> bool:
"""Return True if the slug matches the allowed pattern."""
return bool(_SLUG_RE.match(slug))
+2
View File
@@ -0,0 +1,2 @@
/* Admin portal custom styles — extended in later phases */
body { background-color: #f4f6f9; }
+2
View File
@@ -0,0 +1,2 @@
/* Tenant portal custom styles — extended in later phases */
body { background-color: #f8f9fa; }
+28
View File
@@ -0,0 +1,28 @@
{% extends "admin/base.html" %}
{% block title %}Admin Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">Admin Portal</h4>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('admin_auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-dark w-100">Sign In</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin Portal{% endblock %} — Nails Salon POS</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='admin/css/main.css') }}">
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-dark bg-dark">
<div class="container-fluid">
<span class="navbar-brand">Salon POS Admin</span>
<div class="d-flex align-items-center gap-3">
<span class="text-light small">{{ current_user.name }}</span>
<a href="{{ url_for('admin_auth.logout') }}" class="btn btn-outline-light btn-sm">Logout</a>
</div>
</div>
</nav>
{% endif %}
<main class="container py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{% extends "tenant/base.html" %}
{% block title %}Account Cancelled{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-5 text-center">
<h3 class="text-danger">Account Cancelled</h3>
<p class="text-muted">This account has been cancelled. Please contact support if you believe this is an error.</p>
</div>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "tenant/base.html" %}
{% block title %}Sign In{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">Salon Login</h4>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('tenant_auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
<hr>
<div class="text-center small">
<a href="{{ url_for('tenant_auth.password_reset_request') }}">Forgot password?</a>
&nbsp;|&nbsp;
<a href="{{ url_for('staff_auth.staff_login') }}">Staff login</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "tenant/base.html" %}
{% block title %}Set New Password{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h5 class="card-title mb-3">Set New Password</h5>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" name="password" class="form-control" required minlength="10">
<div class="form-text">Min 10 chars, must include uppercase, lowercase, and a digit.</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">Update Password</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "tenant/base.html" %}
{% block title %}Reset Password{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h5 class="card-title mb-3">Reset Password</h5>
{% if message %}
<div class="alert alert-info">{{ message }}</div>
{% else %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Email address</label>
<input type="email" name="email" class="form-control" required autofocus>
</div>
<button type="submit" class="btn btn-primary w-100">Send Reset Link</button>
</form>
{% endif %}
<div class="text-center mt-3 small">
<a href="{{ url_for('tenant_auth.login') }}">Back to login</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "tenant/base.html" %}
{% block title %}Account Suspended{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-5 text-center">
<h3 class="text-warning">Account Suspended</h3>
<p class="text-muted">Your salon account has been suspended. Please contact support to resolve your billing.</p>
<a href="{{ url_for('tenant_auth.login') }}" class="btn btn-outline-secondary mt-2">Back to Login</a>
</div>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Salon Portal{% endblock %} — Nails Salon POS</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='tenant/css/main.css') }}">
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-light bg-white border-bottom shadow-sm">
<div class="container-fluid">
<span class="navbar-brand fw-bold">
{% if g.tenant %}{{ g.tenant.name }}{% else %}Salon POS{% endif %}
</span>
{% if g.location %}
<span class="badge bg-secondary">{{ g.location.name }}</span>
{% endif %}
<div class="d-flex align-items-center gap-3">
<a href="{{ url_for('tenant_auth.logout') }}" class="btn btn-outline-secondary btn-sm">Logout</a>
</div>
</div>
</nav>
{% endif %}
<main class="container-fluid py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome to {{ tenant.name }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style>
body { background: #f8f9fa; }
.kiosk-card { max-width: 540px; margin: 60px auto; }
.kiosk-title { font-size: 2rem; font-weight: 700; }
</style>
</head>
<body>
<div class="kiosk-card">
{% if confirmed %}
<div class="card shadow text-center p-5" id="confirmation">
<div class="display-1 mb-3"></div>
<h2 class="kiosk-title">You're checked in!</h2>
<p class="text-muted mt-2">A staff member will be right with you.</p>
<p class="text-muted small" id="reset-countdown">Resetting in <span id="countdown">10</span>s…</p>
</div>
<script>
let n = 10;
const el = document.getElementById("countdown");
const timer = setInterval(() => {
n--;
el.textContent = n;
if (n <= 0) { clearInterval(timer); window.location.reload(); }
}, 1000);
</script>
{% else %}
<div class="card shadow p-4">
<h2 class="kiosk-title text-center mb-4">Welcome to {{ tenant.name }}</h2>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="mb-3">
<label class="form-label fs-5">Your Name</label>
<input type="text" name="customer_name" class="form-control form-control-lg"
placeholder="First and last name" required autofocus>
</div>
<div class="mb-3">
<label class="form-label fs-5">Phone Number</label>
<input type="tel" name="customer_phone" class="form-control form-control-lg"
placeholder="e.g. 5551234567" inputmode="numeric" required>
</div>
{% if services %}
<div class="mb-3">
<label class="form-label fs-5">Service (optional)</label>
<select name="service_requested" class="form-select form-select-lg">
<option value="">— Select a service —</option>
{% for svc in services %}
<option value="{{ svc.name }}">{{ svc.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<button type="submit" class="btn btn-primary btn-lg w-100 mt-2">Check In</button>
</form>
</div>
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{% extends "tenant/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h4 class="mb-4">Dashboard
{% if location %}<small class="text-muted fs-6">— {{ location.name }}</small>{% endif %}
</h4>
<div class="alert alert-info">
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts).
</div>
{% endblock %}
@@ -0,0 +1,33 @@
{% extends "tenant/base.html" %}
{% block title %}Staff Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">Staff Login</h4>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('staff_auth.staff_login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Phone Number</label>
<input type="tel" name="phone" class="form-control form-control-lg"
placeholder="e.g. 5551234567" required autofocus inputmode="numeric">
</div>
<div class="mb-3">
<label class="form-label">Passcode</label>
<input type="password" name="passcode" class="form-control form-control-lg"
placeholder="46 digit PIN" maxlength="6" inputmode="numeric" required>
</div>
<button type="submit" class="btn btn-success w-100 btn-lg">Clock In</button>
</form>
<div class="text-center mt-3 small">
<a href="{{ url_for('tenant_auth.login') }}">Manager / Owner login</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+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")