Compare commits
2 Commits
feature/ph
...
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'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ from .views import (
|
|||||||
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
||||||
IndustryViewSet, SeedKeywordViewSet
|
IndustryViewSet, SeedKeywordViewSet
|
||||||
)
|
)
|
||||||
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer
|
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer
|
||||||
from .models import User
|
from .models import User
|
||||||
from .utils import generate_access_token, get_token_expiry, decode_token
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
|
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
|
||||||
@@ -80,7 +78,7 @@ class LoginView(APIView):
|
|||||||
password = serializer.validated_data['password']
|
password = serializer.validated_data['password']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
user = User.objects.get(email=email)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Invalid credentials',
|
error='Invalid credentials',
|
||||||
@@ -109,17 +107,9 @@ class LoginView(APIView):
|
|||||||
user_data = user_serializer.data
|
user_data = user_serializer.data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback if serializer fails (e.g., missing account_id column)
|
# Fallback if serializer fails (e.g., missing account_id column)
|
||||||
# Log the error for debugging but don't fail the login
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.warning(f"UserSerializer failed for user {user.id}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
# Ensure username is properly set (use email prefix if username is empty/default)
|
|
||||||
username = user.username if user.username and user.username != 'user' else user.email.split('@')[0]
|
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': username,
|
'username': user.username,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'role': user.role,
|
'role': user.role,
|
||||||
'account': None,
|
'account': None,
|
||||||
@@ -129,10 +119,12 @@ class LoginView(APIView):
|
|||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'user': user_data,
|
'user': user_data,
|
||||||
|
'tokens': {
|
||||||
'access': access_token,
|
'access': access_token,
|
||||||
'refresh': refresh_token,
|
'refresh': refresh_token,
|
||||||
'access_expires_at': access_expires_at.isoformat(),
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
message='Login successful',
|
message='Login successful',
|
||||||
request=request
|
request=request
|
||||||
@@ -188,84 +180,6 @@ class ChangePasswordView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=['Authentication'],
|
|
||||||
summary='Refresh Token',
|
|
||||||
description='Refresh access token using refresh token'
|
|
||||||
)
|
|
||||||
class RefreshTokenView(APIView):
|
|
||||||
"""Refresh access token endpoint."""
|
|
||||||
permission_classes = [permissions.AllowAny]
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
serializer = RefreshTokenSerializer(data=request.data)
|
|
||||||
if not serializer.is_valid():
|
|
||||||
return error_response(
|
|
||||||
error='Validation failed',
|
|
||||||
errors=serializer.errors,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
refresh_token = serializer.validated_data['refresh']
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Decode and validate refresh token
|
|
||||||
payload = decode_token(refresh_token)
|
|
||||||
|
|
||||||
# Verify it's a refresh token
|
|
||||||
if payload.get('type') != 'refresh':
|
|
||||||
return error_response(
|
|
||||||
error='Invalid token type',
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get user
|
|
||||||
user_id = payload.get('user_id')
|
|
||||||
account_id = payload.get('account_id')
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return error_response(
|
|
||||||
error='User not found',
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get account
|
|
||||||
account = None
|
|
||||||
if account_id:
|
|
||||||
try:
|
|
||||||
from .models import Account
|
|
||||||
account = Account.objects.get(id=account_id)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not account:
|
|
||||||
account = getattr(user, 'account', None)
|
|
||||||
|
|
||||||
# Generate new access token
|
|
||||||
access_token = generate_access_token(user, account)
|
|
||||||
access_expires_at = get_token_expiry('access')
|
|
||||||
|
|
||||||
return success_response(
|
|
||||||
data={
|
|
||||||
'access': access_token,
|
|
||||||
'access_expires_at': access_expires_at.isoformat()
|
|
||||||
},
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return error_response(
|
|
||||||
error='Invalid or expired refresh token',
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
|
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
|
||||||
class MeView(APIView):
|
class MeView(APIView):
|
||||||
"""Get current user information."""
|
"""Get current user information."""
|
||||||
@@ -287,7 +201,6 @@ urlpatterns = [
|
|||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
||||||
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
||||||
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
|
|
||||||
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
||||||
path('me/', MeView.as_view(), name='auth-me'),
|
path('me/', MeView.as_view(), name='auth-me'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -933,10 +933,12 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'user': user_serializer.data,
|
'user': user_serializer.data,
|
||||||
|
'tokens': {
|
||||||
'access': access_token,
|
'access': access_token,
|
||||||
'refresh': refresh_token,
|
'refresh': refresh_token,
|
||||||
'access_expires_at': access_expires_at.isoformat(),
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
message='Login successful',
|
message='Login successful',
|
||||||
request=request
|
request=request
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get plan credits per month (use get_effective_credits_per_month for Phase 0 compatibility)
|
# Get plan credits per month
|
||||||
plan_credits_per_month = account.plan.get_effective_credits_per_month() if account.plan else 0
|
plan_credits_per_month = account.plan.credits_per_month if account.plan else 0
|
||||||
|
|
||||||
# Calculate credits used this month
|
# Calculate credits used this month
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
@@ -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,39 +0,0 @@
|
|||||||
# Generated manually for Phase 0: Module Enable Settings
|
|
||||||
# Using RunSQL to create table directly to avoid model resolution issues with new unified API model
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('system', '0006_alter_systemstatus_unique_together_and_more'),
|
|
||||||
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
# Create table using raw SQL to avoid model resolution issues
|
|
||||||
# The model state is automatically discovered from models.py
|
|
||||||
migrations.RunSQL(
|
|
||||||
sql="""
|
|
||||||
CREATE TABLE IF NOT EXISTS igny8_module_enable_settings (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
planner_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
writer_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
thinker_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
automation_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
site_builder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
linker_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
optimizer_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
publisher_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
tenant_id BIGINT NOT NULL REFERENCES igny8_tenants(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_tenant_id_idx ON igny8_module_enable_settings(tenant_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_account_created_idx ON igny8_module_enable_settings(tenant_id, created_at);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS unique_account_module_enable_settings ON igny8_module_enable_settings(tenant_id);
|
|
||||||
""",
|
|
||||||
reverse_sql="DROP TABLE IF EXISTS igny8_module_enable_settings CASCADE;",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -235,15 +235,6 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
def retrieve(self, request, pk=None):
|
def retrieve(self, request, pk=None):
|
||||||
"""Get setting by key (pk can be key string)"""
|
"""Get setting by key (pk can be key string)"""
|
||||||
# Special case: if pk is "enable", this is likely a routing conflict
|
|
||||||
# The correct endpoint is /settings/modules/enable/ which should go to ModuleEnableSettingsViewSet
|
|
||||||
if pk == 'enable':
|
|
||||||
return error_response(
|
|
||||||
error='Use /api/v1/system/settings/modules/enable/ endpoint for module enable settings',
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
try:
|
try:
|
||||||
# Try to get by ID first
|
# Try to get by ID first
|
||||||
@@ -291,164 +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', 'get_current']:
|
|
||||||
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
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get', 'put'], url_path='current', url_name='current')
|
|
||||||
def get_current(self, request):
|
|
||||||
"""Get or update current account's module enable settings"""
|
|
||||||
if request.method == 'GET':
|
|
||||||
return self.list(request)
|
|
||||||
else:
|
|
||||||
return self.update(request, pk=None)
|
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if table exists (migration might not have been run)
|
|
||||||
try:
|
|
||||||
# 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 db_error:
|
|
||||||
# Check if it's a "table does not exist" error
|
|
||||||
error_str = str(db_error)
|
|
||||||
if 'does not exist' in error_str.lower() or 'relation' in error_str.lower():
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.error(f"ModuleEnableSettings table does not exist. Migration 0007_add_module_enable_settings needs to be run: {error_str}")
|
|
||||||
return error_response(
|
|
||||||
error='Module enable settings table not found. Please run migration: python manage.py migrate igny8_core_modules_system 0007',
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
# Re-raise other database errors
|
|
||||||
raise
|
|
||||||
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')
|
||||||
@@ -16,8 +16,8 @@ router.register(r'strategies', StrategyViewSet, basename='strategy')
|
|||||||
router.register(r'settings/system', SystemSettingsViewSet, basename='system-settings')
|
router.register(r'settings/system', SystemSettingsViewSet, basename='system-settings')
|
||||||
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')
|
||||||
# Register ModuleSettingsViewSet first
|
|
||||||
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings')
|
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings')
|
||||||
|
router.register(r'settings/account-modules', AccountModuleSettingsViewSet, basename='account-module-settings')
|
||||||
router.register(r'settings/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
|
||||||
@@ -50,20 +50,7 @@ integration_image_gen_settings_viewset = IntegrationSettingsViewSet.as_view({
|
|||||||
'get': 'get_image_generation_settings',
|
'get': 'get_image_generation_settings',
|
||||||
})
|
})
|
||||||
|
|
||||||
# Custom view for module enable settings to avoid URL routing conflict with ModuleSettingsViewSet
|
|
||||||
# This must be defined as a custom path BEFORE router.urls to ensure it matches first
|
|
||||||
# The update method handles pk=None correctly, so we can use as_view
|
|
||||||
module_enable_viewset = ModuleEnableSettingsViewSet.as_view({
|
|
||||||
'get': 'list',
|
|
||||||
'put': 'update',
|
|
||||||
'patch': 'partial_update',
|
|
||||||
})
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Module enable settings endpoint - MUST come before router.urls to avoid conflict
|
|
||||||
# When /settings/modules/enable/ is called, it would match ModuleSettingsViewSet with pk='enable'
|
|
||||||
# So we define it as a custom path first
|
|
||||||
path('settings/modules/enable/', module_enable_viewset, name='module-enable-settings'),
|
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
# Public health check endpoint (API Standard v1.0 requirement)
|
# Public health check endpoint (API Standard v1.0 requirement)
|
||||||
path('ping/', ping, name='system-ping'),
|
path('ping/', ping, name='system-ping'),
|
||||||
|
|||||||
@@ -411,9 +411,9 @@ frontend/
|
|||||||
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
||||||
<Route path="/reference/industries" element={<ReferenceIndustries />} />
|
<Route path="/reference/industries" element={<ReferenceIndustries />} />
|
||||||
|
|
||||||
{/* Automation */}
|
{/* Automation & Schedules */}
|
||||||
<Route path="/automation" element={<AutomationDashboard />} />
|
<Route path="/automation" element={<AutomationDashboard />} />
|
||||||
{/* Note: Schedules functionality is integrated into Automation Dashboard */}
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={<GeneralSettings />} />
|
<Route path="/settings" element={<GeneralSettings />} />
|
||||||
|
|||||||
@@ -644,12 +644,9 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
"data": {
|
"data": {
|
||||||
"user": { ... },
|
"user": { ... },
|
||||||
"access": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
"access": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||||
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..."
|
||||||
"access_expires_at": "2025-01-XXT...",
|
|
||||||
"refresh_expires_at": "2025-01-XXT..."
|
|
||||||
},
|
},
|
||||||
"message": "Login successful",
|
"message": "Login successful"
|
||||||
"request_id": "550e8400-e29b-41d4-a716-446655440000"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -278,10 +278,11 @@ frontend/src/
|
|||||||
│ ├── Billing/ # Existing
|
│ ├── Billing/ # Existing
|
||||||
│ ├── Settings/ # Existing
|
│ ├── Settings/ # Existing
|
||||||
│ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT
|
│ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT
|
||||||
│ │ ├── Dashboard.tsx # Automation overview (includes schedules functionality)
|
│ │ ├── Dashboard.tsx # Automation overview
|
||||||
│ │ ├── Rules.tsx # Automation rules management
|
│ │ ├── Rules.tsx # Automation rules management
|
||||||
│ │ ├── Workflows.tsx # Workflow templates
|
│ │ ├── Workflows.tsx # Workflow templates
|
||||||
│ │ └── History.tsx # Automation execution history
|
│ │ └── History.tsx # Automation execution history
|
||||||
|
│ ├── Schedules.tsx # EXISTING (placeholder) - IMPLEMENT
|
||||||
│ ├── Linker/ # NEW
|
│ ├── Linker/ # NEW
|
||||||
│ │ ├── Dashboard.tsx
|
│ │ ├── Dashboard.tsx
|
||||||
│ │ ├── Candidates.tsx
|
│ │ ├── Candidates.tsx
|
||||||
@@ -652,7 +653,7 @@ docker-data/
|
|||||||
| **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH |
|
| **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH |
|
||||||
| **Implement Automation API** | `modules/automation/` | TODO | HIGH |
|
| **Implement Automation API** | `modules/automation/` | TODO | HIGH |
|
||||||
| **Implement Automation UI** | `frontend/src/pages/Automation/` | TODO | HIGH |
|
| **Implement Automation UI** | `frontend/src/pages/Automation/` | TODO | HIGH |
|
||||||
| **Note**: Schedules functionality will be integrated into Automation UI, not as a separate page | - | - | - |
|
| **Implement Schedules UI** | `frontend/src/pages/Schedules.tsx` | TODO | HIGH |
|
||||||
|
|
||||||
### 9.2 Phase 1: Site Builder
|
### 9.2 Phase 1: Site Builder
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ CREDIT_COSTS = {
|
|||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Automation Dashboard** | `frontend/src/pages/Automation/Dashboard.tsx` | EXISTING (placeholder) |
|
| **Automation Dashboard** | `frontend/src/pages/Automation/Dashboard.tsx` | EXISTING (placeholder) |
|
||||||
| **Rules Management** | `frontend/src/pages/Automation/Rules.tsx` | NEW |
|
| **Rules Management** | `frontend/src/pages/Automation/Rules.tsx` | NEW |
|
||||||
| **Schedules (within Automation)** | Integrated into Automation Dashboard | Part of automation menu |
|
| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) |
|
||||||
| **Automation API Client** | `frontend/src/services/automation.api.ts` | NEW |
|
| **Automation API Client** | `frontend/src/services/automation.api.ts` | NEW |
|
||||||
|
|
||||||
### 2.6 Testing
|
### 2.6 Testing
|
||||||
|
|||||||
@@ -462,11 +462,13 @@ urlpatterns = router.urls
|
|||||||
- Test rule
|
- Test rule
|
||||||
- Manual execution
|
- Manual execution
|
||||||
|
|
||||||
#### Schedules (Part of Automation Menu)
|
#### Schedules Page
|
||||||
|
|
||||||
**Note**: Schedules functionality will be integrated into the Automation menu group, not as a separate page.
|
| Task | File | Dependencies | Implementation |
|
||||||
|
|------|------|--------------|----------------|
|
||||||
|
| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) | View scheduled task history |
|
||||||
|
|
||||||
**Schedules Features** (within Automation Dashboard):
|
**Schedules Page Features**:
|
||||||
- List scheduled tasks
|
- List scheduled tasks
|
||||||
- Filter by status, rule, date
|
- Filter by status, rule, date
|
||||||
- View execution results
|
- View execution results
|
||||||
@@ -551,11 +553,11 @@ export const automationApi = {
|
|||||||
|
|
||||||
- [ ] Implement `frontend/src/pages/Automation/Dashboard.tsx`
|
- [ ] Implement `frontend/src/pages/Automation/Dashboard.tsx`
|
||||||
- [ ] Create `frontend/src/pages/Automation/Rules.tsx`
|
- [ ] Create `frontend/src/pages/Automation/Rules.tsx`
|
||||||
- [ ] Integrate schedules functionality into Automation Dashboard (not as separate page)
|
- [ ] Implement `frontend/src/pages/Schedules.tsx`
|
||||||
- [ ] Create `frontend/src/services/automation.api.ts`
|
- [ ] Create `frontend/src/services/automation.api.ts`
|
||||||
- [ ] Create rule creation wizard
|
- [ ] Create rule creation wizard
|
||||||
- [ ] Create rule editor
|
- [ ] Create rule editor
|
||||||
- [ ] Create schedule history table (within Automation Dashboard)
|
- [ ] Create schedule history table
|
||||||
|
|
||||||
### Testing Tasks
|
### Testing Tasks
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -50,6 +49,7 @@ const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
|||||||
const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
|
const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
|
||||||
|
|
||||||
// Other Pages - Lazy loaded
|
// Other Pages - Lazy loaded
|
||||||
|
const Schedules = lazy(() => import("./pages/Schedules"));
|
||||||
const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard"));
|
const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard"));
|
||||||
|
|
||||||
// Settings - Lazy loaded
|
// Settings - Lazy loaded
|
||||||
@@ -133,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>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -288,9 +256,12 @@ 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>
|
||||||
|
} />
|
||||||
|
<Route path="/schedules" element={
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Schedules />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
|||||||
@@ -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}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ import { useAuthStore } from "../../store/authStore";
|
|||||||
* - /settings (including /settings/sites)
|
* - /settings (including /settings/sites)
|
||||||
* - /dashboard
|
* - /dashboard
|
||||||
* - /analytics
|
* - /analytics
|
||||||
|
* - /schedules
|
||||||
* - /thinker
|
* - /thinker
|
||||||
* - /signin, /signup
|
* - /signin, /signup
|
||||||
*/
|
*/
|
||||||
@@ -36,6 +37,7 @@ const SITE_SWITCHER_HIDDEN_PATHS = [
|
|||||||
'/settings',
|
'/settings',
|
||||||
'/dashboard',
|
'/dashboard',
|
||||||
'/analytics',
|
'/analytics',
|
||||||
|
'/schedules',
|
||||||
'/thinker',
|
'/thinker',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -51,6 +51,11 @@ export const routes: RouteConfig[] = [
|
|||||||
{ path: '/thinker/profile', label: 'Profile', breadcrumb: 'Profile' },
|
{ path: '/thinker/profile', label: 'Profile', breadcrumb: 'Profile' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/schedules',
|
||||||
|
label: 'Schedules',
|
||||||
|
icon: 'Schedules',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getBreadcrumbs = (pathname: string): Array<{ label: string; path: string }> => {
|
export const getBreadcrumbs = (pathname: string): Array<{ label: string; path: string }> => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PlugInIcon,
|
PlugInIcon,
|
||||||
TaskIcon,
|
TaskIcon,
|
||||||
BoltIcon,
|
BoltIcon,
|
||||||
|
TimeIcon,
|
||||||
DocsIcon,
|
DocsIcon,
|
||||||
PageIcon,
|
PageIcon,
|
||||||
DollarLineIcon,
|
DollarLineIcon,
|
||||||
@@ -19,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 = {
|
||||||
@@ -37,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(
|
||||||
@@ -46,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;
|
||||||
@@ -66,84 +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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
label: "OVERVIEW",
|
label: "OVERVIEW",
|
||||||
items: [
|
items: [
|
||||||
@@ -161,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",
|
||||||
@@ -197,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(() => ({
|
||||||
@@ -299,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;
|
||||||
}
|
}
|
||||||
@@ -330,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function Help() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "How do I set up automation?",
|
question: "How do I set up automation?",
|
||||||
answer: "Go to Dashboard > Automation Setup section. Enable automation for each step (Keywords, Ideas, Content, Images) and configure settings like how many keywords to process per cycle. Advanced scheduling settings are available in the Automation menu."
|
answer: "Go to Dashboard > Automation Setup section. Enable automation for each step (Keywords, Ideas, Content, Images) and configure settings like how many keywords to process per cycle. Advanced settings are available in Schedules page."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can I edit AI-generated content?",
|
question: "Can I edit AI-generated content?",
|
||||||
@@ -539,7 +539,7 @@ export default function Help() {
|
|||||||
|
|
||||||
<div className="mt-6 p-4 bg-brand-50 dark:bg-brand-900/10 rounded-lg border border-brand-200 dark:border-brand-800">
|
<div className="mt-6 p-4 bg-brand-50 dark:bg-brand-900/10 rounded-lg border border-brand-200 dark:border-brand-800">
|
||||||
<p className="text-sm text-brand-800 dark:text-brand-300">
|
<p className="text-sm text-brand-800 dark:text-brand-300">
|
||||||
<strong>Note:</strong> Configure automation in Dashboard > Automation Setup. For advanced scheduling, go to the Automation menu.
|
<strong>Note:</strong> Configure automation in Dashboard > Automation Setup. For advanced scheduling, go to Schedules page.
|
||||||
</p>
|
</p>
|
||||||
</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) {
|
||||||
@@ -194,14 +175,13 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo
|
|||||||
|
|
||||||
if (refreshResponse.ok) {
|
if (refreshResponse.ok) {
|
||||||
const refreshData = await refreshResponse.json();
|
const refreshData = await refreshResponse.json();
|
||||||
const accessToken = refreshData.data?.access || refreshData.access;
|
if (refreshData.success && refreshData.access) {
|
||||||
if (refreshData.success && accessToken) {
|
|
||||||
// Update token in store
|
// Update token in store
|
||||||
try {
|
try {
|
||||||
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);
|
||||||
parsed.state.token = accessToken;
|
parsed.state.token = refreshData.access;
|
||||||
localStorage.setItem('auth-storage', JSON.stringify(parsed));
|
localStorage.setItem('auth-storage', JSON.stringify(parsed));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -211,7 +191,7 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo
|
|||||||
// Retry original request with new token
|
// Retry original request with new token
|
||||||
const newHeaders = {
|
const newHeaders = {
|
||||||
...headers,
|
...headers,
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${refreshData.access}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
@@ -1494,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;
|
||||||
@@ -1532,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',
|
||||||
|
|||||||
@@ -60,17 +60,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok || !data.success) {
|
if (!response.ok || !data.success) {
|
||||||
throw new Error(data.error || data.message || 'Login failed');
|
throw new Error(data.message || 'Login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store user and JWT tokens (handle both old and new API formats)
|
// Store user and JWT tokens
|
||||||
const responseData = data.data || data;
|
|
||||||
// Support both formats: new (access/refresh at top level) and old (tokens.access/refresh)
|
|
||||||
const tokens = responseData.tokens || {};
|
|
||||||
set({
|
set({
|
||||||
user: responseData.user || data.user,
|
user: data.user,
|
||||||
token: responseData.access || tokens.access || data.access || null,
|
token: data.tokens?.access || null,
|
||||||
refreshToken: responseData.refresh || tokens.refresh || data.refresh || null,
|
refreshToken: data.tokens?.refresh || null,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
@@ -122,8 +119,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// Store user and JWT tokens
|
// Store user and JWT tokens
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
token: data.data?.access || data.access || null,
|
token: data.tokens?.access || null,
|
||||||
refreshToken: data.data?.refresh || data.refresh || null,
|
refreshToken: data.tokens?.refresh || null,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
@@ -171,8 +168,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
throw new Error(data.message || 'Token refresh failed');
|
throw new Error(data.message || 'Token refresh failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update access token (API returns access at top level of data)
|
// Update access token
|
||||||
set({ token: data.data?.access || data.access });
|
set({ token: data.access });
|
||||||
|
|
||||||
// Also refresh user data to get latest account/plan information
|
// Also refresh user data to get latest account/plan information
|
||||||
// This ensures account/plan changes are reflected immediately
|
// This ensures account/plan changes are reflected immediately
|
||||||
|
|||||||
@@ -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