Phase 3 - credts, usage, plans app pages #Migrations

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-06 21:28:13 +00:00
parent cb8e747387
commit 9ca048fb9d
37 changed files with 9328 additions and 1149 deletions

View File

@@ -157,6 +157,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.ai.ai_core import AICore
from igny8_core.ai.prompts import PromptRegistry
from igny8_core.business.billing.services.credit_service import CreditService
logger.info("=" * 80)
logger.info(f"process_image_generation_queue STARTED")
@@ -709,6 +710,33 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
})
failed += 1
else:
# Deduct credits for successful image generation
credits_deducted = 0
cost_usd = result.get('cost_usd', 0)
if account:
try:
credits_deducted = CreditService.deduct_credits_for_image(
account=account,
model_name=model,
num_images=1,
description=f"Image generation: {content.title[:50] if content else 'Image'}" if content else f"Image {image_id}",
metadata={
'image_id': image_id,
'content_id': content_id,
'provider': provider,
'model': model,
'image_type': image.image_type if image else 'unknown',
'size': image_size,
},
cost_usd=cost_usd,
related_object_type='image',
related_object_id=image_id
)
logger.info(f"[process_image_generation_queue] Credits deducted for image {image_id}: account balance now {credits_deducted}")
except Exception as credit_error:
logger.error(f"[process_image_generation_queue] Failed to deduct credits for image {image_id}: {credit_error}")
# Don't fail the image generation if credit deduction fails
# Update progress: Complete (100%)
self.update_state(
state='PROGRESS',

View File

@@ -132,6 +132,16 @@ class TeamManagementViewSet(viewsets.ViewSet):
status=status.HTTP_400_BAD_REQUEST
)
# Check hard limit for users BEFORE creating
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
try:
LimitService.check_hard_limit(account, 'users', additional_count=1)
except HardLimitExceededError as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
# Create user (simplified - in production, send invitation email)
user = User.objects.create_user(
email=email,

View File

@@ -117,7 +117,7 @@ class PlanResource(resources.ModelResource):
class Meta:
model = Plan
fields = ('id', 'name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users',
'max_keywords', 'max_content_words', 'included_credits', 'is_active', 'is_featured')
'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@@ -127,7 +127,7 @@ class PlanResource(resources.ModelResource):
class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = PlanResource
"""Plan admin - Global, no account filtering needed"""
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active', 'is_featured']
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured']
list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured']
search_fields = ['name', 'slug']
readonly_fields = ['created_at']
@@ -147,12 +147,12 @@ class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
'description': 'Persistent limits for account-level resources'
}),
('Hard Limits (Persistent)', {
'fields': ('max_keywords', 'max_clusters'),
'fields': ('max_keywords',),
'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'
'fields': ('max_ahrefs_queries',),
'description': 'Monthly Ahrefs keyword research queries (0 = disabled)'
}),
('Billing & Credits', {
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')

View File

@@ -25,18 +25,7 @@ class Command(BaseCommand):
'max_users': 999999,
'max_sites': 999999,
'max_keywords': 999999,
'max_clusters': 999999,
'max_content_ideas': 999999,
'monthly_word_count_limit': 999999999,
'daily_content_tasks': 999999,
'daily_ai_requests': 999999,
'daily_ai_request_limit': 999999,
'monthly_ai_credit_limit': 999999,
'monthly_image_count': 999999,
'daily_image_generation_limit': 999999,
'monthly_cluster_ai_credits': 999999,
'monthly_content_ai_credits': 999999,
'monthly_image_ai_credits': 999999,
'max_ahrefs_queries': 999999,
'included_credits': 999999,
'is_active': True,
'features': ['ai_writer', 'image_gen', 'auto_publish', 'custom_prompts', 'unlimited'],

View File

@@ -0,0 +1,100 @@
# Generated by IGNY8 Phase 1: Simplify Credits & Limits
# Migration: Remove unused limit fields, add Ahrefs query tracking
# Date: January 5, 2026
from django.db import migrations, models
import django.core.validators
class Migration(migrations.Migration):
"""
Simplify the credits and limits system:
PLAN MODEL:
- REMOVE: max_clusters, max_content_ideas, max_content_words,
max_images_basic, max_images_premium, max_image_prompts
- ADD: max_ahrefs_queries (monthly keyword research queries)
ACCOUNT MODEL:
- REMOVE: usage_content_ideas, usage_content_words, usage_images_basic,
usage_images_premium, usage_image_prompts
- ADD: usage_ahrefs_queries
RATIONALE:
All consumption is now controlled by credits only. The only non-credit
limits are: sites, users, keywords (hard limits) and ahrefs_queries (monthly).
"""
dependencies = [
('igny8_core_auth', '0018_add_country_remove_intent_seedkeyword'),
]
operations = [
# STEP 1: Add new Ahrefs fields FIRST (before removing old ones)
migrations.AddField(
model_name='plan',
name='max_ahrefs_queries',
field=models.IntegerField(
default=0,
validators=[django.core.validators.MinValueValidator(0)],
help_text='Monthly Ahrefs keyword research queries (0 = disabled)'
),
),
migrations.AddField(
model_name='account',
name='usage_ahrefs_queries',
field=models.IntegerField(
default=0,
validators=[django.core.validators.MinValueValidator(0)],
help_text='Ahrefs queries used this month'
),
),
# STEP 2: Remove unused Plan fields
migrations.RemoveField(
model_name='plan',
name='max_clusters',
),
migrations.RemoveField(
model_name='plan',
name='max_content_ideas',
),
migrations.RemoveField(
model_name='plan',
name='max_content_words',
),
migrations.RemoveField(
model_name='plan',
name='max_images_basic',
),
migrations.RemoveField(
model_name='plan',
name='max_images_premium',
),
migrations.RemoveField(
model_name='plan',
name='max_image_prompts',
),
# STEP 3: Remove unused Account fields
migrations.RemoveField(
model_name='account',
name='usage_content_ideas',
),
migrations.RemoveField(
model_name='account',
name='usage_content_words',
),
migrations.RemoveField(
model_name='account',
name='usage_images_basic',
),
migrations.RemoveField(
model_name='account',
name='usage_images_premium',
),
migrations.RemoveField(
model_name='account',
name='usage_image_prompts',
),
]

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.2.9 on 2026-01-06 00:11
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0019_simplify_credits_limits'),
]
operations = [
migrations.RemoveField(
model_name='historicalaccount',
name='usage_content_ideas',
),
migrations.RemoveField(
model_name='historicalaccount',
name='usage_content_words',
),
migrations.RemoveField(
model_name='historicalaccount',
name='usage_image_prompts',
),
migrations.RemoveField(
model_name='historicalaccount',
name='usage_images_basic',
),
migrations.RemoveField(
model_name='historicalaccount',
name='usage_images_premium',
),
migrations.AddField(
model_name='historicalaccount',
name='usage_ahrefs_queries',
field=models.IntegerField(default=0, help_text='Ahrefs queries used this month', validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@@ -108,11 +108,7 @@ class Account(SoftDeletableModel):
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_ahrefs_queries = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Ahrefs queries used 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")
@@ -216,37 +212,12 @@ class Plan(models.Model):
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,
max_ahrefs_queries = models.IntegerField(
default=0,
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"
help_text="Monthly Ahrefs keyword research queries (0 = disabled)"
)
# Billing & Credits (Phase 0: Credit-only system)

View File

@@ -13,9 +13,7 @@ class PlanSerializer(serializers.ModelSerializer):
'id', 'name', 'slug', 'price', 'original_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',
'max_keywords', 'max_ahrefs_queries',
'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'

View File

@@ -1,6 +1,9 @@
"""
Management command to backfill usage tracking for existing content.
Usage: python manage.py backfill_usage [account_id]
NOTE: Since the simplification of limits (Jan 2026), this command only
tracks Ahrefs queries. All other usage is tracked via CreditUsageLog.
"""
from django.core.management.base import BaseCommand
from django.apps import apps
@@ -9,7 +12,7 @@ from igny8_core.auth.models import Account
class Command(BaseCommand):
help = 'Backfill usage tracking for existing content'
help = 'Backfill usage tracking for existing content (Ahrefs queries only)'
def add_arguments(self, parser):
parser.add_argument(
@@ -30,10 +33,6 @@ class Command(BaseCommand):
else:
accounts = Account.objects.filter(plan__isnull=False).select_related('plan')
ContentIdeas = apps.get_model('planner', 'ContentIdeas')
Content = apps.get_model('writer', 'Content')
Images = apps.get_model('writer', 'Images')
total_accounts = accounts.count()
self.stdout.write(f'Processing {total_accounts} account(s)...\n')
@@ -43,45 +42,14 @@ class Command(BaseCommand):
self.stdout.write(f'Plan: {account.plan.name if account.plan else "No Plan"}')
self.stdout.write('=' * 60)
# Count content ideas
ideas_count = ContentIdeas.objects.filter(account=account).count()
self.stdout.write(f'Content Ideas: {ideas_count}')
# Ahrefs queries are tracked in CreditUsageLog with operation_type='ahrefs_query'
# We don't backfill these as they should be tracked in real-time going forward
# This command is primarily for verification
# Count content words
from django.db.models import Sum
total_words = Content.objects.filter(account=account).aggregate(
total=Sum('word_count')
)['total'] or 0
self.stdout.write(f'Content Words: {total_words}')
# Count images
total_images = Images.objects.filter(account=account).count()
images_with_prompts = Images.objects.filter(
account=account, prompt__isnull=False
).exclude(prompt='').count()
self.stdout.write(f'Total Images: {total_images}')
self.stdout.write(f'Images with Prompts: {images_with_prompts}')
# Update account usage fields
with transaction.atomic():
account.usage_content_ideas = ideas_count
account.usage_content_words = total_words
account.usage_images_basic = total_images
account.usage_images_premium = 0 # Premium not implemented yet
account.usage_image_prompts = images_with_prompts
account.save(update_fields=[
'usage_content_ideas', 'usage_content_words',
'usage_images_basic', 'usage_images_premium', 'usage_image_prompts',
'updated_at'
])
self.stdout.write(self.style.SUCCESS('\n✅ Updated usage tracking:'))
self.stdout.write(f' usage_content_ideas: {account.usage_content_ideas}')
self.stdout.write(f' usage_content_words: {account.usage_content_words}')
self.stdout.write(f' usage_images_basic: {account.usage_images_basic}')
self.stdout.write(f' usage_images_premium: {account.usage_images_premium}')
self.stdout.write(f' usage_image_prompts: {account.usage_image_prompts}\n')
self.stdout.write(f'Ahrefs queries used this month: {account.usage_ahrefs_queries}')
self.stdout.write(self.style.SUCCESS('\n✅ Verified usage tracking'))
self.stdout.write(f' usage_ahrefs_queries: {account.usage_ahrefs_queries}\n')
self.stdout.write('=' * 60)
self.stdout.write(self.style.SUCCESS('Backfill complete!'))
self.stdout.write(self.style.SUCCESS('Verification complete!'))
self.stdout.write('=' * 60)

View File

@@ -1,6 +1,6 @@
"""
Limit Service for Plan Limit Enforcement
Manages hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images, prompts)
Manages hard limits (sites, users, keywords) and monthly limits (ahrefs_queries)
"""
from django.db import transaction
from django.utils import timezone
@@ -18,12 +18,12 @@ class LimitExceededError(Exception):
class HardLimitExceededError(LimitExceededError):
"""Raised when a hard limit (sites, users, keywords, clusters) is exceeded"""
"""Raised when a hard limit (sites, users, keywords) is exceeded"""
pass
class MonthlyLimitExceededError(LimitExceededError):
"""Raised when a monthly limit (ideas, words, images, prompts) is exceeded"""
"""Raised when a monthly limit (ahrefs_queries) is exceeded"""
pass
@@ -31,6 +31,7 @@ class LimitService:
"""Service for managing and enforcing plan limits"""
# Map limit types to model/field names
# Simplified to only 3 hard limits: sites, users, keywords
HARD_LIMIT_MAPPINGS = {
'sites': {
'model': 'igny8_core_auth.Site',
@@ -39,10 +40,10 @@ class LimitService:
'filter_field': 'account',
},
'users': {
'model': 'igny8_core_auth.SiteUserAccess',
'model': 'igny8_core_auth.User',
'plan_field': 'max_users',
'display_name': 'Team Users',
'filter_field': 'site__account',
'display_name': 'Team Members',
'filter_field': 'account',
},
'keywords': {
'model': 'planner.Keywords',
@@ -50,39 +51,15 @@ class LimitService:
'display_name': 'Keywords',
'filter_field': 'account',
},
'clusters': {
'model': 'planner.Clusters',
'plan_field': 'max_clusters',
'display_name': 'Clusters',
'filter_field': 'account',
},
}
# Simplified to only 1 monthly limit: ahrefs_queries
# All other consumption is controlled by credits only
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',
'ahrefs_queries': {
'plan_field': 'max_ahrefs_queries',
'usage_field': 'usage_ahrefs_queries',
'display_name': 'Keyword Research Queries',
},
}
@@ -318,11 +295,8 @@ class LimitService:
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
# Reset only ahrefs_queries (the only monthly limit now)
account.usage_ahrefs_queries = 0
old_period_end = account.usage_period_end
@@ -341,8 +315,7 @@ class LimitService:
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_ahrefs_queries',
'usage_period_start', 'usage_period_end', 'updated_at'
])
@@ -353,5 +326,5 @@ class LimitService:
'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,
'limits_reset': 1,
}

View File

@@ -68,6 +68,10 @@ class DefaultsService:
Returns:
Tuple of (Site, PublishingSettings, AutomationConfig)
"""
# Check hard limit for sites BEFORE creating
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
LimitService.check_hard_limit(self.account, 'sites', additional_count=1)
# Create the site
site = Site.objects.create(
account=self.account,

View File

@@ -29,23 +29,10 @@ class Command(BaseCommand):
],
'Planner': [
('max_keywords', 'Max Keywords'),
('max_clusters', 'Max Clusters'),
('max_content_ideas', 'Max Content Ideas'),
('daily_cluster_limit', 'Daily Cluster Limit'),
('max_ahrefs_queries', 'Max Ahrefs Queries'),
],
'Writer': [
('monthly_word_count_limit', 'Monthly Word Count Limit'),
('daily_content_tasks', 'Daily Content Tasks'),
],
'Images': [
('monthly_image_count', 'Monthly Image Count'),
('daily_image_generation_limit', 'Daily Image Generation Limit'),
],
'AI Credits': [
('monthly_ai_credit_limit', 'Monthly AI Credit Limit'),
('monthly_cluster_ai_credits', 'Monthly Cluster AI Credits'),
('monthly_content_ai_credits', 'Monthly Content AI Credits'),
('monthly_image_ai_credits', 'Monthly Image AI Credits'),
'Credits': [
('included_credits', 'Included Credits'),
],
}