2 Commits

Author SHA1 Message Date
Desktop
67283ad3e7 docs: Add Phase 0 implementation to CHANGELOG 2025-11-16 23:28:40 +05:00
Desktop
72a31b2edb Phase 0: Foundation & Credit System - Initial implementation
- Updated CREDIT_COSTS constants to Phase 0 format with new operations
- Enhanced CreditService with get_credit_cost() method and operation_type support
- Created AccountModuleSettings model for module enable/disable functionality
- Added AccountModuleSettingsSerializer and ViewSet
- Registered module settings API endpoint: /api/v1/system/settings/account-modules/
- Maintained backward compatibility with existing credit system
2025-11-16 23:24:44 +05:00
23 changed files with 462 additions and 950 deletions

View File

@@ -27,6 +27,27 @@ Each entry follows this format:
## [Unreleased]
### Added
- **Phase 0: Foundation & Credit System - Initial Implementation**
- Updated `CREDIT_COSTS` constants to Phase 0 format with new operations
- Added new credit costs: `linking` (8 credits), `optimization` (1 credit per 200 words), `site_structure_generation` (50 credits), `site_page_generation` (20 credits)
- Maintained backward compatibility with legacy operation names (`ideas`, `content`, `images`, `reparse`)
- Enhanced `CreditService` with `get_credit_cost()` method for dynamic cost calculation
- Supports variable costs based on operation type and amount (word count, etc.)
- Updated `check_credits()` and `deduct_credits()` to support both legacy `required_credits` parameter and new `operation_type`/`amount` parameters
- Maintained full backward compatibility with existing code
- Created `AccountModuleSettings` model for module enable/disable functionality
- One settings record per account (get_or_create pattern)
- Enable/disable flags for all 8 modules: `planner_enabled`, `writer_enabled`, `thinker_enabled`, `automation_enabled`, `site_builder_enabled`, `linker_enabled`, `optimizer_enabled`, `publisher_enabled`
- Helper method `is_module_enabled(module_name)` for easy module checking
- Added `AccountModuleSettingsSerializer` and `AccountModuleSettingsViewSet`
- API endpoint: `/api/v1/system/settings/account-modules/`
- Custom action: `check/(?P<module_name>[^/.]+)` to check if a specific module is enabled
- Automatic account assignment on create
- Unified API Standard v1.0 compliant
- **Affected Areas**: Billing module (`constants.py`, `services.py`), System module (`settings_models.py`, `settings_serializers.py`, `settings_views.py`, `urls.py`)
- **Documentation**: See `docs/planning/phases/PHASE-0-FOUNDATION-CREDIT-SYSTEM.md` for complete details
- **Impact**: Foundation for credit-only system and module-based feature access control
- **Planning Documents Organization**: Organized architecture and implementation planning documents
- Created `docs/planning/` directory for all planning documents
- Moved `IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md` to `docs/planning/`

View File

@@ -192,31 +192,6 @@ class AIEngine:
self.step_tracker.add_request_step("PREP", "success", prep_message)
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
if self.account:
try:
from igny8_core.modules.billing.services import CreditService
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
# Map function name to operation type
operation_type = self._get_operation_type(function_name)
# Calculate estimated cost
estimated_amount = self._get_estimated_amount(function_name, data, payload)
# Check credits BEFORE AI call
CreditService.check_credits(self.account, operation_type, estimated_amount)
logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}")
except InsufficientCreditsError as e:
error_msg = str(e)
error_type = 'InsufficientCreditsError'
logger.error(f"[AIEngine] {error_msg}")
return self._handle_error(error_msg, fn, error_type=error_type)
except Exception as e:
logger.warning(f"[AIEngine] Failed to check credits: {e}", exc_info=True)
# Don't fail the operation if credit check fails (for backward compatibility)
# Phase 3: AI_CALL - Provider API Call (25-70%)
# Validate account exists before proceeding
if not self.account:
@@ -350,45 +325,37 @@ class AIEngine:
# Store save_msg for use in DONE phase
final_save_msg = save_msg
# Phase 5.5: DEDUCT CREDITS - Deduct credits after successful save
# Track credit usage after successful save
if self.account and raw_response:
try:
from igny8_core.modules.billing.services import CreditService
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
from igny8_core.modules.billing.models import CreditUsageLog
# Map function name to operation type
operation_type = self._get_operation_type(function_name)
# Calculate credits used (based on tokens or fixed cost)
credits_used = self._calculate_credits_for_clustering(
keyword_count=len(data.get('keywords', [])) if isinstance(data, dict) else len(data) if isinstance(data, list) else 1,
tokens=raw_response.get('total_tokens', 0),
cost=raw_response.get('cost', 0)
)
# Calculate actual amount based on results
actual_amount = self._get_actual_amount(function_name, save_result, parsed, data)
# Deduct credits using the new convenience method
CreditService.deduct_credits_for_operation(
# Log credit usage (don't deduct from account.credits, just log)
CreditUsageLog.objects.create(
account=self.account,
operation_type=operation_type,
amount=actual_amount,
operation_type='clustering',
credits_used=credits_used,
cost_usd=raw_response.get('cost'),
model_used=raw_response.get('model', ''),
tokens_input=raw_response.get('tokens_input', 0),
tokens_output=raw_response.get('tokens_output', 0),
related_object_type=self._get_related_object_type(function_name),
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
related_object_type='cluster',
metadata={
'function_name': function_name,
'clusters_created': clusters_created,
'keywords_updated': keywords_updated,
'count': count,
**save_result
'function_name': function_name
}
)
logger.info(f"[AIEngine] Credits deducted: {operation_type}, amount: {actual_amount}")
except InsufficientCreditsError as e:
# This shouldn't happen since we checked before, but log it
logger.error(f"[AIEngine] Insufficient credits during deduction: {e}")
except Exception as e:
logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True)
# Don't fail the operation if credit deduction fails (for backward compatibility)
logger.warning(f"Failed to log credit usage: {e}", exc_info=True)
# Phase 6: DONE - Finalization (98-100%)
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
@@ -486,74 +453,18 @@ class AIEngine:
# Don't fail the task if logging fails
logger.warning(f"Failed to log to database: {e}")
def _get_operation_type(self, function_name):
"""Map function name to operation type for credit system"""
mapping = {
'auto_cluster': 'clustering',
'generate_ideas': 'idea_generation',
'generate_content': 'content_generation',
'generate_image_prompts': 'image_prompt_extraction',
'generate_images': 'image_generation',
}
return mapping.get(function_name, function_name)
def _get_estimated_amount(self, function_name, data, payload):
"""Get estimated amount for credit calculation (before operation)"""
if function_name == 'generate_content':
# Estimate word count from task or default
if isinstance(data, dict):
return data.get('estimated_word_count', 1000)
return 1000 # Default estimate
elif function_name == 'generate_images':
# Count images to generate
if isinstance(payload, dict):
image_ids = payload.get('image_ids', [])
return len(image_ids) if image_ids else 1
return 1
elif function_name == 'generate_ideas':
# Count clusters
if isinstance(data, dict) and 'cluster_data' in data:
return len(data['cluster_data'])
return 1
# For fixed cost operations (clustering, image_prompt_extraction), return None
return None
def _get_actual_amount(self, function_name, save_result, parsed, data):
"""Get actual amount for credit calculation (after operation)"""
if function_name == 'generate_content':
# Get actual word count from saved content
if isinstance(save_result, dict):
word_count = save_result.get('word_count')
if word_count:
return word_count
# Fallback: estimate from parsed content
if isinstance(parsed, dict) and 'content' in parsed:
content = parsed['content']
return len(content.split()) if isinstance(content, str) else 1000
return 1000
elif function_name == 'generate_images':
# Count successfully generated images
count = save_result.get('count', 0)
if count > 0:
return count
return 1
elif function_name == 'generate_ideas':
# Count ideas generated
count = save_result.get('count', 0)
if count > 0:
return count
return 1
# For fixed cost operations, return None
return None
def _get_related_object_type(self, function_name):
"""Get related object type for credit logging"""
mapping = {
'auto_cluster': 'cluster',
'generate_ideas': 'content_idea',
'generate_content': 'content',
'generate_image_prompts': 'image',
'generate_images': 'image',
}
return mapping.get(function_name, 'unknown')
def _calculate_credits_for_clustering(self, keyword_count, tokens, cost):
"""Calculate credits used for clustering operation"""
# Use plan's cost per request if available, otherwise calculate from tokens
if self.account and hasattr(self.account, 'plan') and self.account.plan:
plan = self.account.plan
# Check if plan has ai_cost_per_request config
if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request:
cluster_cost = plan.ai_cost_per_request.get('cluster', 0)
if cluster_cost:
return int(cluster_cost)
# Fallback: 1 credit per 30 keywords (minimum 1)
credits = max(1, int(keyword_count / 30))
return credits

