05/06/2026 Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Shared package marker — do not instantiate apps here.
|
||||
# Use create_admin_app() or create_tenant_app() instead.
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/analytics/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
analytics_bp = Blueprint("analytics", __name__, url_prefix="/analytics")
|
||||
@@ -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")
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/billing/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
billing_bp = Blueprint("billing", __name__, url_prefix="/billing")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/plans/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
plans_bp = Blueprint("plans", __name__, url_prefix="/plans")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/tenants/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants")
|
||||
+126
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}>"
|
||||
@@ -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: 1–100 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 4–6 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: 1–5 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")
|
||||
|
||||
|
||||
|
||||
@@ -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, 2–80 chars
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{1,78}[a-z0-9]$")
|
||||
|
||||
# Strip ASCII control characters (0x00–0x1F, 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))
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Admin portal custom styles — extended in later phases */
|
||||
body { background-color: #f4f6f9; }
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Tenant portal custom styles — extended in later phases */
|
||||
body { background-color: #f8f9fa; }
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="4–6 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 %}
|
||||
@@ -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 3–6:
|
||||
# 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
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/appointments/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/booking/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
booking_bp = Blueprint("booking", __name__, url_prefix="")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/customers/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/inventory/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/locations/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/marketing/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/pos/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/reconciliation/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/reports/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/reviews/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/services/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
services_bp = Blueprint("services", __name__, url_prefix="/services")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/settings/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/staff/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
staff_bp = Blueprint("staff", __name__, url_prefix="/staff")
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/waitlist/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
|
||||
Reference in New Issue
Block a user