master plan implemenattion
This commit is contained in:
@@ -159,20 +159,24 @@ class AICore:
|
||||
logger.info(f" - Model used in request: {active_model}")
|
||||
tracker.ai_call(f"Using model: {active_model}")
|
||||
|
||||
if active_model not in MODEL_RATES:
|
||||
error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}"
|
||||
logger.error(f"[AICore] {error_msg}")
|
||||
tracker.error('ConfigurationError', error_msg)
|
||||
return {
|
||||
'content': None,
|
||||
'error': error_msg,
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'total_tokens': 0,
|
||||
'model': active_model,
|
||||
'cost': 0.0,
|
||||
'api_id': None,
|
||||
}
|
||||
# Use ModelRegistry for validation with fallback to constants
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
if not ModelRegistry.validate_model(active_model):
|
||||
# Fallback check against constants for backward compatibility
|
||||
if active_model not in MODEL_RATES:
|
||||
error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}"
|
||||
logger.error(f"[AICore] {error_msg}")
|
||||
tracker.error('ConfigurationError', error_msg)
|
||||
return {
|
||||
'content': None,
|
||||
'error': error_msg,
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'total_tokens': 0,
|
||||
'model': active_model,
|
||||
'cost': 0.0,
|
||||
'api_id': None,
|
||||
}
|
||||
|
||||
tracker.ai_call(f"Using model: {active_model}")
|
||||
|
||||
@@ -291,9 +295,17 @@ class AICore:
|
||||
tracker.parse(f"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})")
|
||||
tracker.parse(f"Content length: {len(content)} characters")
|
||||
|
||||
# Step 10: Calculate cost
|
||||
rates = MODEL_RATES.get(active_model, {'input': 2.00, 'output': 8.00})
|
||||
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000
|
||||
# Step 10: Calculate cost using ModelRegistry (with fallback to constants)
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
cost = float(ModelRegistry.calculate_cost(
|
||||
active_model,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens
|
||||
))
|
||||
# Fallback to constants if ModelRegistry returns 0
|
||||
if cost == 0:
|
||||
rates = MODEL_RATES.get(active_model, {'input': 2.00, 'output': 8.00})
|
||||
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000
|
||||
tracker.parse(f"Cost calculated: ${cost:.6f}")
|
||||
|
||||
tracker.done("Request completed successfully")
|
||||
|
||||
339
backend/igny8_core/ai/model_registry.py
Normal file
339
backend/igny8_core/ai/model_registry.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Model Registry Service
|
||||
Central registry for AI model configurations with caching.
|
||||
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
|
||||
|
||||
This service provides:
|
||||
- Database-driven model configuration (from AIModelConfig)
|
||||
- Fallback to constants.py for backward compatibility
|
||||
- Caching for performance
|
||||
- Cost calculation methods
|
||||
|
||||
Usage:
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Get model config
|
||||
model = ModelRegistry.get_model('gpt-4o-mini')
|
||||
|
||||
# Get rate
|
||||
input_rate = ModelRegistry.get_rate('gpt-4o-mini', 'input')
|
||||
|
||||
# Calculate cost
|
||||
cost = ModelRegistry.calculate_cost('gpt-4o-mini', input_tokens=1000, output_tokens=500)
|
||||
"""
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
from django.core.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache TTL in seconds (5 minutes)
|
||||
MODEL_CACHE_TTL = 300
|
||||
|
||||
# Cache key prefix
|
||||
CACHE_KEY_PREFIX = 'ai_model_'
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""
|
||||
Central registry for AI model configurations with caching.
|
||||
Uses AIModelConfig from database with fallback to constants.py
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _get_cache_key(cls, model_id: str) -> str:
|
||||
"""Generate cache key for model"""
|
||||
return f"{CACHE_KEY_PREFIX}{model_id}"
|
||||
|
||||
@classmethod
|
||||
def _get_from_db(cls, model_id: str) -> Optional[Any]:
|
||||
"""Get model config from database"""
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
return AIModelConfig.objects.filter(
|
||||
model_name=model_id,
|
||||
is_active=True
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch model {model_id} from DB: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _get_from_constants(cls, model_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get model config from constants.py as fallback.
|
||||
Returns a dict mimicking AIModelConfig attributes.
|
||||
"""
|
||||
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
|
||||
|
||||
# Check text models first
|
||||
if model_id in MODEL_RATES:
|
||||
rates = MODEL_RATES[model_id]
|
||||
return {
|
||||
'model_name': model_id,
|
||||
'display_name': model_id,
|
||||
'model_type': 'text',
|
||||
'provider': 'openai',
|
||||
'input_cost_per_1m': Decimal(str(rates.get('input', 0))),
|
||||
'output_cost_per_1m': Decimal(str(rates.get('output', 0))),
|
||||
'cost_per_image': None,
|
||||
'is_active': True,
|
||||
'_from_constants': True
|
||||
}
|
||||
|
||||
# Check image models
|
||||
if model_id in IMAGE_MODEL_RATES:
|
||||
cost = IMAGE_MODEL_RATES[model_id]
|
||||
return {
|
||||
'model_name': model_id,
|
||||
'display_name': model_id,
|
||||
'model_type': 'image',
|
||||
'provider': 'openai' if 'dall-e' in model_id else 'runware',
|
||||
'input_cost_per_1m': None,
|
||||
'output_cost_per_1m': None,
|
||||
'cost_per_image': Decimal(str(cost)),
|
||||
'is_active': True,
|
||||
'_from_constants': True
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_model(cls, model_id: str) -> Optional[Any]:
|
||||
"""
|
||||
Get model configuration by model_id.
|
||||
|
||||
Order of lookup:
|
||||
1. Cache
|
||||
2. Database (AIModelConfig)
|
||||
3. constants.py fallback
|
||||
|
||||
Args:
|
||||
model_id: The model identifier (e.g., 'gpt-4o-mini', 'dall-e-3')
|
||||
|
||||
Returns:
|
||||
AIModelConfig instance or dict with model config, None if not found
|
||||
"""
|
||||
cache_key = cls._get_cache_key(model_id)
|
||||
|
||||
# Try cache first
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Try database
|
||||
model_config = cls._get_from_db(model_id)
|
||||
|
||||
if model_config:
|
||||
cache.set(cache_key, model_config, MODEL_CACHE_TTL)
|
||||
return model_config
|
||||
|
||||
# Fallback to constants
|
||||
fallback = cls._get_from_constants(model_id)
|
||||
if fallback:
|
||||
cache.set(cache_key, fallback, MODEL_CACHE_TTL)
|
||||
return fallback
|
||||
|
||||
logger.warning(f"Model {model_id} not found in DB or constants")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_rate(cls, model_id: str, rate_type: str) -> Decimal:
|
||||
"""
|
||||
Get specific rate for a model.
|
||||
|
||||
Args:
|
||||
model_id: The model identifier
|
||||
rate_type: 'input', 'output' (for text models) or 'image' (for image models)
|
||||
|
||||
Returns:
|
||||
Decimal rate value, 0 if not found
|
||||
"""
|
||||
model = cls.get_model(model_id)
|
||||
if not model:
|
||||
return Decimal('0')
|
||||
|
||||
# Handle dict (from constants fallback)
|
||||
if isinstance(model, dict):
|
||||
if rate_type == 'input':
|
||||
return model.get('input_cost_per_1m') or Decimal('0')
|
||||
elif rate_type == 'output':
|
||||
return model.get('output_cost_per_1m') or Decimal('0')
|
||||
elif rate_type == 'image':
|
||||
return model.get('cost_per_image') or Decimal('0')
|
||||
return Decimal('0')
|
||||
|
||||
# Handle AIModelConfig instance
|
||||
if rate_type == 'input':
|
||||
return model.input_cost_per_1m or Decimal('0')
|
||||
elif rate_type == 'output':
|
||||
return model.output_cost_per_1m or Decimal('0')
|
||||
elif rate_type == 'image':
|
||||
return model.cost_per_image or Decimal('0')
|
||||
|
||||
return Decimal('0')
|
||||
|
||||
@classmethod
|
||||
def calculate_cost(cls, model_id: str, input_tokens: int = 0, output_tokens: int = 0, num_images: int = 0) -> Decimal:
|
||||
"""
|
||||
Calculate cost for model usage.
|
||||
|
||||
For text models: Uses input/output token counts
|
||||
For image models: Uses num_images
|
||||
|
||||
Args:
|
||||
model_id: The model identifier
|
||||
input_tokens: Number of input tokens (for text models)
|
||||
output_tokens: Number of output tokens (for text models)
|
||||
num_images: Number of images (for image models)
|
||||
|
||||
Returns:
|
||||
Decimal cost in USD
|
||||
"""
|
||||
model = cls.get_model(model_id)
|
||||
if not model:
|
||||
return Decimal('0')
|
||||
|
||||
# Determine model type
|
||||
model_type = model.get('model_type') if isinstance(model, dict) else model.model_type
|
||||
|
||||
if model_type == 'text':
|
||||
input_rate = cls.get_rate(model_id, 'input')
|
||||
output_rate = cls.get_rate(model_id, 'output')
|
||||
|
||||
cost = (
|
||||
(Decimal(input_tokens) * input_rate) +
|
||||
(Decimal(output_tokens) * output_rate)
|
||||
) / Decimal('1000000')
|
||||
|
||||
return cost
|
||||
|
||||
elif model_type == 'image':
|
||||
image_rate = cls.get_rate(model_id, 'image')
|
||||
return image_rate * Decimal(num_images)
|
||||
|
||||
return Decimal('0')
|
||||
|
||||
@classmethod
|
||||
def get_default_model(cls, model_type: str = 'text') -> Optional[str]:
|
||||
"""
|
||||
Get the default model for a given type.
|
||||
|
||||
Args:
|
||||
model_type: 'text' or 'image'
|
||||
|
||||
Returns:
|
||||
model_id string or None
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
default = AIModelConfig.objects.filter(
|
||||
model_type=model_type,
|
||||
is_active=True,
|
||||
is_default=True
|
||||
).first()
|
||||
|
||||
if default:
|
||||
return default.model_name
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get default {model_type} model from DB: {e}")
|
||||
|
||||
# Fallback to constants
|
||||
from igny8_core.ai.constants import DEFAULT_AI_MODEL
|
||||
if model_type == 'text':
|
||||
return DEFAULT_AI_MODEL
|
||||
elif model_type == 'image':
|
||||
return 'dall-e-3'
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def list_models(cls, model_type: Optional[str] = None, provider: Optional[str] = None) -> list:
|
||||
"""
|
||||
List all available models, optionally filtered by type or provider.
|
||||
|
||||
Args:
|
||||
model_type: Filter by 'text', 'image', or 'embedding'
|
||||
provider: Filter by 'openai', 'anthropic', 'runware', etc.
|
||||
|
||||
Returns:
|
||||
List of model configs
|
||||
"""
|
||||
models = []
|
||||
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
queryset = AIModelConfig.objects.filter(is_active=True)
|
||||
|
||||
if model_type:
|
||||
queryset = queryset.filter(model_type=model_type)
|
||||
if provider:
|
||||
queryset = queryset.filter(provider=provider)
|
||||
|
||||
models = list(queryset.order_by('sort_order', 'model_name'))
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not list models from DB: {e}")
|
||||
|
||||
# Add models from constants if not in DB
|
||||
if not models:
|
||||
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
|
||||
|
||||
if model_type in (None, 'text'):
|
||||
for model_id in MODEL_RATES:
|
||||
fallback = cls._get_from_constants(model_id)
|
||||
if fallback:
|
||||
models.append(fallback)
|
||||
|
||||
if model_type in (None, 'image'):
|
||||
for model_id in IMAGE_MODEL_RATES:
|
||||
fallback = cls._get_from_constants(model_id)
|
||||
if fallback:
|
||||
models.append(fallback)
|
||||
|
||||
return models
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, model_id: Optional[str] = None):
|
||||
"""
|
||||
Clear model cache.
|
||||
|
||||
Args:
|
||||
model_id: Clear specific model cache, or all if None
|
||||
"""
|
||||
if model_id:
|
||||
cache.delete(cls._get_cache_key(model_id))
|
||||
else:
|
||||
# Clear all model caches - use pattern if available
|
||||
try:
|
||||
from django.core.cache import caches
|
||||
default_cache = caches['default']
|
||||
if hasattr(default_cache, 'delete_pattern'):
|
||||
default_cache.delete_pattern(f"{CACHE_KEY_PREFIX}*")
|
||||
else:
|
||||
# Fallback: clear known models
|
||||
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
|
||||
for model_id in list(MODEL_RATES.keys()) + list(IMAGE_MODEL_RATES.keys()):
|
||||
cache.delete(cls._get_cache_key(model_id))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clear all model caches: {e}")
|
||||
|
||||
@classmethod
|
||||
def validate_model(cls, model_id: str) -> bool:
|
||||
"""
|
||||
Check if a model ID is valid and active.
|
||||
|
||||
Args:
|
||||
model_id: The model identifier to validate
|
||||
|
||||
Returns:
|
||||
True if model exists and is active, False otherwise
|
||||
"""
|
||||
model = cls.get_model(model_id)
|
||||
if not model:
|
||||
return False
|
||||
|
||||
# Check if active
|
||||
if isinstance(model, dict):
|
||||
return model.get('is_active', True)
|
||||
return model.is_active
|
||||
@@ -1643,14 +1643,22 @@ class AutomationService:
|
||||
raise
|
||||
|
||||
def _get_credits_used(self) -> int:
|
||||
"""Get total credits used by this run so far"""
|
||||
"""
|
||||
Get total credits used by this run so far.
|
||||
Uses CreditUsageLog (same source as /account/usage/credits endpoint) for accuracy.
|
||||
"""
|
||||
if not self.run:
|
||||
return 0
|
||||
|
||||
total = AITaskLog.objects.filter(
|
||||
# FIXED: Use CreditUsageLog instead of counting AITaskLog records
|
||||
# This matches the source of truth used by /account/usage/credits endpoint
|
||||
from igny8_core.business.billing.models import CreditUsageLog
|
||||
from django.db.models import Sum
|
||||
|
||||
total = CreditUsageLog.objects.filter(
|
||||
account=self.account,
|
||||
created_at__gte=self.run.started_at
|
||||
).aggregate(total=Count('id'))['total'] or 0
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
return total
|
||||
|
||||
|
||||
@@ -833,11 +833,16 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
|
||||
class AccountPaymentMethodViewSet(AccountModelViewSet):
|
||||
"""ViewSet for account payment methods"""
|
||||
"""ViewSet for account payment methods - Full CRUD support"""
|
||||
queryset = AccountPaymentMethod.objects.all()
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return serializer class"""
|
||||
from igny8_core.modules.billing.serializers import AccountPaymentMethodSerializer
|
||||
return AccountPaymentMethodSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter payment methods by account"""
|
||||
queryset = super().get_queryset()
|
||||
@@ -845,6 +850,15 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
|
||||
queryset = queryset.filter(account=self.request.account)
|
||||
return queryset.order_by('-is_default', 'type')
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Add account to serializer context"""
|
||||
context = super().get_serializer_context()
|
||||
account = getattr(self.request, 'account', None)
|
||||
if not account and hasattr(self.request, 'user') and self.request.user:
|
||||
account = getattr(self.request.user, 'account', None)
|
||||
context['account'] = account
|
||||
return context
|
||||
|
||||
def list(self, request):
|
||||
"""List payment methods for current account"""
|
||||
queryset = self.get_queryset()
|
||||
@@ -854,18 +868,108 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
|
||||
results = []
|
||||
for method in (page if page is not None else []):
|
||||
results.append({
|
||||
'id': str(method.id),
|
||||
'id': method.id,
|
||||
'type': method.type,
|
||||
'display_name': method.display_name,
|
||||
'is_default': method.is_default,
|
||||
'is_enabled': method.is_enabled if hasattr(method, 'is_enabled') else True,
|
||||
'is_enabled': method.is_enabled,
|
||||
'is_verified': method.is_verified,
|
||||
'instructions': method.instructions,
|
||||
'metadata': method.metadata,
|
||||
'created_at': method.created_at.isoformat() if method.created_at else None,
|
||||
'updated_at': method.updated_at.isoformat() if method.updated_at else None,
|
||||
})
|
||||
|
||||
return paginated_response(
|
||||
{'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results},
|
||||
request=request
|
||||
)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Create a new payment method"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
instance = serializer.save()
|
||||
return success_response(
|
||||
data={
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'display_name': instance.display_name,
|
||||
'is_default': instance.is_default,
|
||||
'is_enabled': instance.is_enabled,
|
||||
'is_verified': instance.is_verified,
|
||||
'instructions': instance.instructions,
|
||||
},
|
||||
message='Payment method created successfully',
|
||||
request=request,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Update a payment method"""
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
instance = serializer.save()
|
||||
return success_response(
|
||||
data={
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'display_name': instance.display_name,
|
||||
'is_default': instance.is_default,
|
||||
'is_enabled': instance.is_enabled,
|
||||
'is_verified': instance.is_verified,
|
||||
'instructions': instance.instructions,
|
||||
},
|
||||
message='Payment method updated successfully',
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete a payment method"""
|
||||
try:
|
||||
instance = self.get_object()
|
||||
|
||||
# Don't allow deleting the only default payment method
|
||||
if instance.is_default:
|
||||
other_methods = AccountPaymentMethod.objects.filter(
|
||||
account=instance.account
|
||||
).exclude(pk=instance.pk).count()
|
||||
if other_methods == 0:
|
||||
return error_response(
|
||||
error='Cannot delete the only payment method',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
instance.delete()
|
||||
return success_response(
|
||||
data=None,
|
||||
message='Payment method deleted successfully',
|
||||
request=request,
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""
|
||||
Planning business logic - Keywords, Clusters, ContentIdeas models and services
|
||||
"""
|
||||
|
||||
# Import signals to register cascade handlers
|
||||
from . import signals # noqa: F401
|
||||
|
||||
130
backend/igny8_core/business/planning/signals.py
Normal file
130
backend/igny8_core/business/planning/signals.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Cascade signals for Planning models
|
||||
Handles status updates and relationship cleanup when parent records are deleted
|
||||
"""
|
||||
import logging
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='planner.Clusters')
|
||||
def handle_cluster_soft_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When a Cluster is deleted:
|
||||
- Set Keywords.cluster = NULL
|
||||
- Reset Keywords.status to 'new'
|
||||
- Set ContentIdeas.keyword_cluster = NULL
|
||||
- Reset ContentIdeas.status to 'new'
|
||||
"""
|
||||
from igny8_core.business.planning.models import Keywords, ContentIdeas
|
||||
|
||||
# Check if this is a soft delete (is_deleted=True) vs hard delete
|
||||
# Soft deletes trigger delete() which calls soft_delete()
|
||||
if hasattr(instance, 'is_deleted') and instance.is_deleted:
|
||||
return # Skip if already soft-deleted
|
||||
|
||||
try:
|
||||
# Update related Keywords - clear cluster FK and reset status
|
||||
updated_keywords = Keywords.objects.filter(cluster=instance).update(
|
||||
cluster=None,
|
||||
status='new'
|
||||
)
|
||||
if updated_keywords:
|
||||
logger.info(
|
||||
f"[Cascade] Cluster '{instance.name}' (ID: {instance.id}) deleted: "
|
||||
f"Reset {updated_keywords} keywords to status='new', cluster=NULL"
|
||||
)
|
||||
|
||||
# Update related ContentIdeas - clear cluster FK and reset status
|
||||
updated_ideas = ContentIdeas.objects.filter(keyword_cluster=instance).update(
|
||||
keyword_cluster=None,
|
||||
status='new'
|
||||
)
|
||||
if updated_ideas:
|
||||
logger.info(
|
||||
f"[Cascade] Cluster '{instance.name}' (ID: {instance.id}) deleted: "
|
||||
f"Reset {updated_ideas} content ideas to status='new', keyword_cluster=NULL"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cascade] Error handling cluster deletion cascade: {e}", exc_info=True)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='planner.ContentIdeas')
|
||||
def handle_idea_soft_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When a ContentIdea is deleted:
|
||||
- Set Tasks.idea = NULL (don't delete tasks, they may have content)
|
||||
- Log orphaned tasks
|
||||
"""
|
||||
from igny8_core.business.content.models import Tasks
|
||||
|
||||
if hasattr(instance, 'is_deleted') and instance.is_deleted:
|
||||
return
|
||||
|
||||
try:
|
||||
# Update related Tasks - clear idea FK
|
||||
updated_tasks = Tasks.objects.filter(idea=instance).update(idea=None)
|
||||
if updated_tasks:
|
||||
logger.info(
|
||||
f"[Cascade] ContentIdea '{instance.idea_title}' (ID: {instance.id}) deleted: "
|
||||
f"Cleared idea reference from {updated_tasks} tasks"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cascade] Error handling content idea deletion cascade: {e}", exc_info=True)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='writer.Tasks')
|
||||
def handle_task_soft_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When a Task is deleted:
|
||||
- Set Content.task = NULL
|
||||
"""
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
if hasattr(instance, 'is_deleted') and instance.is_deleted:
|
||||
return
|
||||
|
||||
try:
|
||||
# Update related Content - clear task FK
|
||||
updated_content = Content.objects.filter(task=instance).update(task=None)
|
||||
if updated_content:
|
||||
logger.info(
|
||||
f"[Cascade] Task '{instance.title}' (ID: {instance.id}) deleted: "
|
||||
f"Cleared task reference from {updated_content} content items"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cascade] Error handling task deletion cascade: {e}", exc_info=True)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='writer.Content')
|
||||
def handle_content_soft_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When Content is deleted:
|
||||
- Soft delete related Images (cascade soft delete)
|
||||
- Clear PublishingRecord references
|
||||
"""
|
||||
from igny8_core.business.content.models import Images
|
||||
|
||||
if hasattr(instance, 'is_deleted') and instance.is_deleted:
|
||||
return
|
||||
|
||||
try:
|
||||
# Soft delete related Images
|
||||
related_images = Images.objects.filter(content=instance)
|
||||
for image in related_images:
|
||||
image.soft_delete(reason='cascade_from_content')
|
||||
|
||||
count = related_images.count()
|
||||
if count:
|
||||
logger.info(
|
||||
f"[Cascade] Content '{instance.title}' (ID: {instance.id}) deleted: "
|
||||
f"Soft deleted {count} related images"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cascade] Error handling content deletion cascade: {e}", exc_info=True)
|
||||
@@ -143,6 +143,83 @@ class UsageLimitsSerializer(serializers.Serializer):
|
||||
limits: LimitCardSerializer = LimitCardSerializer(many=True)
|
||||
|
||||
|
||||
class AccountPaymentMethodSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for Account Payment Methods
|
||||
Handles CRUD operations for account-specific payment methods
|
||||
"""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
type = serializers.ChoiceField(
|
||||
choices=[
|
||||
('stripe', 'Stripe (Credit/Debit Card)'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer (Manual)'),
|
||||
('local_wallet', 'Local Wallet (Manual)'),
|
||||
('manual', 'Manual Payment'),
|
||||
]
|
||||
)
|
||||
display_name = serializers.CharField(max_length=100)
|
||||
is_default = serializers.BooleanField(default=False)
|
||||
is_enabled = serializers.BooleanField(default=True)
|
||||
is_verified = serializers.BooleanField(read_only=True, default=False)
|
||||
instructions = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
metadata = serializers.JSONField(required=False, default=dict)
|
||||
created_at = serializers.DateTimeField(read_only=True)
|
||||
updated_at = serializers.DateTimeField(read_only=True)
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display_name uniqueness per account"""
|
||||
account = self.context.get('account')
|
||||
instance = getattr(self, 'instance', None)
|
||||
|
||||
if account:
|
||||
from igny8_core.business.billing.models import AccountPaymentMethod
|
||||
existing = AccountPaymentMethod.objects.filter(
|
||||
account=account,
|
||||
display_name=value
|
||||
)
|
||||
if instance:
|
||||
existing = existing.exclude(pk=instance.pk)
|
||||
if existing.exists():
|
||||
raise serializers.ValidationError(
|
||||
f"A payment method with name '{value}' already exists for this account."
|
||||
)
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
from igny8_core.business.billing.models import AccountPaymentMethod
|
||||
account = self.context.get('account')
|
||||
if not account:
|
||||
raise serializers.ValidationError("Account context is required")
|
||||
|
||||
# If this is marked as default, unset other defaults
|
||||
if validated_data.get('is_default', False):
|
||||
AccountPaymentMethod.objects.filter(
|
||||
account=account,
|
||||
is_default=True
|
||||
).update(is_default=False)
|
||||
|
||||
return AccountPaymentMethod.objects.create(
|
||||
account=account,
|
||||
**validated_data
|
||||
)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
from igny8_core.business.billing.models import AccountPaymentMethod
|
||||
|
||||
# If this is marked as default, unset other defaults
|
||||
if validated_data.get('is_default', False) and not instance.is_default:
|
||||
AccountPaymentMethod.objects.filter(
|
||||
account=instance.account,
|
||||
is_default=True
|
||||
).exclude(pk=instance.pk).update(is_default=False)
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class AIModelConfigSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for AI Model Configuration (Read-Only API)
|
||||
|
||||
@@ -39,6 +39,7 @@ class ClustersResource(resources.ModelResource):
|
||||
class ClustersAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||
resource_class = ClustersResource
|
||||
list_display = ['name', 'site', 'sector', 'keywords_count', 'volume', 'status', 'created_at']
|
||||
list_select_related = ['site', 'sector', 'account']
|
||||
list_filter = [
|
||||
('status', ChoicesDropdownFilter),
|
||||
('site', RelatedDropdownFilter),
|
||||
@@ -97,6 +98,7 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||
resource_class = KeywordsResource
|
||||
list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'country', 'status', 'created_at']
|
||||
list_editable = ['status'] # Enable inline editing for status
|
||||
list_select_related = ['site', 'sector', 'cluster', 'seed_keyword', 'seed_keyword__industry', 'seed_keyword__sector', 'account']
|
||||
list_filter = [
|
||||
('status', ChoicesDropdownFilter),
|
||||
('country', ChoicesDropdownFilter),
|
||||
@@ -107,7 +109,7 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||
('difficulty', RangeNumericFilter),
|
||||
('created_at', RangeDateFilter),
|
||||
]
|
||||
search_fields = ['keyword', 'seed_keyword__keyword']
|
||||
search_fields = ['seed_keyword__keyword']
|
||||
ordering = ['-created_at']
|
||||
autocomplete_fields = ['cluster', 'site', 'sector', 'seed_keyword']
|
||||
actions = [
|
||||
@@ -218,6 +220,7 @@ class ContentIdeasResource(resources.ModelResource):
|
||||
class ContentIdeasAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||
resource_class = ContentIdeasResource
|
||||
list_display = ['idea_title', 'site', 'sector', 'description_preview', 'content_type', 'content_structure', 'status', 'keyword_cluster', 'estimated_word_count', 'created_at']
|
||||
list_select_related = ['site', 'sector', 'keyword_cluster', 'account']
|
||||
list_filter = [
|
||||
('status', ChoicesDropdownFilter),
|
||||
('content_type', ChoicesDropdownFilter),
|
||||
|
||||
Reference in New Issue
Block a user