283 lines
12 KiB
Python
283 lines
12 KiB
Python
"""
|
||
app/tenant/services/routes.py
|
||
Services, products, and promotions catalogue.
|
||
"""
|
||
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
|