feat: add Usage Limits Panel component with usage tracking and visual indicators for limits
style: implement custom color schemes and gradients for account section, enhancing visual hierarchy
This commit is contained in:
357
backend/igny8_core/business/billing/services/limit_service.py
Normal file
357
backend/igny8_core/business/billing/services/limit_service.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Limit Service for Plan Limit Enforcement
|
||||
Manages hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images, prompts)
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from igny8_core.auth.models import Account
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LimitExceededError(Exception):
|
||||
"""Base exception for limit exceeded errors"""
|
||||
pass
|
||||
|
||||
|
||||
class HardLimitExceededError(LimitExceededError):
|
||||
"""Raised when a hard limit (sites, users, keywords, clusters) is exceeded"""
|
||||
pass
|
||||
|
||||
|
||||
class MonthlyLimitExceededError(LimitExceededError):
|
||||
"""Raised when a monthly limit (ideas, words, images, prompts) is exceeded"""
|
||||
pass
|
||||
|
||||
|
||||
class LimitService:
|
||||
"""Service for managing and enforcing plan limits"""
|
||||
|
||||
# Map limit types to model/field names
|
||||
HARD_LIMIT_MAPPINGS = {
|
||||
'sites': {
|
||||
'model': 'igny8_core_auth.Site',
|
||||
'plan_field': 'max_sites',
|
||||
'display_name': 'Sites',
|
||||
'filter_field': 'account',
|
||||
},
|
||||
'users': {
|
||||
'model': 'igny8_core_auth.SiteUserAccess',
|
||||
'plan_field': 'max_users',
|
||||
'display_name': 'Team Users',
|
||||
'filter_field': 'site__account',
|
||||
},
|
||||
'keywords': {
|
||||
'model': 'planner.Keywords',
|
||||
'plan_field': 'max_keywords',
|
||||
'display_name': 'Keywords',
|
||||
'filter_field': 'account',
|
||||
},
|
||||
'clusters': {
|
||||
'model': 'planner.Clusters',
|
||||
'plan_field': 'max_clusters',
|
||||
'display_name': 'Clusters',
|
||||
'filter_field': 'account',
|
||||
},
|
||||
}
|
||||
|
||||
MONTHLY_LIMIT_MAPPINGS = {
|
||||
'content_ideas': {
|
||||
'plan_field': 'max_content_ideas',
|
||||
'usage_field': 'usage_content_ideas',
|
||||
'display_name': 'Content Ideas',
|
||||
},
|
||||
'content_words': {
|
||||
'plan_field': 'max_content_words',
|
||||
'usage_field': 'usage_content_words',
|
||||
'display_name': 'Content Words',
|
||||
},
|
||||
'images_basic': {
|
||||
'plan_field': 'max_images_basic',
|
||||
'usage_field': 'usage_images_basic',
|
||||
'display_name': 'Basic Images',
|
||||
},
|
||||
'images_premium': {
|
||||
'plan_field': 'max_images_premium',
|
||||
'usage_field': 'usage_images_premium',
|
||||
'display_name': 'Premium Images',
|
||||
},
|
||||
'image_prompts': {
|
||||
'plan_field': 'max_image_prompts',
|
||||
'usage_field': 'usage_image_prompts',
|
||||
'display_name': 'Image Prompts',
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def check_hard_limit(account: Account, limit_type: str, additional_count: int = 1) -> bool:
|
||||
"""
|
||||
Check if adding items would exceed hard limit.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
limit_type: Type of limit
|
||||
additional_count: Number of items to add
|
||||
|
||||
Returns:
|
||||
bool: True if within limit
|
||||
|
||||
Raises:
|
||||
HardLimitExceededError: If limit would be exceeded
|
||||
"""
|
||||
from django.apps import apps
|
||||
|
||||
if limit_type not in LimitService.HARD_LIMIT_MAPPINGS:
|
||||
raise ValueError(f"Invalid hard limit type: {limit_type}")
|
||||
|
||||
config = LimitService.HARD_LIMIT_MAPPINGS[limit_type]
|
||||
plan = account.plan
|
||||
|
||||
if not plan:
|
||||
raise ValueError("Account has no plan")
|
||||
|
||||
plan_limit = getattr(plan, config['plan_field'])
|
||||
model_path = config['model']
|
||||
app_label, model_name = model_path.split('.')
|
||||
Model = apps.get_model(app_label, model_name)
|
||||
|
||||
filter_field = config.get('filter_field', 'account')
|
||||
filter_kwargs = {filter_field: account}
|
||||
current_count = Model.objects.filter(**filter_kwargs).count()
|
||||
new_count = current_count + additional_count
|
||||
|
||||
logger.info(f"Hard limit check: {limit_type} - Current: {current_count}, Requested: {additional_count}, Limit: {plan_limit}")
|
||||
|
||||
if new_count > plan_limit:
|
||||
raise HardLimitExceededError(
|
||||
f"{config['display_name']} limit exceeded. "
|
||||
f"Current: {current_count}, Limit: {plan_limit}. "
|
||||
f"Upgrade your plan to increase this limit."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_monthly_limit(account: Account, limit_type: str, amount: int = 1) -> bool:
|
||||
"""
|
||||
Check if operation would exceed monthly limit.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
limit_type: Type of limit
|
||||
amount: Amount to use
|
||||
|
||||
Returns:
|
||||
bool: True if within limit
|
||||
|
||||
Raises:
|
||||
MonthlyLimitExceededError: If limit would be exceeded
|
||||
"""
|
||||
if limit_type not in LimitService.MONTHLY_LIMIT_MAPPINGS:
|
||||
raise ValueError(f"Invalid monthly limit type: {limit_type}")
|
||||
|
||||
config = LimitService.MONTHLY_LIMIT_MAPPINGS[limit_type]
|
||||
plan = account.plan
|
||||
|
||||
if not plan:
|
||||
raise ValueError("Account has no plan")
|
||||
|
||||
plan_limit = getattr(plan, config['plan_field'])
|
||||
current_usage = getattr(account, config['usage_field'], 0)
|
||||
new_usage = current_usage + amount
|
||||
|
||||
logger.info(f"Monthly limit check: {limit_type} - Current: {current_usage}, Requested: {amount}, Limit: {plan_limit}")
|
||||
|
||||
if new_usage > plan_limit:
|
||||
period_end = account.usage_period_end or timezone.now().date()
|
||||
raise MonthlyLimitExceededError(
|
||||
f"{config['display_name']} limit exceeded. "
|
||||
f"Used: {current_usage}, Requested: {amount}, Limit: {plan_limit}. "
|
||||
f"Resets on {period_end.strftime('%B %d, %Y')}. "
|
||||
f"Upgrade your plan or wait for reset."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def increment_usage(account: Account, limit_type: str, amount: int = 1, metadata: dict = None) -> int:
|
||||
"""
|
||||
Increment monthly usage after successful operation.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
limit_type: Type of limit
|
||||
amount: Amount to increment
|
||||
metadata: Optional metadata
|
||||
|
||||
Returns:
|
||||
int: New usage amount
|
||||
"""
|
||||
if limit_type not in LimitService.MONTHLY_LIMIT_MAPPINGS:
|
||||
raise ValueError(f"Invalid monthly limit type: {limit_type}")
|
||||
|
||||
config = LimitService.MONTHLY_LIMIT_MAPPINGS[limit_type]
|
||||
usage_field = config['usage_field']
|
||||
|
||||
current_usage = getattr(account, usage_field, 0)
|
||||
new_usage = current_usage + amount
|
||||
setattr(account, usage_field, new_usage)
|
||||
account.save(update_fields=[usage_field, 'updated_at'])
|
||||
|
||||
logger.info(f"Incremented {limit_type} usage by {amount}. New total: {new_usage}")
|
||||
|
||||
return new_usage
|
||||
|
||||
@staticmethod
|
||||
def get_current_period(account: Account) -> tuple:
|
||||
"""
|
||||
Get current billing period start and end dates from account.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
|
||||
Returns:
|
||||
tuple: (period_start, period_end) as datetime objects
|
||||
"""
|
||||
if account.usage_period_start and account.usage_period_end:
|
||||
return account.usage_period_start, account.usage_period_end
|
||||
|
||||
subscription = getattr(account, 'subscription', None)
|
||||
|
||||
if subscription and hasattr(subscription, 'current_period_start'):
|
||||
period_start = subscription.current_period_start
|
||||
period_end = subscription.current_period_end
|
||||
else:
|
||||
now = timezone.now()
|
||||
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if now.month == 12:
|
||||
next_month = now.replace(year=now.year + 1, month=1, day=1)
|
||||
else:
|
||||
next_month = now.replace(month=now.month + 1, day=1)
|
||||
|
||||
period_end = next_month - timedelta(days=1)
|
||||
period_end = period_end.replace(hour=23, minute=59, second=59)
|
||||
|
||||
account.usage_period_start = period_start
|
||||
account.usage_period_end = period_end
|
||||
account.save(update_fields=['usage_period_start', 'usage_period_end', 'updated_at'])
|
||||
|
||||
return period_start, period_end
|
||||
|
||||
@staticmethod
|
||||
def get_usage_summary(account: Account) -> dict:
|
||||
"""
|
||||
Get comprehensive usage summary for all limits.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
|
||||
Returns:
|
||||
dict: Usage summary with hard and monthly limits
|
||||
"""
|
||||
from django.apps import apps
|
||||
|
||||
plan = account.plan
|
||||
if not plan:
|
||||
return {'error': 'No plan assigned to account'}
|
||||
|
||||
period_start, period_end = LimitService.get_current_period(account)
|
||||
days_until_reset = (period_end.date() - timezone.now().date()).days if period_end else 0
|
||||
|
||||
summary = {
|
||||
'account_id': account.id,
|
||||
'account_name': account.name,
|
||||
'plan_name': plan.name,
|
||||
'period_start': period_start.isoformat() if period_start else None,
|
||||
'period_end': period_end.isoformat() if period_end else None,
|
||||
'days_until_reset': days_until_reset,
|
||||
'hard_limits': {},
|
||||
'monthly_limits': {},
|
||||
}
|
||||
|
||||
for limit_type, config in LimitService.HARD_LIMIT_MAPPINGS.items():
|
||||
model_path = config['model']
|
||||
app_label, model_name = model_path.split('.')
|
||||
Model = apps.get_model(app_label, model_name)
|
||||
|
||||
filter_field = config.get('filter_field', 'account')
|
||||
filter_kwargs = {filter_field: account}
|
||||
current_count = Model.objects.filter(**filter_kwargs).count()
|
||||
plan_limit = getattr(plan, config['plan_field'])
|
||||
|
||||
summary['hard_limits'][limit_type] = {
|
||||
'display_name': config['display_name'],
|
||||
'current': current_count,
|
||||
'limit': plan_limit,
|
||||
'remaining': max(0, plan_limit - current_count),
|
||||
'percentage_used': int((current_count / plan_limit) * 100) if plan_limit > 0 else 0,
|
||||
}
|
||||
|
||||
for limit_type, config in LimitService.MONTHLY_LIMIT_MAPPINGS.items():
|
||||
plan_limit = getattr(plan, config['plan_field'])
|
||||
current_usage = getattr(account, config['usage_field'], 0)
|
||||
|
||||
summary['monthly_limits'][limit_type] = {
|
||||
'display_name': config['display_name'],
|
||||
'current': current_usage,
|
||||
'limit': plan_limit,
|
||||
'remaining': max(0, plan_limit - current_usage),
|
||||
'percentage_used': int((current_usage / plan_limit) * 100) if plan_limit > 0 else 0,
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def reset_monthly_limits(account: Account) -> dict:
|
||||
"""
|
||||
Reset all monthly limits for an account.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
|
||||
Returns:
|
||||
dict: Summary of reset operation
|
||||
"""
|
||||
account.usage_content_ideas = 0
|
||||
account.usage_content_words = 0
|
||||
account.usage_images_basic = 0
|
||||
account.usage_images_premium = 0
|
||||
account.usage_image_prompts = 0
|
||||
|
||||
old_period_end = account.usage_period_end
|
||||
|
||||
now = timezone.now()
|
||||
new_period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if now.month == 12:
|
||||
next_month = now.replace(year=now.year + 1, month=1, day=1)
|
||||
else:
|
||||
next_month = now.replace(month=now.month + 1, day=1)
|
||||
|
||||
new_period_end = next_month - timedelta(days=1)
|
||||
new_period_end = new_period_end.replace(hour=23, minute=59, second=59)
|
||||
|
||||
account.usage_period_start = new_period_start
|
||||
account.usage_period_end = new_period_end
|
||||
|
||||
account.save(update_fields=[
|
||||
'usage_content_ideas', 'usage_content_words',
|
||||
'usage_images_basic', 'usage_images_premium', 'usage_image_prompts',
|
||||
'usage_period_start', 'usage_period_end', 'updated_at'
|
||||
])
|
||||
|
||||
logger.info(f"Reset monthly limits for account {account.id}")
|
||||
|
||||
return {
|
||||
'account_id': account.id,
|
||||
'old_period_end': old_period_end.isoformat() if old_period_end else None,
|
||||
'new_period_start': new_period_start.isoformat(),
|
||||
'new_period_end': new_period_end.isoformat(),
|
||||
'limits_reset': 5,
|
||||
}
|
||||
Reference in New Issue
Block a user