Compare commits
2 Commits
1531f41226
...
phase-0-fo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67283ad3e7 | ||
|
|
72a31b2edb |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -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/`
|
||||||
|
|||||||
Binary file not shown.
@@ -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')
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,21 @@ class PlanAdmin(admin.ModelAdmin):
|
|||||||
('Plan Info', {
|
('Plan Info', {
|
||||||
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
|
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
|
||||||
}),
|
}),
|
||||||
('Account Management Limits', {
|
('User / Site Limits', {
|
||||||
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
|
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
|
||||||
}),
|
}),
|
||||||
|
('Planner Limits', {
|
||||||
|
'fields': ('max_keywords', 'max_clusters', 'daily_cluster_limit', 'daily_keyword_import_limit', 'monthly_cluster_ai_credits')
|
||||||
|
}),
|
||||||
|
('Writer Limits', {
|
||||||
|
'fields': ('daily_content_tasks', 'daily_ai_requests', 'monthly_word_count_limit', 'monthly_content_ai_credits')
|
||||||
|
}),
|
||||||
|
('Image Limits', {
|
||||||
|
'fields': ('monthly_image_count', 'monthly_image_ai_credits', 'max_images_per_task', 'image_model_choices')
|
||||||
|
}),
|
||||||
|
('AI Controls', {
|
||||||
|
'fields': ('daily_ai_request_limit', 'monthly_ai_credit_limit')
|
||||||
|
}),
|
||||||
('Billing & Credits', {
|
('Billing & Credits', {
|
||||||
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# Generated manually for Phase 0: Remove plan operation limit fields (credit-only system)
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('igny8_core_auth', '0013_remove_ai_cost_per_request'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
# Remove Planner Limits
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='max_keywords',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='max_clusters',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='max_content_ideas',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='daily_cluster_limit',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='daily_keyword_import_limit',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='monthly_cluster_ai_credits',
|
|
||||||
),
|
|
||||||
# Remove Writer Limits
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='daily_content_tasks',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='daily_ai_requests',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='monthly_word_count_limit',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='monthly_content_ai_credits',
|
|
||||||
),
|
|
||||||
# Remove Image Generation Limits
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='monthly_image_count',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='daily_image_generation_limit',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='monthly_image_ai_credits',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='max_images_per_task',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='image_model_choices',
|
|
||||||
),
|
|
||||||
# Remove AI Request Controls
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='daily_ai_request_limit',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='plan',
|
|
||||||
name='monthly_ai_credit_limit',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -93,8 +93,8 @@ class Account(models.Model):
|
|||||||
|
|
||||||
class Plan(models.Model):
|
class Plan(models.Model):
|
||||||
"""
|
"""
|
||||||
Subscription plan model - Phase 0: Credit-only system.
|
Subscription plan model with comprehensive limits and features.
|
||||||
Plans define credits, billing, and account management limits only.
|
Plans define limits for users, sites, content generation, AI usage, and billing.
|
||||||
"""
|
"""
|
||||||
BILLING_CYCLE_CHOICES = [
|
BILLING_CYCLE_CHOICES = [
|
||||||
('monthly', 'Monthly'),
|
('monthly', 'Monthly'),
|
||||||
@@ -110,7 +110,7 @@ class Plan(models.Model):
|
|||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
# Account Management Limits (kept - not operation limits)
|
# User / Site / Scope Limits
|
||||||
max_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account")
|
max_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account")
|
||||||
max_sites = models.IntegerField(
|
max_sites = models.IntegerField(
|
||||||
default=1,
|
default=1,
|
||||||
@@ -120,7 +120,32 @@ class Plan(models.Model):
|
|||||||
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
|
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
|
||||||
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
|
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
|
||||||
|
|
||||||
# Billing & Credits (Phase 0: Credit-only system)
|
# Planner Limits
|
||||||
|
max_keywords = models.IntegerField(default=1000, validators=[MinValueValidator(0)], help_text="Total keywords allowed (global limit)")
|
||||||
|
max_clusters = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Total clusters allowed (global)")
|
||||||
|
max_content_ideas = models.IntegerField(default=300, validators=[MinValueValidator(0)], help_text="Total content ideas allowed (global limit)")
|
||||||
|
daily_cluster_limit = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max clusters that can be created per day")
|
||||||
|
daily_keyword_import_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="SeedKeywords import limit per day")
|
||||||
|
monthly_cluster_ai_credits = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="AI credits allocated for clustering")
|
||||||
|
|
||||||
|
# Writer Limits
|
||||||
|
daily_content_tasks = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max number of content tasks (blogs) per day")
|
||||||
|
daily_ai_requests = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="Total AI executions (content + idea + image) allowed per day")
|
||||||
|
monthly_word_count_limit = models.IntegerField(default=50000, validators=[MinValueValidator(0)], help_text="Monthly word limit (for generated content)")
|
||||||
|
monthly_content_ai_credits = models.IntegerField(default=200, validators=[MinValueValidator(0)], help_text="AI credit pool for content generation")
|
||||||
|
|
||||||
|
# Image Generation Limits
|
||||||
|
monthly_image_count = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Max images per month")
|
||||||
|
daily_image_generation_limit = models.IntegerField(default=25, validators=[MinValueValidator(0)], help_text="Max images that can be generated per day")
|
||||||
|
monthly_image_ai_credits = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="AI credit pool for image generation")
|
||||||
|
max_images_per_task = models.IntegerField(default=4, validators=[MinValueValidator(1)], help_text="Max images per content task")
|
||||||
|
image_model_choices = models.JSONField(default=list, blank=True, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])")
|
||||||
|
|
||||||
|
# AI Request Controls
|
||||||
|
daily_ai_request_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Global daily AI request cap")
|
||||||
|
monthly_ai_credit_limit = models.IntegerField(default=500, validators=[MinValueValidator(0)], help_text="Unified credit ceiling per month (all AI functions)")
|
||||||
|
|
||||||
|
# Billing & Add-ons
|
||||||
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
|
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
|
||||||
extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit")
|
extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit")
|
||||||
allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?")
|
allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?")
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
model = Plan
|
model = Plan
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
|
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
|
||||||
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas',
|
||||||
'included_credits', 'extra_credit_price', 'allow_credit_topup',
|
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count',
|
||||||
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit',
|
||||||
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
'included_credits', 'image_model_choices', 'credits_per_month'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Celery configuration for IGNY8
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
from celery.schedules import crontab
|
|
||||||
|
|
||||||
# Set the default Django settings module for the 'celery' program.
|
# Set the default Django settings module for the 'celery' program.
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||||
@@ -19,13 +18,6 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
|
|||||||
# Load task modules from all registered Django apps.
|
# Load task modules from all registered Django apps.
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
|
|
||||||
# Celery Beat schedule for periodic tasks
|
|
||||||
app.conf.beat_schedule = {
|
|
||||||
'replenish-monthly-credits': {
|
|
||||||
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
|
|
||||||
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.task(bind=True, ignore_result=True)
|
@app.task(bind=True, ignore_result=True)
|
||||||
def debug_task(self):
|
def debug_task(self):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
# 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)
|
'linking': 8, # Per content piece (NEW)
|
||||||
'optimization': 1, # Per 200 words (NEW)
|
'optimization': 1, # Per 200 words (NEW)
|
||||||
'site_structure_generation': 50, # Per site blueprint (NEW)
|
'site_structure_generation': 50, # Per site blueprint (NEW)
|
||||||
'site_page_generation': 20, # Per page (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,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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
"""
|
|
||||||
Celery tasks for billing operations
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from celery import shared_task
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.db import transaction
|
|
||||||
from igny8_core.auth.models import Account
|
|
||||||
from .services import CreditService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='igny8_core.modules.billing.tasks.replenish_monthly_credits')
|
|
||||||
def replenish_monthly_credits():
|
|
||||||
"""
|
|
||||||
Replenish monthly credits for all active accounts.
|
|
||||||
Runs on the first day of each month at midnight.
|
|
||||||
|
|
||||||
For each active account with a plan:
|
|
||||||
- Adds plan.included_credits to account.credits
|
|
||||||
- Creates a CreditTransaction record
|
|
||||||
- Logs the replenishment
|
|
||||||
"""
|
|
||||||
logger.info("=" * 80)
|
|
||||||
logger.info("MONTHLY CREDIT REPLENISHMENT TASK STARTED")
|
|
||||||
logger.info(f"Timestamp: {timezone.now()}")
|
|
||||||
logger.info("=" * 80)
|
|
||||||
|
|
||||||
# Get all active accounts with plans
|
|
||||||
accounts = Account.objects.filter(
|
|
||||||
status='active',
|
|
||||||
plan__isnull=False
|
|
||||||
).select_related('plan')
|
|
||||||
|
|
||||||
total_accounts = accounts.count()
|
|
||||||
logger.info(f"Found {total_accounts} active accounts with plans")
|
|
||||||
|
|
||||||
replenished = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = 0
|
|
||||||
|
|
||||||
for account in accounts:
|
|
||||||
try:
|
|
||||||
plan = account.plan
|
|
||||||
|
|
||||||
# Get monthly credits from plan
|
|
||||||
monthly_credits = plan.included_credits or plan.credits_per_month or 0
|
|
||||||
|
|
||||||
if monthly_credits <= 0:
|
|
||||||
logger.info(f"Account {account.id} ({account.name}): Plan has no included credits, skipping")
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add credits using CreditService
|
|
||||||
with transaction.atomic():
|
|
||||||
new_balance = CreditService.add_credits(
|
|
||||||
account=account,
|
|
||||||
amount=monthly_credits,
|
|
||||||
transaction_type='subscription',
|
|
||||||
description=f"Monthly credit replenishment - {plan.name} plan",
|
|
||||||
metadata={
|
|
||||||
'plan_id': plan.id,
|
|
||||||
'plan_name': plan.name,
|
|
||||||
'monthly_credits': monthly_credits,
|
|
||||||
'replenishment_date': timezone.now().isoformat()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Account {account.id} ({account.name}): "
|
|
||||||
f"Added {monthly_credits} credits (balance: {new_balance})"
|
|
||||||
)
|
|
||||||
replenished += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Account {account.id} ({account.name}): "
|
|
||||||
f"Failed to replenish credits: {str(e)}",
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
logger.info("=" * 80)
|
|
||||||
logger.info("MONTHLY CREDIT REPLENISHMENT TASK COMPLETED")
|
|
||||||
logger.info(f"Total accounts: {total_accounts}")
|
|
||||||
logger.info(f"Replenished: {replenished}")
|
|
||||||
logger.info(f"Skipped: {skipped}")
|
|
||||||
logger.info(f"Errors: {errors}")
|
|
||||||
logger.info("=" * 80)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'total_accounts': total_accounts,
|
|
||||||
'replenished': replenished,
|
|
||||||
'skipped': skipped,
|
|
||||||
'errors': errors
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -207,10 +207,7 @@ class CreditUsageViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='limits', url_name='limits')
|
@action(detail=False, methods=['get'], url_path='limits', url_name='limits')
|
||||||
def limits(self, request):
|
def limits(self, request):
|
||||||
"""
|
"""Get plan limits and current usage statistics"""
|
||||||
Get account limits and credit usage statistics (Phase 0: Credit-only system).
|
|
||||||
Returns account management limits and credit usage only.
|
|
||||||
"""
|
|
||||||
# Try multiple ways to get account
|
# Try multiple ways to get account
|
||||||
account = getattr(request, 'account', None)
|
account = getattr(request, 'account', None)
|
||||||
|
|
||||||
@@ -228,7 +225,13 @@ class CreditUsageViewSet(AccountModelViewSet):
|
|||||||
except (AttributeError, UserModel.DoesNotExist, Exception) as e:
|
except (AttributeError, UserModel.DoesNotExist, Exception) as e:
|
||||||
account = None
|
account = None
|
||||||
|
|
||||||
|
# Debug logging
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f'Limits endpoint - User: {getattr(request, "user", None)}, Account: {account}, Account has plan: {account.plan if account else False}')
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
|
logger.warning(f'No account found in limits endpoint')
|
||||||
# Return empty limits instead of error - frontend will show "no data" message
|
# Return empty limits instead of error - frontend will show "no data" message
|
||||||
return success_response(data={'limits': []}, request=request)
|
return success_response(data={'limits': []}, request=request)
|
||||||
|
|
||||||
@@ -238,16 +241,115 @@ class CreditUsageViewSet(AccountModelViewSet):
|
|||||||
return success_response(data={'limits': []}, request=request)
|
return success_response(data={'limits': []}, request=request)
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
|
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||||
|
from igny8_core.modules.writer.models import Tasks, Images
|
||||||
from igny8_core.auth.models import User, Site
|
from igny8_core.auth.models import User, Site
|
||||||
|
|
||||||
# Get current month boundaries
|
# Get current month boundaries
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
# Calculate usage statistics
|
# Calculate usage statistics
|
||||||
limits_data = []
|
limits_data = []
|
||||||
|
|
||||||
# Credit Usage (Phase 0: Credit-only system)
|
# Planner Limits
|
||||||
|
keywords_count = Keywords.objects.filter(account=account).count()
|
||||||
|
clusters_count = Clusters.objects.filter(account=account).count()
|
||||||
|
content_ideas_count = ContentIdeas.objects.filter(account=account).count()
|
||||||
|
clusters_today = Clusters.objects.filter(account=account, created_at__gte=start_of_day).count()
|
||||||
|
|
||||||
|
limits_data.extend([
|
||||||
|
{
|
||||||
|
'title': 'Keywords',
|
||||||
|
'limit': plan.max_keywords or 0,
|
||||||
|
'used': keywords_count,
|
||||||
|
'available': max(0, (plan.max_keywords or 0) - keywords_count),
|
||||||
|
'unit': 'keywords',
|
||||||
|
'category': 'planner',
|
||||||
|
'percentage': (keywords_count / (plan.max_keywords or 1)) * 100 if plan.max_keywords else 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Clusters',
|
||||||
|
'limit': plan.max_clusters or 0,
|
||||||
|
'used': clusters_count,
|
||||||
|
'available': max(0, (plan.max_clusters or 0) - clusters_count),
|
||||||
|
'unit': 'clusters',
|
||||||
|
'category': 'planner',
|
||||||
|
'percentage': (clusters_count / (plan.max_clusters or 1)) * 100 if plan.max_clusters else 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Content Ideas',
|
||||||
|
'limit': plan.max_content_ideas or 0,
|
||||||
|
'used': content_ideas_count,
|
||||||
|
'available': max(0, (plan.max_content_ideas or 0) - content_ideas_count),
|
||||||
|
'unit': 'ideas',
|
||||||
|
'category': 'planner',
|
||||||
|
'percentage': (content_ideas_count / (plan.max_content_ideas or 1)) * 100 if plan.max_content_ideas else 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Daily Cluster Limit',
|
||||||
|
'limit': plan.daily_cluster_limit or 0,
|
||||||
|
'used': clusters_today,
|
||||||
|
'available': max(0, (plan.daily_cluster_limit or 0) - clusters_today),
|
||||||
|
'unit': 'per day',
|
||||||
|
'category': 'planner',
|
||||||
|
'percentage': (clusters_today / (plan.daily_cluster_limit or 1)) * 100 if plan.daily_cluster_limit else 0
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
# Writer Limits
|
||||||
|
tasks_today = Tasks.objects.filter(account=account, created_at__gte=start_of_day).count()
|
||||||
|
tasks_month = Tasks.objects.filter(account=account, created_at__gte=start_of_month)
|
||||||
|
word_count_month = tasks_month.aggregate(total=Sum('word_count'))['total'] or 0
|
||||||
|
|
||||||
|
limits_data.extend([
|
||||||
|
{
|
||||||
|
'title': 'Monthly Word Count',
|
||||||
|
'limit': plan.monthly_word_count_limit or 0,
|
||||||
|
'used': word_count_month,
|
||||||
|
'available': max(0, (plan.monthly_word_count_limit or 0) - word_count_month),
|
||||||
|
'unit': 'words',
|
||||||
|
'category': 'writer',
|
||||||
|
'percentage': (word_count_month / (plan.monthly_word_count_limit or 1)) * 100 if plan.monthly_word_count_limit else 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Daily Content Tasks',
|
||||||
|
'limit': plan.daily_content_tasks or 0,
|
||||||
|
'used': tasks_today,
|
||||||
|
'available': max(0, (plan.daily_content_tasks or 0) - tasks_today),
|
||||||
|
'unit': 'per day',
|
||||||
|
'category': 'writer',
|
||||||
|
'percentage': (tasks_today / (plan.daily_content_tasks or 1)) * 100 if plan.daily_content_tasks else 0
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
# Image Limits
|
||||||
|
images_month = Images.objects.filter(account=account, created_at__gte=start_of_month).count()
|
||||||
|
images_today = Images.objects.filter(account=account, created_at__gte=start_of_day).count()
|
||||||
|
|
||||||
|
limits_data.extend([
|
||||||
|
{
|
||||||
|
'title': 'Monthly Images',
|
||||||
|
'limit': plan.monthly_image_count or 0,
|
||||||
|
'used': images_month,
|
||||||
|
'available': max(0, (plan.monthly_image_count or 0) - images_month),
|
||||||
|
'unit': 'images',
|
||||||
|
'category': 'images',
|
||||||
|
'percentage': (images_month / (plan.monthly_image_count or 1)) * 100 if plan.monthly_image_count else 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Daily Image Generation',
|
||||||
|
'limit': plan.daily_image_generation_limit or 0,
|
||||||
|
'used': images_today,
|
||||||
|
'available': max(0, (plan.daily_image_generation_limit or 0) - images_today),
|
||||||
|
'unit': 'per day',
|
||||||
|
'category': 'images',
|
||||||
|
'percentage': (images_today / (plan.daily_image_generation_limit or 1)) * 100 if plan.daily_image_generation_limit else 0
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
# AI Credits
|
||||||
credits_used_month = CreditUsageLog.objects.filter(
|
credits_used_month = CreditUsageLog.objects.filter(
|
||||||
account=account,
|
account=account,
|
||||||
created_at__gte=start_of_month
|
created_at__gte=start_of_month
|
||||||
@@ -256,89 +358,64 @@ class CreditUsageViewSet(AccountModelViewSet):
|
|||||||
# Get credits by operation type
|
# Get credits by operation type
|
||||||
cluster_credits = CreditUsageLog.objects.filter(
|
cluster_credits = CreditUsageLog.objects.filter(
|
||||||
account=account,
|
account=account,
|
||||||
operation_type__in=['clustering'],
|
operation_type='clustering',
|
||||||
created_at__gte=start_of_month
|
created_at__gte=start_of_month
|
||||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||||
|
|
||||||
content_credits = CreditUsageLog.objects.filter(
|
content_credits = CreditUsageLog.objects.filter(
|
||||||
account=account,
|
account=account,
|
||||||
operation_type__in=['content', 'content_generation'],
|
operation_type='content',
|
||||||
created_at__gte=start_of_month
|
created_at__gte=start_of_month
|
||||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||||
|
|
||||||
image_credits = CreditUsageLog.objects.filter(
|
image_credits = CreditUsageLog.objects.filter(
|
||||||
account=account,
|
account=account,
|
||||||
operation_type__in=['images', 'image_generation', 'image_prompt_extraction'],
|
operation_type='image',
|
||||||
created_at__gte=start_of_month
|
created_at__gte=start_of_month
|
||||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||||
|
|
||||||
idea_credits = CreditUsageLog.objects.filter(
|
plan_credits = plan.monthly_ai_credit_limit or plan.credits_per_month or 0
|
||||||
account=account,
|
|
||||||
operation_type__in=['ideas', 'idea_generation'],
|
|
||||||
created_at__gte=start_of_month
|
|
||||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
|
||||||
|
|
||||||
# Use included_credits from plan (Phase 0: Credit-only)
|
|
||||||
plan_credits = plan.included_credits or plan.credits_per_month or 0
|
|
||||||
|
|
||||||
limits_data.extend([
|
limits_data.extend([
|
||||||
{
|
{
|
||||||
'title': 'Monthly Credits',
|
'title': 'Monthly AI Credits',
|
||||||
'limit': plan_credits,
|
'limit': plan_credits,
|
||||||
'used': credits_used_month,
|
'used': credits_used_month,
|
||||||
'available': max(0, plan_credits - credits_used_month),
|
'available': max(0, plan_credits - credits_used_month),
|
||||||
'unit': 'credits',
|
'unit': 'credits',
|
||||||
'category': 'credits',
|
'category': 'ai',
|
||||||
'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0
|
'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Current Balance',
|
'title': 'Content AI Credits',
|
||||||
'limit': None, # No limit - shows current balance
|
'limit': plan.monthly_content_ai_credits or 0,
|
||||||
'used': None,
|
|
||||||
'available': account.credits,
|
|
||||||
'unit': 'credits',
|
|
||||||
'category': 'credits',
|
|
||||||
'percentage': None
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'title': 'Clustering Credits',
|
|
||||||
'limit': None,
|
|
||||||
'used': cluster_credits,
|
|
||||||
'available': None,
|
|
||||||
'unit': 'credits',
|
|
||||||
'category': 'credits',
|
|
||||||
'percentage': None
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'title': 'Content Generation Credits',
|
|
||||||
'limit': None,
|
|
||||||
'used': content_credits,
|
'used': content_credits,
|
||||||
'available': None,
|
'available': max(0, (plan.monthly_content_ai_credits or 0) - content_credits),
|
||||||
'unit': 'credits',
|
'unit': 'credits',
|
||||||
'category': 'credits',
|
'category': 'ai',
|
||||||
'percentage': None
|
'percentage': (content_credits / (plan.monthly_content_ai_credits or 1)) * 100 if plan.monthly_content_ai_credits else 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Image Generation Credits',
|
'title': 'Image AI Credits',
|
||||||
'limit': None,
|
'limit': plan.monthly_image_ai_credits or 0,
|
||||||
'used': image_credits,
|
'used': image_credits,
|
||||||
'available': None,
|
'available': max(0, (plan.monthly_image_ai_credits or 0) - image_credits),
|
||||||
'unit': 'credits',
|
'unit': 'credits',
|
||||||
'category': 'credits',
|
'category': 'ai',
|
||||||
'percentage': None
|
'percentage': (image_credits / (plan.monthly_image_ai_credits or 1)) * 100 if plan.monthly_image_ai_credits else 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Idea Generation Credits',
|
'title': 'Cluster AI Credits',
|
||||||
'limit': None,
|
'limit': plan.monthly_cluster_ai_credits or 0,
|
||||||
'used': idea_credits,
|
'used': cluster_credits,
|
||||||
'available': None,
|
'available': max(0, (plan.monthly_cluster_ai_credits or 0) - cluster_credits),
|
||||||
'unit': 'credits',
|
'unit': 'credits',
|
||||||
'category': 'credits',
|
'category': 'ai',
|
||||||
'percentage': None
|
'percentage': (cluster_credits / (plan.monthly_cluster_ai_credits or 1)) * 100 if plan.monthly_cluster_ai_credits else 0
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
# Account Management Limits (kept - not operation limits)
|
# General Limits
|
||||||
users_count = User.objects.filter(account=account).count()
|
users_count = User.objects.filter(account=account).count()
|
||||||
sites_count = Site.objects.filter(account=account).count()
|
sites_count = Site.objects.filter(account=account).count()
|
||||||
|
|
||||||
@@ -349,7 +426,7 @@ class CreditUsageViewSet(AccountModelViewSet):
|
|||||||
'used': users_count,
|
'used': users_count,
|
||||||
'available': max(0, (plan.max_users or 0) - users_count),
|
'available': max(0, (plan.max_users or 0) - users_count),
|
||||||
'unit': 'users',
|
'unit': 'users',
|
||||||
'category': 'account',
|
'category': 'general',
|
||||||
'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0
|
'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -358,7 +435,7 @@ class CreditUsageViewSet(AccountModelViewSet):
|
|||||||
'used': sites_count,
|
'used': sites_count,
|
||||||
'available': max(0, (plan.max_sites or 0) - sites_count),
|
'available': max(0, (plan.max_sites or 0) - sites_count),
|
||||||
'unit': 'sites',
|
'unit': 'sites',
|
||||||
'category': 'account',
|
'category': 'general',
|
||||||
'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0
|
'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,140 +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)
|
||||||
Read access: All authenticated users
|
|
||||||
Write access: Admins/Owners only
|
|
||||||
"""
|
"""
|
||||||
queryset = ModuleEnableSettings.objects.all()
|
queryset = AccountModuleSettings.objects.all()
|
||||||
serializer_class = ModuleEnableSettingsSerializer
|
serializer_class = AccountModuleSettingsSerializer
|
||||||
|
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_permissions(self):
|
|
||||||
"""
|
|
||||||
Allow read access to all authenticated users,
|
|
||||||
but restrict write access to admins/owners
|
|
||||||
"""
|
|
||||||
if self.action in ['list', 'retrieve']:
|
|
||||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
|
||||||
else:
|
|
||||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
|
|
||||||
return [permission() for permission in permission_classes]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Get module enable settings for current account"""
|
"""Get module settings for current account"""
|
||||||
# Return queryset filtered by account - but list() will handle get_or_create
|
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
# Filter by account if available
|
return queryset.filter(account=self.request.account)
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
"""Get or create module settings for account"""
|
||||||
|
account = request.account
|
||||||
|
settings = AccountModuleSettings.get_or_create_for_account(account)
|
||||||
|
serializer = self.get_serializer(settings)
|
||||||
|
return success_response(data=serializer.data, request=request)
|
||||||
|
|
||||||
|
def retrieve(self, request, pk=None):
|
||||||
|
"""Get module 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)
|
account = getattr(self.request, 'account', None)
|
||||||
if not account:
|
if not account:
|
||||||
user = getattr(self.request, 'user', None)
|
user = getattr(self.request, 'user', None)
|
||||||
if user:
|
if user:
|
||||||
account = getattr(user, 'account', None)
|
account = getattr(user, 'account', None)
|
||||||
if account:
|
|
||||||
queryset = queryset.filter(account=account)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
|
||||||
"""Get or create module enable settings for current account"""
|
|
||||||
try:
|
|
||||||
account = getattr(request, 'account', None)
|
|
||||||
if not account:
|
|
||||||
user = getattr(request, 'user', None)
|
|
||||||
if user and hasattr(user, 'account'):
|
|
||||||
account = user.account
|
|
||||||
|
|
||||||
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,
|
|
||||||
|
serializer.save(account=account)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='check/(?P<module_name>[^/.]+)', url_name='check_module')
|
||||||
|
def check_module(self, request, module_name=None):
|
||||||
|
"""Check if a specific module is enabled"""
|
||||||
|
account = request.account
|
||||||
|
settings = AccountModuleSettings.get_or_create_for_account(account)
|
||||||
|
is_enabled = settings.is_module_enabled(module_name)
|
||||||
|
return success_response(
|
||||||
|
data={'module_name': module_name, 'enabled': is_enabled},
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get or create settings for account (one per account)
|
|
||||||
try:
|
|
||||||
settings = ModuleEnableSettings.objects.get(account=account)
|
|
||||||
except ModuleEnableSettings.DoesNotExist:
|
|
||||||
# Create default settings for account
|
|
||||||
settings = ModuleEnableSettings.objects.create(account=account)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(settings)
|
|
||||||
return success_response(data=serializer.data, request=request)
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
error_trace = traceback.format_exc()
|
|
||||||
return error_response(
|
|
||||||
error=f'Failed to load module enable settings: {str(e)}',
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
def retrieve(self, request, pk=None, *args, **kwargs):
|
|
||||||
"""Get module enable settings for current account"""
|
|
||||||
try:
|
|
||||||
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, created = ModuleEnableSettings.objects.get_or_create(account=account)
|
|
||||||
serializer = self.get_serializer(settings)
|
|
||||||
return success_response(data=serializer.data, request=request)
|
|
||||||
except Exception as e:
|
|
||||||
return error_response(
|
|
||||||
error=f'Failed to load module enable settings: {str(e)}',
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
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(
|
@extend_schema_view(
|
||||||
list=extend_schema(tags=['System']),
|
list=extend_schema(tags=['System']),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,121 +133,89 @@ 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>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -289,9 +256,7 @@ 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={
|
||||||
|
|||||||
@@ -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}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -24,29 +24,8 @@ const LayoutContent: React.FC = () => {
|
|||||||
const [debugEnabled, setDebugEnabled] = useState(false);
|
const [debugEnabled, setDebugEnabled] = useState(false);
|
||||||
const lastUserRefresh = useRef<number>(0);
|
const lastUserRefresh = useRef<number>(0);
|
||||||
|
|
||||||
// Initialize site store on mount - only once, but only if authenticated
|
// Initialize site store on mount - only once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only load sites if user is authenticated AND has a token
|
|
||||||
if (!isAuthenticated) return;
|
|
||||||
|
|
||||||
// Check if token exists - if not, wait a bit for Zustand persist to write it
|
|
||||||
const checkTokenAndLoad = () => {
|
|
||||||
const authState = useAuthStore.getState();
|
|
||||||
if (!authState?.token) {
|
|
||||||
// Token not available yet - wait a bit and retry (Zustand persist might still be writing)
|
|
||||||
setTimeout(() => {
|
|
||||||
const retryAuthState = useAuthStore.getState();
|
|
||||||
if (retryAuthState?.token && !hasLoadedSite.current && !isLoadingSite.current) {
|
|
||||||
loadSites();
|
|
||||||
}
|
|
||||||
}, 100); // Wait 100ms for persist to write
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSites();
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadSites = () => {
|
|
||||||
if (!hasLoadedSite.current && !isLoadingSite.current) {
|
if (!hasLoadedSite.current && !isLoadingSite.current) {
|
||||||
hasLoadedSite.current = true;
|
hasLoadedSite.current = true;
|
||||||
isLoadingSite.current = true;
|
isLoadingSite.current = true;
|
||||||
@@ -65,11 +44,8 @@ const LayoutContent: React.FC = () => {
|
|||||||
|
|
||||||
loadActiveSite()
|
loadActiveSite()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// Don't log 403 errors as they're expected when not authenticated
|
|
||||||
if (error.status !== 403) {
|
|
||||||
console.error('AppLayout: Error loading active site:', error);
|
console.error('AppLayout: Error loading active site:', error);
|
||||||
addError(error, 'AppLayout.loadActiveSite');
|
addError(error, 'AppLayout.loadActiveSite');
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -77,10 +53,7 @@ const LayoutContent: React.FC = () => {
|
|||||||
isLoadingSite.current = false;
|
isLoadingSite.current = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, []); // Empty deps - only run once on mount
|
||||||
|
|
||||||
checkTokenAndLoad();
|
|
||||||
}, [isAuthenticated]); // Run when authentication state changes
|
|
||||||
|
|
||||||
// Load sectors when active site changes (by ID, not object reference)
|
// Load sectors when active site changes (by ID, not object reference)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,19 +114,6 @@ const LayoutContent: React.FC = () => {
|
|||||||
// Throttle: only refresh if last refresh was more than 30 seconds ago (unless forced)
|
// Throttle: only refresh if last refresh was more than 30 seconds ago (unless forced)
|
||||||
if (!force && now - lastUserRefresh.current < 30000) return;
|
if (!force && now - lastUserRefresh.current < 30000) return;
|
||||||
|
|
||||||
// Check if token exists before making API call
|
|
||||||
const authState = useAuthStore.getState();
|
|
||||||
if (!authState?.token) {
|
|
||||||
// Token not available yet - wait a bit for Zustand persist to write it
|
|
||||||
setTimeout(() => {
|
|
||||||
const retryAuthState = useAuthStore.getState();
|
|
||||||
if (retryAuthState?.token && retryAuthState?.isAuthenticated) {
|
|
||||||
refreshUserData(force);
|
|
||||||
}
|
|
||||||
}, 100); // Wait 100ms for persist to write
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
lastUserRefresh.current = now;
|
lastUserRefresh.current = now;
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
|
|||||||
@@ -20,7 +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 ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator";
|
import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator";
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
@@ -38,8 +37,7 @@ type MenuSection = {
|
|||||||
const AppSidebar: React.FC = () => {
|
const AppSidebar: React.FC = () => {
|
||||||
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = 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(
|
||||||
@@ -47,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 - memoized to prevent infinite loops
|
|
||||||
const moduleEnabled = useCallback((moduleName: string): boolean => {
|
|
||||||
if (!moduleEnableSettings) return true; // Default to enabled if not loaded
|
|
||||||
return checkModuleEnabled(moduleName);
|
|
||||||
}, [moduleEnableSettings, checkModuleEnabled]);
|
|
||||||
|
|
||||||
const [openSubmenu, setOpenSubmenu] = useState<{
|
const [openSubmenu, setOpenSubmenu] = useState<{
|
||||||
sectionIndex: number;
|
sectionIndex: number;
|
||||||
itemIndex: number;
|
itemIndex: number;
|
||||||
@@ -67,90 +59,8 @@ const AppSidebar: React.FC = () => {
|
|||||||
[location.pathname]
|
[location.pathname]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load module enable settings on mount (only once) - but only if user is authenticated
|
|
||||||
useEffect(() => {
|
|
||||||
// Only load if user is authenticated and settings aren't already loaded
|
|
||||||
if (user && isAuthenticated && !moduleEnableSettings && !settingsLoading) {
|
|
||||||
loadModuleEnableSettings().catch((error) => {
|
|
||||||
console.warn('Failed to load module enable settings:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [user, isAuthenticated]); // Only run when user/auth state changes
|
|
||||||
|
|
||||||
// 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[] = [
|
|
||||||
{
|
|
||||||
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",
|
label: "OVERVIEW",
|
||||||
items: [
|
items: [
|
||||||
@@ -168,7 +78,58 @@ const AppSidebar: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "WORKFLOWS",
|
label: "WORKFLOWS",
|
||||||
items: workflowItems,
|
items: [
|
||||||
|
{
|
||||||
|
icon: <PlugInIcon />,
|
||||||
|
name: "Setup",
|
||||||
|
subItems: [
|
||||||
|
{ name: "Sites", path: "/settings/sites" },
|
||||||
|
{ name: "Keywords Opportunities", path: "/planner/keyword-opportunities" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ListIcon />,
|
||||||
|
name: "Planner",
|
||||||
|
subItems: [
|
||||||
|
{ name: "Dashboard", path: "/planner" },
|
||||||
|
{ name: "Keywords", path: "/planner/keywords" },
|
||||||
|
{ name: "Clusters", path: "/planner/clusters" },
|
||||||
|
{ name: "Ideas", path: "/planner/ideas" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <TaskIcon />,
|
||||||
|
name: "Writer",
|
||||||
|
subItems: [
|
||||||
|
{ name: "Dashboard", path: "/writer" },
|
||||||
|
{ name: "Tasks", path: "/writer/tasks" },
|
||||||
|
{ name: "Content", path: "/writer/content" },
|
||||||
|
{ name: "Images", path: "/writer/images" },
|
||||||
|
{ name: "Published", path: "/writer/published" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BoltIcon />,
|
||||||
|
name: "Thinker",
|
||||||
|
subItems: [
|
||||||
|
{ name: "Dashboard", path: "/thinker" },
|
||||||
|
{ name: "Prompts", path: "/thinker/prompts" },
|
||||||
|
{ name: "Author Profiles", path: "/thinker/author-profiles" },
|
||||||
|
{ name: "Strategies", path: "/thinker/strategies" },
|
||||||
|
{ name: "Image Testing", path: "/thinker/image-testing" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BoltIcon />,
|
||||||
|
name: "Automation",
|
||||||
|
path: "/automation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <TimeIcon />,
|
||||||
|
name: "Schedules",
|
||||||
|
path: "/schedules",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "ACCOUNT & SETTINGS",
|
label: "ACCOUNT & SETTINGS",
|
||||||
@@ -204,8 +165,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
], []);
|
||||||
}, [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(() => ({
|
||||||
@@ -306,15 +266,9 @@ const AppSidebar: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
setOpenSubmenu((prev) => {
|
setOpenSubmenu({
|
||||||
// Only update if different to prevent infinite loops
|
|
||||||
if (prev?.sectionIndex === sectionIndex && prev?.itemIndex === itemIndex) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
sectionIndex,
|
sectionIndex,
|
||||||
itemIndex,
|
itemIndex,
|
||||||
};
|
|
||||||
});
|
});
|
||||||
foundMatch = true;
|
foundMatch = true;
|
||||||
}
|
}
|
||||||
@@ -337,16 +291,10 @@ const AppSidebar: React.FC = () => {
|
|||||||
// scrollHeight should work even when height is 0px due to overflow-hidden
|
// scrollHeight should work even when height is 0px due to overflow-hidden
|
||||||
const scrollHeight = element.scrollHeight;
|
const scrollHeight = element.scrollHeight;
|
||||||
if (scrollHeight > 0) {
|
if (scrollHeight > 0) {
|
||||||
setSubMenuHeight((prevHeights) => {
|
setSubMenuHeight((prevHeights) => ({
|
||||||
// Only update if height changed to prevent infinite loops
|
|
||||||
if (prevHeights[key] === scrollHeight) {
|
|
||||||
return prevHeights;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...prevHeights,
|
...prevHeights,
|
||||||
[key]: scrollHeight,
|
[key]: scrollHeight,
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|||||||
@@ -5,19 +5,6 @@ import { fetchCreditBalance, CreditBalance } from '../../services/api';
|
|||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
|
|
||||||
// Credit costs per operation (Phase 0: Credit-only system)
|
|
||||||
const CREDIT_COSTS: Record<string, { cost: number | string; description: string }> = {
|
|
||||||
clustering: { cost: 10, description: 'Per clustering request' },
|
|
||||||
idea_generation: { cost: 15, description: 'Per cluster → ideas request' },
|
|
||||||
content_generation: { cost: '1 per 100 words', description: 'Per 100 words generated' },
|
|
||||||
image_prompt_extraction: { cost: 2, description: 'Per content piece' },
|
|
||||||
image_generation: { cost: 5, description: 'Per image generated' },
|
|
||||||
linking: { cost: 8, description: 'Per content piece' },
|
|
||||||
optimization: { cost: '1 per 200 words', description: 'Per 200 words optimized' },
|
|
||||||
site_structure_generation: { cost: 50, description: 'Per site blueprint' },
|
|
||||||
site_page_generation: { cost: 20, description: 'Per page generated' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Credits() {
|
export default function Credits() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [balance, setBalance] = useState<CreditBalance | null>(null);
|
const [balance, setBalance] = useState<CreditBalance | null>(null);
|
||||||
@@ -101,35 +88,6 @@ export default function Credits() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Credit Costs Reference */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Credit Costs per Operation</h2>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
Understanding how credits are consumed for each operation type
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{Object.entries(CREDIT_COSTS).map(([operation, info]) => (
|
|
||||||
<div key={operation} className="flex items-start justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-gray-900 dark:text-white capitalize">
|
|
||||||
{operation.replace(/_/g, ' ')}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{info.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 text-right">
|
|
||||||
<Badge variant="light" color="primary" className="font-semibold">
|
|
||||||
{typeof info.cost === 'number' ? `${info.cost} credits` : info.cost}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,6 @@ import { fetchCreditUsage, CreditUsageLog, fetchUsageLimits, LimitCard } from '.
|
|||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
|
|
||||||
// Credit costs per operation (Phase 0: Credit-only system)
|
|
||||||
const CREDIT_COSTS: Record<string, { cost: number | string; description: string }> = {
|
|
||||||
clustering: { cost: 10, description: 'Per clustering request' },
|
|
||||||
idea_generation: { cost: 15, description: 'Per cluster → ideas request' },
|
|
||||||
content_generation: { cost: '1 per 100 words', description: 'Per 100 words generated' },
|
|
||||||
image_prompt_extraction: { cost: 2, description: 'Per content piece' },
|
|
||||||
image_generation: { cost: 5, description: 'Per image generated' },
|
|
||||||
linking: { cost: 8, description: 'Per content piece' },
|
|
||||||
optimization: { cost: '1 per 200 words', description: 'Per 200 words optimized' },
|
|
||||||
site_structure_generation: { cost: 50, description: 'Per site blueprint' },
|
|
||||||
site_page_generation: { cost: 20, description: 'Per page generated' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Usage() {
|
export default function Usage() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [usageLogs, setUsageLogs] = useState<CreditUsageLog[]>([]);
|
const [usageLogs, setUsageLogs] = useState<CreditUsageLog[]>([]);
|
||||||
@@ -46,8 +33,13 @@ export default function Usage() {
|
|||||||
try {
|
try {
|
||||||
setLimitsLoading(true);
|
setLimitsLoading(true);
|
||||||
const response = await fetchUsageLimits();
|
const response = await fetchUsageLimits();
|
||||||
|
console.log('Usage limits response:', response);
|
||||||
setLimits(response.limits || []);
|
setLimits(response.limits || []);
|
||||||
|
if (!response.limits || response.limits.length === 0) {
|
||||||
|
console.warn('No limits data received from API');
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.error('Error loading usage limits:', error);
|
||||||
toast.error(`Failed to load usage limits: ${error.message}`);
|
toast.error(`Failed to load usage limits: ${error.message}`);
|
||||||
setLimits([]);
|
setLimits([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,82 +47,120 @@ export default function Usage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter limits to show only credits and account management (Phase 0: Credit-only system)
|
const groupedLimits = {
|
||||||
const creditLimits = limits.filter(l => l.category === 'credits');
|
planner: limits.filter(l => l.category === 'planner'),
|
||||||
const accountLimits = limits.filter(l => l.category === 'account');
|
writer: limits.filter(l => l.category === 'writer'),
|
||||||
|
images: limits.filter(l => l.category === 'images'),
|
||||||
|
ai: limits.filter(l => l.category === 'ai'),
|
||||||
|
general: limits.filter(l => l.category === 'general'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug info
|
||||||
|
console.log('[Usage Component] Render state:', {
|
||||||
|
limitsLoading,
|
||||||
|
limitsCount: limits.length,
|
||||||
|
groupedLimits,
|
||||||
|
plannerCount: groupedLimits.planner.length,
|
||||||
|
writerCount: groupedLimits.writer.length,
|
||||||
|
imagesCount: groupedLimits.images.length,
|
||||||
|
aiCount: groupedLimits.ai.length,
|
||||||
|
generalCount: groupedLimits.general.length,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<PageMeta title="Usage" description="Monitor your credit usage and account limits" />
|
<PageMeta title="Usage" description="Monitor your plan limits and usage statistics" />
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Credit Usage & Limits</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Acoount Limits Usage 12</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Monitor your credit usage and account management limits</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-1">Monitor your plan limits and usage statistics</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Credit Costs Reference */}
|
{/* Debug Info - Remove in production */}
|
||||||
<Card className="p-6 mb-6">
|
{import.meta.env.DEV && (
|
||||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Credit Costs per Operation</h2>
|
<Card className="p-4 mb-4 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{Object.entries(CREDIT_COSTS).map(([operation, info]) => (
|
<strong>Debug:</strong> Loading={limitsLoading ? 'Yes' : 'No'}, Limits={limits.length},
|
||||||
<div key={operation} className="flex items-start justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
Planner={groupedLimits.planner.length}, Writer={groupedLimits.writer.length},
|
||||||
<div className="flex-1">
|
Images={groupedLimits.images.length}, AI={groupedLimits.ai.length}, General={groupedLimits.general.length}
|
||||||
<div className="font-medium text-gray-900 dark:text-white capitalize">
|
|
||||||
{operation.replace(/_/g, ' ')}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{info.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 text-right">
|
|
||||||
<Badge variant="light" color="primary" className="font-semibold">
|
|
||||||
{typeof info.cost === 'number' ? `${info.cost} credits` : info.cost}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Credit Limits */}
|
{/* Limit Cards by Category */}
|
||||||
{limitsLoading ? (
|
{limitsLoading ? (
|
||||||
<Card className="p-6 mb-8">
|
<Card className="p-6 mb-8">
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<div className="text-gray-500">Loading limits...</div>
|
<div className="text-gray-500">Loading limits...</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : limits.length === 0 ? (
|
||||||
<div className="space-y-6 mb-8">
|
<Card className="p-6 mb-8">
|
||||||
{/* Credit Usage Limits */}
|
|
||||||
{creditLimits.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Credit Usage</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{creditLimits.map((limit, idx) => (
|
|
||||||
<LimitCardComponent key={idx} limit={limit} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Account Management Limits */}
|
|
||||||
{accountLimits.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Account Management</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{accountLimits.map((limit, idx) => (
|
|
||||||
<LimitCardComponent key={idx} limit={limit} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{creditLimits.length === 0 && accountLimits.length === 0 && (
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||||
<p className="mb-2 font-medium">No limits data available.</p>
|
<p className="mb-2 font-medium">No usage limits data available.</p>
|
||||||
<p className="text-sm">Your account may not have a plan configured.</p>
|
<p className="text-sm">The API endpoint may not be responding or your account may not have a plan configured.</p>
|
||||||
|
<p className="text-xs mt-2 text-gray-400">Check browser console for errors. Endpoint: /v1/billing/credits/usage/limits/</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 mb-8">
|
||||||
|
{/* Planner Limits */}
|
||||||
|
{groupedLimits.planner.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Planner Limits</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{groupedLimits.planner.map((limit, idx) => (
|
||||||
|
<LimitCardComponent key={idx} limit={limit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Writer Limits */}
|
||||||
|
{groupedLimits.writer.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Writer Limits</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{groupedLimits.writer.map((limit, idx) => (
|
||||||
|
<LimitCardComponent key={idx} limit={limit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Limits */}
|
||||||
|
{groupedLimits.images.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Image Generation Limits</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{groupedLimits.images.map((limit, idx) => (
|
||||||
|
<LimitCardComponent key={idx} limit={limit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Credits */}
|
||||||
|
{groupedLimits.ai.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">AI Credits</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{groupedLimits.ai.map((limit, idx) => (
|
||||||
|
<LimitCardComponent key={idx} limit={limit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* General Limits */}
|
||||||
|
{groupedLimits.general.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">General Limits</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{groupedLimits.general.map((limit, idx) => (
|
||||||
|
<LimitCardComponent key={idx} limit={limit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -189,20 +219,22 @@ export default function Usage() {
|
|||||||
function LimitCardComponent({ limit }: { limit: LimitCard }) {
|
function LimitCardComponent({ limit }: { limit: LimitCard }) {
|
||||||
const getCategoryColor = (category: string) => {
|
const getCategoryColor = (category: string) => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'credits': return 'primary';
|
case 'planner': return 'blue';
|
||||||
case 'account': return 'gray';
|
case 'writer': return 'green';
|
||||||
|
case 'images': return 'purple';
|
||||||
|
case 'ai': return 'orange';
|
||||||
|
case 'general': return 'gray';
|
||||||
default: return 'gray';
|
default: return 'gray';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUsageStatus = (percentage: number | null) => {
|
const getUsageStatus = (percentage: number) => {
|
||||||
if (percentage === null) return 'info';
|
|
||||||
if (percentage >= 90) return 'danger';
|
if (percentage >= 90) return 'danger';
|
||||||
if (percentage >= 75) return 'warning';
|
if (percentage >= 75) return 'warning';
|
||||||
return 'success';
|
return 'success';
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentage = limit.percentage !== null && limit.percentage !== undefined ? Math.min(limit.percentage, 100) : null;
|
const percentage = Math.min(limit.percentage, 100);
|
||||||
const status = getUsageStatus(percentage);
|
const status = getUsageStatus(percentage);
|
||||||
const color = getCategoryColor(limit.category);
|
const color = getCategoryColor(limit.category);
|
||||||
|
|
||||||
@@ -210,16 +242,12 @@ function LimitCardComponent({ limit }: { limit: LimitCard }) {
|
|||||||
? 'bg-red-500'
|
? 'bg-red-500'
|
||||||
: status === 'warning'
|
: status === 'warning'
|
||||||
? 'bg-yellow-500'
|
? 'bg-yellow-500'
|
||||||
: status === 'info'
|
|
||||||
? 'bg-blue-500'
|
|
||||||
: 'bg-green-500';
|
: 'bg-green-500';
|
||||||
|
|
||||||
const statusTextColor = status === 'danger'
|
const statusTextColor = status === 'danger'
|
||||||
? 'text-red-600 dark:text-red-400'
|
? 'text-red-600 dark:text-red-400'
|
||||||
: status === 'warning'
|
: status === 'warning'
|
||||||
? 'text-yellow-600 dark:text-yellow-400'
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
: status === 'info'
|
|
||||||
? 'text-blue-600 dark:text-blue-400'
|
|
||||||
: 'text-green-600 dark:text-green-400';
|
: 'text-green-600 dark:text-green-400';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -230,21 +258,10 @@ function LimitCardComponent({ limit }: { limit: LimitCard }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
{limit.limit !== null && limit.limit !== undefined ? (
|
|
||||||
<>
|
|
||||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">{limit.used.toLocaleString()}</span>
|
<span className="text-2xl font-bold text-gray-900 dark:text-white">{limit.used.toLocaleString()}</span>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">/ {limit.limit.toLocaleString()}</span>
|
<span className="text-sm text-gray-500 dark:text-gray-400">/ {limit.limit.toLocaleString()}</span>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{limit.available !== null && limit.available !== undefined ? limit.available.toLocaleString() : limit.used.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{limit.unit && (
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">{limit.unit}</span>
|
<span className="text-xs text-gray-400 dark:text-gray-500">{limit.unit}</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{percentage !== null && (
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
@@ -253,21 +270,14 @@ function LimitCardComponent({ limit }: { limit: LimitCard }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
{limit.available !== null && limit.available !== undefined ? (
|
|
||||||
<span className={statusTextColor}>
|
<span className={statusTextColor}>
|
||||||
{limit.available.toLocaleString()} available
|
{limit.available.toLocaleString()} available
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">Current value</span>
|
|
||||||
)}
|
|
||||||
{percentage !== null && (
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
{percentage.toFixed(1)}% used
|
{percentage.toFixed(1)}% used
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -78,16 +78,9 @@ function getActiveSectorId(): number | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get auth token from store - try Zustand store first, then localStorage as fallback
|
// Get auth token from store
|
||||||
const getAuthToken = (): string | null => {
|
const getAuthToken = (): string | null => {
|
||||||
try {
|
try {
|
||||||
// First try to get from Zustand store directly (faster, no parsing)
|
|
||||||
const authState = useAuthStore.getState();
|
|
||||||
if (authState?.token) {
|
|
||||||
return authState.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to localStorage (for cases where store hasn't initialized yet)
|
|
||||||
const authStorage = localStorage.getItem('auth-storage');
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
if (authStorage) {
|
if (authStorage) {
|
||||||
const parsed = JSON.parse(authStorage);
|
const parsed = JSON.parse(authStorage);
|
||||||
@@ -99,16 +92,9 @@ const getAuthToken = (): string | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get refresh token from store - try Zustand store first, then localStorage as fallback
|
// Get refresh token from store
|
||||||
const getRefreshToken = (): string | null => {
|
const getRefreshToken = (): string | null => {
|
||||||
try {
|
try {
|
||||||
// First try to get from Zustand store directly (faster, no parsing)
|
|
||||||
const authState = useAuthStore.getState();
|
|
||||||
if (authState?.refreshToken) {
|
|
||||||
return authState.refreshToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to localStorage (for cases where store hasn't initialized yet)
|
|
||||||
const authStorage = localStorage.getItem('auth-storage');
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
if (authStorage) {
|
if (authStorage) {
|
||||||
const parsed = JSON.parse(authStorage);
|
const parsed = JSON.parse(authStorage);
|
||||||
@@ -162,14 +148,9 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo
|
|||||||
if (errorData?.detail?.includes('Authentication credentials') ||
|
if (errorData?.detail?.includes('Authentication credentials') ||
|
||||||
errorData?.message?.includes('Authentication credentials') ||
|
errorData?.message?.includes('Authentication credentials') ||
|
||||||
errorData?.error?.includes('Authentication credentials')) {
|
errorData?.error?.includes('Authentication credentials')) {
|
||||||
// Only logout if we actually have a token stored (means it's invalid)
|
// Token is invalid - clear auth state and force re-login
|
||||||
// If no token, it might be a race condition after login - don't logout
|
|
||||||
const authState = useAuthStore.getState();
|
|
||||||
if (authState?.token || authState?.isAuthenticated) {
|
|
||||||
// Token exists but is invalid - clear auth state and force re-login
|
|
||||||
const { logout } = useAuthStore.getState();
|
const { logout } = useAuthStore.getState();
|
||||||
logout();
|
logout();
|
||||||
}
|
|
||||||
// Don't throw here - let the error handling below show the error
|
// Don't throw here - let the error handling below show the error
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1493,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;
|
||||||
@@ -1531,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',
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user