Files
MyPOS/app/tenant/staff/routes.py
T
2026-05-07 12:17:19 -04:00

196 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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("/<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)