195 lines
8.0 KiB
Python
195 lines
8.0 KiB
Python
"""
|
||
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, assigned_ids=set(), 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, assigned_ids=set(),
|
||
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, assigned_ids=set(),
|
||
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, assigned_ids=set(), 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, assigned_ids=set())
|
||
|
||
|
||
@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) |