fina autoamtiona adn billing and credits
This commit is contained in:
@@ -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}
|
||||
|
||||
52
backend/igny8_core/ai/validators/__init__.py
Normal file
52
backend/igny8_core/ai/validators/__init__.py
Normal 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',
|
||||
]
|
||||
105
backend/igny8_core/ai/validators/cluster_validators.py
Normal file
105
backend/igny8_core/ai/validators/cluster_validators.py
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
81
backend/igny8_core/business/billing/admin.py
Normal file
81
backend/igny8_core/business/billing/admin.py
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Management commands package"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Commands package"""
|
||||
@@ -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')
|
||||
)
|
||||
@@ -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'),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
# Billing app migrations
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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']
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user