View File

@@ -19,9 +19,21 @@ class PlanAdmin(admin.ModelAdmin):
('Plan Info', {
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
}),
('Account Management Limits', {
('User / Site Limits', {
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
}),
('Planner Limits', {
'fields': ('max_keywords', 'max_clusters', 'daily_cluster_limit', 'daily_keyword_import_limit', 'monthly_cluster_ai_credits')
}),
('Writer Limits', {
'fields': ('daily_content_tasks', 'daily_ai_requests', 'monthly_word_count_limit', 'monthly_content_ai_credits')
}),
('Image Limits', {
'fields': ('monthly_image_count', 'monthly_image_ai_credits', 'max_images_per_task', 'image_model_choices')
}),
('AI Controls', {
'fields': ('daily_ai_request_limit', 'monthly_ai_credit_limit')
}),
('Billing & Credits', {
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
}),

View File

@@ -1,86 +0,0 @@
# Generated manually for Phase 0: Remove plan operation limit fields (credit-only system)
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0013_remove_ai_cost_per_request'),
]
operations = [
# Remove Planner Limits
migrations.RemoveField(
model_name='plan',
name='max_keywords',
),
migrations.RemoveField(
model_name='plan',
name='max_clusters',
),
migrations.RemoveField(
model_name='plan',
name='max_content_ideas',
),
migrations.RemoveField(
model_name='plan',
name='daily_cluster_limit',
),
migrations.RemoveField(
model_name='plan',
name='daily_keyword_import_limit',
),
migrations.RemoveField(
model_name='plan',
name='monthly_cluster_ai_credits',
),
# Remove Writer Limits
migrations.RemoveField(
model_name='plan',
name='daily_content_tasks',
),
migrations.RemoveField(
model_name='plan',
name='daily_ai_requests',
),
migrations.RemoveField(
model_name='plan',
name='monthly_word_count_limit',
),
migrations.RemoveField(
model_name='plan',
name='monthly_content_ai_credits',
),
# Remove Image Generation Limits
migrations.RemoveField(
model_name='plan',
name='monthly_image_count',
),
migrations.RemoveField(
model_name='plan',
name='daily_image_generation_limit',
),
migrations.RemoveField(
model_name='plan',
name='monthly_image_ai_credits',
),
migrations.RemoveField(
model_name='plan',
name='max_images_per_task',
),
migrations.RemoveField(
model_name='plan',
name='image_model_choices',
),
# Remove AI Request Controls
migrations.RemoveField(
model_name='plan',
name='daily_ai_request_limit',
),
migrations.RemoveField(
model_name='plan',
name='monthly_ai_credit_limit',
),
]

View File

@@ -93,8 +93,8 @@ class Account(models.Model):
class Plan(models.Model):
"""
Subscription plan model - Phase 0: Credit-only system.
Plans define credits, billing, and account management limits only.
Subscription plan model with comprehensive limits and features.
Plans define limits for users, sites, content generation, AI usage, and billing.
"""
BILLING_CYCLE_CHOICES = [
('monthly', 'Monthly'),
@@ -110,7 +110,7 @@ class Plan(models.Model):
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
# Account Management Limits (kept - not operation limits)
# User / Site / Scope Limits
max_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account")
max_sites = models.IntegerField(
default=1,
@@ -120,7 +120,32 @@ class Plan(models.Model):
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
# Billing & Credits (Phase 0: Credit-only system)
# Planner Limits
max_keywords = models.IntegerField(default=1000, validators=[MinValueValidator(0)], help_text="Total keywords allowed (global limit)")
max_clusters = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Total clusters allowed (global)")
max_content_ideas = models.IntegerField(default=300, validators=[MinValueValidator(0)], help_text="Total content ideas allowed (global limit)")
daily_cluster_limit = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max clusters that can be created per day")
daily_keyword_import_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="SeedKeywords import limit per day")
monthly_cluster_ai_credits = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="AI credits allocated for clustering")
# Writer Limits
daily_content_tasks = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max number of content tasks (blogs) per day")
daily_ai_requests = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="Total AI executions (content + idea + image) allowed per day")
monthly_word_count_limit = models.IntegerField(default=50000, validators=[MinValueValidator(0)], help_text="Monthly word limit (for generated content)")
monthly_content_ai_credits = models.IntegerField(default=200, validators=[MinValueValidator(0)], help_text="AI credit pool for content generation")
# Image Generation Limits
monthly_image_count = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Max images per month")
daily_image_generation_limit = models.IntegerField(default=25, validators=[MinValueValidator(0)], help_text="Max images that can be generated per day")
monthly_image_ai_credits = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="AI credit pool for image generation")
max_images_per_task = models.IntegerField(default=4, validators=[MinValueValidator(1)], help_text="Max images per content task")
image_model_choices = models.JSONField(default=list, blank=True, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])")
# AI Request Controls
daily_ai_request_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Global daily AI request cap")
monthly_ai_credit_limit = models.IntegerField(default=500, validators=[MinValueValidator(0)], help_text="Unified credit ceiling per month (all AI functions)")
# Billing & Add-ons
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit")
allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?")

View File

@@ -11,10 +11,10 @@ class PlanSerializer(serializers.ModelSerializer):
model = Plan
fields = [
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
'included_credits', 'extra_credit_price', 'allow_credit_topup',
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas',
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count',
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit',
'included_credits', 'image_model_choices', 'credits_per_month'
]

View File

