Creditsupdates in fotoer wdigets adn hoemapge and singe site settigns page #Run MIgration 0033 #MAJOR

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-12 07:22:08 +00:00
parent 368601f68c
commit b390e02aa5
17 changed files with 251 additions and 45 deletions

View File

@@ -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,

View File

@@ -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
} }

View File

@@ -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,
} }

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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(

View File

@@ -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):

View File

@@ -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
) )

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -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

View File

@@ -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()}

View File

@@ -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 */}

View File

@@ -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}
/> />

View File

@@ -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}