""" 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//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//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//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//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//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