Compare commits
7 Commits
phase-0-fo
...
5842ca2dfc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5842ca2dfc | ||
|
|
9b3fb25bc9 | ||
|
|
dbe8da589f | ||
|
|
8102aa74eb | ||
|
|
13bd7fa134 | ||
|
|
a73b2ae22b | ||
|
|
5b11c4001e |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -27,27 +27,6 @@ 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/`
|
||||
|
||||
@@ -192,6 +192,31 @@ 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:
|
||||
@@ -325,37 +350,45 @@ class AIEngine:
|
||||
# Store save_msg for use in DONE phase
|
||||
final_save_msg = save_msg
|
||||
|
||||
# Track credit usage after successful save
|
||||
# Phase 5.5: DEDUCT CREDITS - Deduct credits after successful save
|
||||
if self.account and raw_response:
|
||||
try:
|
||||
from igny8_core.modules.billing.services import CreditService
|
||||
from igny8_core.modules.billing.models import CreditUsageLog
|
||||
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
# 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)
|
||||
)
|
||||
# Map function name to operation type
|
||||
operation_type = self._get_operation_type(function_name)
|
||||
|
||||
# Log credit usage (don't deduct from account.credits, just log)
|
||||
CreditUsageLog.objects.create(
|
||||
# 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(
|
||||
account=self.account,
|
||||
operation_type='clustering',
|
||||
credits_used=credits_used,
|
||||
operation_type=operation_type,
|
||||
amount=actual_amount,
|
||||
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='cluster',
|
||||
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'),
|
||||
metadata={
|
||||
'function_name': function_name,
|
||||
'clusters_created': clusters_created,
|
||||
'keywords_updated': keywords_updated,
|
||||
'function_name': function_name
|
||||
'count': count,
|
||||
**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:
|
||||
logger.warning(f"Failed to log credit usage: {e}", exc_info=True)
|
||||
logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True)
|
||||
# Don't fail the operation if credit deduction fails (for backward compatibility)
|
||||
|
||||
# Phase 6: DONE - Finalization (98-100%)
|
||||
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
|
||||
@@ -453,18 +486,74 @@ class AIEngine:
|
||||
# Don't fail the task if logging fails
|
||||
logger.warning(f"Failed to log to database: {e}")
|
||||
|
||||
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
|
||||
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')
|
||||
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
"""
|
||||
Credit Cost Constants - Phase 0: Credit-Only System
|
||||
All features are unlimited. Only credits restrict usage.
|
||||
Credit Cost Constants
|
||||
Phase 0: Credit-only system costs per operation
|
||||
"""
|
||||
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
|
||||
|
||||
# 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)
|
||||
'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
|
||||
}
|
||||
|
||||
|
||||
@@ -19,43 +19,67 @@ class CreditService:
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation (from CREDIT_COSTS)
|
||||
amount: Optional amount (word count, etc.) for variable costs
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
|
||||
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 costs based on amount
|
||||
# Variable cost operations
|
||||
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, required_credits=None, operation_type=None, amount=None):
|
||||
def check_credits(account, operation_type, amount=None):
|
||||
"""
|
||||
Check if account has enough credits.
|
||||
Check if account has sufficient credits for an operation.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
required_credits: Number of credits required (legacy parameter)
|
||||
operation_type: Type of operation (new parameter)
|
||||
amount: Optional amount for variable costs (new parameter)
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
# 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")
|
||||
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).
|
||||
|
||||
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}"
|
||||
@@ -83,8 +107,8 @@ class CreditService:
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Check sufficient credits
|
||||
CreditService.check_credits(account, amount)
|
||||
# Check sufficient credits (legacy: amount is already calculated)
|
||||
CreditService.check_credits_legacy(account, amount)
|
||||
|
||||
# Deduct from account.credits
|
||||
account.credits -= amount
|
||||
@@ -116,6 +140,61 @@ 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):
|
||||
@@ -152,9 +231,7 @@ class CreditService:
|
||||
def calculate_credits_for_operation(operation_type, **kwargs):
|
||||
"""
|
||||
Calculate credits needed for an operation.
|
||||
|
||||
DEPRECATED: Use get_credit_cost() instead.
|
||||
Kept for backward compatibility.
|
||||
Legacy method - use get_credit_cost() instead.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation
|
||||
@@ -166,31 +243,22 @@ class CreditService:
|
||||
Raises:
|
||||
CreditCalculationError: If calculation fails
|
||||
"""
|
||||
# Map old operation types to new ones
|
||||
operation_mapping = {
|
||||
'ideas': 'idea_generation',
|
||||
'content': 'content_generation',
|
||||
'images': 'image_generation',
|
||||
'reparse': 'image_prompt_extraction',
|
||||
}
|
||||
# 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'
|
||||
|
||||
mapped_type = operation_mapping.get(operation_type, operation_type)
|
||||
# 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')
|
||||
|
||||
# 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)
|
||||
return CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@ from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
# Import settings models
|
||||
from .settings_models import (
|
||||
SystemSettings, AccountSettings, UserSettings, ModuleSettings, AccountModuleSettings, AISettings
|
||||
SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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, AISettings
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||
|
||||
|
||||
@admin.register(SystemSettings)
|
||||
|
||||
@@ -92,12 +92,8 @@ class ModuleSettings(BaseSettings):
|
||||
return f"ModuleSetting: {self.module_name} - {self.key}"
|
||||
|
||||
|
||||
class AccountModuleSettings(AccountBaseModel):
|
||||
"""
|
||||
Account-level module enable/disable settings.
|
||||
Phase 0: Credit System - Module Settings
|
||||
"""
|
||||
# Module enable/disable flags
|
||||
class ModuleEnableSettings(AccountBaseModel):
|
||||
"""Module enable/disable settings per account"""
|
||||
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")
|
||||
@@ -107,34 +103,23 @@ class AccountModuleSettings(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_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']),
|
||||
]
|
||||
db_table = 'igny8_module_enable_settings'
|
||||
unique_together = [['account']] # One record per account
|
||||
|
||||
def __str__(self):
|
||||
account = getattr(self, 'account', None)
|
||||
return f"ModuleSettings: {account.name if account else 'No Account'}"
|
||||
return f"ModuleEnableSettings: {account.name if account else 'No Account'}"
|
||||
|
||||
@classmethod
|
||||
def get_or_create_for_account(cls, account):
|
||||
"""Get or create module settings for an account"""
|
||||
"""Get or create module enable 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"""
|
||||
module_map = {
|
||||
mapping = {
|
||||
'planner': self.planner_enabled,
|
||||
'writer': self.writer_enabled,
|
||||
'thinker': self.thinker_enabled,
|
||||
@@ -144,7 +129,7 @@ class AccountModuleSettings(AccountBaseModel):
|
||||
'optimizer': self.optimizer_enabled,
|
||||
'publisher': self.publisher_enabled,
|
||||
}
|
||||
return module_map.get(module_name, True) # Default to enabled if module not found
|
||||
return mapping.get(module_name, True) # Default to enabled if unknown
|
||||
|
||||
|
||||
# AISettings extends IntegrationSettings (which already exists)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Serializers for Settings Models
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AccountModuleSettings, AISettings
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||
from .validators import validate_settings_schema
|
||||
|
||||
|
||||
@@ -58,10 +58,9 @@ class ModuleSettingsSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
|
||||
class AccountModuleSettingsSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Account Module Settings (Phase 0)"""
|
||||
class ModuleEnableSettingsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AccountModuleSettings
|
||||
model = ModuleEnableSettings
|
||||
fields = [
|
||||
'id', 'planner_enabled', 'writer_enabled', 'thinker_enabled',
|
||||
'automation_enabled', 'site_builder_enabled', 'linker_enabled',
|
||||
|
||||
@@ -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, AccountModuleSettings, AISettings
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||
from .settings_serializers import (
|
||||
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
||||
ModuleSettingsSerializer, AccountModuleSettingsSerializer, AISettingsSerializer
|
||||
ModuleSettingsSerializer, ModuleEnableSettingsSerializer, AISettingsSerializer
|
||||
)
|
||||
|
||||
|
||||
@@ -282,67 +282,97 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
||||
update=extend_schema(tags=['System']),
|
||||
partial_update=extend_schema(tags=['System']),
|
||||
)
|
||||
class AccountModuleSettingsViewSet(AccountModelViewSet):
|
||||
class ModuleEnableSettingsViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing account module enable/disable settings.
|
||||
Phase 0: Credit System - Module Settings
|
||||
One settings record per account (get_or_create pattern)
|
||||
ViewSet for managing module enable/disable settings
|
||||
Unified API Standard v1.0 compliant
|
||||
One record per account
|
||||
"""
|
||||
queryset = AccountModuleSettings.objects.all()
|
||||
serializer_class = AccountModuleSettingsSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
queryset = ModuleEnableSettings.objects.all()
|
||||
serializer_class = ModuleEnableSettingsSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get module settings for current account"""
|
||||
"""Get module enable settings for current account"""
|
||||
queryset = super().get_queryset()
|
||||
return queryset.filter(account=self.request.account)
|
||||
return queryset
|
||||
|
||||
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 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 perform_create(self, serializer):
|
||||
"""Set account automatically"""
|
||||
account = getattr(self.request, 'account', None)
|
||||
def list(self, request):
|
||||
"""Get or create module enable settings for current account"""
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(self.request, 'user', None)
|
||||
user = getattr(request, 'user', None)
|
||||
if user:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
if not account:
|
||||
from rest_framework.exceptions import ValidationError
|
||||
raise ValidationError("Account is required")
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
serializer.save(account=account)
|
||||
# Get or create settings for account
|
||||
settings = ModuleEnableSettings.get_or_create_for_account(account)
|
||||
serializer = self.get_serializer(settings)
|
||||
return success_response(data=serializer.data, request=request)
|
||||
|
||||
@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},
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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,
|
||||
request=request
|
||||
)
|
||||
|
||||
def partial_update(self, request, pk=None):
|
||||
"""Partial update module enable settings"""
|
||||
return self.update(request, pk)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
|
||||
@@ -7,7 +7,7 @@ from .views import AIPromptViewSet, AuthorProfileViewSet, StrategyViewSet, syste
|
||||
from .integration_views import IntegrationSettingsViewSet
|
||||
from .settings_views import (
|
||||
SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet,
|
||||
ModuleSettingsViewSet, AccountModuleSettingsViewSet, AISettingsViewSet
|
||||
ModuleSettingsViewSet, ModuleEnableSettingsViewSet, 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/account-modules', AccountModuleSettingsViewSet, basename='account-module-settings')
|
||||
router.register(r'settings/modules/enable', ModuleEnableSettingsViewSet, basename='module-enable-settings')
|
||||
router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings')
|
||||
|
||||
# Custom URL patterns for integration settings - matching reference plugin structure
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
|
||||
@@ -133,90 +134,122 @@ export default function App() {
|
||||
{/* Planner Module */}
|
||||
<Route path="/planner" element={
|
||||
<Suspense fallback={null}>
|
||||
<PlannerDashboard />
|
||||
<ModuleGuard module="planner">
|
||||
<PlannerDashboard />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/planner/keywords" element={
|
||||
<Suspense fallback={null}>
|
||||
<Keywords />
|
||||
<ModuleGuard module="planner">
|
||||
<Keywords />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/planner/clusters" element={
|
||||
<Suspense fallback={null}>
|
||||
<Clusters />
|
||||
<ModuleGuard module="planner">
|
||||
<Clusters />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/planner/ideas" element={
|
||||
<Suspense fallback={null}>
|
||||
<Ideas />
|
||||
<ModuleGuard module="planner">
|
||||
<Ideas />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Writer Module */}
|
||||
<Route path="/writer" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="writer">
|
||||
<WriterDashboard />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/writer/tasks" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="writer">
|
||||
<Tasks />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</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 />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</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 />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
|
||||
<Route path="/writer/images" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="writer">
|
||||
<Images />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/writer/published" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="writer">
|
||||
<Published />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Thinker Module */}
|
||||
<Route path="/thinker" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="thinker">
|
||||
<ThinkerDashboard />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/prompts" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="thinker">
|
||||
<Prompts />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/author-profiles" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="thinker">
|
||||
<AuthorProfiles />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/profile" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="thinker">
|
||||
<ThinkerProfile />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/strategies" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="thinker">
|
||||
<Strategies />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/image-testing" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="thinker">
|
||||
<ImageTesting />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Billing Module */}
|
||||
@@ -256,8 +289,10 @@ export default function App() {
|
||||
{/* Other Pages */}
|
||||
<Route path="/automation" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="automation">
|
||||
<AutomationDashboard />
|
||||
</Suspense>
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/schedules" element={
|
||||
<Suspense fallback={null}>
|
||||
|
||||
40
frontend/src/components/common/ModuleGuard.tsx
Normal file
40
frontend/src/components/common/ModuleGuard.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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}</>;
|
||||
}
|
||||
|
||||
110
frontend/src/config/modules.config.ts
Normal file
110
frontend/src/config/modules.config.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ 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 = {
|
||||
@@ -38,12 +40,19 @@ 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;
|
||||
@@ -60,77 +69,98 @@ const AppSidebar: React.FC = () => {
|
||||
);
|
||||
|
||||
// Define menu sections with useMemo to prevent recreation on every render
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
// 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,
|
||||
},
|
||||
{
|
||||
label: "ACCOUNT & SETTINGS",
|
||||
items: [
|
||||
@@ -165,7 +195,8 @@ const AppSidebar: React.FC = () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
], []);
|
||||
];
|
||||
}, [moduleEnableSettings, moduleEnabled]);
|
||||
|
||||
// Admin section - only shown for users in aws-admin account
|
||||
const adminSection: MenuSection = useMemo(() => ({
|
||||
@@ -251,6 +282,14 @@ 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;
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { MODULES } from '../../config/modules.config';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Switch from '../../components/form/switch/Switch';
|
||||
|
||||
export default function ModuleSettings() {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const {
|
||||
moduleEnableSettings,
|
||||
loadModuleEnableSettings,
|
||||
updateModuleEnableSettings,
|
||||
loading,
|
||||
} = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
loadModuleEnableSettings();
|
||||
}, [loadModuleEnableSettings]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
const handleToggle = async (moduleName: string, enabled: boolean) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchAPI('/v1/system/settings/modules/');
|
||||
setSettings(response.results || []);
|
||||
const enabledKey = `${moduleName}_enabled` as keyof typeof moduleEnableSettings;
|
||||
await updateModuleEnableSettings({
|
||||
[enabledKey]: enabled,
|
||||
} as any);
|
||||
toast.success(`${MODULES[moduleName]?.name || moduleName} ${enabled ? 'enabled' : 'disabled'}`);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load module settings: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
toast.error(`Failed to update module: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
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">Module-specific configuration</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Enable or disable modules for your account</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -39,7 +51,38 @@ export default function ModuleSettings() {
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">Module settings management interface coming soon.</p>
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1474,6 +1474,20 @@ 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;
|
||||
@@ -1498,6 +1512,19 @@ 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',
|
||||
|
||||
@@ -13,13 +13,17 @@ 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;
|
||||
|
||||
@@ -29,6 +33,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -37,6 +44,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
(set, get) => ({
|
||||
accountSettings: {},
|
||||
moduleSettings: {},
|
||||
moduleEnableSettings: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -135,10 +143,40 @@ 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,
|
||||
});
|
||||
@@ -149,6 +187,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
partialize: (state) => ({
|
||||
accountSettings: state.accountSettings,
|
||||
moduleSettings: state.moduleSettings,
|
||||
moduleEnableSettings: state.moduleEnableSettings,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user