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
18 changed files with 284 additions and 800 deletions

View File

@@ -27,6 +27,27 @@ Each entry follows this format:
## [Unreleased] ## [Unreleased]
### Added ### 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 - **Planning Documents Organization**: Organized architecture and implementation planning documents
- Created `docs/planning/` directory for all planning documents - Created `docs/planning/` directory for all planning documents
- Moved `IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md` to `docs/planning/` - 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.step_tracker.add_request_step("PREP", "success", prep_message)
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta()) 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%) # Phase 3: AI_CALL - Provider API Call (25-70%)
# Validate account exists before proceeding # Validate account exists before proceeding
if not self.account: if not self.account:
@@ -350,45 +325,37 @@ class AIEngine:
# Store save_msg for use in DONE phase # Store save_msg for use in DONE phase
final_save_msg = save_msg 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: if self.account and raw_response:
try: try:
from igny8_core.modules.billing.services import CreditService 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 # Calculate credits used (based on tokens or fixed cost)
operation_type = self._get_operation_type(function_name) 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 # Log credit usage (don't deduct from account.credits, just log)
actual_amount = self._get_actual_amount(function_name, save_result, parsed, data) CreditUsageLog.objects.create(
# Deduct credits using the new convenience method
CreditService.deduct_credits_for_operation(
account=self.account, account=self.account,
operation_type=operation_type, operation_type='clustering',
amount=actual_amount, credits_used=credits_used,
cost_usd=raw_response.get('cost'), cost_usd=raw_response.get('cost'),
model_used=raw_response.get('model', ''), model_used=raw_response.get('model', ''),
tokens_input=raw_response.get('tokens_input', 0), tokens_input=raw_response.get('tokens_input', 0),
tokens_output=raw_response.get('tokens_output', 0), tokens_output=raw_response.get('tokens_output', 0),
related_object_type=self._get_related_object_type(function_name), related_object_type='cluster',
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
metadata={ metadata={
'function_name': function_name,
'clusters_created': clusters_created, 'clusters_created': clusters_created,
'keywords_updated': keywords_updated, 'keywords_updated': keywords_updated,
'count': count, 'function_name': function_name
**save_result
} }
) )
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: except Exception as e:
logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True) logger.warning(f"Failed to log credit usage: {e}", exc_info=True)
# Don't fail the operation if credit deduction fails (for backward compatibility)
# Phase 6: DONE - Finalization (98-100%) # Phase 6: DONE - Finalization (98-100%)
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully" 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 # Don't fail the task if logging fails
logger.warning(f"Failed to log to database: {e}") logger.warning(f"Failed to log to database: {e}")
def _get_operation_type(self, function_name): def _calculate_credits_for_clustering(self, keyword_count, tokens, cost):
"""Map function name to operation type for credit system""" """Calculate credits used for clustering operation"""
mapping = { # Use plan's cost per request if available, otherwise calculate from tokens
'auto_cluster': 'clustering', if self.account and hasattr(self.account, 'plan') and self.account.plan:
'generate_ideas': 'idea_generation', plan = self.account.plan
'generate_content': 'content_generation', # Check if plan has ai_cost_per_request config
'generate_image_prompts': 'image_prompt_extraction', if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request:
'generate_images': 'image_generation', cluster_cost = plan.ai_cost_per_request.get('cluster', 0)
} if cluster_cost:
return mapping.get(function_name, function_name) return int(cluster_cost)
def _get_estimated_amount(self, function_name, data, payload): # Fallback: 1 credit per 30 keywords (minimum 1)
"""Get estimated amount for credit calculation (before operation)""" credits = max(1, int(keyword_count / 30))
if function_name == 'generate_content': return credits
# 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')

View File

