From b390e02aa5ab60579a69939ed69673032d6602c2 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 12 Jan 2026 07:22:08 +0000 Subject: [PATCH] Creditsupdates in fotoer wdigets adn hoemapge and singe site settigns page #Run MIgration 0033 #MAJOR --- backend/igny8_core/ai/engine.py | 4 + .../igny8_core/ai/functions/auto_cluster.py | 3 +- .../ai/functions/generate_content.py | 1 + .../igny8_core/ai/functions/generate_ideas.py | 12 ++- .../ai/functions/generate_image_prompts.py | 1 + .../ai/functions/generate_images.py | 3 +- .../ai/functions/optimize_content.py | 1 + backend/igny8_core/api/account_views.py | 4 + backend/igny8_core/business/billing/models.py | 13 ++- .../billing/services/credit_service.py | 74 ++++++++------- .../0033_add_site_to_credit_usage_log.py | 28 ++++++ .../0034_backfill_credit_usage_log_site.py | 90 +++++++++++++++++++ backend/igny8_core/modules/billing/views.py | 8 ++ .../dashboard/CreditsUsageWidget.tsx | 6 ++ .../dashboard/OperationsCostsWidget.tsx | 28 +++++- frontend/src/pages/Dashboard/Home.tsx | 3 +- frontend/src/pages/Sites/Dashboard.tsx | 17 +++- 17 files changed, 251 insertions(+), 45 deletions(-) create mode 100644 backend/igny8_core/modules/billing/migrations/0033_add_site_to_credit_usage_log.py create mode 100644 backend/igny8_core/modules/billing/migrations/0034_backfill_credit_usage_log_site.py diff --git a/backend/igny8_core/ai/engine.py b/backend/igny8_core/ai/engine.py index 932e8b58..0ed48e34 100644 --- a/backend/igny8_core/ai/engine.py +++ b/backend/igny8_core/ai/engine.py @@ -444,6 +444,9 @@ class AIEngine: tokens_input = raw_response.get('input_tokens', 0) tokens_output = raw_response.get('output_tokens', 0) + # Extract site_id from save_result (could be from content, cluster, or task) + site_id = save_result.get('site_id') or save_result.get('site') + # Deduct credits based on actual token usage CreditService.deduct_credits_for_operation( account=self.account, @@ -454,6 +457,7 @@ class AIEngine: model_used=raw_response.get('model', ''), related_object_type=self._get_related_object_type(function_name), related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'), + site=site_id, metadata={ 'function_name': function_name, 'clusters_created': clusters_created, diff --git a/backend/igny8_core/ai/functions/auto_cluster.py b/backend/igny8_core/ai/functions/auto_cluster.py index e1b61f4c..1e8c7c27 100644 --- a/backend/igny8_core/ai/functions/auto_cluster.py +++ b/backend/igny8_core/ai/functions/auto_cluster.py @@ -340,6 +340,7 @@ class AutoClusterFunction(BaseAIFunction): return { 'count': clusters_created, 'clusters_created': clusters_created, - 'keywords_updated': keywords_updated + 'keywords_updated': keywords_updated, + 'site_id': site.id if site else None } diff --git a/backend/igny8_core/ai/functions/generate_content.py b/backend/igny8_core/ai/functions/generate_content.py index 17c8ef76..aed6b37f 100644 --- a/backend/igny8_core/ai/functions/generate_content.py +++ b/backend/igny8_core/ai/functions/generate_content.py @@ -329,6 +329,7 @@ class GenerateContentFunction(BaseAIFunction): 'content_id': content_record.id, 'task_id': task.id, 'word_count': word_count, + 'site_id': task.site_id if task.site_id else None, } diff --git a/backend/igny8_core/ai/functions/generate_ideas.py b/backend/igny8_core/ai/functions/generate_ideas.py index 00898721..61bb3d15 100644 --- a/backend/igny8_core/ai/functions/generate_ideas.py +++ b/backend/igny8_core/ai/functions/generate_ideas.py @@ -233,9 +233,19 @@ class GenerateIdeasFunction(BaseAIFunction): cluster.status = 'mapped' cluster.save() + # Get site_id from the first cluster if available + site_id = None + if clusters: + first_cluster = clusters[0] + if first_cluster.site_id: + site_id = first_cluster.site_id + elif first_cluster.sector and first_cluster.sector.site_id: + site_id = first_cluster.sector.site_id + return { 'count': ideas_created, - 'ideas_created': ideas_created + 'ideas_created': ideas_created, + 'site_id': site_id } diff --git a/backend/igny8_core/ai/functions/generate_image_prompts.py b/backend/igny8_core/ai/functions/generate_image_prompts.py index 0b286533..45f4ff96 100644 --- a/backend/igny8_core/ai/functions/generate_image_prompts.py +++ b/backend/igny8_core/ai/functions/generate_image_prompts.py @@ -214,6 +214,7 @@ class GenerateImagePromptsFunction(BaseAIFunction): return { 'count': prompts_created, 'prompts_created': prompts_created, + 'site_id': content.site_id if content.site_id else None } # Helper methods diff --git a/backend/igny8_core/ai/functions/generate_images.py b/backend/igny8_core/ai/functions/generate_images.py index e5c67776..10d736db 100644 --- a/backend/igny8_core/ai/functions/generate_images.py +++ b/backend/igny8_core/ai/functions/generate_images.py @@ -196,7 +196,8 @@ class GenerateImagesFunction(BaseAIFunction): return { 'count': 1, 'images_created': 1, - 'image_id': image.id + 'image_id': image.id, + 'site_id': task.site_id if task.site_id else None } diff --git a/backend/igny8_core/ai/functions/optimize_content.py b/backend/igny8_core/ai/functions/optimize_content.py index 0b0691b8..5434c274 100644 --- a/backend/igny8_core/ai/functions/optimize_content.py +++ b/backend/igny8_core/ai/functions/optimize_content.py @@ -150,6 +150,7 @@ class OptimizeContentFunction(BaseAIFunction): 'html_content': optimized_html, 'meta_title': optimized_meta_title, 'meta_description': optimized_meta_description, + 'site_id': content.site_id if content.site_id else None } # Helper methods diff --git a/backend/igny8_core/api/account_views.py b/backend/igny8_core/api/account_views.py index 2f73649d..404b7dec 100644 --- a/backend/igny8_core/api/account_views.py +++ b/backend/igny8_core/api/account_views.py @@ -303,6 +303,10 @@ class DashboardStatsViewSet(viewsets.ViewSet): account=account, created_at__gte=start_date ) + + # Filter by site if provided + if site_id: + usage_query = usage_query.filter(site_id=site_id) # Get operations grouped by type operations_data = usage_query.values('operation_type').annotate( diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index d6544860..ebe75044 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -85,7 +85,16 @@ class CreditUsageLog(AccountBaseModel): ('content', 'Content Generation'), # Legacy ('images', 'Image Generation'), # Legacy ] - + + # Site relationship - stored at creation time for proper filtering + site = models.ForeignKey( + 'igny8_core_auth.Site', + on_delete=models.CASCADE, + null=True, + blank=True, + help_text='Site where the operation was performed' + ) + operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True) credits_used = models.IntegerField(validators=[MinValueValidator(0)]) cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True) @@ -105,6 +114,8 @@ class CreditUsageLog(AccountBaseModel): models.Index(fields=['account', 'operation_type']), models.Index(fields=['account', 'created_at']), models.Index(fields=['account', 'operation_type', 'created_at']), + models.Index(fields=['site', 'created_at']), + models.Index(fields=['account', 'site', 'created_at']), ] def __str__(self): diff --git a/backend/igny8_core/business/billing/services/credit_service.py b/backend/igny8_core/business/billing/services/credit_service.py index 1b498455..b6878604 100644 --- a/backend/igny8_core/business/billing/services/credit_service.py +++ b/backend/igny8_core/business/billing/services/credit_service.py @@ -336,10 +336,10 @@ class CreditService: @staticmethod @transaction.atomic - def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None): + def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None, site=None): """ Deduct credits and log transaction. - + Args: account: Account instance amount: Number of credits to deduct @@ -352,20 +352,21 @@ class CreditService: tokens_output: Optional output tokens related_object_type: Optional related object type related_object_id: Optional related object ID - + site: Optional Site instance or site_id + Returns: int: New credit balance """ # Check sufficient credits (legacy: amount is already calculated) CreditService.check_credits_legacy(account, amount) - + # Store previous balance for low credits check previous_balance = account.credits - + # Deduct from account.credits account.credits -= amount account.save(update_fields=['credits']) - + # Create CreditTransaction CreditTransaction.objects.create( account=account, @@ -375,10 +376,11 @@ class CreditService: description=description, metadata=metadata or {} ) - + # Create CreditUsageLog CreditUsageLog.objects.create( account=account, + site=site, operation_type=operation_type, credits_used=amount, cost_usd=cost_usd, @@ -389,30 +391,31 @@ class CreditService: related_object_id=related_object_id, metadata=metadata or {} ) - + # Check and send low credits warning if applicable _check_low_credits_warning(account, previous_balance) - + return account.credits @staticmethod @transaction.atomic def deduct_credits_for_operation( - account, - operation_type, - tokens_input, + account, + operation_type, + tokens_input, tokens_output, - description=None, - metadata=None, - cost_usd=None, - model_used=None, - related_object_type=None, - related_object_id=None + description=None, + metadata=None, + cost_usd=None, + model_used=None, + related_object_type=None, + related_object_id=None, + site=None ): """ Deduct credits for an operation based on actual token usage. This is the ONLY way to deduct credits in the token-based system. - + Args: account: Account instance operation_type: Type of operation @@ -424,10 +427,11 @@ class CreditService: model_used: Optional AI model used related_object_type: Optional related object type related_object_id: Optional related object ID - + site: Optional Site instance or site_id + Returns: int: New credit balance - + Raises: ValueError: If tokens_input or tokens_output not provided """ @@ -437,18 +441,18 @@ class CreditService: f"tokens_input and tokens_output are REQUIRED for credit deduction. " f"Got: tokens_input={tokens_input}, tokens_output={tokens_output}" ) - + # Calculate credits from actual token usage credits_required = CreditService.calculate_credits_from_tokens( operation_type, tokens_input, tokens_output ) - + # Check sufficient credits if account.credits < credits_required: raise InsufficientCreditsError( f"Insufficient credits. Required: {credits_required}, Available: {account.credits}" ) - + # Auto-generate description if not provided if not description: total_tokens = tokens_input + tokens_output @@ -456,7 +460,7 @@ class CreditService: f"{operation_type}: {total_tokens} tokens " f"({tokens_input} in, {tokens_output} out) = {credits_required} credits" ) - + return CreditService.deduct_credits( account=account, amount=credits_required, @@ -468,7 +472,8 @@ class CreditService: tokens_input=tokens_input, tokens_output=tokens_output, related_object_type=related_object_type, - related_object_id=related_object_id + related_object_id=related_object_id, + site=site ) @staticmethod @@ -513,11 +518,12 @@ class CreditService: metadata: dict = None, cost_usd: float = None, related_object_type: str = None, - related_object_id: int = None + related_object_id: int = None, + site = None ): """ Deduct credits for image generation based on model's credits_per_image. - + Args: account: Account instance model_name: AI model used (e.g., 'dall-e-3', 'flux-1-1-pro') @@ -527,20 +533,21 @@ class CreditService: cost_usd: Optional cost in USD related_object_type: Optional related object type related_object_id: Optional related object ID - + site: Optional Site instance or site_id + Returns: int: New credit balance """ credits_required = CreditService.calculate_credits_for_image(model_name, num_images) - + if account.credits < credits_required: raise InsufficientCreditsError( f"Insufficient credits. Required: {credits_required}, Available: {account.credits}" ) - + if not description: description = f"Image generation: {num_images} images with {model_name} = {credits_required} credits" - + return CreditService.deduct_credits( account=account, amount=credits_required, @@ -552,6 +559,7 @@ class CreditService: tokens_input=None, tokens_output=None, related_object_type=related_object_type, - related_object_id=related_object_id + related_object_id=related_object_id, + site=site ) diff --git a/backend/igny8_core/modules/billing/migrations/0033_add_site_to_credit_usage_log.py b/backend/igny8_core/modules/billing/migrations/0033_add_site_to_credit_usage_log.py new file mode 100644 index 00000000..4bd76186 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0033_add_site_to_credit_usage_log.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.10 on 2026-01-12 06:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0032_historicalaimodelconfig_landscape_size_and_more'), + ('igny8_core_auth', '0020_fix_historical_account'), + ] + + operations = [ + migrations.AddField( + model_name='creditusagelog', + name='site', + field=models.ForeignKey(blank=True, help_text='Site where the operation was performed', null=True, on_delete=django.db.models.deletion.CASCADE, to='igny8_core_auth.site'), + ), + migrations.AddIndex( + model_name='creditusagelog', + index=models.Index(fields=['site', 'created_at'], name='igny8_credi_site_id_b628ed_idx'), + ), + migrations.AddIndex( + model_name='creditusagelog', + index=models.Index(fields=['account', 'site', 'created_at'], name='igny8_credi_tenant__ca31e1_idx'), + ), + ] diff --git a/backend/igny8_core/modules/billing/migrations/0034_backfill_credit_usage_log_site.py b/backend/igny8_core/modules/billing/migrations/0034_backfill_credit_usage_log_site.py new file mode 100644 index 00000000..50ef9bb6 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0034_backfill_credit_usage_log_site.py @@ -0,0 +1,90 @@ +# Generated by Django 5.2.10 on 2026-01-12 07:02 + +from django.db import migrations + + +def backfill_site_id(apps, schema_editor): + """ + Backfill site_id for existing CreditUsageLog records by looking up + the related objects (Content, Images, ContentIdeas, Clusters). + """ + CreditUsageLog = apps.get_model('billing', 'CreditUsageLog') + Content = apps.get_model('writer', 'Content') + Images = apps.get_model('writer', 'Images') + ContentIdeas = apps.get_model('planner', 'ContentIdeas') + Clusters = apps.get_model('planner', 'Clusters') + + # Backfill for content records + content_logs = CreditUsageLog.objects.filter( + site__isnull=True, + related_object_type='content', + related_object_id__isnull=False + ) + for log in content_logs: + try: + content = Content.objects.get(id=log.related_object_id) + log.site_id = content.site_id + log.save(update_fields=['site']) + except Content.DoesNotExist: + pass + + # Backfill for image records + image_logs = CreditUsageLog.objects.filter( + site__isnull=True, + related_object_type='image', + related_object_id__isnull=False + ) + for log in image_logs: + try: + image = Images.objects.get(id=log.related_object_id) + log.site_id = image.site_id + log.save(update_fields=['site']) + except Images.DoesNotExist: + pass + + # Backfill for content idea records + idea_logs = CreditUsageLog.objects.filter( + site__isnull=True, + related_object_type='content_idea', + related_object_id__isnull=False + ) + for log in idea_logs: + try: + idea = ContentIdeas.objects.get(id=log.related_object_id) + log.site_id = idea.site_id + log.save(update_fields=['site']) + except ContentIdeas.DoesNotExist: + pass + + # Backfill for cluster records + cluster_logs = CreditUsageLog.objects.filter( + site__isnull=True, + related_object_type='cluster', + related_object_id__isnull=False + ) + for log in cluster_logs: + try: + cluster = Clusters.objects.get(id=log.related_object_id) + log.site_id = cluster.site_id + log.save(update_fields=['site']) + except Clusters.DoesNotExist: + pass + + +def reverse_backfill(apps, schema_editor): + """Reverse migration - set site_id back to NULL for backfilled records.""" + # We don't reverse this as we can't distinguish which were backfilled + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0033_add_site_to_credit_usage_log'), + ('writer', '0016_images_unique_position_constraint'), + ('planner', '0008_soft_delete'), + ] + + operations = [ + migrations.RunPython(backfill_site_id, reverse_backfill), + ] diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index e795206b..82e8548b 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -165,6 +165,14 @@ class CreditUsageViewSet(AccountModelViewSet): created_at__gte=start_date, created_at__lte=end_date ) + + # Filter by site if provided + site_id = request.query_params.get('site_id') + if site_id: + try: + usage_logs = usage_logs.filter(site_id=int(site_id)) + except (ValueError, TypeError): + pass # Calculate totals total_credits_used = usage_logs.aggregate(total=Sum('credits_used'))['total'] or 0 diff --git a/frontend/src/components/dashboard/CreditsUsageWidget.tsx b/frontend/src/components/dashboard/CreditsUsageWidget.tsx index 8cedcd82..e47309a7 100644 --- a/frontend/src/components/dashboard/CreditsUsageWidget.tsx +++ b/frontend/src/components/dashboard/CreditsUsageWidget.tsx @@ -14,6 +14,7 @@ interface CreditsUsageWidgetProps { aiOperations?: { total: number; period: string; + siteName?: string; }; loading?: boolean; } @@ -113,6 +114,11 @@ export default function CreditsUsageWidget({

