05/07 Phase 3: initial codes
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user