fina autoamtiona adn billing and credits

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-04 15:54:15 +00:00
parent f8a9293196
commit 40dfe20ead
40 changed files with 5680 additions and 18 deletions

View File

@@ -40,6 +40,7 @@ class AutoClusterFunction(BaseAIFunction):
def validate(self, payload: dict, account=None) -> Dict:
"""Custom validation for clustering"""
from igny8_core.ai.validators import validate_ids, validate_keywords_exist
from igny8_core.ai.validators.cluster_validators import validate_minimum_keywords
# Base validation (no max_items limit)
result = validate_ids(payload, max_items=None)
@@ -52,6 +53,21 @@ class AutoClusterFunction(BaseAIFunction):
if not keywords_result['valid']:
return keywords_result
# NEW: Validate minimum keywords (5 required for meaningful clustering)
min_validation = validate_minimum_keywords(
keyword_ids=ids,
account=account,
min_required=5
)
if not min_validation['valid']:
logger.warning(f"[AutoCluster] Validation failed: {min_validation['error']}")
return min_validation
logger.info(
f"[AutoCluster] Validation passed: {min_validation['count']} keywords available (min: {min_validation['required']})"
)
# Removed plan limits check
return {'valid': True}

View File

@@ -0,0 +1,52 @@
"""
AI Validators Package
Shared validation logic for AI functions
"""
from .cluster_validators import validate_minimum_keywords, validate_keyword_selection
# The codebase also contains a module-level file `ai/validators.py` which defines
# common validator helpers (e.g. `validate_ids`). Because there is both a
# package directory `ai/validators/` and a module file `ai/validators.py`, Python
# will resolve `igny8_core.ai.validators` to the package and not the module file.
# To avoid changing many imports across the project, load the module file here
# and re-export the commonly used functions.
import importlib.util
import os
_module_path = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', 'validators.py'))
if os.path.exists(_module_path):
spec = importlib.util.spec_from_file_location('igny8_core.ai._validators_module', _module_path)
_validators_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(_validators_mod)
# Re-export commonly used functions from the module file
validate_ids = getattr(_validators_mod, 'validate_ids', None)
validate_keywords_exist = getattr(_validators_mod, 'validate_keywords_exist', None)
validate_cluster_limits = getattr(_validators_mod, 'validate_cluster_limits', None)
validate_cluster_exists = getattr(_validators_mod, 'validate_cluster_exists', None)
validate_tasks_exist = getattr(_validators_mod, 'validate_tasks_exist', None)
validate_api_key = getattr(_validators_mod, 'validate_api_key', None)
validate_model = getattr(_validators_mod, 'validate_model', None)
validate_image_size = getattr(_validators_mod, 'validate_image_size', None)
else:
# Module file missing - keep names defined if cluster validators provide them
validate_ids = None
validate_keywords_exist = None
validate_cluster_limits = None
validate_cluster_exists = None
validate_tasks_exist = None
validate_api_key = None
validate_model = None
validate_image_size = None
__all__ = [
'validate_minimum_keywords',
'validate_keyword_selection',
'validate_ids',
'validate_keywords_exist',
'validate_cluster_limits',
'validate_cluster_exists',
'validate_tasks_exist',
'validate_api_key',
'validate_model',
'validate_image_size',
]

View File

@@ -0,0 +1,105 @@
"""
Cluster-specific validators
Shared between auto-cluster function and automation pipeline
"""
import logging
from typing import Dict, List
logger = logging.getLogger(__name__)
def validate_minimum_keywords(
keyword_ids: List[int],
account=None,
min_required: int = 5
) -> Dict:
"""
Validate that sufficient keywords are available for clustering
Args:
keyword_ids: List of keyword IDs to cluster
account: Account object for filtering
min_required: Minimum number of keywords required (default: 5)
Returns:
Dict with 'valid' (bool) and 'error' (str) or 'count' (int)
"""
from igny8_core.modules.planner.models import Keywords
# Build queryset
queryset = Keywords.objects.filter(id__in=keyword_ids, status='new')
if account:
queryset = queryset.filter(account=account)
# Count available keywords
count = queryset.count()
# Validate minimum
if count < min_required:
return {
'valid': False,
'error': f'Insufficient keywords for clustering. Need at least {min_required} keywords, but only {count} available.',
'count': count,
'required': min_required
}
return {
'valid': True,
'count': count,
'required': min_required
}
def validate_keyword_selection(
selected_ids: List[int],
available_count: int,
min_required: int = 5
) -> Dict:
"""
Validate keyword selection (for frontend validation)
Args:
selected_ids: List of selected keyword IDs
available_count: Total count of available keywords
min_required: Minimum required
Returns:
Dict with validation result
"""
selected_count = len(selected_ids)
# Check if any keywords selected
if selected_count == 0:
return {
'valid': False,
'error': 'No keywords selected',
'type': 'NO_SELECTION'
}
# Check if enough selected
if selected_count < min_required:
return {
'valid': False,
'error': f'Please select at least {min_required} keywords. Currently selected: {selected_count}',
'type': 'INSUFFICIENT_SELECTION',
'selected': selected_count,
'required': min_required
}
# Check if enough available (even if not all selected)
if available_count < min_required:
return {
'valid': False,
'error': f'Not enough keywords available. Need at least {min_required} keywords, but only {available_count} exist.',
'type': 'INSUFFICIENT_AVAILABLE',
'available': available_count,
'required': min_required
}
return {
'valid': True,
'selected': selected_count,
'available': available_count,
'required': min_required
}

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-12-04 15:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('automation', '0003_alter_automationconfig_options_and_more'),
]
operations = [
migrations.AddField(
model_name='automationrun',
name='cancelled_at',
field=models.DateTimeField(blank=True, help_text='When automation was cancelled', null=True),
),
migrations.AddField(
model_name='automationrun',
name='paused_at',
field=models.DateTimeField(blank=True, help_text='When automation was paused', null=True),
),
migrations.AddField(
model_name='automationrun',
name='resumed_at',
field=models.DateTimeField(blank=True, help_text='When automation was last resumed', null=True),
),
migrations.AlterField(
model_name='automationrun',
name='status',
field=models.CharField(choices=[('running', 'Running'), ('paused', 'Paused'), ('cancelled', 'Cancelled'), ('completed', 'Completed'), ('failed', 'Failed')], db_index=True, default='running', max_length=20),
),
]