@@ -1,21 +1,25 @@
""" """
Credit Cost Constants Credit Cost Constants - Phase 0: Credit-Only System
Phase 0: Credit-only system costs per operation All features are unlimited. Only credits restrict usage.
""" """
CREDIT_COSTS = { CREDIT_COSTS = {
# Existing operations
'clustering': 10, # Per clustering request '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 'content_generation': 1, # Per 100 words
'image_prompt_extraction': 2, # Per content piece 'image_prompt_extraction': 2, # Per content piece
'image_generation': 5, # Per image 'image_generation': 5, # Per image
'linking': 8, # Per content piece (NEW)
'optimization': 1, # Per 200 words (NEW) # Legacy operation names (for backward compatibility)
'site_structure_generation': 50, # Per site blueprint (NEW) 'ideas': 15, # Alias for idea_generation
'site_page_generation': 20, # Per page (NEW) 'content': 1, # Alias for content_generation (per 100 words)
# Legacy operation types (for backward compatibility) 'images': 5, # Alias for image_generation
'ideas': 15, # Alias for idea_generation 'reparse': 2, # Alias for image_prompt_extraction
'content': 3, # Legacy: 3 credits per content piece
'images': 5, # Alias for image_generation # NEW: Phase 2+ operations
'reparse': 1, # Per reparse '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: Args:
operation_type: Type of operation (from CREDIT_COSTS) 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: Returns:
int: Number of credits required int: Number of credits required
Raises:
CreditCalculationError: If operation type is unknown
""" """
base_cost = CREDIT_COSTS.get(operation_type, 0) 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: if operation_type == 'content_generation' and amount:
# Per 100 words # Per 100 words
return max(1, int(base_cost * (amount / 100))) return max(1, int(base_cost * (amount / 100)))
elif operation_type == 'optimization' and amount: elif operation_type == 'optimization' and amount:
# Per 200 words # Per 200 words
return max(1, int(base_cost * (amount / 200))) 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 return base_cost
@staticmethod @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: Args:
account: Account instance account: Account instance
operation_type: Type of operation required_credits: Number of credits required (legacy parameter)
amount: Optional amount (word count, image count, etc.) operation_type: Type of operation (new parameter)
amount: Optional amount for variable costs (new parameter)
Raises: Raises:
InsufficientCreditsError: If account doesn't have enough credits InsufficientCreditsError: If account doesn't have enough credits
""" """
required = CreditService.get_credit_cost(operation_type, amount) # Support both old and new API
if account.credits < required: if operation_type:
raise InsufficientCreditsError( required_credits = CreditService.get_credit_cost(operation_type, amount)
f"Insufficient credits. Required: {required}, Available: {account.credits}" elif required_credits is None:
) raise ValueError("Either required_credits or operation_type must be provided")
return True
@staticmethod
def check_credits_legacy(account, required_credits):
"""
Legacy method: Check if account has enough credits (for backward compatibility).
Args:
account: Account instance
required_credits: Number of credits required
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
if account.credits < required_credits: if account.credits < required_credits:
raise InsufficientCreditsError( raise InsufficientCreditsError(
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}" f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
@@ -107,8 +83,8 @@ class CreditService:
Returns: Returns:
int: New credit balance int: New credit balance
""" """
# Check sufficient credits (legacy: amount is already calculated) # Check sufficient credits
CreditService.check_credits_legacy(account, amount) CreditService.check_credits(account, amount)
# Deduct from account.credits # Deduct from account.credits
account.credits -= amount account.credits -= amount
@@ -140,61 +116,6 @@ class CreditService:
return account.credits 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 @staticmethod
@transaction.atomic @transaction.atomic
def add_credits(account, amount, transaction_type, description, metadata=None): def add_credits(account, amount, transaction_type, description, metadata=None):
@@ -231,7 +152,9 @@ class CreditService:
def calculate_credits_for_operation(operation_type, **kwargs): def calculate_credits_for_operation(operation_type, **kwargs):
""" """
Calculate credits needed for an operation. Calculate credits needed for an operation.
Legacy method - use get_credit_cost() instead.
DEPRECATED: Use get_credit_cost() instead.
Kept for backward compatibility.
Args: Args:
operation_type: Type of operation operation_type: Type of operation
@@ -243,22 +166,31 @@ class CreditService:
Raises: Raises:
CreditCalculationError: If calculation fails CreditCalculationError: If calculation fails
""" """
# Map legacy operation types # Map old operation types to new ones
if operation_type == 'ideas': operation_mapping = {
operation_type = 'idea_generation' 'ideas': 'idea_generation',
elif operation_type == 'content': 'content': 'content_generation',
operation_type = 'content_generation' 'images': 'image_generation',
elif operation_type == 'images': 'reparse': 'image_prompt_extraction',
operation_type = 'image_generation' }
# Extract amount from kwargs mapped_type = operation_mapping.get(operation_type, operation_type)
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')
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

@@ -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 # Import settings models
from .settings_models import ( 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 django.contrib import admin
from igny8_core.admin.base import AccountAdminMixin 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) @admin.register(SystemSettings)

View File

@@ -92,8 +92,12 @@ class ModuleSettings(BaseSettings):
return f"ModuleSetting: {self.module_name} - {self.key}" return f"ModuleSetting: {self.module_name} - {self.key}"
class ModuleEnableSettings(AccountBaseModel): class AccountModuleSettings(AccountBaseModel):
"""Module enable/disable settings per account""" """
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") planner_enabled = models.BooleanField(default=True, help_text="Enable Planner module")
writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module") writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module")
thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker 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") optimizer_enabled = models.BooleanField(default=True, help_text="Enable Optimizer module")
publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher 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: class Meta:
db_table = 'igny8_module_enable_settings' db_table = 'igny8_account_module_settings'
unique_together = [['account']] # One record per account 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): def __str__(self):
account = getattr(self, 'account', None) 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 @classmethod
def get_or_create_for_account(cls, account): 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) settings, created = cls.objects.get_or_create(account=account)
return settings return settings
def is_module_enabled(self, module_name): def is_module_enabled(self, module_name):
"""Check if a module is enabled""" """Check if a module is enabled"""
mapping = { module_map = {
'planner': self.planner_enabled, 'planner': self.planner_enabled,
'writer': self.writer_enabled, 'writer': self.writer_enabled,
'thinker': self.thinker_enabled, 'thinker': self.thinker_enabled,
@@ -129,7 +144,7 @@ class ModuleEnableSettings(AccountBaseModel):
'optimizer': self.optimizer_enabled, 'optimizer': self.optimizer_enabled,
'publisher': self.publisher_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) # AISettings extends IntegrationSettings (which already exists)

View File

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

View File

@@ -7,7 +7,7 @@ from .views import AIPromptViewSet, AuthorProfileViewSet, StrategyViewSet, syste
from .integration_views import IntegrationSettingsViewSet from .integration_views import IntegrationSettingsViewSet
from .settings_views import ( from .settings_views import (
SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet, SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet,
ModuleSettingsViewSet, ModuleEnableSettingsViewSet, AISettingsViewSet ModuleSettingsViewSet, AccountModuleSettingsViewSet, AISettingsViewSet
) )
router = DefaultRouter() router = DefaultRouter()
router.register(r'prompts', AIPromptViewSet, basename='prompts') 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/account', AccountSettingsViewSet, basename='account-settings')
router.register(r'settings/user', UserSettingsViewSet, basename='user-settings') router.register(r'settings/user', UserSettingsViewSet, basename='user-settings')
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-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') router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings')
# Custom URL patterns for integration settings - matching reference plugin structure # 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 AppLayout from "./layout/AppLayout";
import { ScrollToTop } from "./components/common/ScrollToTop"; import { ScrollToTop } from "./components/common/ScrollToTop";
import ProtectedRoute from "./components/auth/ProtectedRoute"; import ProtectedRoute from "./components/auth/ProtectedRoute";
import ModuleGuard from "./components/common/ModuleGuard";
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay"; import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
import LoadingStateMonitor from "./components/common/LoadingStateMonitor"; import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
@@ -134,122 +133,90 @@ export default function App() {
{/* Planner Module */} {/* Planner Module */}
<Route path="/planner" element={ <Route path="/planner" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="planner"> <PlannerDashboard />
<PlannerDashboard />
</ModuleGuard>
</Suspense> </Suspense>
} /> } />
<Route path="/planner/keywords" element={ <Route path="/planner/keywords" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="planner"> <Keywords />
<Keywords />
</ModuleGuard>
</Suspense> </Suspense>
} /> } />
<Route path="/planner/clusters" element={ <Route path="/planner/clusters" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="planner"> <Clusters />
<Clusters />
</ModuleGuard>
</Suspense> </Suspense>
} /> } />
<Route path="/planner/ideas" element={ <Route path="/planner/ideas" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="planner"> <Ideas />
<Ideas />
</ModuleGuard>
</Suspense> </Suspense>
} /> } />
{/* Writer Module */} {/* Writer Module */}
<Route path="/writer" element={ <Route path="/writer" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<WriterDashboard /> <WriterDashboard />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/writer/tasks" element={ <Route path="/writer/tasks" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<Tasks /> <Tasks />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
{/* Writer Content Routes - Order matters: list route must come before detail route */} {/* Writer Content Routes - Order matters: list route must come before detail route */}
<Route path="/writer/content" element={ <Route path="/writer/content" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<Content /> <Content />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
{/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */} {/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */}
<Route path="/writer/content/:id" element={ <Route path="/writer/content/:id" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<ContentView /> <ContentView />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} /> <Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
<Route path="/writer/images" element={ <Route path="/writer/images" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<Images /> <Images />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/writer/published" element={ <Route path="/writer/published" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="writer">
<Published /> <Published />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
{/* Thinker Module */} {/* Thinker Module */}
<Route path="/thinker" element={ <Route path="/thinker" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<ThinkerDashboard /> <ThinkerDashboard />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/thinker/prompts" element={ <Route path="/thinker/prompts" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<Prompts /> <Prompts />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/thinker/author-profiles" element={ <Route path="/thinker/author-profiles" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<AuthorProfiles /> <AuthorProfiles />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/thinker/profile" element={ <Route path="/thinker/profile" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<ThinkerProfile /> <ThinkerProfile />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/thinker/strategies" element={ <Route path="/thinker/strategies" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<Strategies /> <Strategies />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/thinker/image-testing" element={ <Route path="/thinker/image-testing" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="thinker">
<ImageTesting /> <ImageTesting />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
{/* Billing Module */} {/* Billing Module */}
@@ -289,10 +256,8 @@ export default function App() {
{/* Other Pages */} {/* Other Pages */}
<Route path="/automation" element={ <Route path="/automation" element={
<Suspense fallback={null}> <Suspense fallback={null}>
<ModuleGuard module="automation">
<AutomationDashboard /> <AutomationDashboard />
</ModuleGuard> </Suspense>
</Suspense>
} /> } />
<Route path="/schedules" element={ <Route path="/schedules" element={
<Suspense fallback={null}> <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 SidebarWidget from "./SidebarWidget";
import { APP_VERSION } from "../config/version"; import { APP_VERSION } from "../config/version";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useSettingsStore } from "../store/settingsStore";
import { isModuleEnabled } from "../config/modules.config";
import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator"; import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator";
type NavItem = { type NavItem = {
@@ -40,7 +38,6 @@ const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const location = useLocation(); const location = useLocation();
const { user } = useAuthStore(); const { user } = useAuthStore();
const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled } = useSettingsStore();
// Show admin menu only for users in aws-admin account // Show admin menu only for users in aws-admin account
const isAwsAdminAccount = Boolean( const isAwsAdminAccount = Boolean(
@@ -48,12 +45,6 @@ const AppSidebar: React.FC = () => {
user?.role === 'developer' // Also show for developers as fallback 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<{ const [openSubmenu, setOpenSubmenu] = useState<{
sectionIndex: number; sectionIndex: number;
itemIndex: number; itemIndex: number;
@@ -69,98 +60,77 @@ const AppSidebar: React.FC = () => {
); );
// Define menu sections with useMemo to prevent recreation on every render // 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 menuSections: MenuSection[] = useMemo(() => { {
const workflowItems: NavItem[] = [ label: "OVERVIEW",
{ items: [
icon: <PlugInIcon />, {
name: "Setup", icon: <GridIcon />,
subItems: [ name: "Dashboard",
{ name: "Sites", path: "/settings/sites" }, path: "/",
{ name: "Keywords Opportunities", path: "/planner/keyword-opportunities" }, },
], {
}, icon: <DocsIcon />,
]; name: "Industry / Sectors",
path: "/reference/industries",
// Add Planner if enabled },
if (moduleEnabled('planner')) { ],
workflowItems.push({ },
icon: <ListIcon />, {
name: "Planner", label: "WORKFLOWS",
subItems: [ items: [
{ name: "Dashboard", path: "/planner" }, {
{ name: "Keywords", path: "/planner/keywords" }, icon: <PlugInIcon />,
{ name: "Clusters", path: "/planner/clusters" }, name: "Setup",
{ name: "Ideas", path: "/planner/ideas" }, subItems: [
], { name: "Sites", path: "/settings/sites" },
}); { name: "Keywords Opportunities", path: "/planner/keyword-opportunities" },
} ],
},
// Add Writer if enabled {
if (moduleEnabled('writer')) { icon: <ListIcon />,
workflowItems.push({ name: "Planner",
icon: <TaskIcon />, subItems: [
name: "Writer", { name: "Dashboard", path: "/planner" },
subItems: [ { name: "Keywords", path: "/planner/keywords" },
{ name: "Dashboard", path: "/writer" }, { name: "Clusters", path: "/planner/clusters" },
{ name: "Tasks", path: "/writer/tasks" }, { name: "Ideas", path: "/planner/ideas" },
{ name: "Content", path: "/writer/content" }, ],
{ name: "Images", path: "/writer/images" }, },
{ name: "Published", path: "/writer/published" }, {
], icon: <TaskIcon />,
}); name: "Writer",
} subItems: [
{ name: "Dashboard", path: "/writer" },
// Add Thinker if enabled { name: "Tasks", path: "/writer/tasks" },
if (moduleEnabled('thinker')) { { name: "Content", path: "/writer/content" },
workflowItems.push({ { name: "Images", path: "/writer/images" },
icon: <BoltIcon />, { name: "Published", path: "/writer/published" },
name: "Thinker", ],
subItems: [ },
{ name: "Dashboard", path: "/thinker" }, {
{ name: "Prompts", path: "/thinker/prompts" }, icon: <BoltIcon />,
{ name: "Author Profiles", path: "/thinker/author-profiles" }, name: "Thinker",
{ name: "Strategies", path: "/thinker/strategies" }, subItems: [
{ name: "Image Testing", path: "/thinker/image-testing" }, { 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 />, icon: <BoltIcon />,
name: "Automation", name: "Automation",
path: "/automation", path: "/automation",
}); },
} {
icon: <TimeIcon />,
workflowItems.push({ name: "Schedules",
icon: <TimeIcon />, path: "/schedules",
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,
},
{ {
label: "ACCOUNT & SETTINGS", label: "ACCOUNT & SETTINGS",
items: [ items: [
@@ -195,8 +165,7 @@ const AppSidebar: React.FC = () => {
}, },
], ],
}, },
]; ], []);
}, [moduleEnableSettings, moduleEnabled]);
// Admin section - only shown for users in aws-admin account // Admin section - only shown for users in aws-admin account
const adminSection: MenuSection = useMemo(() => ({ const adminSection: MenuSection = useMemo(() => ({
@@ -282,14 +251,6 @@ const AppSidebar: React.FC = () => {
: menuSections; : menuSections;
}, [isAwsAdminAccount, menuSections, adminSection]); }, [isAwsAdminAccount, menuSections, adminSection]);
// Load module enable settings on mount
useEffect(() => {
const { loadModuleEnableSettings } = useSettingsStore.getState();
if (!moduleEnableSettings) {
loadModuleEnableSettings();
}
}, [moduleEnableSettings]);
useEffect(() => { useEffect(() => {
const currentPath = location.pathname; const currentPath = location.pathname;
let foundMatch = false; 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 PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { useSettingsStore } from '../../store/settingsStore'; import { fetchAPI } from '../../services/api';
import { MODULES } from '../../config/modules.config';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import Switch from '../../components/form/switch/Switch';
export default function ModuleSettings() { export default function ModuleSettings() {
const toast = useToast(); const toast = useToast();
const { const [settings, setSettings] = useState<any[]>([]);
moduleEnableSettings, const [loading, setLoading] = useState(true);
loadModuleEnableSettings,
updateModuleEnableSettings,
loading,
} = useSettingsStore();
useEffect(() => { useEffect(() => {
loadModuleEnableSettings(); loadSettings();
}, [loadModuleEnableSettings]); }, []);
const handleToggle = async (moduleName: string, enabled: boolean) => { const loadSettings = async () => {
try { try {
const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings; setLoading(true);
await updateModuleEnableSettings({ const response = await fetchAPI('/v1/system/settings/modules/');
[enabledKey]: enabled, setSettings(response.results || []);
} as any);
toast.success(`${MODULES[moduleName]?.name || moduleName} ${enabled ? 'enabled' : 'disabled'}`);
} catch (error: any) { } 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 ( return (
<div className="p-6"> <div className="p-6">
<PageMeta title="Module Settings" /> <PageMeta title="Module Settings" />
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Module Settings</h1> <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> </div>
{loading ? ( {loading ? (
@@ -51,38 +39,7 @@ export default function ModuleSettings() {
</div> </div>
) : ( ) : (
<Card className="p-6"> <Card className="p-6">
<div className="space-y-6"> <p className="text-gray-600 dark:text-gray-400">Module settings management interface coming soon.</p>
{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>
</Card> </Card>
)} )}
</div> </div>

View File

@@ -1474,20 +1474,6 @@ export async function deleteAccountSetting(key: string): Promise<void> {
} }
// Module Settings // 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 { export interface ModuleSetting {
id: number; id: number;
module_name: string; 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> { 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}`, { return fetchAPI(`/v1/system/settings/modules/${key}/?module_name=${moduleName}`, {
method: 'PUT', method: 'PUT',

View File

@@ -13,17 +13,13 @@ import {
fetchModuleSettings, fetchModuleSettings,
createModuleSetting, createModuleSetting,
updateModuleSetting, updateModuleSetting,
fetchModuleEnableSettings,
updateModuleEnableSettings,
AccountSetting, AccountSetting,
ModuleSetting, ModuleSetting,
ModuleEnableSettings,
} from '../services/api'; } from '../services/api';
interface SettingsState { interface SettingsState {
accountSettings: Record<string, AccountSetting>; accountSettings: Record<string, AccountSetting>;
moduleSettings: Record<string, Record<string, ModuleSetting>>; moduleSettings: Record<string, Record<string, ModuleSetting>>;
moduleEnableSettings: ModuleEnableSettings | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@@ -33,9 +29,6 @@ interface SettingsState {
updateAccountSetting: (key: string, value: any) => Promise<void>; updateAccountSetting: (key: string, value: any) => Promise<void>;
loadModuleSettings: (moduleName: string) => Promise<void>; loadModuleSettings: (moduleName: string) => Promise<void>;
updateModuleSetting: (moduleName: string, key: string, value: any) => 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; reset: () => void;
} }
@@ -44,7 +37,6 @@ export const useSettingsStore = create<SettingsState>()(
(set, get) => ({ (set, get) => ({
accountSettings: {}, accountSettings: {},
moduleSettings: {}, moduleSettings: {},
moduleEnableSettings: null,
loading: false, loading: false,
error: null, 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: () => { reset: () => {
set({ set({
accountSettings: {}, accountSettings: {},
moduleSettings: {}, moduleSettings: {},
moduleEnableSettings: null,
loading: false, loading: false,
error: null, error: null,
}); });
@@ -187,7 +149,6 @@ export const useSettingsStore = create<SettingsState>()(
partialize: (state) => ({ partialize: (state) => ({
accountSettings: state.accountSettings, accountSettings: state.accountSettings,
moduleSettings: state.moduleSettings, moduleSettings: state.moduleSettings,
moduleEnableSettings: state.moduleEnableSettings,
}), }),
} }
) )