master plan implemenattion

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-30 08:51:31 +00:00
parent 96aaa4151a
commit 2af7bb725f
10 changed files with 900 additions and 26 deletions

View File

@@ -159,6 +159,10 @@ class AICore:
logger.info(f" - Model used in request: {active_model}") logger.info(f" - Model used in request: {active_model}")
tracker.ai_call(f"Using model: {active_model}") tracker.ai_call(f"Using model: {active_model}")
# 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: if active_model not in MODEL_RATES:
error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}" error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}"
logger.error(f"[AICore] {error_msg}") logger.error(f"[AICore] {error_msg}")
@@ -291,7 +295,15 @@ class AICore:
tracker.parse(f"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})") tracker.parse(f"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})")
tracker.parse(f"Content length: {len(content)} characters") tracker.parse(f"Content length: {len(content)} characters")
# Step 10: Calculate cost # 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}) rates = MODEL_RATES.get(active_model, {'input': 2.00, 'output': 8.00})
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000 cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000
tracker.parse(f"Cost calculated: ${cost:.6f}") tracker.parse(f"Cost calculated: ${cost:.6f}")

View 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

View File

@@ -1643,14 +1643,22 @@ class AutomationService:
raise raise
def _get_credits_used(self) -> int: 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: if not self.run:
return 0 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, account=self.account,
created_at__gte=self.run.started_at created_at__gte=self.run.started_at
).aggregate(total=Count('id'))['total'] or 0 ).aggregate(total=Sum('credits_used'))['total'] or 0
return total return total

View File

@@ -833,11 +833,16 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet):
class AccountPaymentMethodViewSet(AccountModelViewSet): class AccountPaymentMethodViewSet(AccountModelViewSet):
"""ViewSet for account payment methods""" """ViewSet for account payment methods - Full CRUD support"""
queryset = AccountPaymentMethod.objects.all() queryset = AccountPaymentMethod.objects.all()
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
pagination_class = CustomPageNumberPagination 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): def get_queryset(self):
"""Filter payment methods by account""" """Filter payment methods by account"""
queryset = super().get_queryset() queryset = super().get_queryset()
@@ -845,6 +850,15 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
queryset = queryset.filter(account=self.request.account) queryset = queryset.filter(account=self.request.account)
return queryset.order_by('-is_default', 'type') 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): def list(self, request):
"""List payment methods for current account""" """List payment methods for current account"""
queryset = self.get_queryset() queryset = self.get_queryset()
@@ -854,12 +868,16 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
results = [] results = []
for method in (page if page is not None else []): for method in (page if page is not None else []):
results.append({ results.append({
'id': str(method.id), 'id': method.id,
'type': method.type, 'type': method.type,
'display_name': method.display_name, 'display_name': method.display_name,
'is_default': method.is_default, '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, '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( return paginated_response(
@@ -867,6 +885,92 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
request=request 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
)
# ============================================================================ # ============================================================================
# USAGE SUMMARY (Plan Limits) - User-facing endpoint # USAGE SUMMARY (Plan Limits) - User-facing endpoint

View File

@@ -1,4 +1,5 @@
""" """
Planning business logic - Keywords, Clusters, ContentIdeas models and services Planning business logic - Keywords, Clusters, ContentIdeas models and services
""" """
# Import signals to register cascade handlers
from . import signals # noqa: F401

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

View File

@@ -143,6 +143,83 @@ class UsageLimitsSerializer(serializers.Serializer):
limits: LimitCardSerializer = LimitCardSerializer(many=True) 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): class AIModelConfigSerializer(serializers.Serializer):
""" """
Serializer for AI Model Configuration (Read-Only API) Serializer for AI Model Configuration (Read-Only API)

View File

@@ -39,6 +39,7 @@ class ClustersResource(resources.ModelResource):
class ClustersAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): class ClustersAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ClustersResource resource_class = ClustersResource
list_display = ['name', 'site', 'sector', 'keywords_count', 'volume', 'status', 'created_at'] list_display = ['name', 'site', 'sector', 'keywords_count', 'volume', 'status', 'created_at']
list_select_related = ['site', 'sector', 'account']
list_filter = [ list_filter = [
('status', ChoicesDropdownFilter), ('status', ChoicesDropdownFilter),
('site', RelatedDropdownFilter), ('site', RelatedDropdownFilter),
@@ -97,6 +98,7 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = KeywordsResource resource_class = KeywordsResource
list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'country', 'status', 'created_at'] list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'country', 'status', 'created_at']
list_editable = ['status'] # Enable inline editing for status 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 = [ list_filter = [
('status', ChoicesDropdownFilter), ('status', ChoicesDropdownFilter),
('country', ChoicesDropdownFilter), ('country', ChoicesDropdownFilter),
@@ -107,7 +109,7 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
('difficulty', RangeNumericFilter), ('difficulty', RangeNumericFilter),
('created_at', RangeDateFilter), ('created_at', RangeDateFilter),
] ]
search_fields = ['keyword', 'seed_keyword__keyword'] search_fields = ['seed_keyword__keyword']
ordering = ['-created_at'] ordering = ['-created_at']
autocomplete_fields = ['cluster', 'site', 'sector', 'seed_keyword'] autocomplete_fields = ['cluster', 'site', 'sector', 'seed_keyword']
actions = [ actions = [
@@ -218,6 +220,7 @@ class ContentIdeasResource(resources.ModelResource):
class ContentIdeasAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): class ContentIdeasAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentIdeasResource resource_class = ContentIdeasResource
list_display = ['idea_title', 'site', 'sector', 'description_preview', 'content_type', 'content_structure', 'status', 'keyword_cluster', 'estimated_word_count', 'created_at'] 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 = [ list_filter = [
('status', ChoicesDropdownFilter), ('status', ChoicesDropdownFilter),
('content_type', ChoicesDropdownFilter), ('content_type', ChoicesDropdownFilter),

View File

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

View File

@@ -175,7 +175,7 @@
} }
@utility menu-item { @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 { @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; @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 { @layer utilities {
input[type="date"]::-webkit-inner-spin-button, input[type="date"]::-webkit-inner-spin-button,
input[type="time"]::-webkit-inner-spin-button, input[type="time"]::-webkit-inner-spin-button,