View File

@@ -65,6 +65,7 @@ class AutomationRun(models.Model):
STATUS_CHOICES = [
('running', 'Running'),
('paused', 'Paused'),
('cancelled', 'Cancelled'),
('completed', 'Completed'),
('failed', 'Failed'),
]
@@ -77,6 +78,11 @@ class AutomationRun(models.Model):
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='running', db_index=True)
current_stage = models.IntegerField(default=1, help_text="Current stage number (1-7)")
# Pause/Resume tracking
paused_at = models.DateTimeField(null=True, blank=True, help_text="When automation was paused")
resumed_at = models.DateTimeField(null=True, blank=True, help_text="When automation was last resumed")
cancelled_at = models.DateTimeField(null=True, blank=True, help_text="When automation was cancelled")
started_at = models.DateTimeField(auto_now_add=True, db_index=True)
completed_at = models.DateTimeField(null=True, blank=True)

View File

@@ -24,7 +24,7 @@ from igny8_core.ai.functions.auto_cluster import AutoClusterFunction
from igny8_core.ai.functions.generate_ideas import GenerateIdeasFunction
from igny8_core.ai.functions.generate_content import GenerateContentFunction
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
from igny8_core.ai.functions.generate_images import GenerateImagesFunction
from igny8_core.ai.tasks import process_image_generation_queue
logger = logging.getLogger(__name__)
@@ -58,6 +58,26 @@ class AutomationService:
service.run = run
return service
def _check_should_stop(self) -> tuple[bool, str]:
"""
Check if automation should stop (paused or cancelled)
Returns:
(should_stop, reason)
"""
if not self.run:
return False, ""
# Refresh run from database
self.run.refresh_from_db()
if self.run.status == 'paused':
return True, "paused"
elif self.run.status == 'cancelled':
return True, "cancelled"
return False, ""
def start_automation(self, trigger_type: str = 'manual') -> str:
"""
Start automation run
@@ -130,6 +150,45 @@ class AutomationService:
total_count = pending_keywords.count()
# NEW: Pre-stage validation for minimum keywords
from igny8_core.ai.validators.cluster_validators import validate_minimum_keywords
keyword_ids_for_validation = list(pending_keywords.values_list('id', flat=True))
min_validation = validate_minimum_keywords(
keyword_ids=keyword_ids_for_validation,
account=self.account,
min_required=5
)
if not min_validation['valid']:
# Log validation failure
self.logger.log_stage_start(
self.run.run_id, self.account.id, self.site.id,
stage_number, stage_name, total_count
)
error_msg = min_validation['error']
self.logger.log_stage_error(
self.run.run_id, self.account.id, self.site.id,
stage_number, error_msg
)
# Skip stage with proper result
self.run.stage_1_result = {
'keywords_processed': 0,
'clusters_created': 0,
'batches_run': 0,
'skipped': True,
'skip_reason': error_msg,
'credits_used': 0
}
self.run.current_stage = 2
self.run.save()
logger.warning(f"[AutomationService] Stage 1 skipped: {error_msg}")
return
# Log stage start
self.logger.log_stage_start(
self.run.run_id, self.account.id, self.site.id,
@@ -929,15 +988,25 @@ class AutomationService:
stage_number, f"Generating image {idx}/{total_images}: {image.image_type} for '{content_title}'"
)
# Call AI function via AIEngine
engine = AIEngine(account=self.account)
result = engine.execute(
fn=GenerateImagesFunction(),
payload={'image_ids': [image.id]}
)
# Call process_image_generation_queue directly (same as Writer/Images page)
# Queue the task
if hasattr(process_image_generation_queue, 'delay'):
task = process_image_generation_queue.delay(
image_ids=[image.id],
account_id=self.account.id,
content_id=image.content.id if image.content else None
)
task_id = str(task.id)
else:
# Fallback for testing (synchronous)
result = process_image_generation_queue(
image_ids=[image.id],
account_id=self.account.id,
content_id=image.content.id if image.content else None
)
task_id = None
# Monitor task
task_id = result.get('task_id')
# Monitor task (if async)
if task_id:
# FIXED: Pass continue_on_error=True to keep processing other images on failure
self._wait_for_task(task_id, stage_number, f"Image for '{content_title}'", continue_on_error=True)
@@ -1185,3 +1254,250 @@ class AutomationService:
minutes = int(elapsed // 60)
seconds = int(elapsed % 60)
return f"{minutes}m {seconds}s"
def get_current_processing_state(self) -> dict:
"""
Get real-time processing state for current automation run
Returns detailed info about what's currently being processed
"""
if not self.run or self.run.status != 'running':
return None
stage = self.run.current_stage
# Get stage-specific data based on current stage
if stage == 1: # Keywords → Clusters
return self._get_stage_1_state()
elif stage == 2: # Clusters → Ideas
return self._get_stage_2_state()
elif stage == 3: # Ideas → Tasks
return self._get_stage_3_state()
elif stage == 4: # Tasks → Content
return self._get_stage_4_state()
elif stage == 5: # Content → Image Prompts
return self._get_stage_5_state()
elif stage == 6: # Image Prompts → Images
return self._get_stage_6_state()
elif stage == 7: # Manual Review Gate
return self._get_stage_7_state()
return None
def _get_stage_1_state(self) -> dict:
"""Get processing state for Stage 1: Keywords → Clusters"""
queue = Keywords.objects.filter(
site=self.site, status='new'
).order_by('id')
processed = self._get_processed_count(1)
total = queue.count() + processed
return {
'stage_number': 1,
'stage_name': 'Keywords → Clusters',
'stage_type': 'AI',
'total_items': total,
'processed_items': processed,
'percentage': round((processed / total * 100) if total > 0 else 0),
'currently_processing': self._get_current_items(queue, 3),
'up_next': self._get_next_items(queue, 2, skip=3),
'remaining_count': queue.count()
}
def _get_stage_2_state(self) -> dict:
"""Get processing state for Stage 2: Clusters → Ideas"""
queue = Clusters.objects.filter(
site=self.site, status='new', disabled=False
).order_by('id')
processed = self._get_processed_count(2)
total = queue.count() + processed
return {
'stage_number': 2,
'stage_name': 'Clusters → Ideas',
'stage_type': 'AI',
'total_items': total,
'processed_items': processed,
'percentage': round((processed / total * 100) if total > 0 else 0),
'currently_processing': self._get_current_items(queue, 1),
'up_next': self._get_next_items(queue, 2, skip=1),
'remaining_count': queue.count()
}
def _get_stage_3_state(self) -> dict:
"""Get processing state for Stage 3: Ideas → Tasks"""
queue = ContentIdeas.objects.filter(
site=self.site, status='approved'
).order_by('id')
processed = self._get_processed_count(3)
total = queue.count() + processed
return {
'stage_number': 3,
'stage_name': 'Ideas → Tasks',
'stage_type': 'Local',
'total_items': total,
'processed_items': processed,
'percentage': round((processed / total * 100) if total > 0 else 0),
'currently_processing': self._get_current_items(queue, 1),
'up_next': self._get_next_items(queue, 2, skip=1),
'remaining_count': queue.count()
}
def _get_stage_4_state(self) -> dict:
"""Get processing state for Stage 4: Tasks → Content"""
queue = Tasks.objects.filter(
site=self.site, status='ready'
).order_by('id')
processed = self._get_processed_count(4)
total = queue.count() + processed
return {
'stage_number': 4,
'stage_name': 'Tasks → Content',
'stage_type': 'AI',
'total_items': total,
'processed_items': processed,
'percentage': round((processed / total * 100) if total > 0 else 0),
'currently_processing': self._get_current_items(queue, 1),
'up_next': self._get_next_items(queue, 2, skip=1),
'remaining_count': queue.count()
}
def _get_stage_5_state(self) -> dict:
"""Get processing state for Stage 5: Content → Image Prompts"""
queue = Content.objects.filter(
site=self.site,
status='draft'
).annotate(
images_count=Count('images')
).filter(
images_count=0
).order_by('id')
processed = self._get_processed_count(5)
total = queue.count() + processed
return {
'stage_number': 5,
'stage_name': 'Content → Image Prompts',
'stage_type': 'AI',
'total_items': total,
'processed_items': processed,
'percentage': round((processed / total * 100) if total > 0 else 0),
'currently_processing': self._get_current_items(queue, 1),
'up_next': self._get_next_items(queue, 2, skip=1),
'remaining_count': queue.count()
}
def _get_stage_6_state(self) -> dict:
"""Get processing state for Stage 6: Image Prompts → Images"""
queue = Images.objects.filter(
site=self.site, status='pending'
).order_by('id')
processed = self._get_processed_count(6)
total = queue.count() + processed
return {
'stage_number': 6,
'stage_name': 'Image Prompts → Images',
'stage_type': 'AI',
'total_items': total,
'processed_items': processed,
'percentage': round((processed / total * 100) if total > 0 else 0),
'currently_processing': self._get_current_items(queue, 1),
'up_next': self._get_next_items(queue, 2, skip=1),
'remaining_count': queue.count()
}
def _get_stage_7_state(self) -> dict:
"""Get processing state for Stage 7: Manual Review Gate"""
queue = Content.objects.filter(
site=self.site, status='review'
).order_by('id')
total = queue.count()
return {
'stage_number': 7,
'stage_name': 'Manual Review Gate',
'stage_type': 'Manual',
'total_items': total,
'processed_items': total,
'percentage': 100,
'currently_processing': [],
'up_next': self._get_current_items(queue, 3),
'remaining_count': total
}
def _get_processed_count(self, stage: int) -> int:
"""Get count of items processed in current stage"""
if not self.run:
return 0
result_key = f'stage_{stage}_result'
result = getattr(self.run, result_key, {})
if not result:
return 0
# Extract appropriate count from result
if stage == 1:
return result.get('keywords_processed', 0)
elif stage == 2:
return result.get('clusters_processed', 0)
elif stage == 3:
return result.get('ideas_processed', 0)
elif stage == 4:
return result.get('tasks_processed', 0)
elif stage == 5:
return result.get('content_processed', 0)
elif stage == 6:
return result.get('images_processed', 0)
return 0
def _get_current_items(self, queryset, count: int) -> list:
"""Get currently processing items"""
items = queryset[:count]
return [
{
'id': item.id,
'title': self._get_item_title(item),
'type': queryset.model.__name__.lower()
}
for item in items
]
def _get_next_items(self, queryset, count: int, skip: int = 0) -> list:
"""Get next items in queue"""
items = queryset[skip:skip + count]
return [
{
'id': item.id,
'title': self._get_item_title(item),
'type': queryset.model.__name__.lower()
}
for item in items
]
def _get_item_title(self, item) -> str:
"""Extract title from various model types"""
# Try different title fields based on model type
if hasattr(item, 'keyword'):
return item.keyword
elif hasattr(item, 'cluster_name'):
return item.cluster_name
elif hasattr(item, 'idea_title'):
return item.idea_title
elif hasattr(item, 'title'):
return item.title
elif hasattr(item, 'image_type') and hasattr(item, 'content'):
content_title = item.content.title if item.content else 'Unknown'
return f"{item.image_type} for '{content_title}'"
return 'Unknown'

View File

@@ -147,6 +147,10 @@ def resume_automation_task(self, run_id: str):
run.error_message = str(e)
run.completed_at = timezone.now()
run.save()
# Alias for continue_automation_task (same as resume)
continue_automation_task = resume_automation_task
# Release lock
from django.core.cache import cache

View File

@@ -474,3 +474,198 @@ class AutomationViewSet(viewsets.ViewSet):
]
})
@action(detail=False, methods=['get'], url_path='current_processing')
def current_processing(self, request):
"""
GET /api/v1/automation/current_processing/?site_id=123&run_id=abc
Get current processing state for active automation run
"""
site_id = request.query_params.get('site_id')
run_id = request.query_params.get('run_id')
if not site_id or not run_id:
return Response(
{'error': 'site_id and run_id required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
# Get the site
site = get_object_or_404(Site, id=site_id, account=request.user.account)
# Get the run
run = AutomationRun.objects.get(run_id=run_id, site=site)
# If not running, return None
if run.status != 'running':
return Response({'data': None})
# Get current processing state
service = AutomationService.from_run_id(run_id)
state = service.get_current_processing_state()
return Response({'data': state})
except AutomationRun.DoesNotExist:
return Response(
{'error': 'Run not found'},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['post'], url_path='pause')
def pause_automation(self, request):
"""
POST /api/v1/automation/pause/?site_id=123&run_id=abc
Pause current automation run
Will complete current queue item then pause before next item
"""
site_id = request.query_params.get('site_id')
run_id = request.query_params.get('run_id')
if not site_id or not run_id:
return Response(
{'error': 'site_id and run_id required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
site = get_object_or_404(Site, id=site_id, account=request.user.account)
run = AutomationRun.objects.get(run_id=run_id, site=site)
if run.status != 'running':
return Response(
{'error': f'Cannot pause automation with status: {run.status}'},
status=status.HTTP_400_BAD_REQUEST
)
# Update status to paused
run.status = 'paused'
run.paused_at = timezone.now()
run.save(update_fields=['status', 'paused_at'])
return Response({
'message': 'Automation paused',
'status': run.status,
'paused_at': run.paused_at
})
except AutomationRun.DoesNotExist:
return Response(
{'error': 'Run not found'},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['post'], url_path='resume')
def resume_automation(self, request):
"""
POST /api/v1/automation/resume/?site_id=123&run_id=abc
Resume paused automation run
Will continue from next queue item in current stage
"""
site_id = request.query_params.get('site_id')
run_id = request.query_params.get('run_id')
if not site_id or not run_id:
return Response(
{'error': 'site_id and run_id required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
site = get_object_or_404(Site, id=site_id, account=request.user.account)
run = AutomationRun.objects.get(run_id=run_id, site=site)
if run.status != 'paused':
return Response(
{'error': f'Cannot resume automation with status: {run.status}'},
status=status.HTTP_400_BAD_REQUEST
)
# Update status to running
run.status = 'running'
run.resumed_at = timezone.now()
run.save(update_fields=['status', 'resumed_at'])
# Queue continuation task
from igny8_core.business.automation.tasks import continue_automation_task
continue_automation_task.delay(run_id)
return Response({
'message': 'Automation resumed',
'status': run.status,
'resumed_at': run.resumed_at
})
except AutomationRun.DoesNotExist:
return Response(
{'error': 'Run not found'},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['post'], url_path='cancel')
def cancel_automation(self, request):
"""
POST /api/v1/automation/cancel/?site_id=123&run_id=abc
Cancel current automation run
Will complete current queue item then stop permanently
"""
site_id = request.query_params.get('site_id')
run_id = request.query_params.get('run_id')
if not site_id or not run_id:
return Response(
{'error': 'site_id and run_id required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
site = get_object_or_404(Site, id=site_id, account=request.user.account)
run = AutomationRun.objects.get(run_id=run_id, site=site)
if run.status not in ['running', 'paused']:
return Response(
{'error': f'Cannot cancel automation with status: {run.status}'},
status=status.HTTP_400_BAD_REQUEST
)
# Update status to cancelled
run.status = 'cancelled'
run.cancelled_at = timezone.now()
run.completed_at = timezone.now()
run.save(update_fields=['status', 'cancelled_at', 'completed_at'])
return Response({
'message': 'Automation cancelled',
'status': run.status,
'cancelled_at': run.cancelled_at
})
except AutomationRun.DoesNotExist:
return Response(
{'error': 'Run not found'},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -0,0 +1,81 @@
"""
Billing Business Logic Admin
"""
from django.contrib import admin
from django.utils.html import format_html
from .models import CreditCostConfig
@admin.register(CreditCostConfig)
class CreditCostConfigAdmin(admin.ModelAdmin):
list_display = [
'operation_type',
'display_name',
'credits_cost_display',
'unit',
'is_active',
'cost_change_indicator',
'updated_at',
'updated_by'
]
list_filter = ['is_active', 'unit', 'updated_at']
search_fields = ['operation_type', 'display_name', 'description']
fieldsets = (
('Operation', {
'fields': ('operation_type', 'display_name', 'description')
}),
('Cost Configuration', {
'fields': ('credits_cost', 'unit', 'is_active')
}),
('Audit Trail', {
'fields': ('previous_cost', 'updated_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at', 'previous_cost']
def credits_cost_display(self, obj):
"""Show cost with color coding"""
if obj.credits_cost >= 20:
color = 'red'
elif obj.credits_cost >= 10:
color = 'orange'
else:
color = 'green'
return format_html(
'<span style="color: {}; font-weight: bold;">{} credits</span>',
color,
obj.credits_cost
)
credits_cost_display.short_description = 'Cost'
def cost_change_indicator(self, obj):
"""Show if cost changed recently"""
if obj.previous_cost is not None:
if obj.credits_cost > obj.previous_cost:
icon = '📈' # Increased
color = 'red'
elif obj.credits_cost < obj.previous_cost:
icon = '📉' # Decreased
color = 'green'
else:
icon = '➡️' # Same
color = 'gray'
return format_html(
'{} <span style="color: {};">({}{})</span>',
icon,
color,
obj.previous_cost,
obj.credits_cost
)
return ''
cost_change_indicator.short_description = 'Recent Change'
def save_model(self, request, obj, form, change):
"""Track who made the change"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1 @@
"""Management commands package"""

