Creditsupdates in fotoer wdigets adn hoemapge and singe site settigns page #Run MIgration 0033 #MAJOR
This commit is contained in:
@@ -444,6 +444,9 @@ class AIEngine:
|
|||||||
tokens_input = raw_response.get('input_tokens', 0)
|
tokens_input = raw_response.get('input_tokens', 0)
|
||||||
tokens_output = raw_response.get('output_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
|
# Deduct credits based on actual token usage
|
||||||
CreditService.deduct_credits_for_operation(
|
CreditService.deduct_credits_for_operation(
|
||||||
account=self.account,
|
account=self.account,
|
||||||
@@ -454,6 +457,7 @@ class AIEngine:
|
|||||||
model_used=raw_response.get('model', ''),
|
model_used=raw_response.get('model', ''),
|
||||||
related_object_type=self._get_related_object_type(function_name),
|
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'),
|
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
|
||||||
|
site=site_id,
|
||||||
metadata={
|
metadata={
|
||||||
'function_name': function_name,
|
'function_name': function_name,
|
||||||
'clusters_created': clusters_created,
|
'clusters_created': clusters_created,
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ class AutoClusterFunction(BaseAIFunction):
|
|||||||
return {
|
return {
|
||||||
'count': clusters_created,
|
'count': clusters_created,
|
||||||
'clusters_created': clusters_created,
|
'clusters_created': clusters_created,
|
||||||
'keywords_updated': keywords_updated
|
'keywords_updated': keywords_updated,
|
||||||
|
'site_id': site.id if site else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
'content_id': content_record.id,
|
'content_id': content_record.id,
|
||||||
'task_id': task.id,
|
'task_id': task.id,
|
||||||
'word_count': word_count,
|
'word_count': word_count,
|
||||||
|
'site_id': task.site_id if task.site_id else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -233,9 +233,19 @@ class GenerateIdeasFunction(BaseAIFunction):
|
|||||||
cluster.status = 'mapped'
|
cluster.status = 'mapped'
|
||||||
cluster.save()
|
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 {
|
return {
|
||||||
'count': ideas_created,
|
'count': ideas_created,
|
||||||
'ideas_created': ideas_created
|
'ideas_created': ideas_created,
|
||||||
|
'site_id': site_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
|||||||
return {
|
return {
|
||||||
'count': prompts_created,
|
'count': prompts_created,
|
||||||
'prompts_created': prompts_created,
|
'prompts_created': prompts_created,
|
||||||
|
'site_id': content.site_id if content.site_id else None
|
||||||
}
|
}
|
||||||
|
|
||||||
# Helper methods
|
# Helper methods
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ class GenerateImagesFunction(BaseAIFunction):
|
|||||||
return {
|
return {
|
||||||
'count': 1,
|
'count': 1,
|
||||||
'images_created': 1,
|
'images_created': 1,
|
||||||
'image_id': image.id
|
'image_id': image.id,
|
||||||
|
'site_id': task.site_id if task.site_id else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ class OptimizeContentFunction(BaseAIFunction):
|
|||||||
'html_content': optimized_html,
|
'html_content': optimized_html,
|
||||||
'meta_title': optimized_meta_title,
|
'meta_title': optimized_meta_title,
|
||||||
'meta_description': optimized_meta_description,
|
'meta_description': optimized_meta_description,
|
||||||
|
'site_id': content.site_id if content.site_id else None
|
||||||
}
|
}
|
||||||
|
|
||||||
# Helper methods
|
# Helper methods
|
||||||
|
|||||||
@@ -303,6 +303,10 @@ class DashboardStatsViewSet(viewsets.ViewSet):
|
|||||||
account=account,
|
account=account,
|
||||||
created_at__gte=start_date
|
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
|
# Get operations grouped by type
|
||||||
operations_data = usage_query.values('operation_type').annotate(
|
operations_data = usage_query.values('operation_type').annotate(
|
||||||
|
|||||||
@@ -85,7 +85,16 @@ class CreditUsageLog(AccountBaseModel):
|
|||||||
('content', 'Content Generation'), # Legacy
|
('content', 'Content Generation'), # Legacy
|
||||||
('images', 'Image 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)
|
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
|
||||||
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
|
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
|
||||||
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
|
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', 'operation_type']),
|
||||||
models.Index(fields=['account', 'created_at']),
|
models.Index(fields=['account', 'created_at']),
|
||||||
models.Index(fields=['account', 'operation_type', '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):
|
def __str__(self):
|
||||||
|
|||||||
@@ -336,10 +336,10 @@ class CreditService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@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.
|
Deduct credits and log transaction.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
amount: Number of credits to deduct
|
amount: Number of credits to deduct
|
||||||
@@ -352,20 +352,21 @@ class CreditService:
|
|||||||
tokens_output: Optional output tokens
|
tokens_output: Optional output tokens
|
||||||
related_object_type: Optional related object type
|
related_object_type: Optional related object type
|
||||||
related_object_id: Optional related object ID
|
related_object_id: Optional related object ID
|
||||||
|
site: Optional Site instance or site_id
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: New credit balance
|
int: New credit balance
|
||||||
"""
|
"""
|
||||||
# Check sufficient credits (legacy: amount is already calculated)
|
# Check sufficient credits (legacy: amount is already calculated)
|
||||||
CreditService.check_credits_legacy(account, amount)
|
CreditService.check_credits_legacy(account, amount)
|
||||||
|
|
||||||
# Store previous balance for low credits check
|
# Store previous balance for low credits check
|
||||||
previous_balance = account.credits
|
previous_balance = account.credits
|
||||||
|
|
||||||
# Deduct from account.credits
|
# Deduct from account.credits
|
||||||
account.credits -= amount
|
account.credits -= amount
|
||||||
account.save(update_fields=['credits'])
|
account.save(update_fields=['credits'])
|
||||||
|
|
||||||
# Create CreditTransaction
|
# Create CreditTransaction
|
||||||
CreditTransaction.objects.create(
|
CreditTransaction.objects.create(
|
||||||
account=account,
|
account=account,
|
||||||
@@ -375,10 +376,11 @@ class CreditService:
|
|||||||
description=description,
|
description=description,
|
||||||
metadata=metadata or {}
|
metadata=metadata or {}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create CreditUsageLog
|
# Create CreditUsageLog
|
||||||
CreditUsageLog.objects.create(
|
CreditUsageLog.objects.create(
|
||||||
account=account,
|
account=account,
|
||||||
|
site=site,
|
||||||
operation_type=operation_type,
|
operation_type=operation_type,
|
||||||
credits_used=amount,
|
credits_used=amount,
|
||||||
cost_usd=cost_usd,
|
cost_usd=cost_usd,
|
||||||
@@ -389,30 +391,31 @@ class CreditService:
|
|||||||
related_object_id=related_object_id,
|
related_object_id=related_object_id,
|
||||||
metadata=metadata or {}
|
metadata=metadata or {}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check and send low credits warning if applicable
|
# Check and send low credits warning if applicable
|
||||||
_check_low_credits_warning(account, previous_balance)
|
_check_low_credits_warning(account, previous_balance)
|
||||||
|
|
||||||
return account.credits
|
return account.credits
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def deduct_credits_for_operation(
|
def deduct_credits_for_operation(
|
||||||
account,
|
account,
|
||||||
operation_type,
|
operation_type,
|
||||||
tokens_input,
|
tokens_input,
|
||||||
tokens_output,
|
tokens_output,
|
||||||
description=None,
|
description=None,
|
||||||
metadata=None,
|
metadata=None,
|
||||||
cost_usd=None,
|
cost_usd=None,
|
||||||
model_used=None,
|
model_used=None,
|
||||||
related_object_type=None,
|
related_object_type=None,
|
||||||
related_object_id=None
|
related_object_id=None,
|
||||||
|
site=None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Deduct credits for an operation based on actual token usage.
|
Deduct credits for an operation based on actual token usage.
|
||||||
This is the ONLY way to deduct credits in the token-based system.
|
This is the ONLY way to deduct credits in the token-based system.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
operation_type: Type of operation
|
operation_type: Type of operation
|
||||||
@@ -424,10 +427,11 @@ class CreditService:
|
|||||||
model_used: Optional AI model used
|
model_used: Optional AI model used
|
||||||
related_object_type: Optional related object type
|
related_object_type: Optional related object type
|
||||||
related_object_id: Optional related object ID
|
related_object_id: Optional related object ID
|
||||||
|
site: Optional Site instance or site_id
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: New credit balance
|
int: New credit balance
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If tokens_input or tokens_output not provided
|
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"tokens_input and tokens_output are REQUIRED for credit deduction. "
|
||||||
f"Got: tokens_input={tokens_input}, tokens_output={tokens_output}"
|
f"Got: tokens_input={tokens_input}, tokens_output={tokens_output}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate credits from actual token usage
|
# Calculate credits from actual token usage
|
||||||
credits_required = CreditService.calculate_credits_from_tokens(
|
credits_required = CreditService.calculate_credits_from_tokens(
|
||||||
operation_type, tokens_input, tokens_output
|
operation_type, tokens_input, tokens_output
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check sufficient credits
|
# Check sufficient credits
|
||||||
if account.credits < credits_required:
|
if account.credits < credits_required:
|
||||||
raise InsufficientCreditsError(
|
raise InsufficientCreditsError(
|
||||||
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
|
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-generate description if not provided
|
# Auto-generate description if not provided
|
||||||
if not description:
|
if not description:
|
||||||
total_tokens = tokens_input + tokens_output
|
total_tokens = tokens_input + tokens_output
|
||||||
@@ -456,7 +460,7 @@ class CreditService:
|
|||||||
f"{operation_type}: {total_tokens} tokens "
|
f"{operation_type}: {total_tokens} tokens "
|
||||||
f"({tokens_input} in, {tokens_output} out) = {credits_required} credits"
|
f"({tokens_input} in, {tokens_output} out) = {credits_required} credits"
|
||||||
)
|
)
|
||||||
|
|
||||||
return CreditService.deduct_credits(
|
return CreditService.deduct_credits(
|
||||||
account=account,
|
account=account,
|
||||||
amount=credits_required,
|
amount=credits_required,
|
||||||
@@ -468,7 +472,8 @@ class CreditService:
|
|||||||
tokens_input=tokens_input,
|
tokens_input=tokens_input,
|
||||||
tokens_output=tokens_output,
|
tokens_output=tokens_output,
|
||||||
related_object_type=related_object_type,
|
related_object_type=related_object_type,
|
||||||
related_object_id=related_object_id
|
related_object_id=related_object_id,
|
||||||
|
site=site
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -513,11 +518,12 @@ class CreditService:
|
|||||||
metadata: dict = None,
|
metadata: dict = None,
|
||||||
cost_usd: float = None,
|
cost_usd: float = None,
|
||||||
related_object_type: str = 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.
|
Deduct credits for image generation based on model's credits_per_image.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
model_name: AI model used (e.g., 'dall-e-3', 'flux-1-1-pro')
|
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
|
cost_usd: Optional cost in USD
|
||||||
related_object_type: Optional related object type
|
related_object_type: Optional related object type
|
||||||
related_object_id: Optional related object ID
|
related_object_id: Optional related object ID
|
||||||
|
site: Optional Site instance or site_id
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: New credit balance
|
int: New credit balance
|
||||||
"""
|
"""
|
||||||
credits_required = CreditService.calculate_credits_for_image(model_name, num_images)
|
credits_required = CreditService.calculate_credits_for_image(model_name, num_images)
|
||||||
|
|
||||||
if account.credits < credits_required:
|
if account.credits < credits_required:
|
||||||
raise InsufficientCreditsError(
|
raise InsufficientCreditsError(
|
||||||
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
|
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not description:
|
if not description:
|
||||||
description = f"Image generation: {num_images} images with {model_name} = {credits_required} credits"
|
description = f"Image generation: {num_images} images with {model_name} = {credits_required} credits"
|
||||||
|
|
||||||
return CreditService.deduct_credits(
|
return CreditService.deduct_credits(
|
||||||
account=account,
|
account=account,
|
||||||
amount=credits_required,
|
amount=credits_required,
|
||||||
@@ -552,6 +559,7 @@ class CreditService:
|
|||||||
tokens_input=None,
|
tokens_input=None,
|
||||||
tokens_output=None,
|
tokens_output=None,
|
||||||
related_object_type=related_object_type,
|
related_object_type=related_object_type,
|
||||||
related_object_id=related_object_id
|
related_object_id=related_object_id,
|
||||||
|
site=site
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -165,6 +165,14 @@ class CreditUsageViewSet(AccountModelViewSet):
|
|||||||
created_at__gte=start_date,
|
created_at__gte=start_date,
|
||||||
created_at__lte=end_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
|
# Calculate totals
|
||||||
total_credits_used = usage_logs.aggregate(total=Sum('credits_used'))['total'] or 0
|
total_credits_used = usage_logs.aggregate(total=Sum('credits_used'))['total'] or 0
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface CreditsUsageWidgetProps {
|
|||||||
aiOperations?: {
|
aiOperations?: {
|
||||||
total: number;
|
total: number;
|
||||||
period: string;
|
period: string;
|
||||||
|
siteName?: string;
|
||||||
};
|
};
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,11 @@ export default function CreditsUsageWidget({
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-base text-gray-500 dark:text-gray-400">
|
<p className="text-base text-gray-500 dark:text-gray-400">
|
||||||
AI Operations ({aiOperations.period})
|
AI Operations ({aiOperations.period})
|
||||||
|
{aiOperations.siteName && aiOperations.total > 0 && (
|
||||||
|
<span className="ml-2 text-sm">
|
||||||
|
· Site: {aiOperations.siteName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{aiOperations.total.toLocaleString()}
|
{aiOperations.total.toLocaleString()}
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ interface OperationStat {
|
|||||||
|
|
||||||
interface OperationsCostsWidgetProps {
|
interface OperationsCostsWidgetProps {
|
||||||
operations: OperationStat[];
|
operations: OperationStat[];
|
||||||
period?: '7d' | '30d' | 'total';
|
period?: '7d' | '30d' | '90d';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onPeriodChange?: (period: '7d' | '30d' | '90d') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const operationConfig = {
|
const operationConfig = {
|
||||||
@@ -54,9 +55,10 @@ const operationConfig = {
|
|||||||
export default function OperationsCostsWidget({
|
export default function OperationsCostsWidget({
|
||||||
operations,
|
operations,
|
||||||
period = '7d',
|
period = '7d',
|
||||||
loading = false
|
loading = false,
|
||||||
|
onPeriodChange
|
||||||
}: OperationsCostsWidgetProps) {
|
}: 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 totalOps = operations.reduce((sum, op) => sum + op.count, 0);
|
||||||
const totalCredits = operations.reduce((sum, op) => sum + op.creditsUsed, 0);
|
const totalCredits = operations.reduce((sum, op) => sum + op.creditsUsed, 0);
|
||||||
@@ -68,7 +70,25 @@ export default function OperationsCostsWidget({
|
|||||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||||
AI Operations
|
AI Operations
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">{periodLabel}</span>
|
{onPeriodChange ? (
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
|
||||||
|
{(['7d', '30d', '90d'] as const).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPeriodChange(p)}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded transition-colors ${
|
||||||
|
period === p
|
||||||
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p === '7d' ? '7d' : p === '30d' ? '30d' : '90d'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">{periodLabel}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operations List */}
|
{/* Operations List */}
|
||||||
|
|||||||
@@ -370,11 +370,12 @@ export default function Home() {
|
|||||||
onAddSite={canAddMoreSites ? handleAddSiteClick : undefined}
|
onAddSite={canAddMoreSites ? handleAddSiteClick : undefined}
|
||||||
maxSites={maxSites}
|
maxSites={maxSites}
|
||||||
/>
|
/>
|
||||||
<CreditsUsageWidget
|
<CreditsUsageWidget
|
||||||
balance={balance}
|
balance={balance}
|
||||||
aiOperations={{
|
aiOperations={{
|
||||||
total: aiOperations.totals.count,
|
total: aiOperations.totals.count,
|
||||||
period: aiOperations.period === '7d' ? 'Last 7 days' : aiOperations.period === '30d' ? 'Last 30 days' : 'Last 90 days',
|
period: aiOperations.period === '7d' ? 'Last 7 days' : aiOperations.period === '30d' ? 'Last 30 days' : 'Last 90 days',
|
||||||
|
siteName: siteFilter !== 'all' ? sites.find(s => s.id === siteFilter)?.name : undefined,
|
||||||
}}
|
}}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export default function SiteDashboard() {
|
|||||||
});
|
});
|
||||||
const [operations, setOperations] = useState<OperationStat[]>([]);
|
const [operations, setOperations] = useState<OperationStat[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [aiPeriod, setAiPeriod] = useState<'7d' | '30d' | '90d'>('7d');
|
||||||
|
|
||||||
|
const handlePeriodChange = (period: '7d' | '30d' | '90d') => {
|
||||||
|
setAiPeriod(period);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (siteId) {
|
if (siteId) {
|
||||||
@@ -100,7 +105,7 @@ export default function SiteDashboard() {
|
|||||||
loadSiteData(currentSiteId);
|
loadSiteData(currentSiteId);
|
||||||
loadBalance();
|
loadBalance();
|
||||||
}
|
}
|
||||||
}, [siteId]);
|
}, [siteId, aiPeriod]);
|
||||||
|
|
||||||
const loadSiteData = async (currentSiteId: string) => {
|
const loadSiteData = async (currentSiteId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -175,7 +180,8 @@ export default function SiteDashboard() {
|
|||||||
|
|
||||||
// Load operation stats from real API data
|
// Load operation stats from real API data
|
||||||
try {
|
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
|
// Map operation types from API to display types
|
||||||
const operationTypeMap: Record<string, 'clustering' | 'ideas' | 'content' | 'images'> = {
|
const operationTypeMap: Record<string, 'clustering' | 'ideas' | 'content' | 'images'> = {
|
||||||
@@ -354,7 +360,12 @@ export default function SiteDashboard() {
|
|||||||
authorProfilesCount={setupState.authorProfilesCount}
|
authorProfilesCount={setupState.authorProfilesCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OperationsCostsWidget operations={operations} />
|
<OperationsCostsWidget
|
||||||
|
operations={operations}
|
||||||
|
period={aiPeriod}
|
||||||
|
loading={loading}
|
||||||
|
onPeriodChange={handlePeriodChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<CreditAvailabilityWidget
|
<CreditAvailabilityWidget
|
||||||
availableCredits={balance?.credits_remaining ?? 0}
|
availableCredits={balance?.credits_remaining ?? 0}
|
||||||
|
|||||||
Reference in New Issue
Block a user