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

153 lines
4.5 KiB
Python

"""
app/tenant/utils.py
Shared helpers used across all tenant portal blueprints.
"""
import logging
from datetime import datetime, timezone
from functools import wraps
from flask import g, flash, redirect, url_for, request, jsonify
from flask_login import current_user
logger = logging.getLogger(__name__)
def log_tenant_action(action, target_type=None, target_id=None, details=None):
"""
Write a structured log entry for any tenant create/edit/delete action.
Not persisted to audit_log (tenant-level actions use application logs).
"""
logger.info(
"TENANT_ACTION action=%s target_type=%s target_id=%s tenant=%s user=%s details=%s",
action,
target_type,
target_id,
getattr(g, 'tenant', None) and g.tenant.id,
current_user.get_id() if current_user.is_authenticated else None,
details,
)
def plan_limit_check(resource: str) -> tuple[bool, str]:
"""
Check whether the current tenant has headroom to add one more of `resource`.
resource: 'staff' | 'location'
Returns (allowed: bool, error_message: str | None)
"""
from app.models.salon import Staff, Location
tenant = getattr(g, 'tenant', None)
if not tenant or not tenant.plan:
return True, None
plan = tenant.plan
if resource == 'staff':
if plan.max_staff is None:
return True, None
count = Staff.query.filter_by(
tenant_id=tenant.id, is_active=True
).filter(Staff.deleted_at.is_(None)).count()
if count >= plan.max_staff:
return False, (
f"Your plan allows a maximum of {plan.max_staff} active staff members. "
"Please upgrade your plan to add more."
)
elif resource == 'location':
if plan.max_locations is None:
return True, None
count = Location.query.filter_by(
tenant_id=tenant.id, is_active=True
).count()
if count >= plan.max_locations:
return False, (
f"Your plan allows a maximum of {plan.max_locations} locations. "
"Please upgrade your plan to add more."
)
return True, None
def get_active_promotion(tenant_id: int, item_id: int, item_type: str):
"""
Promotion engine — resolves the single best active promotion for a
service or product line item at checkout time.
item_type: 'service' | 'product'
Priority:
1. Specific promotion targeting this exact item ID
2. 'all_services' / 'all_products' promotion
3. 'all' promotion (covers both services and products)
If multiple promotions match at the same priority level, the highest
discount_percent wins. Returns None if no active promotion applies.
"""
from app.models.salon import Promotion
now = datetime.now(timezone.utc)
active = Promotion.query.filter_by(
tenant_id=tenant_id, is_active=True
).filter(
Promotion.starts_at <= now,
Promotion.ends_at >= now,
).all()
if not active:
return None
# Collect all candidates
candidates = []
for promo in active:
at = promo.applies_to
if at == 'all':
candidates.append((0, promo))
elif at == 'all_services' and item_type == 'service':
candidates.append((1, promo))
elif at == 'all_products' and item_type == 'product':
candidates.append((1, promo))
elif at == item_type:
# Specific IDs — check membership
target_ids = promo.target_ids_json or []
if item_id in target_ids:
candidates.append((2, promo))
if not candidates:
return None
# Sort: highest specificity first (2 > 1 > 0), then highest discount
candidates.sort(key=lambda x: (x[0], x[1].discount_percent), reverse=True)
return candidates[0][1]
def apply_promotion_to_price(price, promotion):
"""
Apply a promotion to a unit price.
Returns (discounted_price, discount_percent).
If promotion is None, returns original price and 0.
"""
from decimal import Decimal, ROUND_HALF_UP
if promotion is None:
return price, 0
pct = promotion.discount_percent
price = Decimal(str(price))
discount = (price * Decimal(pct) / Decimal(100)).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP
)
return float(price - discount), pct
def is_api_request():
return request.path.startswith('/api/') or \
request.accept_mimetypes.best == 'application/json'