View File

@@ -0,0 +1 @@
"""Commands package"""

View File

@@ -0,0 +1,103 @@
"""
Initialize Credit Cost Configurations
Migrates hardcoded CREDIT_COSTS constants to database
"""
from django.core.management.base import BaseCommand
from igny8_core.business.billing.models import CreditCostConfig
from igny8_core.business.billing.constants import CREDIT_COSTS
class Command(BaseCommand):
help = 'Initialize credit cost configurations from constants'
def handle(self, *args, **options):
"""Migrate hardcoded costs to database"""
operation_metadata = {
'clustering': {
'display_name': 'Auto Clustering',
'description': 'Group keywords into semantic clusters using AI',
'unit': 'per_request'
},
'idea_generation': {
'display_name': 'Idea Generation',
'description': 'Generate content ideas from keyword clusters',
'unit': 'per_request'
},
'content_generation': {
'display_name': 'Content Generation',
'description': 'Generate article content using AI',
'unit': 'per_100_words'
},
'image_prompt_extraction': {
'display_name': 'Image Prompt Extraction',
'description': 'Extract image prompts from content',
'unit': 'per_request'
},
'image_generation': {
'display_name': 'Image Generation',
'description': 'Generate images using AI (DALL-E, Runware)',
'unit': 'per_image'
},
'linking': {
'display_name': 'Content Linking',
'description': 'Generate internal links between content',
'unit': 'per_request'
},
'optimization': {
'display_name': 'Content Optimization',
'description': 'Optimize content for SEO',
'unit': 'per_200_words'
},
'site_structure_generation': {
'display_name': 'Site Structure Generation',
'description': 'Generate complete site blueprint',
'unit': 'per_request'
},
'site_page_generation': {
'display_name': 'Site Page Generation',
'description': 'Generate site pages from blueprint',
'unit': 'per_item'
},
'reparse': {
'display_name': 'Content Reparse',
'description': 'Reparse and update existing content',
'unit': 'per_request'
},
}
created_count = 0
updated_count = 0
for operation_type, cost in CREDIT_COSTS.items():
# Skip legacy aliases
if operation_type in ['ideas', 'content', 'images']:
continue
metadata = operation_metadata.get(operation_type, {})
config, created = CreditCostConfig.objects.get_or_create(
operation_type=operation_type,
defaults={
'credits_cost': cost,
'display_name': metadata.get('display_name', operation_type.replace('_', ' ').title()),
'description': metadata.get('description', ''),
'unit': metadata.get('unit', 'per_request'),
'is_active': True
}
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'✅ Created: {config.display_name} - {cost} credits')
)
else:
updated_count += 1
self.stdout.write(
self.style.WARNING(f'⚠️ Already exists: {config.display_name}')
)
self.stdout.write(
self.style.SUCCESS(f'\n✅ Complete: {created_count} created, {updated_count} already existed')
)