AI Operations ({aiOperations.period}) + {aiOperations.siteName && aiOperations.total > 0 && ( + + ยท Site: {aiOperations.siteName} + + )}

{aiOperations.total.toLocaleString()} diff --git a/frontend/src/components/dashboard/OperationsCostsWidget.tsx b/frontend/src/components/dashboard/OperationsCostsWidget.tsx index 93cd24c8..0341f071 100644 --- a/frontend/src/components/dashboard/OperationsCostsWidget.tsx +++ b/frontend/src/components/dashboard/OperationsCostsWidget.tsx @@ -20,8 +20,9 @@ interface OperationStat { interface OperationsCostsWidgetProps { operations: OperationStat[]; - period?: '7d' | '30d' | 'total'; + period?: '7d' | '30d' | '90d'; loading?: boolean; + onPeriodChange?: (period: '7d' | '30d' | '90d') => void; } const operationConfig = { @@ -54,9 +55,10 @@ const operationConfig = { export default function OperationsCostsWidget({ operations, period = '7d', - loading = false + loading = false, + onPeriodChange }: OperationsCostsWidgetProps) { - const periodLabel = period === '7d' ? 'Last 7 Days' : period === '30d' ? 'Last 30 Days' : 'All Time'; + const periodLabel = period === '7d' ? 'Last 7 Days' : period === '30d' ? 'Last 30 Days' : 'Last 90 Days'; const totalOps = operations.reduce((sum, op) => sum + op.count, 0); const totalCredits = operations.reduce((sum, op) => sum + op.creditsUsed, 0); @@ -68,7 +70,25 @@ export default function OperationsCostsWidget({

AI Operations

- {periodLabel} + {onPeriodChange ? ( +
+ {(['7d', '30d', '90d'] as const).map((p) => ( + + ))} +
+ ) : ( + {periodLabel} + )}
{/* Operations List */} diff --git a/frontend/src/pages/Dashboard/Home.tsx b/frontend/src/pages/Dashboard/Home.tsx index 4ee24e93..913e40b1 100644 --- a/frontend/src/pages/Dashboard/Home.tsx +++ b/frontend/src/pages/Dashboard/Home.tsx @@ -370,11 +370,12 @@ export default function Home() { onAddSite={canAddMoreSites ? handleAddSiteClick : undefined} maxSites={maxSites} /> - s.id === siteFilter)?.name : undefined, }} loading={loading} /> diff --git a/frontend/src/pages/Sites/Dashboard.tsx b/frontend/src/pages/Sites/Dashboard.tsx index 699936ba..c1bea801 100644 --- a/frontend/src/pages/Sites/Dashboard.tsx +++ b/frontend/src/pages/Sites/Dashboard.tsx @@ -85,6 +85,11 @@ export default function SiteDashboard() { }); const [operations, setOperations] = useState([]); const [loading, setLoading] = useState(true); + const [aiPeriod, setAiPeriod] = useState<'7d' | '30d' | '90d'>('7d'); + + const handlePeriodChange = (period: '7d' | '30d' | '90d') => { + setAiPeriod(period); + }; useEffect(() => { if (siteId) { @@ -100,7 +105,7 @@ export default function SiteDashboard() { loadSiteData(currentSiteId); loadBalance(); } - }, [siteId]); + }, [siteId, aiPeriod]); const loadSiteData = async (currentSiteId: string) => { try { @@ -175,7 +180,8 @@ export default function SiteDashboard() { // Load operation stats from real API data try { - const stats = await getDashboardStats({ site_id: Number(currentSiteId), days: 7 }); + const periodDays = aiPeriod === '7d' ? 7 : aiPeriod === '30d' ? 30 : 90; + const stats = await getDashboardStats({ site_id: Number(currentSiteId), days: periodDays }); // Map operation types from API to display types const operationTypeMap: Record = { @@ -354,7 +360,12 @@ export default function SiteDashboard() { authorProfilesCount={setupState.authorProfilesCount} /> - +