@@ -1,21 +1,25 @@
"""
Credit Cost Constants
Phase 0: Credit-only system costs per operation
Credit Cost Constants - Phase 0: Credit-Only System
All features are unlimited. Only credits restrict usage.
"""
CREDIT_COSTS = {
# Existing operations
'clustering': 10, # Per clustering request
'idea_generation': 15, # Per cluster → ideas request
'idea_generation': 15, # Per cluster → ideas request
'content_generation': 1, # Per 100 words
'image_prompt_extraction': 2, # Per content piece
'image_generation': 5, # Per image
'linking': 8, # Per content piece (NEW)
'optimization': 1, # Per 200 words (NEW)
'site_structure_generation': 50, # Per site blueprint (NEW)
'site_page_generation': 20, # Per page (NEW)
# Legacy operation types (for backward compatibility)
'ideas': 15, # Alias for idea_generation
'content': 3, # Legacy: 3 credits per content piece
'images': 5, # Alias for image_generation
'reparse': 1, # Per reparse
'image_prompt_extraction': 2, # Per content piece
'image_generation': 5, # Per image
# Legacy operation names (for backward compatibility)
'ideas': 15, # Alias for idea_generation
'content': 1, # Alias for content_generation (per 100 words)
'images': 5, # Alias for image_generation
'reparse': 2, # Alias for image_prompt_extraction
# NEW: Phase 2+ operations
'linking': 8, # Per content piece (NEW)
'optimization': 1, # Per 200 words (NEW)
'site_structure_generation': 50, # Per site blueprint (NEW)
'site_page_generation': 20, # Per page (NEW)
}

View File

@@ -19,67 +19,43 @@ class CreditService:
Args:
operation_type: Type of operation (from CREDIT_COSTS)
amount: Optional amount (word count, image count, etc.)
amount: Optional amount (word count, etc.) for variable costs
Returns:
int: Number of credits required
Raises:
CreditCalculationError: If operation type is unknown
"""
base_cost = CREDIT_COSTS.get(operation_type, 0)
if base_cost == 0:
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
# Variable cost operations
# Variable costs based on amount
if operation_type == 'content_generation' and amount:
# Per 100 words
return max(1, int(base_cost * (amount / 100)))
elif operation_type == 'optimization' and amount:
# Per 200 words
return max(1, int(base_cost * (amount / 200)))
elif operation_type == 'image_generation' and amount:
# Per image
return base_cost * amount
elif operation_type == 'idea_generation' and amount:
# Per idea
return base_cost * amount
# Fixed cost operations
return base_cost
@staticmethod
def check_credits(account, operation_type, amount=None):
def check_credits(account, required_credits=None, operation_type=None, amount=None):
"""
Check if account has sufficient credits for an operation.
Check if account has enough credits.
Args:
account: Account instance
operation_type: Type of operation
amount: Optional amount (word count, image count, etc.)
required_credits: Number of credits required (legacy parameter)
operation_type: Type of operation (new parameter)
amount: Optional amount for variable costs (new parameter)
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
required = CreditService.get_credit_cost(operation_type, amount)
if account.credits < required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required}, Available: {account.credits}"
)
return True
@staticmethod
def check_credits_legacy(account, required_credits):
"""
Legacy method: Check if account has enough credits (for backward compatibility).
# Support both old and new API
if operation_type:
required_credits = CreditService.get_credit_cost(operation_type, amount)
elif required_credits is None:
raise ValueError("Either required_credits or operation_type must be provided")
Args:
account: Account instance
required_credits: Number of credits required
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
if account.credits < required_credits:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
@@ -107,8 +83,8 @@ class CreditService:
Returns:
int: New credit balance
"""
# Check sufficient credits (legacy: amount is already calculated)
CreditService.check_credits_legacy(account, amount)
# Check sufficient credits
CreditService.check_credits(account, amount)
# Deduct from account.credits
account.credits -= amount
@@ -140,61 +116,6 @@ class CreditService:
return account.credits
@staticmethod
@transaction.atomic
def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
"""
Deduct credits for an operation (convenience method that calculates cost automatically).
Args:
account: Account instance
operation_type: Type of operation
amount: Optional amount (word count, image count, etc.)
description: Optional description (auto-generated if not provided)
metadata: Optional metadata dict
cost_usd: Optional cost in USD
model_used: Optional AI model used
tokens_input: Optional input tokens
tokens_output: Optional output tokens
related_object_type: Optional related object type
related_object_id: Optional related object ID
Returns:
int: New credit balance
"""
# Calculate credit cost
credits_required = CreditService.get_credit_cost(operation_type, amount)
# Check sufficient credits
CreditService.check_credits(account, operation_type, amount)
# Auto-generate description if not provided
if not description:
if operation_type == 'clustering':
description = f"Clustering operation"
elif operation_type == 'idea_generation':
description = f"Generated {amount or 1} idea(s)"
elif operation_type == 'content_generation':
description = f"Generated content ({amount or 0} words)"
elif operation_type == 'image_generation':
description = f"Generated {amount or 1} image(s)"
else:
description = f"{operation_type} operation"
return CreditService.deduct_credits(
account=account,
amount=credits_required,
operation_type=operation_type,
description=description,
metadata=metadata,
cost_usd=cost_usd,
model_used=model_used,
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type,
related_object_id=related_object_id
)
@staticmethod
@transaction.atomic
def add_credits(account, amount, transaction_type, description, metadata=None):
@@ -231,7 +152,9 @@ class CreditService:
def calculate_credits_for_operation(operation_type, **kwargs):
"""
Calculate credits needed for an operation.
Legacy method - use get_credit_cost() instead.
DEPRECATED: Use get_credit_cost() instead.
Kept for backward compatibility.
Args:
operation_type: Type of operation
@@ -243,22 +166,31 @@ class CreditService:
Raises:
CreditCalculationError: If calculation fails
"""
# Map legacy operation types
if operation_type == 'ideas':
operation_type = 'idea_generation'
elif operation_type == 'content':
operation_type = 'content_generation'
elif operation_type == 'images':
operation_type = 'image_generation'
# Map old operation types to new ones
operation_mapping = {
'ideas': 'idea_generation',
'content': 'content_generation',
'images': 'image_generation',
'reparse': 'image_prompt_extraction',
}
# Extract amount from kwargs
amount = None
if 'word_count' in kwargs:
amount = kwargs.get('word_count')
elif 'image_count' in kwargs:
amount = kwargs.get('image_count')
elif 'idea_count' in kwargs:
amount = kwargs.get('idea_count')
mapped_type = operation_mapping.get(operation_type, operation_type)
return CreditService.get_credit_cost(operation_type, amount)
# Handle variable costs
if mapped_type == 'content_generation':
word_count = kwargs.get('word_count') or kwargs.get('content_count', 1000) * 100
return CreditService.get_credit_cost(mapped_type, word_count)
elif mapped_type == 'clustering':
keyword_count = kwargs.get('keyword_count', 0)
# Clustering is fixed cost per request
return CreditService.get_credit_cost(mapped_type)
elif mapped_type == 'idea_generation':
idea_count = kwargs.get('idea_count', 1)
# Fixed cost per request
return CreditService.get_credit_cost(mapped_type)
elif mapped_type == 'image_generation':
image_count = kwargs.get('image_count', 1)
return CreditService.get_credit_cost(mapped_type) * image_count
return CreditService.get_credit_cost(mapped_type)

View File