View File

@@ -0,0 +1,99 @@
# Generated by Django for IGNY8 Billing App
# Date: December 4, 2025
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CreditTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transaction_type', models.CharField(
choices=[
('purchase', 'Purchase'),
('deduction', 'Deduction'),
('refund', 'Refund'),
('grant', 'Grant'),
('adjustment', 'Manual Adjustment'),
],
max_length=20
)),
('amount', models.IntegerField(help_text='Positive for additions, negative for deductions')),
('balance_after', models.IntegerField(help_text='Account balance after this transaction')),
('description', models.CharField(max_length=255)),
('metadata', models.JSONField(default=dict, help_text='Additional transaction details')),
('created_at', models.DateTimeField(auto_now_add=True)),
('account', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='credit_transactions',
to=settings.AUTH_USER_MODEL
)),
],
options={
'verbose_name': 'Credit Transaction',
'verbose_name_plural': 'Credit Transactions',
'db_table': 'igny8_credit_transactions',
'ordering': ['-created_at'],
'indexes': [
models.Index(fields=['account', '-created_at'], name='idx_credit_txn_account_date'),
models.Index(fields=['transaction_type'], name='idx_credit_txn_type'),
],
},
),
migrations.CreateModel(
name='CreditUsageLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('operation_type', models.CharField(
choices=[
('clustering', 'Clustering'),
('idea_generation', 'Idea Generation'),
('content_generation', 'Content Generation'),
('image_generation', 'Image Generation'),
('image_prompt_extraction', 'Image Prompt Extraction'),
('taxonomy_generation', 'Taxonomy Generation'),
('content_rewrite', 'Content Rewrite'),
('keyword_research', 'Keyword Research'),
('site_page_generation', 'Site Page Generation'),
],
max_length=50
)),
('credits_used', models.IntegerField()),
('cost_usd', models.DecimalField(decimal_places=2, max_digits=10, null=True, blank=True)),
('model_used', models.CharField(max_length=100, blank=True)),
('tokens_input', models.IntegerField(null=True, blank=True)),
('tokens_output', models.IntegerField(null=True, blank=True)),
('related_object_type', models.CharField(max_length=50, blank=True)),
('related_object_id', models.IntegerField(null=True, blank=True)),
('metadata', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('account', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='credit_usage_logs',
to=settings.AUTH_USER_MODEL
)),
],
options={
'verbose_name': 'Credit Usage Log',
'verbose_name_plural': 'Credit Usage Logs',
'db_table': 'igny8_credit_usage_logs',
'ordering': ['-created_at'],
'indexes': [
models.Index(fields=['account', '-created_at'], name='idx_credit_usage_account_date'),
models.Index(fields=['operation_type'], name='idx_credit_usage_operation'),
],
},
),
]

