diff --git a/backend/igny8_core/ai/ai_core.py b/backend/igny8_core/ai/ai_core.py index eb220ce9..aa1d2a65 100644 --- a/backend/igny8_core/ai/ai_core.py +++ b/backend/igny8_core/ai/ai_core.py @@ -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") diff --git a/backend/igny8_core/ai/model_registry.py b/backend/igny8_core/ai/model_registry.py new file mode 100644 index 00000000..d0d3f597 --- /dev/null +++ b/backend/igny8_core/ai/model_registry.py @@ -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 diff --git a/backend/igny8_core/business/automation/services/automation_service.py b/backend/igny8_core/business/automation/services/automation_service.py index aaedd1de..8931fe82 100644 --- a/backend/igny8_core/business/automation/services/automation_service.py +++ b/backend/igny8_core/business/automation/services/automation_service.py @@ -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 diff --git a/backend/igny8_core/business/billing/views.py b/backend/igny8_core/business/billing/views.py index 3a9d8c8f..cb1359f6 100644 --- a/backend/igny8_core/business/billing/views.py +++ b/backend/igny8_core/business/billing/views.py @@ -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 + ) # ============================================================================ diff --git a/backend/igny8_core/business/planning/__init__.py b/backend/igny8_core/business/planning/__init__.py index 042b9746..da01f390 100644 --- a/backend/igny8_core/business/planning/__init__.py +++ b/backend/igny8_core/business/planning/__init__.py @@ -1,4 +1,5 @@ """ Planning business logic - Keywords, Clusters, ContentIdeas models and services """ - +# Import signals to register cascade handlers +from . import signals # noqa: F401 diff --git a/backend/igny8_core/business/planning/signals.py b/backend/igny8_core/business/planning/signals.py new file mode 100644 index 00000000..ea4ed8f5 --- /dev/null +++ b/backend/igny8_core/business/planning/signals.py @@ -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) diff --git a/backend/igny8_core/modules/billing/serializers.py b/backend/igny8_core/modules/billing/serializers.py index c8170c2c..2bee0b57 100644 --- a/backend/igny8_core/modules/billing/serializers.py +++ b/backend/igny8_core/modules/billing/serializers.py @@ -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) diff --git a/backend/igny8_core/modules/planner/admin.py b/backend/igny8_core/modules/planner/admin.py index 932af095..8aeb293e 100644 --- a/backend/igny8_core/modules/planner/admin.py +++ b/backend/igny8_core/modules/planner/admin.py @@ -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), diff --git a/frontend/src/config/colors.config.ts b/frontend/src/config/colors.config.ts new file mode 100644 index 00000000..29c7fd56 --- /dev/null +++ b/frontend/src/config/colors.config.ts @@ -0,0 +1,162 @@ +/** + * Module Color Configuration + * Single source of truth for module-specific colors throughout the UI + * + * These colors should be used consistently across: + * - Module icons + * - Progress bars + * - Metric cards + * - Badges + * - Chart segments + */ + +export const MODULE_COLORS = { + // Planner Module + keywords: { + bg: 'bg-brand-500', + bgLight: 'bg-brand-50', + text: 'text-brand-600', + textDark: 'text-brand-700', + border: 'border-brand-500', + gradient: 'from-brand-500 to-brand-600', + hex: '#2C7AA1', + }, + clusters: { + bg: 'bg-purple-500', + bgLight: 'bg-purple-50', + text: 'text-purple-600', + textDark: 'text-purple-700', + border: 'border-purple-500', + gradient: 'from-purple-500 to-purple-600', + hex: '#7c3aed', + }, + ideas: { + bg: 'bg-purple-600', + bgLight: 'bg-purple-50', + text: 'text-purple-700', + textDark: 'text-purple-800', + border: 'border-purple-600', + gradient: 'from-purple-600 to-purple-700', + hex: '#6d28d9', + }, + + // Writer Module + tasks: { + bg: 'bg-success-600', + bgLight: 'bg-success-50', + text: 'text-success-600', + textDark: 'text-success-700', + border: 'border-success-600', + gradient: 'from-success-500 to-success-600', + hex: '#059669', + }, + content: { + bg: 'bg-success-500', + bgLight: 'bg-success-50', + text: 'text-success-600', + textDark: 'text-success-700', + border: 'border-success-500', + gradient: 'from-success-500 to-success-600', + hex: '#10b981', + }, + images: { + bg: 'bg-purple-500', + bgLight: 'bg-purple-50', + text: 'text-purple-600', + textDark: 'text-purple-700', + border: 'border-purple-500', + gradient: 'from-purple-500 to-purple-600', + hex: '#7c3aed', + }, + + // Automation + automation: { + bg: 'bg-brand-500', + bgLight: 'bg-brand-50', + text: 'text-brand-600', + textDark: 'text-brand-700', + border: 'border-brand-500', + gradient: 'from-brand-500 to-brand-600', + hex: '#2C7AA1', + }, + + // Billing / Credits + billing: { + bg: 'bg-warning-500', + bgLight: 'bg-warning-50', + text: 'text-warning-600', + textDark: 'text-warning-700', + border: 'border-warning-500', + gradient: 'from-warning-500 to-warning-600', + hex: '#D9A12C', + }, + credits: { + bg: 'bg-warning-500', + bgLight: 'bg-warning-50', + text: 'text-warning-600', + textDark: 'text-warning-700', + border: 'border-warning-500', + gradient: 'from-warning-500 to-warning-600', + hex: '#D9A12C', + }, + + // Status Colors + success: { + bg: 'bg-success-500', + bgLight: 'bg-success-50', + text: 'text-success-600', + textDark: 'text-success-700', + border: 'border-success-500', + gradient: 'from-success-500 to-success-600', + hex: '#10b981', + }, + error: { + bg: 'bg-error-500', + bgLight: 'bg-error-50', + text: 'text-error-600', + textDark: 'text-error-700', + border: 'border-error-500', + gradient: 'from-error-500 to-error-600', + hex: '#ef4444', + }, + warning: { + bg: 'bg-warning-500', + bgLight: 'bg-warning-50', + text: 'text-warning-600', + textDark: 'text-warning-700', + border: 'border-warning-500', + gradient: 'from-warning-500 to-warning-600', + hex: '#D9A12C', + }, +} as const; + +/** + * Get balanced color sequence for multiple elements + * Ensures no more than 2 consecutive items share the same color family + */ +export const BALANCED_COLOR_SEQUENCE = [ + MODULE_COLORS.keywords, // blue + MODULE_COLORS.clusters, // purple + MODULE_COLORS.content, // green + MODULE_COLORS.billing, // amber + MODULE_COLORS.ideas, // purple (darker) + MODULE_COLORS.tasks, // green (darker) +] as const; + +/** + * Get module color by name + */ +export function getModuleColor(module: keyof typeof MODULE_COLORS) { + return MODULE_COLORS[module]; +} + +/** + * Get a color from the balanced sequence by index + * Cycles through to ensure visual balance + */ +export function getBalancedColor(index: number) { + return BALANCED_COLOR_SEQUENCE[index % BALANCED_COLOR_SEQUENCE.length]; +} + +export type ModuleColorKey = keyof typeof MODULE_COLORS; +export type ModuleColor = typeof MODULE_COLORS[ModuleColorKey]; diff --git a/frontend/src/index.css b/frontend/src/index.css index 70bb0a8c..1af9382b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -175,7 +175,7 @@ } @utility menu-item { - @apply relative flex items-center w-full gap-3 px-3 py-2 font-medium rounded-lg text-theme-sm; + @apply relative flex items-center w-full gap-3 px-3.5 py-2.5 font-medium rounded-lg text-theme-sm; } @utility menu-item-active { @@ -186,6 +186,44 @@ @apply text-gray-700 hover:bg-gray-100 group-hover:text-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-gray-300; } +/* Menu icon sizing - consistent across sidebar */ +@utility menu-item-icon-size { + @apply w-5 h-5 flex-shrink-0; +} + +@utility menu-item-icon-active { + @apply text-brand-500; +} + +@utility menu-item-icon-inactive { + @apply text-gray-500 group-hover:text-gray-700; +} + +/* Dropdown menu items - increased spacing */ +@utility menu-dropdown-item { + @apply block px-3 py-2 text-theme-sm font-medium rounded-md transition-colors; +} + +@utility menu-dropdown-item-active { + @apply text-brand-600 bg-brand-50; +} + +@utility menu-dropdown-item-inactive { + @apply text-gray-600 hover:text-gray-900 hover:bg-gray-50; +} + +@utility menu-dropdown-badge { + @apply px-1.5 py-0.5 text-xs font-medium rounded; +} + +@utility menu-dropdown-badge-active { + @apply bg-brand-100 text-brand-700; +} + +@utility menu-dropdown-badge-inactive { + @apply bg-gray-100 text-gray-600; +} + @layer utilities { input[type="date"]::-webkit-inner-spin-button, input[type="time"]::-webkit-inner-spin-button,