@@ -207,10 +207,7 @@ class CreditUsageViewSet(AccountModelViewSet):
@action(detail=False, methods=['get'], url_path='limits', url_name='limits')
def limits(self, request):
"""
Get account limits and credit usage statistics (Phase 0: Credit-only system).
Returns account management limits and credit usage only.
"""
"""Get plan limits and current usage statistics"""
# Try multiple ways to get account
account = getattr(request, 'account', None)
@@ -228,7 +225,13 @@ class CreditUsageViewSet(AccountModelViewSet):
except (AttributeError, UserModel.DoesNotExist, Exception) as e:
account = None
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.info(f'Limits endpoint - User: {getattr(request, "user", None)}, Account: {account}, Account has plan: {account.plan if account else False}')
if not account:
logger.warning(f'No account found in limits endpoint')
# Return empty limits instead of error - frontend will show "no data" message
return success_response(data={'limits': []}, request=request)
@@ -238,16 +241,115 @@ class CreditUsageViewSet(AccountModelViewSet):
return success_response(data={'limits': []}, request=request)
# Import models
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
from igny8_core.modules.writer.models import Tasks, Images
from igny8_core.auth.models import User, Site
# Get current month boundaries
now = timezone.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Calculate usage statistics
limits_data = []
# Credit Usage (Phase 0: Credit-only system)
# Planner Limits
keywords_count = Keywords.objects.filter(account=account).count()
clusters_count = Clusters.objects.filter(account=account).count()
content_ideas_count = ContentIdeas.objects.filter(account=account).count()
clusters_today = Clusters.objects.filter(account=account, created_at__gte=start_of_day).count()
limits_data.extend([
{
'title': 'Keywords',
'limit': plan.max_keywords or 0,
'used': keywords_count,
'available': max(0, (plan.max_keywords or 0) - keywords_count),
'unit': 'keywords',
'category': 'planner',
'percentage': (keywords_count / (plan.max_keywords or 1)) * 100 if plan.max_keywords else 0
},
{
'title': 'Clusters',
'limit': plan.max_clusters or 0,
'used': clusters_count,
'available': max(0, (plan.max_clusters or 0) - clusters_count),
'unit': 'clusters',
'category': 'planner',
'percentage': (clusters_count / (plan.max_clusters or 1)) * 100 if plan.max_clusters else 0
},
{
'title': 'Content Ideas',
'limit': plan.max_content_ideas or 0,
'used': content_ideas_count,
'available': max(0, (plan.max_content_ideas or 0) - content_ideas_count),
'unit': 'ideas',
'category': 'planner',
'percentage': (content_ideas_count / (plan.max_content_ideas or 1)) * 100 if plan.max_content_ideas else 0
},
{
'title': 'Daily Cluster Limit',
'limit': plan.daily_cluster_limit or 0,
'used': clusters_today,
'available': max(0, (plan.daily_cluster_limit or 0) - clusters_today),
'unit': 'per day',
'category': 'planner',
'percentage': (clusters_today / (plan.daily_cluster_limit or 1)) * 100 if plan.daily_cluster_limit else 0
},
])
# Writer Limits
tasks_today = Tasks.objects.filter(account=account, created_at__gte=start_of_day).count()
tasks_month = Tasks.objects.filter(account=account, created_at__gte=start_of_month)
word_count_month = tasks_month.aggregate(total=Sum('word_count'))['total'] or 0
limits_data.extend([
{
'title': 'Monthly Word Count',
'limit': plan.monthly_word_count_limit or 0,
'used': word_count_month,
'available': max(0, (plan.monthly_word_count_limit or 0) - word_count_month),
'unit': 'words',
'category': 'writer',
'percentage': (word_count_month / (plan.monthly_word_count_limit or 1)) * 100 if plan.monthly_word_count_limit else 0
},
{
'title': 'Daily Content Tasks',
'limit': plan.daily_content_tasks or 0,
'used': tasks_today,
'available': max(0, (plan.daily_content_tasks or 0) - tasks_today),
'unit': 'per day',
'category': 'writer',
'percentage': (tasks_today / (plan.daily_content_tasks or 1)) * 100 if plan.daily_content_tasks else 0
},
])
# Image Limits
images_month = Images.objects.filter(account=account, created_at__gte=start_of_month).count()
images_today = Images.objects.filter(account=account, created_at__gte=start_of_day).count()
limits_data.extend([
{
'title': 'Monthly Images',
'limit': plan.monthly_image_count or 0,
'used': images_month,
'available': max(0, (plan.monthly_image_count or 0) - images_month),
'unit': 'images',
'category': 'images',
'percentage': (images_month / (plan.monthly_image_count or 1)) * 100 if plan.monthly_image_count else 0
},
{
'title': 'Daily Image Generation',
'limit': plan.daily_image_generation_limit or 0,
'used': images_today,
'available': max(0, (plan.daily_image_generation_limit or 0) - images_today),
'unit': 'per day',
'category': 'images',
'percentage': (images_today / (plan.daily_image_generation_limit or 1)) * 100 if plan.daily_image_generation_limit else 0
},
])
# AI Credits
credits_used_month = CreditUsageLog.objects.filter(
account=account,
created_at__gte=start_of_month
@@ -256,89 +358,64 @@ class CreditUsageViewSet(AccountModelViewSet):
# Get credits by operation type
cluster_credits = CreditUsageLog.objects.filter(
account=account,
operation_type__in=['clustering'],
operation_type='clustering',
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
content_credits = CreditUsageLog.objects.filter(
account=account,
operation_type__in=['content', 'content_generation'],
operation_type='content',
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
image_credits = CreditUsageLog.objects.filter(
account=account,
operation_type__in=['images', 'image_generation', 'image_prompt_extraction'],
operation_type='image',
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
idea_credits = CreditUsageLog.objects.filter(
account=account,
operation_type__in=['ideas', 'idea_generation'],
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
# Use included_credits from plan (Phase 0: Credit-only)
plan_credits = plan.included_credits or plan.credits_per_month or 0
plan_credits = plan.monthly_ai_credit_limit or plan.credits_per_month or 0
limits_data.extend([
{
'title': 'Monthly Credits',
'title': 'Monthly AI Credits',
'limit': plan_credits,
'used': credits_used_month,
'available': max(0, plan_credits - credits_used_month),
'unit': 'credits',
'category': 'credits',
'category': 'ai',
'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0
},
{
'title': 'Current Balance',
'limit': None, # No limit - shows current balance
'used': None,
'available': account.credits,
'unit': 'credits',
'category': 'credits',
'percentage': None
},
{
'title': 'Clustering Credits',
'limit': None,
'used': cluster_credits,
'available': None,
'unit': 'credits',
'category': 'credits',
'percentage': None
},
{
'title': 'Content Generation Credits',
'limit': None,
'title': 'Content AI Credits',
'limit': plan.monthly_content_ai_credits or 0,
'used': content_credits,
'available': None,
'available': max(0, (plan.monthly_content_ai_credits or 0) - content_credits),
'unit': 'credits',
'category': 'credits',
'percentage': None
'category': 'ai',
'percentage': (content_credits / (plan.monthly_content_ai_credits or 1)) * 100 if plan.monthly_content_ai_credits else 0
},
{
'title': 'Image Generation Credits',
'limit': None,
'title': 'Image AI Credits',
'limit': plan.monthly_image_ai_credits or 0,
'used': image_credits,
'available': None,
'available': max(0, (plan.monthly_image_ai_credits or 0) - image_credits),
'unit': 'credits',
'category': 'credits',
'percentage': None
'category': 'ai',
'percentage': (image_credits / (plan.monthly_image_ai_credits or 1)) * 100 if plan.monthly_image_ai_credits else 0
},
{
'title': 'Idea Generation Credits',
'limit': None,
'used': idea_credits,
'available': None,
'title': 'Cluster AI Credits',
'limit': plan.monthly_cluster_ai_credits or 0,
'used': cluster_credits,
'available': max(0, (plan.monthly_cluster_ai_credits or 0) - cluster_credits),
'unit': 'credits',
'category': 'credits',
'percentage': None
'category': 'ai',
'percentage': (cluster_credits / (plan.monthly_cluster_ai_credits or 1)) * 100 if plan.monthly_cluster_ai_credits else 0
},
])
# Account Management Limits (kept - not operation limits)
# General Limits
users_count = User.objects.filter(account=account).count()
sites_count = Site.objects.filter(account=account).count()
@@ -349,7 +426,7 @@ class CreditUsageViewSet(AccountModelViewSet):
'used': users_count,
'available': max(0, (plan.max_users or 0) - users_count),
'unit': 'users',
'category': 'account',
'category': 'general',
'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0
},
{
@@ -358,7 +435,7 @@ class CreditUsageViewSet(AccountModelViewSet):
'used': sites_count,
'available': max(0, (plan.max_sites or 0) - sites_count),
'unit': 'sites',
'category': 'account',
'category': 'general',
'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0
},
])

View File

@@ -1,37 +0,0 @@
# Generated manually for Phase 0: Module Enable Settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_modules_system', '0006_alter_systemstatus_unique_together_and_more'),
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
]
operations = [
migrations.CreateModel(
name='ModuleEnableSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('planner_enabled', models.BooleanField(default=True, help_text='Enable Planner module')),
('writer_enabled', models.BooleanField(default=True, help_text='Enable Writer module')),
('thinker_enabled', models.BooleanField(default=True, help_text='Enable Thinker module')),
('automation_enabled', models.BooleanField(default=True, help_text='Enable Automation module')),
('site_builder_enabled', models.BooleanField(default=True, help_text='Enable Site Builder module')),
('linker_enabled', models.BooleanField(default=True, help_text='Enable Linker module')),
('optimizer_enabled', models.BooleanField(default=True, help_text='Enable Optimizer module')),
('publisher_enabled', models.BooleanField(default=True, help_text='Enable Publisher module')),
('account', models.ForeignKey(on_delete=models.CASCADE, to='igny8_core_auth.account', db_column='tenant_id')),
],
options={
'db_table': 'igny8_module_enable_settings',
},
),
migrations.AddConstraint(
model_name='moduleenablesettings',
constraint=models.UniqueConstraint(fields=('account',), name='unique_account_module_enable_settings'),
),
]

