05/07 Phase 3: initial codes

This commit is contained in:
2026-05-07 12:17:19 -04:00
parent 6e5518c103
commit ce462c57b2
107 changed files with 6365 additions and 208 deletions
+190 -2
View File
@@ -1,7 +1,195 @@
"""
app/tenant/staff/routes.py
Phase 3+ implementation.
Staff profiles, passcode management, location assignment.
Phase 4 adds pay structure, schedules, and commission config.
"""
from flask import Blueprint
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("/<int:staff_id>/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("/<int:staff_id>/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)