05/07 Phase 3: initial codes
This commit is contained in:
+28
-8
@@ -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 3–6:
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 1–100%.")
|
||||
|
||||
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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user