""" app/tenant/staff/routes.py Staff profiles, passcode management, location assignment. Phase 4 adds pay structure, schedules, and commission config. """ import logging from flask import Blueprint, render_template, redirect, url_for, flash, request, g from flask_login import login_required from app.extensions import db, bcrypt from app.models.salon import Staff, StaffLocation, Location from app.decorators import require_role, demo_readonly from app.tenant.utils import log_tenant_action, plan_limit_check from app.security import validate_passcode logger = logging.getLogger(__name__) staff_bp = Blueprint("staff", __name__, url_prefix="/staff") @staff_bp.route("/") @login_required @require_role("tenant_admin", "tenant_manager") def index(): staff_list = Staff.query.filter_by( tenant_id=g.tenant.id).filter( Staff.deleted_at.is_(None)).order_by(Staff.name).all() return render_template("tenant/staff/index.html", staff_list=staff_list) @staff_bp.route("/new", methods=["GET", "POST"]) @login_required @require_role("tenant_admin") @demo_readonly def create(): allowed, err = plan_limit_check("staff") if not allowed: flash(err, "warning") return redirect(url_for("staff.index")) locations = Location.query.filter_by( tenant_id=g.tenant.id, is_active=True).order_by(Location.name).all() if request.method == "POST": name = request.form.get("name", "").strip() phone = request.form.get("phone", "").strip() passcode = request.form.get("passcode", "").strip() min_len = 4 max_len = 6 if not name or not phone: return render_template("tenant/staff/form.html", mode="create", locations=locations, error="Name and phone are required.") if not validate_passcode(passcode, min_len, max_len): return render_template("tenant/staff/form.html", mode="create", locations=locations, error=f"Passcode must be {min_len}–{max_len} digits.") if Staff.query.filter_by(tenant_id=g.tenant.id, phone=phone).filter( Staff.deleted_at.is_(None)).first(): return render_template("tenant/staff/form.html", mode="create", locations=locations, error="A staff member with that phone number already exists.") allowed, err = plan_limit_check("staff") if not allowed: return render_template("tenant/staff/form.html", mode="create", locations=locations, error=err) member = Staff( tenant_id=g.tenant.id, name=name, phone=phone, passcode_hash=bcrypt.generate_password_hash(passcode).decode("utf-8"), staff_type=request.form.get("staff_type", "full_time"), pay_type=request.form.get("pay_type", "hourly"), is_active=True, ) db.session.add(member) db.session.flush() # Location assignments loc_ids = request.form.getlist("location_ids") for lid_str in loc_ids: try: lid = int(lid_str) loc = Location.query.filter_by( id=lid, tenant_id=g.tenant.id).first() if loc: assignment = StaffLocation( tenant_id=g.tenant.id, staff_id=member.id, location_id=lid, ) db.session.add(assignment) except ValueError: pass log_tenant_action("staff.create", "staff", member.id, {"name": name}) db.session.commit() flash(f"Staff member \'{name}\' created.", "success") return redirect(url_for("staff.index")) return render_template("tenant/staff/form.html", mode="create", locations=locations) @staff_bp.route("//edit", methods=["GET", "POST"]) @login_required @require_role("tenant_admin") @demo_readonly def edit(staff_id): member = Staff.query.filter_by( id=staff_id, tenant_id=g.tenant.id).filter( Staff.deleted_at.is_(None)).first_or_404() locations = Location.query.filter_by( tenant_id=g.tenant.id, is_active=True).order_by(Location.name).all() assigned_ids = {a.location_id for a in StaffLocation.query.filter_by( staff_id=staff_id, tenant_id=g.tenant.id).all()} if request.method == "POST": name = request.form.get("name", "").strip() phone = request.form.get("phone", "").strip() if not name or not phone: return render_template("tenant/staff/form.html", mode="edit", staff=member, locations=locations, assigned_ids=assigned_ids, error="Name and phone are required.") # Check phone uniqueness excluding self conflict = Staff.query.filter_by( tenant_id=g.tenant.id, phone=phone).filter( Staff.id != staff_id, Staff.deleted_at.is_(None)).first() if conflict: return render_template("tenant/staff/form.html", mode="edit", staff=member, locations=locations, assigned_ids=assigned_ids, error="Another staff member has that phone number.") member.name = name member.phone = phone member.staff_type = request.form.get("staff_type", member.staff_type) member.is_active = request.form.get("is_active") == "1" # Update location assignments StaffLocation.query.filter_by( staff_id=staff_id, tenant_id=g.tenant.id).delete() loc_ids = request.form.getlist("location_ids") for lid_str in loc_ids: try: lid = int(lid_str) loc = Location.query.filter_by( id=lid, tenant_id=g.tenant.id).first() if loc: db.session.add(StaffLocation( tenant_id=g.tenant.id, staff_id=staff_id, location_id=lid, )) except ValueError: pass log_tenant_action("staff.edit", "staff", member.id, {"name": name}) db.session.commit() flash(f"\'{name}\' updated.", "success") return redirect(url_for("staff.index")) return render_template("tenant/staff/form.html", mode="edit", staff=member, locations=locations, assigned_ids=assigned_ids) @staff_bp.route("//reset-passcode", methods=["GET", "POST"]) @login_required @require_role("tenant_admin", "tenant_manager") @demo_readonly def reset_passcode(staff_id): member = Staff.query.filter_by( id=staff_id, tenant_id=g.tenant.id).filter( Staff.deleted_at.is_(None)).first_or_404() if request.method == "POST": new_passcode = request.form.get("passcode", "").strip() min_len = 4 max_len = 6 if not validate_passcode(new_passcode, min_len, max_len): return render_template("tenant/staff/reset_passcode.html", staff=member, error=f"Passcode must be {min_len}–{max_len} digits.") member.passcode_hash = bcrypt.generate_password_hash(new_passcode).decode("utf-8") member.passcode_failed_attempts = 0 member.passcode_locked_until = None log_tenant_action("staff.reset_passcode", "staff", member.id, {"name": member.name}) db.session.commit() flash(f"Passcode for \'{member.name}\' reset. Show it to them once, then discard it.", "success") return redirect(url_for("staff.index")) return render_template("tenant/staff/reset_passcode.html", staff=member)