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
+28 -8
View File
@@ -92,16 +92,36 @@ def create_tenant_app(config_override=None):
from app.tenant.staff_auth.routes import staff_auth_bp
from app.tenant.checkin.routes import checkin_bp
from app.tenant.dashboard.routes import dashboard_bp
from app.tenant.locations.routes import locations_bp
from app.tenant.customers.routes import customers_bp
from app.tenant.services.routes import services_bp
from app.tenant.appointments.routes import appointments_bp
from app.tenant.pos.routes import pos_bp
from app.tenant.gift_cards.routes import gift_cards_bp
from app.tenant.staff.routes import staff_bp
from app.tenant.staff_portal.routes import staff_portal_bp
from app.tenant.booking.routes import booking_bp
from app.tenant.waitlist.routes import waitlist_bp
from app.tenant.reviews.routes import reviews_bp
from app.tenant.reconciliation.routes import reconciliation_bp
from app.tenant.settings.routes import settings_bp
flask_app.register_blueprint(tenant_auth_bp)
flask_app.register_blueprint(staff_auth_bp)
flask_app.register_blueprint(checkin_bp)
flask_app.register_blueprint(dashboard_bp)
for bp in [
tenant_auth_bp, staff_auth_bp, checkin_bp, dashboard_bp,
locations_bp, customers_bp, services_bp, appointments_bp,
pos_bp, gift_cards_bp, staff_bp, staff_portal_bp,
booking_bp, waitlist_bp, reviews_bp, reconciliation_bp,
settings_bp,
]:
flask_app.register_blueprint(bp)
# Remaining blueprints registered in Phases 36:
# locations, customers, appointments, services, pos, staff,
# staff_portal, booking, waitlist, gift_cards, reviews,
# reconciliation, inventory, marketing, reports, settings
# Phase 4+ stubs (inventory, marketing, reports)
from app.tenant.inventory.routes import inventory_bp
from app.tenant.marketing.routes import marketing_bp
from app.tenant.reports.routes import reports_bp
flask_app.register_blueprint(inventory_bp)
flask_app.register_blueprint(marketing_bp)
flask_app.register_blueprint(reports_bp)
# ── Import all models for Migrate ─────────────────────────
import app.models # noqa: F401
+222 -2
View File
@@ -1,7 +1,227 @@
"""
app/tenant/appointments/routes.py
Phase 3+ implementation.
Appointment management: calendar view, create, edit, status workflow,
cancellation reason, no-show capture.
"""
from flask import Blueprint
import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, redirect, url_for, flash, request, g, jsonify
from flask_login import login_required
from app.extensions import db
from app.models.salon import Appointment, Customer, Staff, Service, Location
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
VALID_STATUSES = {"pending", "confirmed", "in_progress", "completed", "cancelled", "no_show"}
@appointments_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
# Default to today
date_str = request.args.get("date", date.today().isoformat())
try:
view_date = date.fromisoformat(date_str)
except ValueError:
view_date = date.today()
day_start = datetime.combine(view_date, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
appts = Appointment.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
).order_by(Appointment.start_time).all()
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
return render_template("tenant/appointments/index.html",
appointments=appts, view_date=view_date,
staff_list=staff_list, services=services)
@appointments_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create():
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
if request.method == "POST":
start_raw = request.form.get("start_time", "")
if not start_raw:
return render_template("tenant/appointments/form.html",
mode="create", staff_list=staff_list,
services=services, customers=customers,
error="Start time is required.")
try:
start_time = datetime.fromisoformat(start_raw)
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
return render_template("tenant/appointments/form.html",
mode="create", staff_list=staff_list,
services=services, customers=customers,
error="Invalid date/time format.")
service_id = request.form.get("service_id", type=int)
svc = Service.query.get(service_id) if service_id else None
duration = svc.duration_min if svc else 30
end_time = start_time + timedelta(minutes=duration)
customer_id = request.form.get("customer_id", type=int) or None
staff_id = request.form.get("staff_id", type=int) or None
is_walk_in = request.form.get("is_walk_in") == "1"
appt = Appointment(
tenant_id=g.tenant.id,
location_id=g.location.id,
customer_id=customer_id,
staff_id=staff_id,
service_id=service_id,
start_time=start_time,
end_time=end_time,
is_walk_in=is_walk_in,
status="confirmed" if not is_walk_in else "in_progress",
notes=request.form.get("notes", "").strip() or None,
rebook_source="manual",
created_by=_user_db_id(),
)
db.session.add(appt)
db.session.flush()
log_tenant_action("appointment.create", "appointment", appt.id,
{"customer": customer_id, "staff": staff_id,
"start": start_raw})
db.session.commit()
flash("Appointment created.", "success")
return redirect(url_for("appointments.index",
date=start_time.date().isoformat()))
return render_template("tenant/appointments/form.html",
mode="create", staff_list=staff_list,
services=services, customers=customers)
@appointments_bp.route("/<int:appt_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def view(appt_id):
appt = Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id).first_or_404()
return render_template("tenant/appointments/view.html", appointment=appt)
@appointments_bp.route("/<int:appt_id>/status", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def set_status(appt_id):
appt = Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id).first_or_404()
new_status = request.form.get("status", "").strip()
if new_status not in VALID_STATUSES:
flash("Invalid status.", "danger")
return redirect(url_for("appointments.view", appt_id=appt_id))
old_status = appt.status
appt.status = new_status
if new_status == "cancelled":
appt.cancellation_reason = request.form.get("reason", "").strip() or None
appt.cancelled_at = datetime.now(timezone.utc)
# Cancel pending reminders
from app.models.salon import AppointmentReminder
AppointmentReminder.query.filter_by(
appointment_id=appt_id, status="pending"
).update({"status": "cancelled"})
elif new_status == "no_show" and appt.customer_id:
# Increment no-show counter on customer record
Customer.query.filter_by(id=appt.customer_id).update(
{"no_show_count": Customer.no_show_count + 1}
)
log_tenant_action("appointment.set_status", "appointment", appt.id,
{"old": old_status, "new": new_status})
db.session.commit()
flash(f"Appointment status updated to '{new_status}'.", "success")
return redirect(url_for("appointments.view", appt_id=appt_id))
@appointments_bp.route("/<int:appt_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit(appt_id):
appt = Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id).first_or_404()
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
if request.method == "POST":
start_raw = request.form.get("start_time", "")
try:
start_time = datetime.fromisoformat(start_raw)
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
return render_template("tenant/appointments/form.html",
mode="edit", appointment=appt,
staff_list=staff_list, services=services,
customers=customers,
error="Invalid date/time.")
service_id = request.form.get("service_id", type=int)
svc = Service.query.get(service_id) if service_id else None
duration = svc.duration_min if svc else 30
appt.customer_id = request.form.get("customer_id", type=int) or None
appt.staff_id = request.form.get("staff_id", type=int) or None
appt.service_id = service_id
appt.start_time = start_time
appt.end_time = start_time + timedelta(minutes=duration)
appt.notes = request.form.get("notes", "").strip() or None
log_tenant_action("appointment.edit", "appointment", appt.id,
{"start": start_raw})
db.session.commit()
flash("Appointment updated.", "success")
return redirect(url_for("appointments.view", appt_id=appt_id))
return render_template("tenant/appointments/form.html",
mode="edit", appointment=appt,
staff_list=staff_list, services=services,
customers=customers)
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+124 -3
View File
@@ -1,7 +1,128 @@
"""
app/tenant/booking/routes.py
Phase 3+ implementation.
Public online customer booking — /book/<tenant_slug>
No authentication required.
"""
from flask import Blueprint
import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, request, g
from app.extensions import db, limiter, csrf, mail
from app.models.platform import Tenant
from app.models.salon import Appointment, Customer, Staff, Service, Location
from app.decorators import tenant_feature_required
booking_bp = Blueprint("booking", __name__, url_prefix="")
logger = logging.getLogger(__name__)
booking_bp = Blueprint("booking", __name__)
@booking_bp.route("/book/<tenant_slug>", methods=["GET", "POST"])
@limiter.limit("15 per minute")
@csrf.exempt
def public_booking(tenant_slug):
tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
Tenant.status.in_(["active", "trial"])
).first()
if not tenant:
return render_template("tenant/booking/not_found.html"), 404
if not tenant.has_feature("online_booking"):
return render_template("tenant/booking/unavailable.html", tenant=tenant), 403
location = Location.query.filter_by(
tenant_id=tenant.id, is_primary=True, is_active=True
).first() or Location.query.filter_by(
tenant_id=tenant.id, is_active=True).first()
if not location:
return render_template("tenant/booking/unavailable.html", tenant=tenant), 403
services = Service.query.filter_by(
tenant_id=tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
staff_list = Staff.query.filter_by(
tenant_id=tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
confirmed = False
error = None
if request.method == "POST":
name = request.form.get("name", "").strip()[:255]
phone = request.form.get("phone", "").strip()[:30]
email = request.form.get("email", "").strip()[:255] or None
service_id = request.form.get("service_id", type=int)
staff_id = request.form.get("staff_id", type=int) or None
date_raw = request.form.get("preferred_date", "")
time_raw = request.form.get("preferred_time", "")
notes = request.form.get("notes", "").strip()[:500] or None
if not name or not phone or not service_id or not date_raw or not time_raw:
error = "Please complete all required fields."
else:
try:
start_time = datetime.fromisoformat(f"{date_raw}T{time_raw}")
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
error = "Invalid date or time."
start_time = None
if start_time and start_time < datetime.now(timezone.utc):
error = "Please choose a future date and time."
if not error:
svc = Service.query.filter_by(
id=service_id, tenant_id=tenant.id).first()
duration = svc.duration_min if svc else 30
end_time = start_time + timedelta(minutes=duration)
# Look up or create customer
customer = Customer.query.filter_by(
tenant_id=tenant.id, phone=phone).filter(
Customer.deleted_at.is_(None)).first()
if not customer:
customer = Customer(
tenant_id=tenant.id, name=name, phone=phone,
email=email, is_active=True, loyalty_points=0,
no_show_count=0,
)
db.session.add(customer)
db.session.flush()
appt = Appointment(
tenant_id=tenant.id, location_id=location.id,
customer_id=customer.id,
staff_id=staff_id, service_id=service_id,
start_time=start_time, end_time=end_time,
is_walk_in=False, status="pending",
notes=notes, rebook_source="online",
)
db.session.add(appt)
db.session.commit()
logger.info("Online booking: tenant=%s appt=%s customer=%s",
tenant.id, appt.id, customer.id)
# Send confirmation email
if email:
try:
from flask_mail import Message
msg = Message(
subject=f"Booking Confirmed — {tenant.name}",
recipients=[email],
body=(
f"Hi {name},\n\nYour appointment has been requested.\n"
f"Service: {svc.name if svc else 'N/A'}\n"
f"Date: {start_time.strftime('%B %d, %Y at %I:%M %p')}\n\n"
f"We'll confirm your booking shortly. Thank you!"
),
)
mail.send(msg)
except Exception as exc:
logger.error("Booking confirmation email failed: %s", exc)
confirmed = True
return render_template(
"tenant/booking/form.html",
tenant=tenant, services=services, staff_list=staff_list,
confirmed=confirmed, error=error,
)
+69 -102
View File
@@ -1,135 +1,102 @@
"""
app/tenant/checkin/routes.py — Customer self check-in kiosk.
Route: GET/POST /checkin/<tenant_slug>
app/tenant/checkin/routes.py
Customer self check-in kiosk — /checkin/<tenant_slug>
No authentication required. CSRF-exempt. Rate-limited.
Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting.
Receptionist alert delivered via 5-second polling: GET /api/v1/checkin/queue?status=waiting
"""
import logging
from datetime import datetime, timezone
from flask import (
Blueprint, render_template, request, jsonify,
current_app, abort,
)
from flask import Blueprint, render_template, request, redirect, url_for, g
from app.extensions import db, limiter, csrf
from app.models.platform import Tenant
from app.models.salon import Customer, CheckinQueue, Location
from app.security import sanitise_string, validate_slug
from app.models.salon import CheckinQueue, Customer, Service, Location
logger = logging.getLogger(__name__)
checkin_bp = Blueprint("checkin", __name__)
@checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"])
@csrf.exempt
@limiter.limit("20 per minute")
def kiosk(tenant_slug: str):
"""
Public kiosk page. No auth required.
Tenant slug validated against known slugs (not guessable).
Accepts: name, phone, service_requested — all other fields ignored.
Phone is sanitised and validated before profile lookup.
Page auto-resets after 10 seconds (configurable per tenant) via JS.
"""
# Validate slug format before hitting the DB
if not validate_slug(tenant_slug):
logger.warning("Kiosk: invalid slug format: %s", tenant_slug)
abort(404)
@csrf.exempt
def kiosk(tenant_slug):
# Validate slug against known tenants — prevents enumeration
tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
Tenant.status.in_(["active", "trial"])
).first()
if not tenant:
return render_template("tenant/checkin/not_found.html"), 404
tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first()
if not tenant or not tenant.is_active_status():
logger.warning("Kiosk: tenant not found or inactive: %s", tenant_slug)
abort(404)
# Resolve the primary location for this tenant
# Resolve location: session-stored or primary
location = Location.query.filter_by(
tenant_id=tenant.id,
is_primary=True,
is_active=True,
).filter(Location.deleted_at.is_(None)).first()
tenant_id=tenant.id, is_primary=True, is_active=True
).first()
if not location:
location = Location.query.filter_by(
tenant_id=tenant.id, is_active=True
).first()
if not location:
return render_template("tenant/checkin/not_found.html"), 404
if location is None:
abort(404)
services = Service.query.filter_by(
tenant_id=tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
confirmed = False
error = None
if request.method == "POST":
raw_name = request.form.get("customer_name", "")
raw_phone = request.form.get("customer_phone", "")
raw_service = request.form.get("service_requested", "")
# Accept ONLY these three fields — all others ignored
customer_name = request.form.get("customer_name", "").strip()[:255]
customer_phone = request.form.get("customer_phone", "").strip()[:30]
service_requested = request.form.get("service_requested", "").strip()[:255] or None
name = sanitise_string(raw_name, max_length=150)
phone = sanitise_string(raw_phone, max_length=30)
service = sanitise_string(raw_service, max_length=150)
if not customer_name or not customer_phone:
error = "Please enter your name and phone number."
else:
# Look up or create customer profile
customer = Customer.query.filter_by(
tenant_id=tenant.id, phone=customer_phone
).filter(Customer.deleted_at.is_(None)).first()
customer_id = None
if customer:
customer_id = customer.id
else:
# Auto-create profile
new_cust = Customer(
tenant_id=tenant.id,
name=customer_name,
phone=customer_phone,
is_active=True,
loyalty_points=0,
no_show_count=0,
)
db.session.add(new_cust)
db.session.flush()
customer_id = new_cust.id
logger.info("Kiosk: new customer profile created tenant=%s phone=%s",
tenant.id, customer_phone)
# Validate required fields
if not name or not phone:
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
error="Name and phone number are required.",
confirmed=False,
)
# Strip non-digit characters from phone for lookup consistency
phone_digits = "".join(filter(str.isdigit, phone))
if len(phone_digits) < 7:
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
error="Please enter a valid phone number.",
confirmed=False,
)
# Look up or create customer profile
customer = Customer.query.filter_by(
tenant_id=tenant.id,
phone=phone_digits,
).filter(Customer.deleted_at.is_(None)).first()
if customer is None:
customer = Customer(
entry = CheckinQueue(
tenant_id=tenant.id,
name=name,
phone=phone_digits,
)
db.session.add(customer)
db.session.flush() # get customer.id before committing
logger.info(
"Kiosk: new customer profile created for tenant %s", tenant.slug
location_id=location.id,
customer_id=customer_id,
customer_name=customer_name,
customer_phone=customer_phone,
service_requested=service_requested,
checked_in_at=datetime.now(timezone.utc),
status="waiting",
)
db.session.add(entry)
db.session.commit()
# Queue the walk-in entry
entry = CheckinQueue(
tenant_id=tenant.id,
location_id=location.id,
customer_id=customer.id,
customer_name=name,
customer_phone=phone_digits,
service_requested=service or None,
status="waiting",
)
db.session.add(entry)
db.session.commit()
logger.info(
"Kiosk: check-in queued for tenant=%s location=%s customer=%s",
tenant.slug, location.id, customer.id,
)
confirmed = True
# Fetch services for the kiosk selector (if configured)
from app.models.salon import Service
services = Service.query.filter_by(
tenant_id=tenant.id,
is_active=True,
).filter(Service.deleted_at.is_(None)).order_by(Service.name).all()
logger.info("Kiosk check-in: tenant=%s location=%s customer=%s service=%s",
tenant.id, location.id, customer_id, service_requested)
confirmed = True
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
services=services,
confirmed=confirmed,
error=None,
error=error,
)
+130 -2
View File
@@ -1,7 +1,135 @@
"""
app/tenant/customers/routes.py
Phase 3+ implementation.
Customer management: list (search), create, view, edit, soft-delete, restore.
"""
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
from app.models.salon import Customer, Appointment, Transaction
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
@customers_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
q = request.args.get("q", "").strip()
query = Customer.query.filter_by(tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None))
if q:
query = query.filter(
db.or_(
Customer.name.ilike(f"%{q}%"),
Customer.phone.ilike(f"%{q}%"),
Customer.email.ilike(f"%{q}%"),
)
)
customers = query.order_by(Customer.name).limit(200).all()
return render_template("tenant/customers/index.html", customers=customers, q=q)
@customers_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create():
if request.method == "POST":
name = request.form.get("name", "").strip()
phone = request.form.get("phone", "").strip() or None
if not name:
return render_template("tenant/customers/form.html",
mode="create", error="Name is required.")
customer = Customer(
tenant_id=g.tenant.id,
name=name, phone=phone,
email=request.form.get("email", "").strip() or None,
date_of_birth=_parse_date(request.form.get("date_of_birth", "")),
notes=request.form.get("notes", "").strip() or None,
is_active=True, loyalty_points=0, no_show_count=0,
)
db.session.add(customer)
db.session.flush()
log_tenant_action("customer.create", "customer", customer.id, {"name": name})
db.session.commit()
flash(f"Customer \'{name}\' created.", "success")
return redirect(url_for("customers.view", customer_id=customer.id))
return render_template("tenant/customers/form.html", mode="create")
@customers_bp.route("/<int:customer_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def view(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
appointments = Appointment.query.filter_by(
tenant_id=g.tenant.id, customer_id=customer_id
).order_by(Appointment.start_time.desc()).limit(20).all()
transactions = Transaction.query.filter_by(
tenant_id=g.tenant.id, customer_id=customer_id
).filter(Transaction.voided_at.is_(None)).order_by(
Transaction.created_at.desc()).limit(20).all()
return render_template("tenant/customers/view.html",
customer=customer,
appointments=appointments,
transactions=transactions)
@customers_bp.route("/<int:customer_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/customers/form.html",
mode="edit", customer=customer,
error="Name is required.")
customer.name = name
customer.phone = request.form.get("phone", "").strip() or None
customer.email = request.form.get("email", "").strip() or None
customer.date_of_birth = _parse_date(request.form.get("date_of_birth", ""))
customer.notes = request.form.get("notes", "").strip() or None
log_tenant_action("customer.edit", "customer", customer.id, {"name": name})
db.session.commit()
flash(f"Customer \'{name}\' updated.", "success")
return redirect(url_for("customers.view", customer_id=customer.id))
return render_template("tenant/customers/form.html",
mode="edit", customer=customer)
@customers_bp.route("/<int:customer_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def soft_delete(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
from datetime import datetime, timezone
customer.deleted_at = datetime.now(timezone.utc)
log_tenant_action("customer.delete", "customer", customer.id,
{"name": customer.name})
db.session.commit()
flash(f"Customer \'{customer.name}\' deleted.", "success")
return redirect(url_for("customers.index"))
def _parse_date(value):
if not value:
return None
try:
from datetime import date
return date.fromisoformat(value)
except ValueError:
return None
+73 -3
View File
@@ -1,14 +1,20 @@
"""
app/tenant/dashboard/routes.py — Tenant dashboard (placeholder for Phase 3 KPIs).
app/tenant/dashboard/routes.py
Dashboard with Phase 3 KPI widgets scoped to active location.
"""
import logging
from datetime import datetime, timezone, timedelta, date
from decimal import Decimal
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import (
Appointment, Transaction, Staff, CheckinQueue, StaffClocking,
)
from app.decorators import require_role
from sqlalchemy import func
logger = logging.getLogger(__name__)
dashboard_bp = Blueprint("dashboard", __name__)
@@ -16,8 +22,72 @@ dashboard_bp = Blueprint("dashboard", __name__)
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
today = date.today()
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
location_id = g.location.id if g.location else None
# ── Today's revenue ──────────────────────────────────────
revenue_row = db.session.query(
func.sum(Transaction.total)
).filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(
Transaction.created_at >= day_start,
Transaction.created_at < day_end,
Transaction.voided_at.is_(None),
).scalar()
today_revenue = float(revenue_row or 0)
# ── Today's appointments ─────────────────────────────────
appt_counts = dict(
db.session.query(Appointment.status, func.count(Appointment.id))
.filter_by(tenant_id=g.tenant.id, location_id=location_id)
.filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
)
.group_by(Appointment.status)
.all()
)
total_appts = sum(appt_counts.values())
completed_appts = appt_counts.get("completed", 0)
pending_appts = appt_counts.get("pending", 0) + appt_counts.get("confirmed", 0)
# ── Staff on shift ────────────────────────────────────────
staff_on_shift = StaffClocking.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(StaffClocking.clocked_out_at.is_(None)).count()
# ── Check-in queue ─────────────────────────────────────────
queue_count = CheckinQueue.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id, status="waiting"
).count()
# ── Upcoming appointments today (next 3) ──────────────────
now = datetime.now(timezone.utc)
upcoming = Appointment.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(
Appointment.start_time >= now,
Appointment.start_time < day_end,
Appointment.status.in_(["pending", "confirmed"]),
).order_by(Appointment.start_time).limit(5).all()
kpis = {
"today_revenue": today_revenue,
"total_appointments": total_appts,
"completed_appointments": completed_appts,
"pending_appointments": pending_appts,
"staff_on_shift": staff_on_shift,
"queue_count": queue_count,
}
return render_template(
"tenant/dashboard/index.html",
tenant=g.tenant,
location=g.location,
kpis=kpis,
upcoming_appointments=upcoming,
today=today,
)
+114 -2
View File
@@ -1,7 +1,119 @@
"""
app/tenant/gift_cards/routes.py
Phase 3+ implementation.
Gift card issuance, management, and balance enquiry.
"""
from flask import Blueprint
import logging
import secrets
import string
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import GiftCard, Customer
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards")
def _generate_code(tenant_id: int) -> str:
"""Generate a unique gift card code for this tenant."""
alphabet = string.ascii_uppercase + string.digits
for _ in range(20):
code = "".join(secrets.choice(alphabet) for _ in range(12))
code = f"{code[:4]}-{code[4:8]}-{code[8:12]}"
if not GiftCard.query.filter_by(tenant_id=tenant_id, code=code).first():
return code
raise RuntimeError("Failed to generate unique gift card code")
@gift_cards_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
cards = GiftCard.query.filter_by(
tenant_id=g.tenant.id
).order_by(GiftCard.created_at.desc()).limit(200).all()
return render_template("tenant/gift_cards/index.html", cards=cards)
@gift_cards_bp.route("/issue", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def issue():
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
if request.method == "POST":
try:
value = float(request.form.get("value", 0))
assert value > 0
except (ValueError, AssertionError):
return render_template("tenant/gift_cards/form.html",
customers=customers, error="Value must be greater than 0.")
customer_id = request.form.get("customer_id", type=int) or None
expires_raw = request.form.get("expires_at", "").strip()
expires_at = None
if expires_raw:
try:
expires_at = datetime.fromisoformat(expires_raw)
except ValueError:
pass
code = _generate_code(g.tenant.id)
card = GiftCard(
tenant_id=g.tenant.id, code=code,
original_value=value, remaining_balance=value,
issued_by=_user_db_id(),
issued_to_customer_id=customer_id,
expires_at=expires_at, is_active=True,
)
db.session.add(card)
db.session.flush()
log_tenant_action("gift_card.issue", "gift_card", card.id,
{"code": code, "value": value})
db.session.commit()
flash(f"Gift card issued: {code} (${value:.2f})", "success")
return redirect(url_for("gift_cards.index"))
return render_template("tenant/gift_cards/form.html", customers=customers)
@gift_cards_bp.route("/lookup")
@login_required
@require_role("tenant_admin", "tenant_manager")
def lookup():
code = request.args.get("code", "").strip().upper()
card = None
if code:
card = GiftCard.query.filter_by(
tenant_id=g.tenant.id, code=code).first()
return render_template("tenant/gift_cards/lookup.html",
card=card, code=code)
@gift_cards_bp.route("/<int:card_id>/deactivate", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def deactivate(card_id):
card = GiftCard.query.filter_by(
id=card_id, tenant_id=g.tenant.id).first_or_404()
card.is_active = False
log_tenant_action("gift_card.deactivate", "gift_card", card.id,
{"code": card.code})
db.session.commit()
flash(f"Gift card {card.code} deactivated.", "success")
return redirect(url_for("gift_cards.index"))
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+12 -2
View File
@@ -1,7 +1,17 @@
"""
app/tenant/inventory/routes.py
Phase 3+ implementation.
Phase 4 stub — implemented in Phase 4.
"""
from flask import Blueprint
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
@inventory_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template("tenant/feature_unavailable.html",
feature="Inventory")
+112 -2
View File
@@ -1,7 +1,117 @@
"""
app/tenant/locations/routes.py
Phase 3+ implementation.
Location management: list, create, edit, set primary, switch.
"""
from flask import Blueprint
import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, g
from flask_login import login_required, current_user
from app.extensions import db
from app.models.salon import Location
from app.decorators import require_role, tenant_feature_required, demo_readonly
from app.tenant.utils import log_tenant_action, plan_limit_check
logger = logging.getLogger(__name__)
locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
@locations_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
locs = Location.query.filter_by(tenant_id=g.tenant.id).order_by(
Location.is_primary.desc(), Location.name).all()
return render_template("tenant/locations/index.html", locations=locs)
@locations_bp.route("/switch/<int:location_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def switch(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id, is_active=True).first_or_404()
session["active_location_id"] = loc.id
logger.info("Location switched: location=%s tenant=%s user=%s",
loc.id, g.tenant.id, current_user.get_id())
flash(f"Switched to {loc.name}.", "info")
return redirect(request.referrer or url_for("dashboard.index"))
@locations_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def create():
allowed, err = plan_limit_check("location")
if not allowed:
flash(err, "warning")
return redirect(url_for("locations.index"))
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/locations/form.html",
mode="create", error="Name is required.")
allowed, err = plan_limit_check("location")
if not allowed:
return render_template("tenant/locations/form.html",
mode="create", error=err)
loc = Location(
tenant_id=g.tenant.id, name=name,
address=request.form.get("address", "").strip() or None,
phone=request.form.get("phone", "").strip() or None,
email=request.form.get("email", "").strip() or None,
timezone=request.form.get("timezone", "America/New_York"),
is_active=True, is_primary=False,
)
db.session.add(loc)
db.session.flush()
log_tenant_action("location.create", "location", loc.id, {"name": name})
db.session.commit()
flash(f"Location \'{name}\' created.", "success")
return redirect(url_for("locations.index"))
return render_template("tenant/locations/form.html", mode="create")
@locations_bp.route("/<int:location_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def edit(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/locations/form.html",
mode="edit", location=loc, error="Name is required.")
old_name = loc.name
loc.name = name
loc.address = request.form.get("address", "").strip() or None
loc.phone = request.form.get("phone", "").strip() or None
loc.email = request.form.get("email", "").strip() or None
loc.timezone = request.form.get("timezone", loc.timezone)
loc.is_active = request.form.get("is_active") == "1"
if request.form.get("is_primary") == "1" and not loc.is_primary:
Location.query.filter_by(tenant_id=g.tenant.id, is_primary=True).update({"is_primary": False})
loc.is_primary = True
log_tenant_action("location.edit", "location", loc.id,
{"old_name": old_name, "new_name": name})
db.session.commit()
flash(f"Location \'{name}\' updated.", "success")
return redirect(url_for("locations.index"))
return render_template("tenant/locations/form.html", mode="edit", location=loc)
@locations_bp.route("/<int:location_id>/set-primary", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def set_primary(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id, is_active=True).first_or_404()
Location.query.filter_by(tenant_id=g.tenant.id, is_primary=True).update({"is_primary": False})
loc.is_primary = True
log_tenant_action("location.set_primary", "location", loc.id)
db.session.commit()
flash(f"\'{loc.name}\' set as primary.", "success")
return redirect(url_for("locations.index"))
+12 -2
View File
@@ -1,7 +1,17 @@
"""
app/tenant/marketing/routes.py
Phase 3+ implementation.
Phase 4 stub — implemented in Phase 4.
"""
from flask import Blueprint
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
@marketing_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template("tenant/feature_unavailable.html",
feature="Marketing")
+370 -2
View File
@@ -1,7 +1,375 @@
"""
app/tenant/pos/routes.py
Phase 3+ implementation.
POS / Checkout: new transaction, line item entry, promotion auto-apply,
tip, gift card redemption, void, receipt.
Rebook-at-checkout creates a new pending appointment.
"""
from flask import Blueprint
import logging
from datetime import datetime, timezone, timedelta
from decimal import Decimal, ROUND_HALF_UP
from flask import Blueprint, render_template, redirect, url_for, flash, request, g, jsonify
from flask_login import login_required
from app.extensions import db
from app.models.salon import (
Transaction, TransactionItem, Appointment, Customer, Staff,
Service, Product, GiftCard, AppointmentReminder,
)
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action, get_active_promotion, apply_promotion_to_price
logger = logging.getLogger(__name__)
pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
PAYMENT_METHODS = ["cash", "zelle", "venmo", "cashapp", "gift_card", "other"]
@pos_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def checkout():
"""New POS transaction entry form."""
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.category, Service.name).all()
products = Product.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Product.deleted_at.is_(None)).order_by(Product.name).all()
# Pre-fill from appointment if provided
appt_id = request.args.get("appointment_id", type=int)
appointment = None
if appt_id:
appointment = Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id).first()
return render_template("tenant/pos/checkout.html",
customers=customers, staff_list=staff_list,
services=services, products=products,
appointment=appointment,
payment_methods=PAYMENT_METHODS)
@pos_bp.route("/submit", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def submit():
"""Process a completed checkout."""
customer_id = request.form.get("customer_id", type=int) or None
staff_id = request.form.get("staff_id", type=int) or None
appt_id = request.form.get("appointment_id", type=int) or None
payment_method = request.form.get("payment_method", "cash")
payment_reference = request.form.get("payment_reference", "").strip() or None
tip_raw = request.form.get("tip_amount", "0")
gc_code = request.form.get("gift_card_code", "").strip().upper() or None
if payment_method not in PAYMENT_METHODS:
flash("Invalid payment method.", "danger")
return redirect(url_for("pos.checkout"))
try:
tip_amount = Decimal(tip_raw or "0").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
except Exception:
tip_amount = Decimal("0")
# Parse line items from form: service_ids[] and product_ids[]
service_ids = request.form.getlist("service_ids")
product_ids = request.form.getlist("product_ids")
if not service_ids and not product_ids:
flash("Please add at least one service or product.", "danger")
return redirect(url_for("pos.checkout"))
# Resolve gift card
gift_card = None
gc_applied = Decimal("0")
if gc_code:
gift_card = GiftCard.query.filter_by(
tenant_id=g.tenant.id, code=gc_code, is_active=True).first()
if not gift_card or gift_card.remaining_balance <= 0:
flash(f"Gift card {gc_code} is invalid or has no balance.", "danger")
return redirect(url_for("pos.checkout"))
# Build transaction
txn = Transaction(
tenant_id=g.tenant.id,
location_id=g.location.id,
appointment_id=appt_id,
customer_id=customer_id,
staff_id=staff_id,
payment_method=payment_method,
payment_reference=payment_reference,
tip_amount=float(tip_amount),
gift_card_id=gift_card.id if gift_card else None,
subtotal=0, discount=0, gift_card_amount=0, total=0,
)
db.session.add(txn)
db.session.flush()
subtotal = Decimal("0")
total_discount = Decimal("0")
# Add service line items
for sid_str in service_ids:
try:
sid = int(sid_str)
except ValueError:
continue
svc = Service.query.filter_by(
id=sid, tenant_id=g.tenant.id).first()
if not svc:
continue
original_price = Decimal(str(svc.price))
promo = get_active_promotion(g.tenant.id, sid, "service")
unit_price_f, disc_pct = apply_promotion_to_price(float(original_price), promo)
unit_price = Decimal(str(unit_price_f))
discount_amt = original_price - unit_price
item = TransactionItem(
transaction_id=txn.id,
service_id=sid, product_id=None,
qty=1, unit_price=float(unit_price),
original_price=float(original_price),
discount_percent=disc_pct,
promotion_id=promo.id if promo else None,
)
db.session.add(item)
subtotal += unit_price
total_discount += discount_amt
# Add product line items
for pid_str in product_ids:
try:
pid = int(pid_str)
except ValueError:
continue
prod = Product.query.filter_by(
id=pid, tenant_id=g.tenant.id).first()
if not prod:
continue
original_price = Decimal(str(prod.sale_price))
promo = get_active_promotion(g.tenant.id, pid, "product")
unit_price_f, disc_pct = apply_promotion_to_price(float(original_price), promo)
unit_price = Decimal(str(unit_price_f))
discount_amt = original_price - unit_price
item = TransactionItem(
transaction_id=txn.id,
service_id=None, product_id=pid,
qty=1, unit_price=float(unit_price),
original_price=float(original_price),
discount_percent=disc_pct,
promotion_id=promo.id if promo else None,
)
db.session.add(item)
subtotal += unit_price
total_discount += discount_amt
# Apply gift card
if gift_card:
gc_available = Decimal(str(gift_card.remaining_balance))
gc_applied = min(gc_available, subtotal + tip_amount)
gift_card.remaining_balance = float(gc_available - gc_applied)
if gift_card.remaining_balance <= 0:
gift_card.is_active = False
total = subtotal + tip_amount - gc_applied
if total < 0:
total = Decimal("0")
txn.subtotal = float(subtotal)
txn.discount = float(total_discount)
txn.tip_amount = float(tip_amount)
txn.gift_card_amount = float(gc_applied)
txn.total = float(total)
# Mark appointment completed if linked
if appt_id:
Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id
).update({"status": "completed"})
# Commission log if staff has commission enabled
if staff_id:
staff = Staff.query.get(staff_id)
if staff and staff.commission_enabled and staff.commission_rate:
from app.models.salon import CommissionLog
commission_amount = float(
(Decimal(str(subtotal)) * Decimal(str(staff.commission_rate)) /
Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
from datetime import date
period = date.today().strftime("%Y-W%U")
comm_log = CommissionLog(
tenant_id=g.tenant.id,
location_id=g.location.id,
staff_id=staff_id,
transaction_id=txn.id,
amount=commission_amount,
period=period,
)
db.session.add(comm_log)
log_tenant_action("transaction.create", "transaction", txn.id,
{"total": float(total), "payment": payment_method})
db.session.commit()
flash(f"Checkout complete — Total: ${float(total):.2f}", "success")
# Rebook at checkout
rebook = request.form.get("rebook") == "1"
if rebook:
return redirect(url_for("pos.rebook", transaction_id=txn.id))
return redirect(url_for("pos.receipt", transaction_id=txn.id))
@pos_bp.route("/receipt/<int:transaction_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def receipt(transaction_id):
txn = Transaction.query.filter_by(
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
items = list(txn.items)
return render_template("tenant/pos/receipt.html",
transaction=txn, items=items, tenant=g.tenant,
location=g.location)
@pos_bp.route("/rebook/<int:transaction_id>", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def rebook(transaction_id):
"""Next-visit scheduling at checkout."""
txn = Transaction.query.filter_by(
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
if request.method == "POST":
start_raw = request.form.get("start_time", "")
service_id = request.form.get("service_id", type=int)
staff_id = request.form.get("staff_id", type=int) or None
try:
start_time = datetime.fromisoformat(start_raw)
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
return render_template("tenant/pos/rebook.html",
transaction=txn, staff_list=staff_list,
services=services,
error="Invalid date/time.")
svc = Service.query.get(service_id) if service_id else None
duration = svc.duration_min if svc else 30
end_time = start_time + timedelta(minutes=duration)
appt = Appointment(
tenant_id=g.tenant.id, location_id=g.location.id,
customer_id=txn.customer_id,
staff_id=staff_id, service_id=service_id,
start_time=start_time, end_time=end_time,
is_walk_in=False, status="pending",
rebook_source="checkout",
rebooked_from_transaction_id=txn.id,
created_by=_user_db_id(),
)
db.session.add(appt)
db.session.flush()
# Schedule 24h reminder
reminder_time = start_time - timedelta(hours=24)
if reminder_time > datetime.now(timezone.utc):
reminder = AppointmentReminder(
tenant_id=g.tenant.id, location_id=g.location.id,
appointment_id=appt.id, reminder_type="24h",
scheduled_for=reminder_time, channel="email", status="pending",
)
db.session.add(reminder)
log_tenant_action("appointment.rebook", "appointment", appt.id,
{"from_transaction": txn.id, "start": start_raw})
db.session.commit()
flash("Next visit scheduled.", "success")
return redirect(url_for("pos.receipt", transaction_id=txn.id))
return render_template("tenant/pos/rebook.html",
transaction=txn, staff_list=staff_list,
services=services)
@pos_bp.route("/void/<int:transaction_id>", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def void(transaction_id):
txn = Transaction.query.filter_by(
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
if txn.voided_at:
flash("This transaction is already voided.", "warning")
return redirect(url_for("pos.receipt", transaction_id=transaction_id))
if request.method == "POST":
reason = request.form.get("reason", "").strip()
if not reason:
return render_template("tenant/pos/void.html",
transaction=txn, error="Reason is required.")
txn.voided_at = datetime.now(timezone.utc)
txn.voided_by = _user_db_id()
txn.void_reason = reason
# Reverse gift card balance if applicable
if txn.gift_card_id and txn.gift_card_amount > 0:
gc = GiftCard.query.get(txn.gift_card_id)
if gc:
gc.remaining_balance = float(
Decimal(str(gc.remaining_balance)) +
Decimal(str(txn.gift_card_amount))
)
gc.is_active = True
log_tenant_action("transaction.void", "transaction", txn.id,
{"reason": reason, "total": txn.total})
db.session.commit()
flash("Transaction voided.", "success")
return redirect(url_for("pos.receipt", transaction_id=transaction_id))
return render_template("tenant/pos/void.html", transaction=txn)
@pos_bp.route("/transactions")
@login_required
@require_role("tenant_admin", "tenant_manager")
def transactions():
from datetime import date
date_str = request.args.get("date", date.today().isoformat())
try:
view_date = date.fromisoformat(date_str)
except ValueError:
view_date = date.today()
day_start = datetime.combine(view_date, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
txns = Transaction.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Transaction.created_at >= day_start,
Transaction.created_at < day_end,
Transaction.voided_at.is_(None),
).order_by(Transaction.created_at.desc()).all()
return render_template("tenant/pos/transactions.html",
transactions=txns, view_date=view_date)
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+122 -2
View File
@@ -1,7 +1,127 @@
"""
app/tenant/reconciliation/routes.py
Phase 3+ implementation.
End-of-day reconciliation: close day, cash count, variance calculation.
"""
from flask import Blueprint
import logging
from datetime import datetime, timezone, timedelta, date
from decimal import Decimal
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import DailyReconciliation, Transaction
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
@reconciliation_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
records = DailyReconciliation.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).order_by(DailyReconciliation.date.desc()).limit(30).all()
return render_template("tenant/reconciliation/index.html", records=records)
@reconciliation_bp.route("/close", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def close_day():
today = date.today()
# Check if already closed for today
existing = DailyReconciliation.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id, date=today
).first()
if existing and existing.closed_at:
flash("Today has already been reconciled.", "info")
return redirect(url_for("reconciliation.index"))
# Compute expected totals from today's transactions
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
txns = Transaction.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Transaction.created_at >= day_start,
Transaction.created_at < day_end,
Transaction.voided_at.is_(None),
).all()
total_cash = sum(
Decimal(str(t.total)) for t in txns if t.payment_method == "cash"
)
total_app = sum(
Decimal(str(t.total)) for t in txns
if t.payment_method in ("zelle", "venmo", "cashapp", "other")
)
total_tips = sum(Decimal(str(t.tip_amount)) for t in txns)
total_gc = sum(Decimal(str(t.gift_card_amount)) for t in txns)
expected_cash = total_cash # Starting float not tracked in MVP
if request.method == "POST":
try:
actual_cash = Decimal(request.form.get("actual_cash", "0"))
except Exception:
return render_template("tenant/reconciliation/close.html",
today=today, total_cash=float(total_cash),
total_app=float(total_app),
total_tips=float(total_tips),
total_gc=float(total_gc),
expected_cash=float(expected_cash),
error="Invalid cash amount.")
variance = actual_cash - expected_cash
notes = request.form.get("notes", "").strip() or None
if existing:
existing.actual_cash_counted = float(actual_cash)
existing.variance = float(variance)
existing.closed_by = _user_db_id()
existing.closed_at = datetime.now(timezone.utc)
existing.notes = notes
rec = existing
else:
rec = DailyReconciliation(
tenant_id=g.tenant.id,
location_id=g.location.id,
date=today,
total_cash=float(total_cash),
total_app_payments=float(total_app),
total_tips=float(total_tips),
total_gift_card_redemptions=float(total_gc),
expected_cash_in_drawer=float(expected_cash),
actual_cash_counted=float(actual_cash),
variance=float(variance),
closed_by=_user_db_id(),
closed_at=datetime.now(timezone.utc),
notes=notes,
)
db.session.add(rec)
log_tenant_action("reconciliation.close", "reconciliation", None,
{"date": str(today), "variance": float(variance)})
db.session.commit()
flash(f"Day closed. Variance: ${float(variance):.2f}", "success")
return redirect(url_for("reconciliation.index"))
return render_template("tenant/reconciliation/close.html",
today=today,
total_cash=float(total_cash),
total_app=float(total_app),
total_tips=float(total_tips),
total_gc=float(total_gc),
expected_cash=float(expected_cash))
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+12 -2
View File
@@ -1,7 +1,17 @@
"""
app/tenant/reports/routes.py
Phase 3+ implementation.
Phase 4 stub — implemented in Phase 4.
"""
from flask import Blueprint
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
@reports_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template("tenant/feature_unavailable.html",
feature="Reports")
+33 -2
View File
@@ -1,7 +1,38 @@
"""
app/tenant/reviews/routes.py
Phase 3+ implementation.
Owner view of customer reviews submitted at checkout.
"""
from flask import Blueprint
import logging
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.models.salon import CheckoutReview
from app.decorators import require_role
from sqlalchemy import func
from app.extensions import db
logger = logging.getLogger(__name__)
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
@reviews_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
reviews = CheckoutReview.query.filter_by(
tenant_id=g.tenant.id
).order_by(CheckoutReview.created_at.desc()).limit(100).all()
avg_row = db.session.query(
func.avg(CheckoutReview.rating)
).filter_by(tenant_id=g.tenant.id).scalar()
avg_rating = round(float(avg_row or 0), 1)
dist = dict(
db.session.query(CheckoutReview.rating, func.count(CheckoutReview.id))
.filter_by(tenant_id=g.tenant.id)
.group_by(CheckoutReview.rating).all()
)
return render_template("tenant/reviews/index.html",
reviews=reviews, avg_rating=avg_rating,
rating_dist=dist)
+277 -2
View File
@@ -1,7 +1,282 @@
"""
app/tenant/services/routes.py
Phase 3+ implementation.
Services, products, and promotions catalogue.
"""
from flask import Blueprint
import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import Service, Product, Promotion
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
services_bp = Blueprint("services", __name__, url_prefix="/services")
# ── Services ──────────────────────────────────────────────────────────────────
@services_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
services = Service.query.filter_by(tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).order_by(
Service.category, Service.name).all()
products = Product.query.filter_by(tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).order_by(
Product.category, Product.name).all()
promotions = Promotion.query.filter_by(
tenant_id=g.tenant.id, is_active=True).order_by(
Promotion.ends_at).all()
return render_template("tenant/services/index.html",
services=services, products=products,
promotions=promotions)
@services_bp.route("/services/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_service():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/service_form.html",
mode="create", error="Name is required.")
try:
price = float(request.form.get("price", 0))
duration = int(request.form.get("duration_min", 30))
except ValueError:
return render_template("tenant/services/service_form.html",
mode="create", error="Invalid price or duration.")
svc = Service(
tenant_id=g.tenant.id, name=name,
category=request.form.get("category", "").strip() or None,
price=price, duration_min=duration, is_active=True,
)
db.session.add(svc)
db.session.flush()
log_tenant_action("service.create", "service", svc.id, {"name": name})
db.session.commit()
flash(f"Service \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/service_form.html", mode="create")
@services_bp.route("/services/<int:service_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit_service(service_id):
svc = Service.query.filter_by(
id=service_id, tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/service_form.html",
mode="edit", service=svc, error="Name is required.")
try:
price = float(request.form.get("price", 0))
duration = int(request.form.get("duration_min", 30))
except ValueError:
return render_template("tenant/services/service_form.html",
mode="edit", service=svc, error="Invalid price or duration.")
svc.name = name
svc.category = request.form.get("category", "").strip() or None
svc.price = price
svc.duration_min = duration
svc.is_active = request.form.get("is_active") == "1"
log_tenant_action("service.edit", "service", svc.id, {"name": name})
db.session.commit()
flash(f"Service \'{name}\' updated.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/service_form.html",
mode="edit", service=svc)
@services_bp.route("/services/<int:service_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def delete_service(service_id):
svc = Service.query.filter_by(
id=service_id, tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).first_or_404()
svc.deleted_at = datetime.now(timezone.utc)
log_tenant_action("service.delete", "service", svc.id, {"name": svc.name})
db.session.commit()
flash(f"Service \'{svc.name}\' deleted.", "success")
return redirect(url_for("services.index"))
# ── Products ──────────────────────────────────────────────────────────────────
@services_bp.route("/products/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_product():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/product_form.html",
mode="create", error="Name is required.")
try:
price = float(request.form.get("sale_price", 0))
except ValueError:
return render_template("tenant/services/product_form.html",
mode="create", error="Invalid price.")
prod = Product(
tenant_id=g.tenant.id, name=name,
sku=request.form.get("sku", "").strip() or None,
category=request.form.get("category", "").strip() or None,
sale_price=price, is_active=True,
)
db.session.add(prod)
db.session.flush()
log_tenant_action("product.create", "product", prod.id, {"name": name})
db.session.commit()
flash(f"Product \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/product_form.html", mode="create")
@services_bp.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit_product(product_id):
prod = Product.query.filter_by(
id=product_id, tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/product_form.html",
mode="edit", product=prod, error="Name is required.")
try:
price = float(request.form.get("sale_price", 0))
except ValueError:
return render_template("tenant/services/product_form.html",
mode="edit", product=prod, error="Invalid price.")
prod.name = name
prod.sku = request.form.get("sku", "").strip() or None
prod.category = request.form.get("category", "").strip() or None
prod.sale_price = price
prod.is_active = request.form.get("is_active") == "1"
log_tenant_action("product.edit", "product", prod.id, {"name": name})
db.session.commit()
flash(f"Product \'{name}\' updated.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/product_form.html",
mode="edit", product=prod)
@services_bp.route("/products/<int:product_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def delete_product(product_id):
prod = Product.query.filter_by(
id=product_id, tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).first_or_404()
prod.deleted_at = datetime.now(timezone.utc)
log_tenant_action("product.delete", "product", prod.id, {"name": prod.name})
db.session.commit()
flash(f"Product \'{prod.name}\' deleted.", "success")
return redirect(url_for("services.index"))
# ── Promotions ─────────────────────────────────────────────────────────────────
@services_bp.route("/promotions/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_promotion():
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
products = Product.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Product.deleted_at.is_(None)).order_by(Product.name).all()
if request.method == "POST":
name = request.form.get("name", "").strip()
applies_to = request.form.get("applies_to", "all_services")
try:
pct = int(request.form.get("discount_percent", 0))
assert 1 <= pct <= 100
except (ValueError, AssertionError):
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Discount must be 1100%.")
starts_raw = request.form.get("starts_at", "")
ends_raw = request.form.get("ends_at", "")
if not starts_raw or not ends_raw:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Start and end dates are required.")
try:
starts_at = datetime.fromisoformat(starts_raw)
ends_at = datetime.fromisoformat(ends_raw)
except ValueError:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Invalid date format.")
# Build target_ids_json for specific targets
target_ids = None
if applies_to in ("service", "product"):
raw_ids = request.form.getlist("target_ids")
target_ids = [int(i) for i in raw_ids if i.isdigit()]
if not target_ids:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Please select at least one target.")
from flask_login import current_user
promo = Promotion(
tenant_id=g.tenant.id, name=name,
discount_percent=pct, applies_to=applies_to,
target_ids_json=target_ids,
starts_at=starts_at, ends_at=ends_at,
is_active=True,
created_by=_user_id(),
)
db.session.add(promo)
db.session.flush()
log_tenant_action("promotion.create", "promotion", promo.id,
{"name": name, "discount": pct})
db.session.commit()
flash(f"Promotion \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products)
@services_bp.route("/promotions/<int:promo_id>/toggle", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def toggle_promotion(promo_id):
promo = Promotion.query.filter_by(
id=promo_id, tenant_id=g.tenant.id).first_or_404()
promo.is_active = not promo.is_active
action = "promotion.activate" if promo.is_active else "promotion.deactivate"
log_tenant_action(action, "promotion", promo.id, {"name": promo.name})
db.session.commit()
status = "activated" if promo.is_active else "deactivated"
flash(f"Promotion \'{promo.name}\' {status}.", "success")
return redirect(url_for("services.index"))
def _user_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+54 -2
View File
@@ -1,7 +1,59 @@
"""
app/tenant/settings/routes.py
Phase 3+ implementation.
Tenant settings management.
"""
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
from app.models.salon import TenantSetting
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
# Keys managed by the UI — any key not in this list is hidden
MANAGED_KEYS = [
"business_hours",
"booking_advance_days",
"auto_confirm_bookings",
"review_request_delay_minutes",
"receipt_footer_note",
"google_review_url",
"facebook_review_url",
"yelp_review_url",
]
@settings_bp.route("/")
@login_required
@require_role("tenant_admin")
def index():
settings = {s.setting_key: s.setting_value for s in TenantSetting.query.filter_by(
tenant_id=g.tenant.id).all()}
return render_template("tenant/settings/index.html",
settings=settings, managed_keys=MANAGED_KEYS)
@settings_bp.route("/save", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def save():
for key in MANAGED_KEYS:
value = request.form.get(key, "").strip()
existing = TenantSetting.query.filter_by(
tenant_id=g.tenant.id, setting_key=key).first()
if existing:
existing.setting_value = value or None
else:
setting = TenantSetting(
tenant_id=g.tenant.id, setting_key=key,
setting_value=value or None,
)
db.session.add(setting)
log_tenant_action("settings.save", "tenant", g.tenant.id)
db.session.commit()
flash("Settings saved.", "success")
return redirect(url_for("settings.index"))
+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)
+183 -2
View File
@@ -1,7 +1,188 @@
"""
app/tenant/staff_portal/routes.py
Phase 3+ implementation.
Staff Portal accessible by tenant_staff role (phone + passcode login).
Routes: personal schedule, upcoming appointments, clock in/out,
commission summary, payment history, read-only profile.
"""
from flask import Blueprint
import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required, current_user
from app.extensions import db
from app.models.salon import (
Staff, Appointment, StaffClocking, CommissionLog,
StaffPayPeriod, Transaction,
)
from app.decorators import require_role
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
def _get_current_staff():
"""Resolve the Staff record from the current user session."""
uid_str = current_user.get_id()
if not uid_str or not uid_str.startswith("staff:"):
return None
try:
sid = int(uid_str.split(":")[1])
return Staff.query.filter_by(id=sid, is_active=True).filter(
Staff.deleted_at.is_(None)).first()
except (ValueError, IndexError):
return None
@staff_portal_bp.route("/")
@login_required
@require_role("tenant_staff")
def index():
staff = _get_current_staff()
if not staff:
flash("Staff profile not found.", "danger")
return redirect(url_for("staff_auth.staff_login"))
today = date.today()
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
# Today's appointments
todays_appts = Appointment.query.filter_by(
tenant_id=staff.tenant_id,
staff_id=staff.id,
).filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
Appointment.status.notin_(["cancelled", "no_show"]),
).order_by(Appointment.start_time).all()
# Current clocking state
current_clocking = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
return render_template("tenant/staff_portal/index.html",
staff=staff,
todays_appointments=todays_appts,
current_clocking=current_clocking,
today=today)
@staff_portal_bp.route("/clock-in", methods=["POST"])
@login_required
@require_role("tenant_staff")
def clock_in():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Check not already clocked in
existing = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
if existing:
flash("You are already clocked in.", "warning")
return redirect(url_for("staff_portal.index"))
location_id = g.location.id if g.location else None
clocking = StaffClocking(
tenant_id=staff.tenant_id,
location_id=location_id,
staff_id=staff.id,
clocked_in_at=datetime.now(timezone.utc),
)
db.session.add(clocking)
log_tenant_action("staff.clock_in", "staff", staff.id,
{"location": location_id})
db.session.commit()
flash("Clocked in successfully.", "success")
return redirect(url_for("staff_portal.index"))
@staff_portal_bp.route("/clock-out", methods=["POST"])
@login_required
@require_role("tenant_staff")
def clock_out():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
clocking = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
if not clocking:
flash("You are not currently clocked in.", "warning")
return redirect(url_for("staff_portal.index"))
now = datetime.now(timezone.utc)
clocking.clocked_out_at = now
total_minutes = int((now - clocking.clocked_in_at.replace(
tzinfo=timezone.utc if clocking.clocked_in_at.tzinfo is None else clocking.clocked_in_at.tzinfo
)).total_seconds() / 60)
clocking.total_minutes = total_minutes
clocking.notes = request.form.get("notes", "").strip() or None
log_tenant_action("staff.clock_out", "staff", staff.id,
{"minutes": total_minutes})
db.session.commit()
hours = total_minutes // 60
mins = total_minutes % 60
flash(f"Clocked out. Shift duration: {hours}h {mins}m.", "success")
return redirect(url_for("staff_portal.index"))
@staff_portal_bp.route("/schedule")
@login_required
@require_role("tenant_staff")
def schedule():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Next 7 days of appointments
now = datetime.now(timezone.utc)
week_ahead = now + timedelta(days=7)
appts = Appointment.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id,
).filter(
Appointment.start_time >= now,
Appointment.start_time < week_ahead,
Appointment.status.notin_(["cancelled", "no_show"]),
).order_by(Appointment.start_time).all()
return render_template("tenant/staff_portal/schedule.html",
staff=staff, appointments=appts)
@staff_portal_bp.route("/commission")
@login_required
@require_role("tenant_staff")
def commission():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Current and previous pay period summary
from datetime import date
today = date.today()
# Current month period label
period = today.strftime("%Y-W%U")
logs = CommissionLog.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id
).order_by(CommissionLog.id.desc()).limit(50).all()
pay_periods = StaffPayPeriod.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id
).order_by(StaffPayPeriod.period_start.desc()).limit(12).all()
return render_template("tenant/staff_portal/commission.html",
staff=staff, commission_logs=logs,
pay_periods=pay_periods)
@staff_portal_bp.route("/profile")
@login_required
@require_role("tenant_staff")
def profile():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
return render_template("tenant/staff_portal/profile.html", staff=staff)
+152
View File
@@ -0,0 +1,152 @@
"""
app/tenant/utils.py
Shared helpers used across all tenant portal blueprints.
"""
import logging
from datetime import datetime, timezone
from functools import wraps
from flask import g, flash, redirect, url_for, request, jsonify
from flask_login import current_user
logger = logging.getLogger(__name__)
def log_tenant_action(action, target_type=None, target_id=None, details=None):
"""
Write a structured log entry for any tenant create/edit/delete action.
Not persisted to audit_log (tenant-level actions use application logs).
"""
logger.info(
"TENANT_ACTION action=%s target_type=%s target_id=%s tenant=%s user=%s details=%s",
action,
target_type,
target_id,
getattr(g, 'tenant', None) and g.tenant.id,
current_user.get_id() if current_user.is_authenticated else None,
details,
)
def plan_limit_check(resource: str) -> tuple[bool, str]:
"""
Check whether the current tenant has headroom to add one more of `resource`.
resource: 'staff' | 'location'
Returns (allowed: bool, error_message: str | None)
"""
from app.models.salon import Staff, Location
tenant = getattr(g, 'tenant', None)
if not tenant or not tenant.plan:
return True, None
plan = tenant.plan
if resource == 'staff':
if plan.max_staff is None:
return True, None
count = Staff.query.filter_by(
tenant_id=tenant.id, is_active=True
).filter(Staff.deleted_at.is_(None)).count()
if count >= plan.max_staff:
return False, (
f"Your plan allows a maximum of {plan.max_staff} active staff members. "
"Please upgrade your plan to add more."
)
elif resource == 'location':
if plan.max_locations is None:
return True, None
count = Location.query.filter_by(
tenant_id=tenant.id, is_active=True
).count()
if count >= plan.max_locations:
return False, (
f"Your plan allows a maximum of {plan.max_locations} locations. "
"Please upgrade your plan to add more."
)
return True, None
def get_active_promotion(tenant_id: int, item_id: int, item_type: str):
"""
Promotion engine resolves the single best active promotion for a
service or product line item at checkout time.
item_type: 'service' | 'product'
Priority:
1. Specific promotion targeting this exact item ID
2. 'all_services' / 'all_products' promotion
3. 'all' promotion (covers both services and products)
If multiple promotions match at the same priority level, the highest
discount_percent wins. Returns None if no active promotion applies.
"""
from app.models.salon import Promotion
now = datetime.now(timezone.utc)
active = Promotion.query.filter_by(
tenant_id=tenant_id, is_active=True
).filter(
Promotion.starts_at <= now,
Promotion.ends_at >= now,
).all()
if not active:
return None
# Collect all candidates
candidates = []
for promo in active:
at = promo.applies_to
if at == 'all':
candidates.append((0, promo))
elif at == 'all_services' and item_type == 'service':
candidates.append((1, promo))
elif at == 'all_products' and item_type == 'product':
candidates.append((1, promo))
elif at == item_type:
# Specific IDs — check membership
target_ids = promo.target_ids_json or []
if item_id in target_ids:
candidates.append((2, promo))
if not candidates:
return None
# Sort: highest specificity first (2 > 1 > 0), then highest discount
candidates.sort(key=lambda x: (x[0], x[1].discount_percent), reverse=True)
return candidates[0][1]
def apply_promotion_to_price(price, promotion):
"""
Apply a promotion to a unit price.
Returns (discounted_price, discount_percent).
If promotion is None, returns original price and 0.
"""
from decimal import Decimal, ROUND_HALF_UP
if promotion is None:
return price, 0
pct = promotion.discount_percent
price = Decimal(str(price))
discount = (price * Decimal(pct) / Decimal(100)).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP
)
return float(price - discount), pct
def is_api_request():
return request.path.startswith('/api/') or \
request.accept_mimetypes.best == 'application/json'
+126 -2
View File
@@ -1,7 +1,131 @@
"""
app/tenant/waitlist/routes.py
Phase 3+ implementation.
Waitlist management: list, add, notify, mark booked, expire.
"""
from flask import Blueprint
import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db, mail
from app.models.salon import Waitlist, Staff, Service
from app.decorators import require_role, demo_readonly, tenant_feature_required
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
@waitlist_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
@tenant_feature_required("waitlist")
def index():
entries = Waitlist.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Waitlist.status.in_(["waiting", "notified"])
).order_by(Waitlist.created_at).all()
return render_template("tenant/waitlist/index.html", entries=entries)
@waitlist_bp.route("/add", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def add():
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
if request.method == "POST":
name = request.form.get("customer_name", "").strip()
phone = request.form.get("customer_phone", "").strip() or None
email_addr = request.form.get("customer_email", "").strip() or None
if not name:
return render_template("tenant/waitlist/form.html",
staff_list=staff_list, services=services,
error="Customer name is required.")
entry = Waitlist(
tenant_id=g.tenant.id, location_id=g.location.id,
customer_name=name, customer_phone=phone,
customer_email=email_addr,
staff_id=request.form.get("staff_id", type=int) or None,
service_id=request.form.get("service_id", type=int) or None,
requested_date=_parse_date(request.form.get("requested_date", "")),
status="waiting",
)
db.session.add(entry)
db.session.flush()
log_tenant_action("waitlist.add", "waitlist", entry.id, {"name": name})
db.session.commit()
flash(f"'{name}' added to waitlist.", "success")
return redirect(url_for("waitlist.index"))
return render_template("tenant/waitlist/form.html",
staff_list=staff_list, services=services)
@waitlist_bp.route("/<int:entry_id>/notify", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def notify(entry_id):
entry = Waitlist.query.filter_by(
id=entry_id, tenant_id=g.tenant.id).first_or_404()
entry.status = "notified"
entry.notified_at = datetime.now(timezone.utc)
# Send email notification if available
if entry.customer_email:
try:
from flask_mail import Message
msg = Message(
subject=f"A slot is available — {g.tenant.name}",
recipients=[entry.customer_email],
body=(
f"Hi {entry.customer_name},\n\n"
"A slot has opened up for you. Please call or book online to confirm.\n\n"
f"{g.tenant.name}"
),
)
mail.send(msg)
except Exception as exc:
logger.error("Waitlist notify email failed: %s", exc)
log_tenant_action("waitlist.notify", "waitlist", entry.id,
{"name": entry.customer_name})
db.session.commit()
flash(f"'{entry.customer_name}' notified.", "success")
return redirect(url_for("waitlist.index"))
@waitlist_bp.route("/<int:entry_id>/set-status", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def set_status(entry_id):
entry = Waitlist.query.filter_by(
id=entry_id, tenant_id=g.tenant.id).first_or_404()
new_status = request.form.get("status", "")
if new_status in ("booked", "expired"):
entry.status = new_status
log_tenant_action("waitlist.set_status", "waitlist", entry.id,
{"status": new_status})
db.session.commit()
flash(f"Entry updated to '{new_status}'.", "success")
return redirect(url_for("waitlist.index"))
def _parse_date(value):
if not value:
return None
try:
from datetime import date
return date.fromisoformat(value)
except ValueError:
return None