05/07 Phase 3: initial codes

This commit is contained in:
2026-05-07 12:17:19 -04:00
parent 6e5518c103
commit ce462c57b2
107 changed files with 6365 additions and 208 deletions
+277 -2
View File
@@ -1,7 +1,282 @@
"""
app/tenant/services/routes.py
Phase 3+ implementation.
Services, products, and promotions catalogue.
"""
from flask import Blueprint
import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import Service, Product, Promotion
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
services_bp = Blueprint("services", __name__, url_prefix="/services")
# ── Services ──────────────────────────────────────────────────────────────────
@services_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
services = Service.query.filter_by(tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).order_by(
Service.category, Service.name).all()
products = Product.query.filter_by(tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).order_by(
Product.category, Product.name).all()
promotions = Promotion.query.filter_by(
tenant_id=g.tenant.id, is_active=True).order_by(
Promotion.ends_at).all()
return render_template("tenant/services/index.html",
services=services, products=products,
promotions=promotions)
@services_bp.route("/services/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_service():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/service_form.html",
mode="create", error="Name is required.")
try:
price = float(request.form.get("price", 0))
duration = int(request.form.get("duration_min", 30))
except ValueError:
return render_template("tenant/services/service_form.html",
mode="create", error="Invalid price or duration.")
svc = Service(
tenant_id=g.tenant.id, name=name,
category=request.form.get("category", "").strip() or None,
price=price, duration_min=duration, is_active=True,
)
db.session.add(svc)
db.session.flush()
log_tenant_action("service.create", "service", svc.id, {"name": name})
db.session.commit()
flash(f"Service \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/service_form.html", mode="create")
@services_bp.route("/services/<int:service_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit_service(service_id):
svc = Service.query.filter_by(
id=service_id, tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/service_form.html",
mode="edit", service=svc, error="Name is required.")
try:
price = float(request.form.get("price", 0))
duration = int(request.form.get("duration_min", 30))
except ValueError:
return render_template("tenant/services/service_form.html",
mode="edit", service=svc, error="Invalid price or duration.")
svc.name = name
svc.category = request.form.get("category", "").strip() or None
svc.price = price
svc.duration_min = duration
svc.is_active = request.form.get("is_active") == "1"
log_tenant_action("service.edit", "service", svc.id, {"name": name})
db.session.commit()
flash(f"Service \'{name}\' updated.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/service_form.html",
mode="edit", service=svc)
@services_bp.route("/services/<int:service_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def delete_service(service_id):
svc = Service.query.filter_by(
id=service_id, tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).first_or_404()
svc.deleted_at = datetime.now(timezone.utc)
log_tenant_action("service.delete", "service", svc.id, {"name": svc.name})
db.session.commit()
flash(f"Service \'{svc.name}\' deleted.", "success")
return redirect(url_for("services.index"))
# ── Products ──────────────────────────────────────────────────────────────────
@services_bp.route("/products/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_product():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/product_form.html",
mode="create", error="Name is required.")
try:
price = float(request.form.get("sale_price", 0))
except ValueError:
return render_template("tenant/services/product_form.html",
mode="create", error="Invalid price.")
prod = Product(
tenant_id=g.tenant.id, name=name,
sku=request.form.get("sku", "").strip() or None,
category=request.form.get("category", "").strip() or None,
sale_price=price, is_active=True,
)
db.session.add(prod)
db.session.flush()
log_tenant_action("product.create", "product", prod.id, {"name": name})
db.session.commit()
flash(f"Product \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/product_form.html", mode="create")
@services_bp.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit_product(product_id):
prod = Product.query.filter_by(
id=product_id, tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/product_form.html",
mode="edit", product=prod, error="Name is required.")
try:
price = float(request.form.get("sale_price", 0))
except ValueError:
return render_template("tenant/services/product_form.html",
mode="edit", product=prod, error="Invalid price.")
prod.name = name
prod.sku = request.form.get("sku", "").strip() or None
prod.category = request.form.get("category", "").strip() or None
prod.sale_price = price
prod.is_active = request.form.get("is_active") == "1"
log_tenant_action("product.edit", "product", prod.id, {"name": name})
db.session.commit()
flash(f"Product \'{name}\' updated.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/product_form.html",
mode="edit", product=prod)
@services_bp.route("/products/<int:product_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def delete_product(product_id):
prod = Product.query.filter_by(
id=product_id, tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).first_or_404()
prod.deleted_at = datetime.now(timezone.utc)
log_tenant_action("product.delete", "product", prod.id, {"name": prod.name})
db.session.commit()
flash(f"Product \'{prod.name}\' deleted.", "success")
return redirect(url_for("services.index"))
# ── Promotions ─────────────────────────────────────────────────────────────────
@services_bp.route("/promotions/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_promotion():
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
products = Product.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Product.deleted_at.is_(None)).order_by(Product.name).all()
if request.method == "POST":
name = request.form.get("name", "").strip()
applies_to = request.form.get("applies_to", "all_services")
try:
pct = int(request.form.get("discount_percent", 0))
assert 1 <= pct <= 100
except (ValueError, AssertionError):
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Discount must be 1100%.")
starts_raw = request.form.get("starts_at", "")
ends_raw = request.form.get("ends_at", "")
if not starts_raw or not ends_raw:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Start and end dates are required.")
try:
starts_at = datetime.fromisoformat(starts_raw)
ends_at = datetime.fromisoformat(ends_raw)
except ValueError:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Invalid date format.")
# Build target_ids_json for specific targets
target_ids = None
if applies_to in ("service", "product"):
raw_ids = request.form.getlist("target_ids")
target_ids = [int(i) for i in raw_ids if i.isdigit()]
if not target_ids:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Please select at least one target.")
from flask_login import current_user
promo = Promotion(
tenant_id=g.tenant.id, name=name,
discount_percent=pct, applies_to=applies_to,
target_ids_json=target_ids,
starts_at=starts_at, ends_at=ends_at,
is_active=True,
created_by=_user_id(),
)
db.session.add(promo)
db.session.flush()
log_tenant_action("promotion.create", "promotion", promo.id,
{"name": name, "discount": pct})
db.session.commit()
flash(f"Promotion \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products)
@services_bp.route("/promotions/<int:promo_id>/toggle", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def toggle_promotion(promo_id):
promo = Promotion.query.filter_by(
id=promo_id, tenant_id=g.tenant.id).first_or_404()
promo.is_active = not promo.is_active
action = "promotion.activate" if promo.is_active else "promotion.deactivate"
log_tenant_action(action, "promotion", promo.id, {"name": promo.name})
db.session.commit()
status = "activated" if promo.is_active else "deactivated"
flash(f"Promotion \'{promo.name}\' {status}.", "success")
return redirect(url_for("services.index"))
def _user_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None