View File

@@ -0,0 +1,89 @@
# Generated by Django for IGNY8 Billing App
# Date: December 4, 2025
# Adds CreditCostConfig model for database-driven credit cost configuration
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('billing', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CreditCostConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('operation_type', models.CharField(
choices=[
('clustering', 'Auto Clustering'),
('idea_generation', 'Idea Generation'),
('content_generation', 'Content Generation'),
('image_generation', 'Image Generation'),
('image_prompt_extraction', 'Image Prompt Extraction'),
('taxonomy_generation', 'Taxonomy Generation'),
('content_rewrite', 'Content Rewrite'),
('keyword_research', 'Keyword Research'),
('site_page_generation', 'Site Page Generation'),
],
help_text='AI operation type',
max_length=50,
unique=True
)),
('credits_cost', models.IntegerField(
help_text='Credits required for this operation',
validators=[django.core.validators.MinValueValidator(0)]
)),
('unit', models.CharField(
choices=[
('per_request', 'Per Request'),
('per_100_words', 'Per 100 Words'),
('per_image', 'Per Image'),
('per_item', 'Per Item'),
('per_keyword', 'Per Keyword'),
],
default='per_request',
help_text='What the cost applies to',
max_length=50
)),
('display_name', models.CharField(help_text='Human-readable name', max_length=100)),
('description', models.TextField(blank=True, help_text='What this operation does')),
('is_active', models.BooleanField(default=True, help_text='Enable/disable this operation')),
('previous_cost', models.IntegerField(
blank=True,
help_text='Cost before last update (for audit trail)',
null=True
)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(
blank=True,
help_text='Admin who last updated',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='credit_cost_updates',
to=settings.AUTH_USER_MODEL
)),
],
options={
'verbose_name': 'Credit Cost Configuration',
'verbose_name_plural': 'Credit Cost Configurations',
'db_table': 'igny8_credit_cost_config',
'ordering': ['operation_type'],
},
),
migrations.AddIndex(
model_name='creditcostconfig',
index=models.Index(fields=['operation_type'], name='idx_credit_cost_op'),
),
migrations.AddIndex(
model_name='creditcostconfig',
index=models.Index(fields=['is_active'], name='idx_credit_cost_active'),
),
]

