Files
MyPOS/app/tenant/services/routes.py
T
2026-05-07 12:17:19 -04:00

283 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 1100%.")
starts_raw = request.form.get("starts_at", "")
ends_raw = request.form.get("ends_at", "")
if not starts_raw or not ends_raw:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Start and end dates are required.")
try:
starts_at = datetime.fromisoformat(starts_raw)
ends_at = datetime.fromisoformat(ends_raw)
except ValueError:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Invalid date format.")
# Build target_ids_json for specific targets
target_ids = None
if applies_to in ("service", "product"):
raw_ids = request.form.getlist("target_ids")
target_ids = [int(i) for i in raw_ids if i.isdigit()]
if not target_ids:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Please select at least one target.")
from flask_login import current_user
promo = Promotion(
tenant_id=g.tenant.id, name=name,
discount_percent=pct, applies_to=applies_to,
target_ids_json=target_ids,
starts_at=starts_at, ends_at=ends_at,
is_active=True,
created_by=_user_id(),
)
db.session.add(promo)
db.session.flush()
log_tenant_action("promotion.create", "promotion", promo.id,
{"name": name, "discount": pct})
db.session.commit()
flash(f"Promotion \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products)
@services_bp.route("/promotions/<int:promo_id>/toggle", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def toggle_promotion(promo_id):
promo = Promotion.query.filter_by(
id=promo_id, tenant_id=g.tenant.id).first_or_404()
promo.is_active = not promo.is_active
action = "promotion.activate" if promo.is_active else "promotion.deactivate"
log_tenant_action(action, "promotion", promo.id, {"name": promo.name})
db.session.commit()
status = "activated" if promo.is_active else "deactivated"
flash(f"Promotion \'{promo.name}\' {status}.", "success")
return redirect(url_for("services.index"))
def _user_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None