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:
@@ -111,17 +111,26 @@ class AccountAdminForm(forms.ModelForm):
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
"""Plan admin - Global, no account filtering needed"""
|
||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'included_credits', 'is_active']
|
||||
list_filter = ['is_active', 'billing_cycle']
|
||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active']
|
||||
list_filter = ['is_active', 'billing_cycle', 'is_internal']
|
||||
search_fields = ['name', 'slug']
|
||||
readonly_fields = ['created_at']
|
||||
|
||||
fieldsets = (
|
||||
('Plan Info', {
|
||||
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
|
||||
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active', 'is_internal')
|
||||
}),
|
||||
('Account Management Limits', {
|
||||
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
|
||||
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles'),
|
||||
'description': 'Persistent limits for account-level resources'
|
||||
}),
|
||||
('Hard Limits (Persistent)', {
|
||||
'fields': ('max_keywords', 'max_clusters'),
|
||||
'description': 'Total allowed - never reset'
|
||||
}),
|
||||
('Monthly Limits (Reset on Billing Cycle)', {
|
||||
'fields': ('max_content_ideas', 'max_content_words', 'max_images_basic', 'max_images_premium', 'max_image_prompts'),
|
||||
'description': 'Monthly allowances - reset at billing cycle'
|
||||
}),
|
||||
('Billing & Credits', {
|
||||
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-12 11:26
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0012_fix_subscription_constraints'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_clusters',
|
||||
field=models.IntegerField(default=100, help_text='Maximum AI keyword clusters allowed (hard limit)', validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_content_ideas',
|
||||
field=models.IntegerField(default=300, help_text='Maximum AI content ideas per month', validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_content_words',
|
||||
field=models.IntegerField(default=100000, help_text='Maximum content words per month (e.g., 100000 = 100K words)', validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_image_prompts',
|
||||
field=models.IntegerField(default=300, help_text='Maximum image prompts per month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_images_basic',
|
||||
field=models.IntegerField(default=300, help_text='Maximum basic AI images per month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_images_premium',
|
||||
field=models.IntegerField(default=60, help_text='Maximum premium AI images per month (DALL-E)', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_keywords',
|
||||
field=models.IntegerField(default=1000, help_text='Maximum total keywords allowed (hard limit)', validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-12 12:24
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0013_plan_max_clusters_plan_max_content_ideas_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='usage_content_ideas',
|
||||
field=models.IntegerField(default=0, help_text='Content ideas generated this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='usage_content_words',
|
||||
field=models.IntegerField(default=0, help_text='Content words generated this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='usage_image_prompts',
|
||||
field=models.IntegerField(default=0, help_text='Image prompts this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='usage_images_basic',
|
||||
field=models.IntegerField(default=0, help_text='Basic AI images this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='usage_images_premium',
|
||||
field=models.IntegerField(default=0, help_text='Premium AI images this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='usage_period_end',
|
||||
field=models.DateTimeField(blank=True, help_text='Current billing period end', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='usage_period_start',
|
||||
field=models.DateTimeField(blank=True, help_text='Current billing period start', null=True),
|
||||
),
|
||||
]
|
||||
@@ -106,6 +106,15 @@ class Account(SoftDeletableModel):
|
||||
billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code")
|
||||
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
||||
|
||||
# Monthly usage tracking (reset on billing cycle)
|
||||
usage_content_ideas = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content ideas generated this month")
|
||||
usage_content_words = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content words generated this month")
|
||||
usage_images_basic = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Basic AI images this month")
|
||||
usage_images_premium = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Premium AI images this month")
|
||||
usage_image_prompts = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Image prompts this month")
|
||||
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
|
||||
usage_period_end = models.DateTimeField(null=True, blank=True, help_text="Current billing period end")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -192,6 +201,45 @@ class Plan(models.Model):
|
||||
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
|
||||
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
|
||||
|
||||
# Hard Limits (Persistent - user manages within limit)
|
||||
max_keywords = models.IntegerField(
|
||||
default=1000,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum total keywords allowed (hard limit)"
|
||||
)
|
||||
max_clusters = models.IntegerField(
|
||||
default=100,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum AI keyword clusters allowed (hard limit)"
|
||||
)
|
||||
|
||||
# Monthly Limits (Reset on billing cycle)
|
||||
max_content_ideas = models.IntegerField(
|
||||
default=300,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum AI content ideas per month"
|
||||
)
|
||||
max_content_words = models.IntegerField(
|
||||
default=100000,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum content words per month (e.g., 100000 = 100K words)"
|
||||
)
|
||||
max_images_basic = models.IntegerField(
|
||||
default=300,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Maximum basic AI images per month"
|
||||
)
|
||||
max_images_premium = models.IntegerField(
|
||||
default=60,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Maximum premium AI images per month (DALL-E)"
|
||||
)
|
||||
max_image_prompts = models.IntegerField(
|
||||
default=300,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Maximum image prompts per month"
|
||||
)
|
||||
|
||||
# Billing & Credits (Phase 0: Credit-only system)
|
||||
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
|
||||
extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit")
|
||||
|
||||
@@ -13,6 +13,9 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
'id', 'name', 'slug', 'price', 'billing_cycle', 'annual_discount_percent',
|
||||
'is_featured', 'features', 'is_active',
|
||||
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
||||
'max_keywords', 'max_clusters',
|
||||
'max_content_ideas', 'max_content_words',
|
||||
'max_images_basic', 'max_images_premium', 'max_image_prompts',
|
||||
'included_credits', 'extra_credit_price', 'allow_credit_topup',
|
||||
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
||||
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
||||
|
||||
@@ -529,6 +529,14 @@ class SiteViewSet(AccountModelViewSet):
|
||||
if user and user.is_authenticated:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Check hard limit for sites
|
||||
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
|
||||
try:
|
||||
LimitService.check_hard_limit(account, 'sites', additional_count=1)
|
||||
except HardLimitExceededError as e:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied(str(e))
|
||||
|
||||
# Multiple sites can be active simultaneously - no constraint
|
||||
site = serializer.save(account=account)
|
||||
|
||||
|
||||
@@ -189,6 +189,83 @@ class CreditCostConfig(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PlanLimitUsage(AccountBaseModel):
|
||||
"""
|
||||
Track monthly usage of plan limits (ideas, words, images, prompts)
|
||||
Resets at start of each billing period
|
||||
"""
|
||||
LIMIT_TYPE_CHOICES = [
|
||||
('content_ideas', 'Content Ideas'),
|
||||
('content_words', 'Content Words'),
|
||||
('images_basic', 'Basic Images'),
|
||||
('images_premium', 'Premium Images'),
|
||||
('image_prompts', 'Image Prompts'),
|
||||
]
|
||||
|
||||
limit_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=LIMIT_TYPE_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Type of limit being tracked"
|
||||
)
|
||||
amount_used = models.IntegerField(
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Amount used in current period"
|
||||
)
|
||||
|
||||
# Billing period tracking
|
||||
period_start = models.DateField(
|
||||
help_text="Start date of billing period"
|
||||
)
|
||||
period_end = models.DateField(
|
||||
help_text="End date of billing period"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Additional tracking data (e.g., breakdown by site)"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'billing'
|
||||
db_table = 'igny8_plan_limit_usage'
|
||||
verbose_name = 'Plan Limit Usage'
|
||||
verbose_name_plural = 'Plan Limit Usage Records'
|
||||
unique_together = [['account', 'limit_type', 'period_start']]
|
||||
ordering = ['-period_start', 'limit_type']
|
||||
indexes = [
|
||||
models.Index(fields=['account', 'limit_type']),
|
||||
models.Index(fields=['account', 'period_start', 'period_end']),
|
||||
models.Index(fields=['limit_type', 'period_start']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
account = getattr(self, 'account', None)
|
||||
return f"{account.name if account else 'No Account'} - {self.get_limit_type_display()} - {self.amount_used} used"
|
||||
|
||||
def is_current_period(self):
|
||||
"""Check if this record is for the current billing period"""
|
||||
from django.utils import timezone
|
||||
today = timezone.now().date()
|
||||
return self.period_start <= today <= self.period_end
|
||||
|
||||
def remaining_allowance(self, plan_limit):
|
||||
"""Calculate remaining allowance"""
|
||||
return max(0, plan_limit - self.amount_used)
|
||||
|
||||
def percentage_used(self, plan_limit):
|
||||
"""Calculate percentage of limit used"""
|
||||
if plan_limit == 0:
|
||||
return 0
|
||||
return min(100, int((self.amount_used / plan_limit) * 100))
|
||||
|
||||
|
||||
class Invoice(AccountBaseModel):
|
||||
"""
|
||||
Invoice for subscription or credit purchases
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ from .views import (
|
||||
PaymentViewSet,
|
||||
CreditPackageViewSet,
|
||||
AccountPaymentMethodViewSet,
|
||||
get_usage_summary,
|
||||
)
|
||||
from igny8_core.modules.billing.views import (
|
||||
CreditBalanceViewSet,
|
||||
@@ -29,4 +30,6 @@ router.register(r'payment-configs', BillingViewSet, basename='payment-configs')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
# User-facing usage summary endpoint for plan limits
|
||||
path('usage-summary/', get_usage_summary, name='usage-summary'),
|
||||
]
|
||||
|
||||
@@ -866,3 +866,44 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
|
||||
{'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results},
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# USAGE SUMMARY (Plan Limits) - User-facing endpoint
|
||||
# ============================================================================
|
||||
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticatedAndActive, HasTenantAccess])
|
||||
def get_usage_summary(request):
|
||||
"""
|
||||
Get comprehensive usage summary for current account.
|
||||
Includes hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images).
|
||||
|
||||
GET /api/v1/billing/usage-summary/
|
||||
"""
|
||||
try:
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
from igny8_core.business.billing.services.limit_service import LimitService
|
||||
summary = LimitService.get_usage_summary(account)
|
||||
|
||||
return success_response(
|
||||
data=summary,
|
||||
message='Usage summary retrieved successfully.',
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting usage summary: {str(e)}', exc_info=True)
|
||||
return error_response(
|
||||
error=f'Failed to retrieve usage summary: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@@ -276,6 +276,55 @@ class Content(SoftDeletableModel, SiteSectorBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return self.title or f"Content {self.id}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to auto-calculate word_count from content_html"""
|
||||
is_new = self.pk is None
|
||||
old_word_count = 0
|
||||
|
||||
# Get old word count if updating
|
||||
if not is_new and self.content_html:
|
||||
try:
|
||||
old_instance = Content.objects.get(pk=self.pk)
|
||||
old_word_count = old_instance.word_count or 0
|
||||
except Content.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Auto-calculate word count if content_html has changed
|
||||
if self.content_html:
|
||||
from igny8_core.utils.word_counter import calculate_word_count
|
||||
calculated_count = calculate_word_count(self.content_html)
|
||||
# Only update if different to avoid unnecessary saves
|
||||
if self.word_count != calculated_count:
|
||||
self.word_count = calculated_count
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Increment usage for new content or if word count increased
|
||||
if self.content_html and self.word_count:
|
||||
# Only count newly generated words
|
||||
new_words = self.word_count - old_word_count if not is_new else self.word_count
|
||||
|
||||
if new_words > 0:
|
||||
from igny8_core.business.billing.services.limit_service import LimitService
|
||||
try:
|
||||
# Get account from site
|
||||
account = self.site.account if self.site else None
|
||||
if account:
|
||||
LimitService.increment_usage(
|
||||
account=account,
|
||||
limit_type='content_words',
|
||||
amount=new_words,
|
||||
metadata={
|
||||
'content_id': self.id,
|
||||
'content_title': self.title,
|
||||
'site_id': self.site.id if self.site else None,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error incrementing word usage for content {self.id}: {str(e)}")
|
||||
|
||||
|
||||
class ContentTaxonomy(SiteSectorBaseModel):
|
||||
|
||||
@@ -30,12 +30,20 @@ class ContentGenerationService:
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
from igny8_core.business.billing.services.limit_service import LimitService, MonthlyLimitExceededError
|
||||
|
||||
# Get tasks
|
||||
tasks = Tasks.objects.filter(id__in=task_ids, account=account)
|
||||
|
||||
# Calculate estimated credits needed based on word count
|
||||
total_word_count = sum(task.word_count or 1000 for task in tasks)
|
||||
|
||||
# Check monthly word count limit
|
||||
try:
|
||||
LimitService.check_monthly_limit(account, 'content_words', amount=total_word_count)
|
||||
except MonthlyLimitExceededError as e:
|
||||
raise InsufficientCreditsError(str(e))
|
||||
|
||||
# Check credits
|
||||
try:
|
||||
self.credit_service.check_credits(account, 'content_generation', total_word_count)
|
||||
|
||||
@@ -25,6 +25,15 @@ app.conf.beat_schedule = {
|
||||
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
|
||||
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
|
||||
},
|
||||
# Plan Limits Tasks
|
||||
'reset-monthly-plan-limits': {
|
||||
'task': 'reset_monthly_plan_limits',
|
||||
'schedule': crontab(hour=0, minute=30), # Daily at 00:30 to check for period end
|
||||
},
|
||||
'check-approaching-limits': {
|
||||
'task': 'check_approaching_limits',
|
||||
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
|
||||
},
|
||||
# Automation Tasks
|
||||
'check-scheduled-automations': {
|
||||
'task': 'automation.check_scheduled_automations',
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-09 13:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0013_add_webhook_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='payment',
|
||||
name='payment_account_status_created_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='invoice',
|
||||
name='billing_email',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='invoice',
|
||||
name='billing_period_end',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='invoice',
|
||||
name='billing_period_start',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='payment',
|
||||
name='transaction_reference',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountpaymentmethod',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('stripe', 'Stripe (Credit/Debit Card)'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer (Manual)'), ('local_wallet', 'Local Wallet (Manual)'), ('manual', 'Manual Payment')], db_index=True, max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='credittransaction',
|
||||
name='reference_id',
|
||||
field=models.CharField(blank=True, help_text='DEPRECATED: Use payment FK. Legacy reference (e.g., payment id, invoice id)', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='payment_method',
|
||||
field=models.CharField(choices=[('stripe', 'Stripe (Credit/Debit Card)'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer (Manual)'), ('local_wallet', 'Local Wallet (Manual)'), ('manual', 'Manual Payment')], max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='webhook_url',
|
||||
field=models.URLField(blank=True, help_text='Webhook URL for payment gateway callbacks'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-12 11:26
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0013_add_webhook_config'),
|
||||
('igny8_core_auth', '0013_plan_max_clusters_plan_max_content_ideas_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PlanLimitUsage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('limit_type', models.CharField(choices=[('content_ideas', 'Content Ideas'), ('content_words', 'Content Words'), ('images_basic', 'Basic Images'), ('images_premium', 'Premium Images'), ('image_prompts', 'Image Prompts')], db_index=True, help_text='Type of limit being tracked', max_length=50)),
|
||||
('amount_used', models.IntegerField(default=0, help_text='Amount used in current period', validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('period_start', models.DateField(help_text='Start date of billing period')),
|
||||
('period_end', models.DateField(help_text='End date of billing period')),
|
||||
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional tracking data (e.g., breakdown by site)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Plan Limit Usage',
|
||||
'verbose_name_plural': 'Plan Limit Usage Records',
|
||||
'db_table': 'igny8_plan_limit_usage',
|
||||
'ordering': ['-period_start', 'limit_type'],
|
||||
'indexes': [models.Index(fields=['account', 'limit_type'], name='igny8_plan__tenant__993f7b_idx'), models.Index(fields=['account', 'period_start', 'period_end'], name='igny8_plan__tenant__aba01f_idx'), models.Index(fields=['limit_type', 'period_start'], name='igny8_plan__limit_t_d0f5ef_idx')],
|
||||
'unique_together': {('account', 'limit_type', 'period_start')},
|
||||
},
|
||||
),
|
||||
]
|
||||
168
backend/igny8_core/tasks/plan_limits.py
Normal file
168
backend/igny8_core/tasks/plan_limits.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Plan Limits Reset Task
|
||||
Scheduled task to reset monthly plan limits at billing period end
|
||||
"""
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from igny8_core.auth.models import Account
|
||||
from igny8_core.business.billing.services.limit_service import LimitService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name='reset_monthly_plan_limits')
|
||||
def reset_monthly_plan_limits():
|
||||
"""
|
||||
Reset monthly plan limits for accounts whose billing period has ended.
|
||||
|
||||
This task should run daily (recommended: midnight UTC).
|
||||
It finds all accounts where the billing period has ended and resets their monthly usage.
|
||||
|
||||
Monthly limits that get reset:
|
||||
- content_ideas
|
||||
- content_words
|
||||
- images_basic
|
||||
- images_premium
|
||||
- image_prompts
|
||||
|
||||
Hard limits (sites, users, keywords, clusters) are NOT reset.
|
||||
"""
|
||||
logger.info("Starting monthly plan limits reset task")
|
||||
|
||||
today = timezone.now().date()
|
||||
reset_count = 0
|
||||
error_count = 0
|
||||
|
||||
# Find all active accounts with subscriptions
|
||||
accounts = Account.objects.filter(
|
||||
status='active',
|
||||
subscription__isnull=False
|
||||
).select_related('subscription', 'plan')
|
||||
|
||||
logger.info(f"Found {accounts.count()} active accounts with subscriptions")
|
||||
|
||||
for account in accounts:
|
||||
try:
|
||||
subscription = account.subscription
|
||||
|
||||
# Check if billing period has ended
|
||||
if subscription.current_period_end and subscription.current_period_end.date() <= today:
|
||||
logger.info(f"Resetting limits for account {account.id} ({account.name}) - "
|
||||
f"period ended {subscription.current_period_end.date()}")
|
||||
|
||||
# Reset monthly limits
|
||||
result = LimitService.reset_monthly_limits(account)
|
||||
|
||||
# Update subscription period
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
# Calculate new period based on billing cycle
|
||||
plan = account.plan
|
||||
if plan.billing_cycle == 'monthly':
|
||||
new_period_start = subscription.current_period_end + timedelta(days=1)
|
||||
new_period_end = new_period_start + relativedelta(months=1) - timedelta(days=1)
|
||||
elif plan.billing_cycle == 'annual':
|
||||
new_period_start = subscription.current_period_end + timedelta(days=1)
|
||||
new_period_end = new_period_start + relativedelta(years=1) - timedelta(days=1)
|
||||
else:
|
||||
# Default to monthly
|
||||
new_period_start = subscription.current_period_end + timedelta(days=1)
|
||||
new_period_end = new_period_start + relativedelta(months=1) - timedelta(days=1)
|
||||
|
||||
# Update subscription
|
||||
subscription.current_period_start = new_period_start
|
||||
subscription.current_period_end = new_period_end
|
||||
subscription.save(update_fields=['current_period_start', 'current_period_end'])
|
||||
|
||||
reset_count += 1
|
||||
|
||||
logger.info(f"Reset complete for account {account.id}: "
|
||||
f"New period {new_period_start} to {new_period_end}")
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(f"Error resetting limits for account {account.id}: {str(e)}", exc_info=True)
|
||||
|
||||
logger.info(f"Monthly plan limits reset task complete: "
|
||||
f"{reset_count} accounts reset, {error_count} errors")
|
||||
|
||||
return {
|
||||
'reset_count': reset_count,
|
||||
'error_count': error_count,
|
||||
'total_accounts': accounts.count(),
|
||||
}
|
||||
|
||||
|
||||
@shared_task(name='check_approaching_limits')
|
||||
def check_approaching_limits(threshold_percentage=80):
|
||||
"""
|
||||
Check for accounts approaching their plan limits and send notifications.
|
||||
|
||||
Args:
|
||||
threshold_percentage: Percentage at which to trigger warning (default 80%)
|
||||
|
||||
This task should run daily.
|
||||
It checks both hard and monthly limits and sends warnings when usage exceeds threshold.
|
||||
"""
|
||||
logger.info(f"Starting limit warning check task (threshold: {threshold_percentage}%)")
|
||||
|
||||
warning_count = 0
|
||||
|
||||
# Find all active accounts
|
||||
accounts = Account.objects.filter(status='active').select_related('plan')
|
||||
|
||||
for account in accounts:
|
||||
try:
|
||||
if not account.plan:
|
||||
continue
|
||||
|
||||
# Get usage summary
|
||||
summary = LimitService.get_usage_summary(account)
|
||||
|
||||
warnings = []
|
||||
|
||||
# Check hard limits
|
||||
for limit_type, data in summary.get('hard_limits', {}).items():
|
||||
if data['percentage_used'] >= threshold_percentage:
|
||||
warnings.append({
|
||||
'type': 'hard',
|
||||
'limit_type': limit_type,
|
||||
'display_name': data['display_name'],
|
||||
'current': data['current'],
|
||||
'limit': data['limit'],
|
||||
'percentage': data['percentage_used'],
|
||||
})
|
||||
|
||||
# Check monthly limits
|
||||
for limit_type, data in summary.get('monthly_limits', {}).items():
|
||||
if data['percentage_used'] >= threshold_percentage:
|
||||
warnings.append({
|
||||
'type': 'monthly',
|
||||
'limit_type': limit_type,
|
||||
'display_name': data['display_name'],
|
||||
'current': data['current'],
|
||||
'limit': data['limit'],
|
||||
'percentage': data['percentage_used'],
|
||||
'resets_in_days': summary.get('days_until_reset', 0),
|
||||
})
|
||||
|
||||
if warnings:
|
||||
warning_count += 1
|
||||
logger.info(f"Account {account.id} ({account.name}) has {len(warnings)} limit warnings")
|
||||
|
||||
# TODO: Send email notification
|
||||
# from igny8_core.business.billing.services.email_service import send_limit_warning_email
|
||||
# send_limit_warning_email(account, warnings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking limits for account {account.id}: {str(e)}", exc_info=True)
|
||||
|
||||
logger.info(f"Limit warning check complete: {warning_count} accounts with warnings")
|
||||
|
||||
return {
|
||||
'warning_count': warning_count,
|
||||
'total_accounts': accounts.count(),
|
||||
}
|
||||
137
backend/igny8_core/utils/word_counter.py
Normal file
137
backend/igny8_core/utils/word_counter.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Word Counter Utility
|
||||
Standardized word counting from HTML content
|
||||
Single source of truth for Content.word_count calculation
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import BeautifulSoup, fallback to regex if not available
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
HAS_BEAUTIFULSOUP = True
|
||||
except ImportError:
|
||||
HAS_BEAUTIFULSOUP = False
|
||||
logger.warning("BeautifulSoup4 not available, using regex fallback for word counting")
|
||||
|
||||
|
||||
def calculate_word_count(html_content: str) -> int:
|
||||
"""
|
||||
Calculate word count from HTML content by stripping tags and counting words.
|
||||
|
||||
This is the SINGLE SOURCE OF TRUTH for word counting in IGNY8.
|
||||
All limit tracking and billing should use Content.word_count which is calculated using this function.
|
||||
|
||||
Args:
|
||||
html_content: HTML string to count words from
|
||||
|
||||
Returns:
|
||||
int: Number of words in the content
|
||||
|
||||
Examples:
|
||||
>>> calculate_word_count("<p>Hello world</p>")
|
||||
2
|
||||
>>> calculate_word_count("<h1>Title</h1><p>This is a paragraph.</p>")
|
||||
5
|
||||
>>> calculate_word_count("")
|
||||
0
|
||||
>>> calculate_word_count(None)
|
||||
0
|
||||
"""
|
||||
# Handle None or empty content
|
||||
if not html_content or not isinstance(html_content, str):
|
||||
return 0
|
||||
|
||||
html_content = html_content.strip()
|
||||
if not html_content:
|
||||
return 0
|
||||
|
||||
try:
|
||||
# Use BeautifulSoup if available (more accurate)
|
||||
if HAS_BEAUTIFULSOUP:
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
# Get text, removing all HTML tags
|
||||
text = soup.get_text(separator=' ', strip=True)
|
||||
else:
|
||||
# Fallback to regex (less accurate but functional)
|
||||
# Remove all HTML tags
|
||||
text = re.sub(r'<[^>]+>', ' ', html_content)
|
||||
# Remove extra whitespace
|
||||
text = ' '.join(text.split())
|
||||
|
||||
# Count words (split on whitespace)
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
words = text.split()
|
||||
word_count = len(words)
|
||||
|
||||
logger.debug(f"Calculated word count: {word_count} from {len(html_content)} chars of HTML")
|
||||
return word_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating word count: {e}", exc_info=True)
|
||||
# Return 0 on error rather than failing
|
||||
return 0
|
||||
|
||||
|
||||
def format_word_count(word_count: int) -> str:
|
||||
"""
|
||||
Format word count for display (e.g., 1000 -> "1K", 100000 -> "100K")
|
||||
|
||||
Args:
|
||||
word_count: Number of words
|
||||
|
||||
Returns:
|
||||
str: Formatted word count
|
||||
|
||||
Examples:
|
||||
>>> format_word_count(500)
|
||||
'500'
|
||||
>>> format_word_count(1500)
|
||||
'1.5K'
|
||||
>>> format_word_count(100000)
|
||||
'100K'
|
||||
>>> format_word_count(1500000)
|
||||
'1.5M'
|
||||
"""
|
||||
if word_count >= 1000000:
|
||||
return f"{word_count / 1000000:.1f}M"
|
||||
elif word_count >= 1000:
|
||||
# Show .5K if not a whole number, otherwise just show K
|
||||
result = word_count / 1000
|
||||
if result == int(result):
|
||||
return f"{int(result)}K"
|
||||
return f"{result:.1f}K"
|
||||
return str(word_count)
|
||||
|
||||
|
||||
def validate_word_count_limit(current_words: int, requested_words: int, limit: int) -> dict:
|
||||
"""
|
||||
Validate if requested word generation would exceed limit.
|
||||
|
||||
Args:
|
||||
current_words: Current word count used in period
|
||||
requested_words: Words being requested
|
||||
limit: Maximum words allowed
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
- allowed (bool): Whether operation is allowed
|
||||
- remaining (int): Remaining words available
|
||||
- would_exceed_by (int): How many words over limit if not allowed
|
||||
"""
|
||||
remaining = max(0, limit - current_words)
|
||||
allowed = current_words + requested_words <= limit
|
||||
would_exceed_by = max(0, (current_words + requested_words) - limit)
|
||||
|
||||
return {
|
||||
'allowed': allowed,
|
||||
'remaining': remaining,
|
||||
'would_exceed_by': would_exceed_by,
|
||||
'current': current_words,
|
||||
'requested': requested_words,
|
||||
'limit': limit,
|
||||
}
|
||||
Reference in New Issue
Block a user