05/07 Phase 3: initial codes
This commit is contained in:
+190
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user