View File

@@ -6,7 +6,7 @@ from igny8_core.auth.models import AccountBaseModel
# Import settings models
from .settings_models import (
SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
SystemSettings, AccountSettings, UserSettings, ModuleSettings, AccountModuleSettings, AISettings
)

View File

@@ -3,7 +3,7 @@ Settings Models Admin
"""
from django.contrib import admin
from igny8_core.admin.base import AccountAdminMixin
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
@admin.register(SystemSettings)

View File

@@ -92,8 +92,12 @@ class ModuleSettings(BaseSettings):
return f"ModuleSetting: {self.module_name} - {self.key}"
class ModuleEnableSettings(AccountBaseModel):
"""Module enable/disable settings per account"""
class AccountModuleSettings(AccountBaseModel):
"""
Account-level module enable/disable settings.
Phase 0: Credit System - Module Settings
"""
# Module enable/disable flags
planner_enabled = models.BooleanField(default=True, help_text="Enable Planner module")
writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module")
thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module")
@@ -103,23 +107,34 @@ class ModuleEnableSettings(AccountBaseModel):
optimizer_enabled = models.BooleanField(default=True, help_text="Enable Optimizer module")
publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module")
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'igny8_module_enable_settings'
unique_together = [['account']] # One record per account
db_table = 'igny8_account_module_settings'
verbose_name = 'Account Module Settings'
verbose_name_plural = 'Account Module Settings'
# One settings record per account
constraints = [
models.UniqueConstraint(fields=['account'], name='unique_account_module_settings')
]
indexes = [
models.Index(fields=['account']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"ModuleEnableSettings: {account.name if account else 'No Account'}"
return f"ModuleSettings: {account.name if account else 'No Account'}"
@classmethod
def get_or_create_for_account(cls, account):
"""Get or create module enable settings for an account"""
"""Get or create module settings for an account"""
settings, created = cls.objects.get_or_create(account=account)
return settings
def is_module_enabled(self, module_name):
"""Check if a module is enabled"""
mapping = {
module_map = {
'planner': self.planner_enabled,
'writer': self.writer_enabled,
'thinker': self.thinker_enabled,
@@ -129,7 +144,7 @@ class ModuleEnableSettings(AccountBaseModel):
'optimizer': self.optimizer_enabled,
'publisher': self.publisher_enabled,
}
return mapping.get(module_name, True) # Default to enabled if unknown
return module_map.get(module_name, True) # Default to enabled if module not found
# AISettings extends IntegrationSettings (which already exists)

View File

@@ -2,7 +2,7 @@
Serializers for Settings Models
"""
from rest_framework import serializers
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AccountModuleSettings, AISettings
from .validators import validate_settings_schema
@@ -58,9 +58,10 @@ class ModuleSettingsSerializer(serializers.ModelSerializer):
return value
class ModuleEnableSettingsSerializer(serializers.ModelSerializer):
class AccountModuleSettingsSerializer(serializers.ModelSerializer):
"""Serializer for Account Module Settings (Phase 0)"""
class Meta:
model = ModuleEnableSettings
model = AccountModuleSettings
fields = [
'id', 'planner_enabled', 'writer_enabled', 'thinker_enabled',
'automation_enabled', 'site_builder_enabled', 'linker_enabled',

View File

@@ -13,10 +13,10 @@ from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAu
from igny8_core.api.pagination import CustomPageNumberPagination
from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AccountModuleSettings, AISettings
from .settings_serializers import (
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
ModuleSettingsSerializer, ModuleEnableSettingsSerializer, AISettingsSerializer
ModuleSettingsSerializer, AccountModuleSettingsSerializer, AISettingsSerializer
)
@@ -282,97 +282,67 @@ class ModuleSettingsViewSet(AccountModelViewSet):
update=extend_schema(tags=['System']),
partial_update=extend_schema(tags=['System']),
)
class ModuleEnableSettingsViewSet(AccountModelViewSet):
class AccountModuleSettingsViewSet(AccountModelViewSet):
"""
ViewSet for managing module enable/disable settings
Unified API Standard v1.0 compliant
One record per account
ViewSet for managing account module enable/disable settings.
Phase 0: Credit System - Module Settings
One settings record per account (get_or_create pattern)
"""
queryset = ModuleEnableSettings.objects.all()
serializer_class = ModuleEnableSettingsSerializer
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
queryset = AccountModuleSettings.objects.all()
serializer_class = AccountModuleSettingsSerializer
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
pagination_class = CustomPageNumberPagination
throttle_scope = 'system'
throttle_classes = [DebugScopedRateThrottle]
def get_queryset(self):
"""Get module enable settings for current account"""
"""Get module settings for current account"""
queryset = super().get_queryset()
return queryset
return queryset.filter(account=self.request.account)
def list(self, request):
"""Get or create module enable settings for current account"""
account = getattr(request, 'account', None)
if not account:
user = getattr(request, 'user', None)
if user:
account = getattr(user, 'account', None)
if not account:
return error_response(
error='Account not found',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get or create settings for account
settings = ModuleEnableSettings.get_or_create_for_account(account)
def list(self, request, *args, **kwargs):
"""Get or create module settings for account"""
account = request.account
settings = AccountModuleSettings.get_or_create_for_account(account)
serializer = self.get_serializer(settings)
return success_response(data=serializer.data, request=request)
def retrieve(self, request, pk=None):
"""Get module enable settings for current account"""
account = getattr(request, 'account', None)
if not account:
user = getattr(request, 'user', None)
if user:
account = getattr(user, 'account', None)
if not account:
return error_response(
error='Account not found',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get or create settings for account
settings = ModuleEnableSettings.get_or_create_for_account(account)
"""Get module settings for account"""
account = request.account
try:
settings = AccountModuleSettings.objects.get(account=account, pk=pk)
except AccountModuleSettings.DoesNotExist:
# Create if doesn't exist
settings = AccountModuleSettings.get_or_create_for_account(account)
serializer = self.get_serializer(settings)
return success_response(data=serializer.data, request=request)
def update(self, request, pk=None):
"""Update module enable settings for current account"""
account = getattr(request, 'account', None)
def perform_create(self, serializer):
"""Set account automatically"""
account = getattr(self.request, 'account', None)
if not account:
user = getattr(request, 'user', None)
user = getattr(self.request, 'user', None)
if user:
account = getattr(user, 'account', None)
if not account:
return error_response(
error='Account not found',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
from rest_framework.exceptions import ValidationError
raise ValidationError("Account is required")
# Get or create settings for account
settings = ModuleEnableSettings.get_or_create_for_account(account)
serializer = self.get_serializer(settings, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return success_response(data=serializer.data, request=request)
return error_response(
error='Validation failed',
errors=serializer.errors,
status_code=status.HTTP_400_BAD_REQUEST,
serializer.save(account=account)
@action(detail=False, methods=['get'], url_path='check/(?P<module_name>[^/.]+)', url_name='check_module')
def check_module(self, request, module_name=None):
"""Check if a specific module is enabled"""
account = request.account
settings = AccountModuleSettings.get_or_create_for_account(account)
is_enabled = settings.is_module_enabled(module_name)
return success_response(
data={'module_name': module_name, 'enabled': is_enabled},
request=request
)
def partial_update(self, request, pk=None):
"""Partial update module enable settings"""
return self.update(request, pk)
@extend_schema_view(

View File

@@ -7,7 +7,7 @@ from .views import AIPromptViewSet, AuthorProfileViewSet, StrategyViewSet, syste
from .integration_views import IntegrationSettingsViewSet
from .settings_views import (
SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet,
ModuleSettingsViewSet, ModuleEnableSettingsViewSet, AISettingsViewSet
ModuleSettingsViewSet, AccountModuleSettingsViewSet, AISettingsViewSet
)
router = DefaultRouter()
router.register(r'prompts', AIPromptViewSet, basename='prompts')
@@ -17,7 +17,7 @@ router.register(r'settings/system', SystemSettingsViewSet, basename='system-sett
router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings')
router.register(r'settings/user', UserSettingsViewSet, basename='user-settings')
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings')
router.register(r'settings/modules/enable', ModuleEnableSettingsViewSet, basename='module-enable-settings')
router.register(r'settings/account-modules', AccountModuleSettingsViewSet, basename='account-module-settings')
router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings')
# Custom URL patterns for integration settings - matching reference plugin structure

View File

@@ -4,7 +4,6 @@ import { HelmetProvider } from "react-helmet-async";
import AppLayout from "./layout/AppLayout";
import { ScrollToTop } from "./components/common/ScrollToTop";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import ModuleGuard from "./components/common/ModuleGuard";
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
@@ -134,122 +133,90 @@ export default function App() {
{/* Planner Module */}
<Route path="/planner" element={
<Suspense fallback={null}>
<ModuleGuard module="planner">
<PlannerDashboard />
</ModuleGuard>
<PlannerDashboard />
</Suspense>
} />
<Route path="/planner/keywords" element={
<Suspense fallback={null}>
<ModuleGuard module="planner">
<Keywords />
</ModuleGuard>
<Keywords />
</Suspense>
} />
<Route path="/planner/clusters" element={
<Suspense fallback={null}>
<ModuleGuard module="planner">
<Clusters />
</ModuleGuard>
<Clusters />
</Suspense>
} />
<Route path="/planner/ideas" element={
<Suspense fallback={null}>
<ModuleGuard module="planner">
<Ideas />
</ModuleGuard>
<Ideas />
</Suspense>
} />
{/* Writer Module */}
<Route path="/writer" element={
<Suspense fallback={null}>
<ModuleGuard module="writer">
<WriterDashboard />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/writer/tasks" element={
<Suspense fallback={null}>
<ModuleGuard module="writer">
<Tasks />
</ModuleGuard>
</Suspense>
</Suspense>
} />
{/* Writer Content Routes - Order matters: list route must come before detail route */}
<Route path="/writer/content" element={
<Suspense fallback={null}>
<ModuleGuard module="writer">
<Content />
</ModuleGuard>
</Suspense>
</Suspense>
} />
{/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */}
<Route path="/writer/content/:id" element={
<Suspense fallback={null}>
<ModuleGuard module="writer">
<ContentView />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
<Route path="/writer/images" element={
<Suspense fallback={null}>
<ModuleGuard module="writer">
<Images />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/writer/published" element={
<Suspense fallback={null}>
<ModuleGuard module="writer">
<Published />
</ModuleGuard>
</Suspense>
</Suspense>
} />
{/* Thinker Module */}
<Route path="/thinker" element={
<Suspense fallback={null}>
<ModuleGuard module="thinker">
<ThinkerDashboard />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/thinker/prompts" element={
<Suspense fallback={null}>
<ModuleGuard module="thinker">
<Prompts />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/thinker/author-profiles" element={
<Suspense fallback={null}>
<ModuleGuard module="thinker">
<AuthorProfiles />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/thinker/profile" element={
<Suspense fallback={null}>
<ModuleGuard module="thinker">
<ThinkerProfile />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/thinker/strategies" element={
<Suspense fallback={null}>
<ModuleGuard module="thinker">
<Strategies />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/thinker/image-testing" element={
<Suspense fallback={null}>
<ModuleGuard module="thinker">
<ImageTesting />
</ModuleGuard>
</Suspense>
</Suspense>
} />
{/* Billing Module */}
@@ -289,10 +256,8 @@ export default function App() {
{/* Other Pages */}
<Route path="/automation" element={
<Suspense fallback={null}>
<ModuleGuard module="automation">
<AutomationDashboard />
</ModuleGuard>
</Suspense>
</Suspense>
} />
<Route path="/schedules" element={
<Suspense fallback={null}>

View File

@@ -1,40 +0,0 @@
import { ReactNode, useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import { useSettingsStore } from '../../store/settingsStore';
import { isModuleEnabled } from '../../config/modules.config';
interface ModuleGuardProps {
module: string;
children: ReactNode;
redirectTo?: string;
}
/**
* ModuleGuard - Protects routes based on module enable status
* Redirects to settings page if module is disabled
*/
export default function ModuleGuard({ module, children, redirectTo = '/settings/modules' }: ModuleGuardProps) {
const { moduleEnableSettings, loadModuleEnableSettings, loading } = useSettingsStore();
useEffect(() => {
// Load module enable settings if not already loaded
if (!moduleEnableSettings && !loading) {
loadModuleEnableSettings();
}
}, [moduleEnableSettings, loading, loadModuleEnableSettings]);
// While loading, show children (optimistic rendering)
if (loading || !moduleEnableSettings) {
return <>{children}</>;
}
// Check if module is enabled
const enabled = isModuleEnabled(module, moduleEnableSettings as any);
if (!enabled) {
return <Navigate to={redirectTo} replace />;
}
return <>{children}</>;
}

View File

@@ -1,110 +0,0 @@
/**
* Module Configuration
* Defines all available modules and their properties
*/
export interface ModuleConfig {
name: string;
route: string;
icon?: string;
description?: string;
enabled: boolean; // Will be checked from API
}
export const MODULES: Record<string, ModuleConfig> = {
planner: {
name: 'Planner',
route: '/planner',
icon: '📊',
description: 'Keyword research and clustering',
enabled: true, // Default, will be checked from API
},
writer: {
name: 'Writer',
route: '/writer',
icon: '✍️',
description: 'Content generation and management',
enabled: true,
},
thinker: {
name: 'Thinker',
route: '/thinker',
icon: '🧠',
description: 'AI prompts and strategies',
enabled: true,
},
automation: {
name: 'Automation',
route: '/automation',
icon: '⚙️',
description: 'Automated workflows and tasks',
enabled: true,
},
site_builder: {
name: 'Site Builder',
route: '/site-builder',
icon: '🏗️',
description: 'Build and manage sites',
enabled: true,
},
linker: {
name: 'Linker',
route: '/linker',
icon: '🔗',
description: 'Internal linking optimization',
enabled: true,
},
optimizer: {
name: 'Optimizer',
route: '/optimizer',
icon: '⚡',
description: 'Content optimization',
enabled: true,
},
publisher: {
name: 'Publisher',
route: '/publisher',
icon: '📤',
description: 'Content publishing',
enabled: true,
},
};
/**
* Get module config by name
*/
export function getModuleConfig(moduleName: string): ModuleConfig | undefined {
return MODULES[moduleName];
}
/**
* Get all enabled modules
*/
export function getEnabledModules(moduleEnableSettings?: Record<string, boolean>): ModuleConfig[] {
return Object.entries(MODULES)
.filter(([key, module]) => {
// If moduleEnableSettings provided, use it; otherwise default to enabled
if (moduleEnableSettings) {
const enabledKey = `${key}_enabled` as keyof typeof moduleEnableSettings;
return moduleEnableSettings[enabledKey] !== false; // Default to true if not set
}
return module.enabled;
})
.map(([, module]) => module);
}
/**
* Check if a module is enabled
*/
export function isModuleEnabled(moduleName: string, moduleEnableSettings?: Record<string, boolean>): boolean {
const module = MODULES[moduleName];
if (!module) return false;
if (moduleEnableSettings) {
const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings;
return moduleEnableSettings[enabledKey] !== false; // Default to true if not set
}
return module.enabled;
}

View File

@@ -20,8 +20,6 @@ import { useSidebar } from "../context/SidebarContext";
import SidebarWidget from "./SidebarWidget";
import { APP_VERSION } from "../config/version";
import { useAuthStore } from "../store/authStore";
import { useSettingsStore } from "../store/settingsStore";
import { isModuleEnabled } from "../config/modules.config";
import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator";
type NavItem = {
@@ -40,19 +38,12 @@ const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const location = useLocation();
const { user } = useAuthStore();
const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled } = useSettingsStore();
// Show admin menu only for users in aws-admin account
const isAwsAdminAccount = Boolean(
user?.account?.slug === 'aws-admin' ||
user?.role === 'developer' // Also show for developers as fallback
);
// Helper to check if module is enabled
const moduleEnabled = (moduleName: string): boolean => {
if (!moduleEnableSettings) return true; // Default to enabled if not loaded
return checkModuleEnabled(moduleName);
};
const [openSubmenu, setOpenSubmenu] = useState<{
sectionIndex: number;
@@ -69,98 +60,77 @@ const AppSidebar: React.FC = () => {
);
// Define menu sections with useMemo to prevent recreation on every render
// Filter out disabled modules based on module enable settings
const menuSections: MenuSection[] = useMemo(() => {
const workflowItems: NavItem[] = [
{
icon: <PlugInIcon />,
name: "Setup",
subItems: [
{ name: "Sites", path: "/settings/sites" },
{ name: "Keywords Opportunities", path: "/planner/keyword-opportunities" },
],
},
];
// Add Planner if enabled
if (moduleEnabled('planner')) {
workflowItems.push({
icon: <ListIcon />,
name: "Planner",
subItems: [
{ name: "Dashboard", path: "/planner" },
{ name: "Keywords", path: "/planner/keywords" },
{ name: "Clusters", path: "/planner/clusters" },
{ name: "Ideas", path: "/planner/ideas" },
],
});
}
// Add Writer if enabled
if (moduleEnabled('writer')) {
workflowItems.push({
icon: <TaskIcon />,
name: "Writer",
subItems: [
{ name: "Dashboard", path: "/writer" },
{ name: "Tasks", path: "/writer/tasks" },
{ name: "Content", path: "/writer/content" },
{ name: "Images", path: "/writer/images" },
{ name: "Published", path: "/writer/published" },
],
});
}
// Add Thinker if enabled
if (moduleEnabled('thinker')) {
workflowItems.push({
icon: <BoltIcon />,
name: "Thinker",
subItems: [
{ name: "Dashboard", path: "/thinker" },
{ name: "Prompts", path: "/thinker/prompts" },
{ name: "Author Profiles", path: "/thinker/author-profiles" },
{ name: "Strategies", path: "/thinker/strategies" },
{ name: "Image Testing", path: "/thinker/image-testing" },
],
});
}
// Add Automation if enabled
if (moduleEnabled('automation')) {
workflowItems.push({
icon: <BoltIcon />,
name: "Automation",
path: "/automation",
});
}
workflowItems.push({
icon: <TimeIcon />,
name: "Schedules",
path: "/schedules",
});
return [
{
label: "OVERVIEW",
items: [
{
icon: <GridIcon />,
name: "Dashboard",
path: "/",
},
{
icon: <DocsIcon />,
name: "Industry / Sectors",
path: "/reference/industries",
},
],
},
{
label: "WORKFLOWS",
items: workflowItems,
},
const menuSections: MenuSection[] = useMemo(() => [
{
label: "OVERVIEW",
items: [
{
icon: <GridIcon />,
name: "Dashboard",
path: "/",
},
{
icon: <DocsIcon />,
name: "Industry / Sectors",
path: "/reference/industries",
},
],
},
{
label: "WORKFLOWS",
items: [
{
icon: <PlugInIcon />,
name: "Setup",
subItems: [
{ name: "Sites", path: "/settings/sites" },
{ name: "Keywords Opportunities", path: "/planner/keyword-opportunities" },
],
},
{
icon: <ListIcon />,
name: "Planner",
subItems: [
{ name: "Dashboard", path: "/planner" },
{ name: "Keywords", path: "/planner/keywords" },
{ name: "Clusters", path: "/planner/clusters" },
{ name: "Ideas", path: "/planner/ideas" },
],
},
{
icon: <TaskIcon />,
name: "Writer",
subItems: [
{ name: "Dashboard", path: "/writer" },
{ name: "Tasks", path: "/writer/tasks" },
{ name: "Content", path: "/writer/content" },
{ name: "Images", path: "/writer/images" },
{ name: "Published", path: "/writer/published" },
],
},
{
icon: <BoltIcon />,
name: "Thinker",
subItems: [
{ name: "Dashboard", path: "/thinker" },
{ name: "Prompts", path: "/thinker/prompts" },
{ name: "Author Profiles", path: "/thinker/author-profiles" },
{ name: "Strategies", path: "/thinker/strategies" },
{ name: "Image Testing", path: "/thinker/image-testing" },
],
},
{
icon: <BoltIcon />,
name: "Automation",
path: "/automation",
},
{
icon: <TimeIcon />,
name: "Schedules",
path: "/schedules",
},
],
},
{
label: "ACCOUNT & SETTINGS",
items: [
@@ -195,8 +165,7 @@ const AppSidebar: React.FC = () => {
},
],
},
];
}, [moduleEnableSettings, moduleEnabled]);
], []);
// Admin section - only shown for users in aws-admin account
const adminSection: MenuSection = useMemo(() => ({
@@ -282,14 +251,6 @@ const AppSidebar: React.FC = () => {
: menuSections;
}, [isAwsAdminAccount, menuSections, adminSection]);
// Load module enable settings on mount
useEffect(() => {
const { loadModuleEnableSettings } = useSettingsStore.getState();
if (!moduleEnableSettings) {
loadModuleEnableSettings();
}
}, [moduleEnableSettings]);
useEffect(() => {
const currentPath = location.pathname;
let foundMatch = false;

View File

@@ -1,48 +1,36 @@
import { useEffect } from 'react';
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { useSettingsStore } from '../../store/settingsStore';
import { MODULES } from '../../config/modules.config';
import { fetchAPI } from '../../services/api';
import { Card } from '../../components/ui/card';
import Switch from '../../components/form/switch/Switch';
export default function ModuleSettings() {
const toast = useToast();
const {
moduleEnableSettings,
loadModuleEnableSettings,
updateModuleEnableSettings,
loading,
} = useSettingsStore();
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadModuleEnableSettings();
}, [loadModuleEnableSettings]);
loadSettings();
}, []);
const handleToggle = async (moduleName: string, enabled: boolean) => {
const loadSettings = async () => {
try {
const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings;
await updateModuleEnableSettings({
[enabledKey]: enabled,
} as any);
toast.success(`${MODULES[moduleName]?.name || moduleName} ${enabled ? 'enabled' : 'disabled'}`);
setLoading(true);
const response = await fetchAPI('/v1/system/settings/modules/');
setSettings(response.results || []);
} catch (error: any) {
toast.error(`Failed to update module: ${error.message}`);
toast.error(`Failed to load module settings: ${error.message}`);
} finally {
setLoading(false);
}
};
const getModuleEnabled = (moduleName: string): boolean => {
if (!moduleEnableSettings) return true; // Default to enabled
const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings;
return moduleEnableSettings[enabledKey] !== false;
};
return (
<div className="p-6">
<PageMeta title="Module Settings" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Module Settings</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Enable or disable modules for your account</p>
<p className="text-gray-600 dark:text-gray-400 mt-1">Module-specific configuration</p>
</div>
{loading ? (
@@ -51,38 +39,7 @@ export default function ModuleSettings() {
</div>
) : (
<Card className="p-6">
<div className="space-y-6">
{Object.entries(MODULES).map(([key, module]) => (
<div
key={key}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-2xl">{module.icon}</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{module.name}
</h3>
{module.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{module.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 dark:text-gray-400">
{getModuleEnabled(key) ? 'Enabled' : 'Disabled'}
</span>
<Switch
label=""
checked={getModuleEnabled(key)}
onChange={(enabled) => handleToggle(key, enabled)}
/>
</div>
</div>
))}
</div>
<p className="text-gray-600 dark:text-gray-400">Module settings management interface coming soon.</p>
</Card>
)}
</div>

View File

@@ -1474,20 +1474,6 @@ export async function deleteAccountSetting(key: string): Promise<void> {
}
// Module Settings
export interface ModuleEnableSettings {
id: number;
planner_enabled: boolean;
writer_enabled: boolean;
thinker_enabled: boolean;
automation_enabled: boolean;
site_builder_enabled: boolean;
linker_enabled: boolean;
optimizer_enabled: boolean;
publisher_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface ModuleSetting {
id: number;
module_name: string;
@@ -1512,19 +1498,6 @@ export async function createModuleSetting(data: { module_name: string; key: stri
});
}
export async function fetchModuleEnableSettings(): Promise<ModuleEnableSettings> {
const response = await fetchAPI('/v1/system/settings/modules/enable/');
return response;
}
export async function updateModuleEnableSettings(data: Partial<ModuleEnableSettings>): Promise<ModuleEnableSettings> {
const response = await fetchAPI('/v1/system/settings/modules/enable/', {
method: 'PUT',
body: JSON.stringify(data),
});
return response;
}
export async function updateModuleSetting(moduleName: string, key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<ModuleSetting> {
return fetchAPI(`/v1/system/settings/modules/${key}/?module_name=${moduleName}`, {
method: 'PUT',

View File

@@ -13,17 +13,13 @@ import {
fetchModuleSettings,
createModuleSetting,
updateModuleSetting,
fetchModuleEnableSettings,
updateModuleEnableSettings,
AccountSetting,
ModuleSetting,
ModuleEnableSettings,
} from '../services/api';
interface SettingsState {
accountSettings: Record<string, AccountSetting>;
moduleSettings: Record<string, Record<string, ModuleSetting>>;
moduleEnableSettings: ModuleEnableSettings | null;
loading: boolean;
error: string | null;
@@ -33,9 +29,6 @@ interface SettingsState {
updateAccountSetting: (key: string, value: any) => Promise<void>;
loadModuleSettings: (moduleName: string) => Promise<void>;
updateModuleSetting: (moduleName: string, key: string, value: any) => Promise<void>;
loadModuleEnableSettings: () => Promise<void>;
updateModuleEnableSettings: (data: Partial<ModuleEnableSettings>) => Promise<void>;
isModuleEnabled: (moduleName: string) => boolean;
reset: () => void;
}
@@ -44,7 +37,6 @@ export const useSettingsStore = create<SettingsState>()(
(set, get) => ({
accountSettings: {},
moduleSettings: {},
moduleEnableSettings: null,
loading: false,
error: null,
@@ -143,40 +135,10 @@ export const useSettingsStore = create<SettingsState>()(
}
},
loadModuleEnableSettings: async () => {
set({ loading: true, error: null });
try {
const settings = await fetchModuleEnableSettings();
set({ moduleEnableSettings: settings, loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
updateModuleEnableSettings: async (data: Partial<ModuleEnableSettings>) => {
set({ loading: true, error: null });
try {
const settings = await updateModuleEnableSettings(data);
set({ moduleEnableSettings: settings, loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
throw error;
}
},
isModuleEnabled: (moduleName: string): boolean => {
const settings = get().moduleEnableSettings;
if (!settings) return true; // Default to enabled if not loaded
const enabledKey = `${moduleName}_enabled` as keyof ModuleEnableSettings;
return settings[enabledKey] !== false; // Default to true if not set
},
reset: () => {
set({
accountSettings: {},
moduleSettings: {},
moduleEnableSettings: null,
loading: false,
error: null,
});
@@ -187,7 +149,6 @@ export const useSettingsStore = create<SettingsState>()(
partialize: (state) => ({
accountSettings: state.accountSettings,
moduleSettings: state.moduleSettings,
moduleEnableSettings: state.moduleEnableSettings,
}),
}
)