View File

@@ -0,0 +1 @@
# Billing app migrations

View File

@@ -3,6 +3,7 @@ Billing Models for Credit System
"""
from django.db import models
from django.core.validators import MinValueValidator
from django.conf import settings
from igny8_core.auth.models import AccountBaseModel
@@ -75,3 +76,85 @@ class CreditUsageLog(AccountBaseModel):
account = getattr(self, 'account', None)
return f"{self.get_operation_type_display()} - {self.credits_used} credits - {account.name if account else 'No Account'}"
class CreditCostConfig(models.Model):
"""
Configurable credit costs per AI function
Admin-editable alternative to hardcoded constants
"""
# Operation identification
operation_type = models.CharField(
max_length=50,
unique=True,
choices=CreditUsageLog.OPERATION_TYPE_CHOICES,
help_text="AI operation type"
)
# Cost configuration
credits_cost = models.IntegerField(
validators=[MinValueValidator(0)],
help_text="Credits required for this operation"
)
# Unit of measurement
UNIT_CHOICES = [
('per_request', 'Per Request'),
('per_100_words', 'Per 100 Words'),
('per_200_words', 'Per 200 Words'),
('per_item', 'Per Item'),
('per_image', 'Per Image'),
]
unit = models.CharField(
max_length=50,
default='per_request',
choices=UNIT_CHOICES,
help_text="What the cost applies to"
)
# Metadata
display_name = models.CharField(max_length=100, help_text="Human-readable name")
description = models.TextField(blank=True, help_text="What this operation does")
# Status
is_active = models.BooleanField(default=True, help_text="Enable/disable this operation")
# Audit fields
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='credit_cost_updates',
help_text="Admin who last updated"
)
# Change tracking
previous_cost = models.IntegerField(
null=True,
blank=True,
help_text="Cost before last update (for audit trail)"
)
class Meta:
app_label = 'billing'
db_table = 'igny8_credit_cost_config'
verbose_name = 'Credit Cost Configuration'
verbose_name_plural = 'Credit Cost Configurations'
ordering = ['operation_type']
def __str__(self):
return f"{self.display_name} - {self.credits_cost} credits {self.unit}"
def save(self, *args, **kwargs):
# Track cost changes
if self.pk:
try:
old = CreditCostConfig.objects.get(pk=self.pk)
if old.credits_cost != self.credits_cost:
self.previous_cost = old.credits_cost
except CreditCostConfig.DoesNotExist:
pass
super().save(*args, **kwargs)

View File

@@ -16,6 +16,7 @@ class CreditService:
def get_credit_cost(operation_type, amount=None):
"""
Get credit cost for operation.
Now checks database config first, falls back to constants.
Args:
operation_type: Type of operation (from CREDIT_COSTS)
@@ -27,11 +28,40 @@ class CreditService:
Raises:
CreditCalculationError: If operation type is unknown
"""
import logging
logger = logging.getLogger(__name__)
# Try to get from database config first
try:
from igny8_core.business.billing.models import CreditCostConfig
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if config:
base_cost = config.credits_cost
# Apply unit-based calculation
if config.unit == 'per_100_words' and amount:
return max(1, int(base_cost * (amount / 100)))
elif config.unit == 'per_200_words' and amount:
return max(1, int(base_cost * (amount / 200)))
elif config.unit in ['per_item', 'per_image'] and amount:
return base_cost * amount
else:
return base_cost
except Exception as e:
logger.warning(f"Failed to get cost from database, using constants: {e}")
# Fallback to hardcoded constants
base_cost = CREDIT_COSTS.get(operation_type, 0)
if base_cost == 0:
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
# Variable cost operations
# Variable cost operations (legacy logic)
if operation_type == 'content_generation' and amount:
# Per 100 words
return max(1, int(base_cost * (amount / 100)))

View File

@@ -0,0 +1,103 @@
"""
Initialize Credit Cost Configurations
Migrates hardcoded CREDIT_COSTS constants to database
"""
from django.core.management.base import BaseCommand
from igny8_core.business.billing.models import CreditCostConfig
from igny8_core.business.billing.constants import CREDIT_COSTS
class Command(BaseCommand):
help = 'Initialize credit cost configurations from constants'
def handle(self, *args, **options):
"""Migrate hardcoded costs to database"""
operation_metadata = {
'clustering': {
'display_name': 'Auto Clustering',
'description': 'Group keywords into semantic clusters using AI',
'unit': 'per_request'
},
'idea_generation': {
'display_name': 'Idea Generation',
'description': 'Generate content ideas from keyword clusters',
'unit': 'per_request'
},
'content_generation': {
'display_name': 'Content Generation',
'description': 'Generate article content using AI',
'unit': 'per_100_words'
},
'image_prompt_extraction': {
'display_name': 'Image Prompt Extraction',
'description': 'Extract image prompts from content',
'unit': 'per_request'
},
'image_generation': {
'display_name': 'Image Generation',
'description': 'Generate images using AI (DALL-E, Runware)',
'unit': 'per_image'
},
'linking': {
'display_name': 'Content Linking',
'description': 'Generate internal links between content',
'unit': 'per_request'
},
'optimization': {
'display_name': 'Content Optimization',
'description': 'Optimize content for SEO',
'unit': 'per_200_words'
},
'site_structure_generation': {
'display_name': 'Site Structure Generation',
'description': 'Generate complete site blueprint',
'unit': 'per_request'
},
'site_page_generation': {
'display_name': 'Site Page Generation',
'description': 'Generate site pages from blueprint',
'unit': 'per_item'
},
'reparse': {
'display_name': 'Content Reparse',
'description': 'Reparse and update existing content',
'unit': 'per_request'
},
}
created_count = 0
updated_count = 0
for operation_type, cost in CREDIT_COSTS.items():
# Skip legacy aliases
if operation_type in ['ideas', 'content', 'images']:
continue
metadata = operation_metadata.get(operation_type, {})
config, created = CreditCostConfig.objects.get_or_create(
operation_type=operation_type,
defaults={
'credits_cost': cost,
'display_name': metadata.get('display_name', operation_type.replace('_', ' ').title()),
'description': metadata.get('description', ''),
'unit': metadata.get('unit', 'per_request'),
'is_active': True
}
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'✅ Created: {config.display_name} - {cost} credits')
)
else:
updated_count += 1
self.stdout.write(
self.style.WARNING(f'⚠️ Already exists: {config.display_name}')
)
self.stdout.write(
self.style.SUCCESS(f'\n✅ Complete: {created_count} created, {updated_count} already existed')
)

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.2.8 on 2025-12-04 14:38
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0002_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CreditCostConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('operation_type', models.CharField(choices=[('clustering', 'Keyword Clustering'), ('idea_generation', 'Content Ideas Generation'), ('content_generation', 'Content Generation'), ('image_generation', 'Image Generation'), ('reparse', 'Content Reparse'), ('ideas', 'Content Ideas Generation'), ('content', 'Content Generation'), ('images', 'Image Generation')], help_text='AI operation type', max_length=50, unique=True)),
('credits_cost', models.IntegerField(help_text='Credits required for this operation', validators=[django.core.validators.MinValueValidator(0)])),
('unit', models.CharField(choices=[('per_request', 'Per Request'), ('per_100_words', 'Per 100 Words'), ('per_200_words', 'Per 200 Words'), ('per_item', 'Per Item'), ('per_image', 'Per Image')], default='per_request', help_text='What the cost applies to', max_length=50)),
('display_name', models.CharField(help_text='Human-readable name', max_length=100)),
('description', models.TextField(blank=True, help_text='What this operation does')),
('is_active', models.BooleanField(default=True, help_text='Enable/disable this operation')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('previous_cost', models.IntegerField(blank=True, help_text='Cost before last update (for audit trail)', null=True)),
('updated_by', models.ForeignKey(blank=True, help_text='Admin who last updated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='credit_cost_updates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Credit Cost Configuration',
'verbose_name_plural': 'Credit Cost Configurations',
'db_table': 'igny8_credit_cost_config',
'ordering': ['operation_type'],
},
),
]

View File

@@ -1,4 +1,4 @@
# Backward compatibility aliases - models moved to business/billing/
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, CreditCostConfig
__all__ = ['CreditTransaction', 'CreditUsageLog']
__all__ = ['CreditTransaction', 'CreditUsageLog', 'CreditCostConfig']

View File

@@ -595,6 +595,7 @@ class KeywordViewSet(SiteSectorModelViewSet):
def auto_cluster(self, request):
"""Auto-cluster keywords using ClusteringService"""
import logging
from igny8_core.ai.validators.cluster_validators import validate_minimum_keywords
logger = logging.getLogger(__name__)
@@ -611,6 +612,32 @@ class KeywordViewSet(SiteSectorModelViewSet):
request=request
)
# NEW: Validate minimum keywords BEFORE queuing task
if not keyword_ids:
return error_response(
error='No keyword IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
validation = validate_minimum_keywords(
keyword_ids=keyword_ids,
account=account,
min_required=5
)
if not validation['valid']:
return error_response(
error=validation['error'],
status_code=status.HTTP_400_BAD_REQUEST,
request=request,
extra_data={
'count': validation.get('count'),
'required': validation.get('required')
}
)
# Validation passed - proceed with clustering
# Use service to cluster keywords
service = ClusteringService()
try:
@@ -621,7 +648,7 @@ class KeywordViewSet(SiteSectorModelViewSet):
# Async task queued
return success_response(
data={'task_id': result['task_id']},
message=result.get('message', 'Clustering started'),
message=f'Clustering started with {validation["count"]} keywords',
request=request
)
else: