Compare commits
54 Commits
phase-0-fo
...
fe7af3c81c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7af3c81c | ||
|
|
ea9ffedc01 | ||
|
|
bf6589449f | ||
|
|
75ba407df5 | ||
|
|
4b21009cf8 | ||
|
|
8a9dd8ed2f | ||
|
|
9930728e8a | ||
|
|
fe95d09bbe | ||
|
|
4ecc1706bc | ||
|
|
0f02bd6409 | ||
|
|
1134285a12 | ||
|
|
1c2c9354ba | ||
|
|
92f51859fe | ||
|
|
7f8982a0ab | ||
|
|
455358ecfc | ||
|
|
cb0e42bb8d | ||
|
|
9ab87416d8 | ||
|
|
56c30e4904 | ||
|
|
51cd021f85 | ||
|
|
fc6dd5623a | ||
|
|
1531f41226 | ||
|
|
37a64fa1ef | ||
|
|
c4daeb1870 | ||
|
|
79aab68acd | ||
|
|
11a5a66c8b | ||
|
|
ab292de06c | ||
|
|
8a9dd44c50 | ||
|
|
b2e60b749a | ||
|
|
9f3c4a6cdd | ||
|
|
219dae83c6 | ||
|
|
066b81dd2a | ||
|
|
8171014a7e | ||
|
|
46b5b5f1b2 | ||
|
|
a267fc0715 | ||
|
|
9ec8908091 | ||
|
|
0d468ef15a | ||
|
|
8fc483251e | ||
|
|
1d39f3f00a | ||
|
|
b20fab8ec1 | ||
|
|
437b0c7516 | ||
|
|
4de9128430 | ||
|
|
f195b6a72a | ||
|
|
ab6b6cc4be | ||
|
|
d0e6b342b5 | ||
|
|
461f3211dd | ||
|
|
abbf6dbabb | ||
|
|
a10e89ab08 | ||
|
|
5842ca2dfc | ||
|
|
9b3fb25bc9 | ||
|
|
dbe8da589f | ||
|
|
8102aa74eb | ||
|
|
13bd7fa134 | ||
|
|
a73b2ae22b | ||
|
|
5b11c4001e |
@@ -6,7 +6,7 @@ Full-stack SaaS platform for SEO keyword management and AI-driven content genera
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architectures
|
||||||
|
|
||||||
- **Backend**: Django 5.2+ with Django REST Framework (Port 8010/8011)
|
- **Backend**: Django 5.2+ with Django REST Framework (Port 8010/8011)
|
||||||
- **Frontend**: React 19 with TypeScript and Vite (Port 5173/8021)
|
- **Frontend**: React 19 with TypeScript and Vite (Port 5173/8021)
|
||||||
|
|||||||
Binary file not shown.
@@ -192,6 +192,31 @@ 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.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.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:
|
||||||
@@ -325,37 +350,45 @@ 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
|
||||||
|
|
||||||
# Track credit usage after successful save
|
# Phase 5.5: DEDUCT CREDITS - Deduct credits 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.business.billing.services.credit_service import CreditService
|
||||||
from igny8_core.modules.billing.models import CreditUsageLog
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
# Calculate credits used (based on tokens or fixed cost)
|
# Map function name to operation type
|
||||||
credits_used = self._calculate_credits_for_clustering(
|
operation_type = self._get_operation_type(function_name)
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log credit usage (don't deduct from account.credits, just log)
|
# Calculate actual amount based on results
|
||||||
CreditUsageLog.objects.create(
|
actual_amount = self._get_actual_amount(function_name, save_result, parsed, data)
|
||||||
|
|
||||||
|
# Deduct credits using the new convenience method
|
||||||
|
CreditService.deduct_credits_for_operation(
|
||||||
account=self.account,
|
account=self.account,
|
||||||
operation_type='clustering',
|
operation_type=operation_type,
|
||||||
credits_used=credits_used,
|
amount=actual_amount,
|
||||||
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='cluster',
|
related_object_type=self._get_related_object_type(function_name),
|
||||||
|
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
|
||||||
metadata={
|
metadata={
|
||||||
|
'function_name': function_name,
|
||||||
'clusters_created': clusters_created,
|
'clusters_created': clusters_created,
|
||||||
'keywords_updated': keywords_updated,
|
'keywords_updated': keywords_updated,
|
||||||
'function_name': function_name
|
'count': count,
|
||||||
|
**save_result
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"[AIEngine] Credits deducted: {operation_type}, amount: {actual_amount}")
|
||||||
|
except InsufficientCreditsError as e:
|
||||||
|
# This shouldn't happen since we checked before, but log it
|
||||||
|
logger.error(f"[AIEngine] Insufficient credits during deduction: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to log credit usage: {e}", exc_info=True)
|
logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True)
|
||||||
|
# Don't fail the operation if credit deduction fails (for backward compatibility)
|
||||||
|
|
||||||
# Phase 6: DONE - Finalization (98-100%)
|
# 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"
|
||||||
@@ -453,18 +486,74 @@ 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 _calculate_credits_for_clustering(self, keyword_count, tokens, cost):
|
def _get_operation_type(self, function_name):
|
||||||
"""Calculate credits used for clustering operation"""
|
"""Map function name to operation type for credit system"""
|
||||||
# Use plan's cost per request if available, otherwise calculate from tokens
|
mapping = {
|
||||||
if self.account and hasattr(self.account, 'plan') and self.account.plan:
|
'auto_cluster': 'clustering',
|
||||||
plan = self.account.plan
|
'generate_ideas': 'idea_generation',
|
||||||
# Check if plan has ai_cost_per_request config
|
'generate_content': 'content_generation',
|
||||||
if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request:
|
'generate_image_prompts': 'image_prompt_extraction',
|
||||||
cluster_cost = plan.ai_cost_per_request.get('cluster', 0)
|
'generate_images': 'image_generation',
|
||||||
if cluster_cost:
|
}
|
||||||
return int(cluster_cost)
|
return mapping.get(function_name, function_name)
|
||||||
|
|
||||||
# Fallback: 1 credit per 30 keywords (minimum 1)
|
def _get_estimated_amount(self, function_name, data, payload):
|
||||||
credits = max(1, int(keyword_count / 30))
|
"""Get estimated amount for credit calculation (before operation)"""
|
||||||
return credits
|
if function_name == 'generate_content':
|
||||||
|
# Estimate word count from task or default
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data.get('estimated_word_count', 1000)
|
||||||
|
return 1000 # Default estimate
|
||||||
|
elif function_name == 'generate_images':
|
||||||
|
# Count images to generate
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
image_ids = payload.get('image_ids', [])
|
||||||
|
return len(image_ids) if image_ids else 1
|
||||||
|
return 1
|
||||||
|
elif function_name == 'generate_ideas':
|
||||||
|
# Count clusters
|
||||||
|
if isinstance(data, dict) and 'cluster_data' in data:
|
||||||
|
return len(data['cluster_data'])
|
||||||
|
return 1
|
||||||
|
# For fixed cost operations (clustering, image_prompt_extraction), return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_actual_amount(self, function_name, save_result, parsed, data):
|
||||||
|
"""Get actual amount for credit calculation (after operation)"""
|
||||||
|
if function_name == 'generate_content':
|
||||||
|
# Get actual word count from saved content
|
||||||
|
if isinstance(save_result, dict):
|
||||||
|
word_count = save_result.get('word_count')
|
||||||
|
if word_count:
|
||||||
|
return word_count
|
||||||
|
# Fallback: estimate from parsed content
|
||||||
|
if isinstance(parsed, dict) and 'content' in parsed:
|
||||||
|
content = parsed['content']
|
||||||
|
return len(content.split()) if isinstance(content, str) else 1000
|
||||||
|
return 1000
|
||||||
|
elif function_name == 'generate_images':
|
||||||
|
# Count successfully generated images
|
||||||
|
count = save_result.get('count', 0)
|
||||||
|
if count > 0:
|
||||||
|
return count
|
||||||
|
return 1
|
||||||
|
elif function_name == 'generate_ideas':
|
||||||
|
# Count ideas generated
|
||||||
|
count = save_result.get('count', 0)
|
||||||
|
if count > 0:
|
||||||
|
return count
|
||||||
|
return 1
|
||||||
|
# For fixed cost operations, return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_related_object_type(self, function_name):
|
||||||
|
"""Get related object type for credit logging"""
|
||||||
|
mapping = {
|
||||||
|
'auto_cluster': 'cluster',
|
||||||
|
'generate_ideas': 'content_idea',
|
||||||
|
'generate_content': 'content',
|
||||||
|
'generate_image_prompts': 'image',
|
||||||
|
'generate_images': 'image',
|
||||||
|
}
|
||||||
|
return mapping.get(function_name, 'unknown')
|
||||||
|
|
||||||
|
|||||||
@@ -67,16 +67,10 @@ class JWTAuthentication(BaseAuthentication):
|
|||||||
try:
|
try:
|
||||||
account = Account.objects.get(id=account_id)
|
account = Account.objects.get(id=account_id)
|
||||||
except Account.DoesNotExist:
|
except Account.DoesNotExist:
|
||||||
pass
|
# Account from token doesn't exist - don't fallback, set to None
|
||||||
|
|
||||||
if not account:
|
|
||||||
try:
|
|
||||||
account = getattr(user, 'account', None)
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
# If account access fails, set to None
|
|
||||||
account = None
|
account = None
|
||||||
|
|
||||||
# Set account on request
|
# Set account on request (only if account_id was in token and account exists)
|
||||||
request.account = account
|
request.account = account
|
||||||
|
|
||||||
return (user, token)
|
return (user, token)
|
||||||
|
|||||||
@@ -19,21 +19,9 @@ 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')
|
||||||
}),
|
}),
|
||||||
('User / Site Limits', {
|
('Account Management 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')
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.db.models import Q
|
|||||||
from igny8_core.auth.models import Account, User, Site, Sector
|
from igny8_core.auth.models import Account, User, Site, Sector
|
||||||
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||||
from igny8_core.modules.writer.models import Tasks, Images, Content
|
from igny8_core.modules.writer.models import Tasks, Images, Content
|
||||||
from igny8_core.modules.billing.models import CreditTransaction, CreditUsageLog
|
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||||
from igny8_core.modules.system.models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
|
from igny8_core.modules.system.models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
|
||||||
from igny8_core.modules.system.settings_models import AccountSettings, UserSettings, ModuleSettings, AISettings
|
from igny8_core.modules.system.settings_models import AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ class AccountContextMiddleware(MiddlewareMixin):
|
|||||||
if not JWT_AVAILABLE:
|
if not JWT_AVAILABLE:
|
||||||
# JWT library not installed yet - skip for now
|
# JWT library not installed yet - skip for now
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Decode JWT token with signature verification
|
# Decode JWT token with signature verification
|
||||||
@@ -94,42 +93,30 @@ class AccountContextMiddleware(MiddlewareMixin):
|
|||||||
if user_id:
|
if user_id:
|
||||||
from .models import User, Account
|
from .models import User, Account
|
||||||
try:
|
try:
|
||||||
# Refresh user from DB with account and plan relationships to get latest data
|
# Get user from DB (but don't set request.user - let DRF authentication handle that)
|
||||||
# This ensures changes to account/plan are reflected immediately without re-login
|
# Only set request.account for account context
|
||||||
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
||||||
request.user = user
|
|
||||||
if account_id:
|
if account_id:
|
||||||
# Verify account still exists and matches user
|
# Verify account still exists
|
||||||
account = Account.objects.get(id=account_id)
|
|
||||||
# If user's account changed, use the new one from user object
|
|
||||||
if user.account and user.account.id != account_id:
|
|
||||||
request.account = user.account
|
|
||||||
else:
|
|
||||||
request.account = account
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
user_account = getattr(user, 'account', None)
|
account = Account.objects.get(id=account_id)
|
||||||
if user_account:
|
request.account = account
|
||||||
request.account = user_account
|
except Account.DoesNotExist:
|
||||||
else:
|
# Account from token doesn't exist - don't fallback, set to None
|
||||||
request.account = None
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
# If account access fails (e.g., column mismatch), set to None
|
|
||||||
request.account = None
|
request.account = None
|
||||||
|
else:
|
||||||
|
# No account_id in token - set to None (don't fallback to user.account)
|
||||||
|
request.account = None
|
||||||
except (User.DoesNotExist, Account.DoesNotExist):
|
except (User.DoesNotExist, Account.DoesNotExist):
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
else:
|
else:
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
|
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fail silently for now - allow unauthenticated access
|
# Fail silently for now - allow unauthenticated access
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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 with comprehensive limits and features.
|
Subscription plan model - Phase 0: Credit-only system.
|
||||||
Plans define limits for users, sites, content generation, AI usage, and billing.
|
Plans define credits, billing, and account management limits only.
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
||||||
# User / Site / Scope Limits
|
# Account Management Limits (kept - not operation 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,32 +120,7 @@ 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")
|
||||||
|
|
||||||
# Planner Limits
|
# Billing & Credits (Phase 0: Credit-only system)
|
||||||
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_keywords', 'max_clusters', 'max_content_ideas',
|
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
||||||
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count',
|
'included_credits', 'extra_credit_price', 'allow_credit_topup',
|
||||||
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit',
|
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
||||||
'included_credits', 'image_model_choices', 'credits_per_month'
|
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ from .views import (
|
|||||||
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
||||||
IndustryViewSet, SeedKeywordViewSet
|
IndustryViewSet, SeedKeywordViewSet
|
||||||
)
|
)
|
||||||
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer
|
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer
|
||||||
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
|
||||||
@@ -78,7 +80,7 @@ class LoginView(APIView):
|
|||||||
password = serializer.validated_data['password']
|
password = serializer.validated_data['password']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(email=email)
|
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Invalid credentials',
|
error='Invalid credentials',
|
||||||
@@ -107,9 +109,17 @@ 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': user.username,
|
'username': username,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'role': user.role,
|
'role': user.role,
|
||||||
'account': None,
|
'account': None,
|
||||||
@@ -119,12 +129,10 @@ 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
|
||||||
@@ -180,6 +188,84 @@ 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."""
|
||||||
@@ -201,6 +287,7 @@ 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,12 +933,10 @@ 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
|
||||||
|
|||||||
5
backend/igny8_core/business/__init__.py
Normal file
5
backend/igny8_core/business/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Business logic layer - Models and Services
|
||||||
|
Separated from API layer (modules/) for clean architecture
|
||||||
|
"""
|
||||||
|
|
||||||
4
backend/igny8_core/business/automation/__init__.py
Normal file
4
backend/igny8_core/business/automation/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Automation business logic - AutomationRule, ScheduledTask models and services
|
||||||
|
"""
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Generated manually for Phase 2: Automation System
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AutomationRule',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(help_text='Rule name', max_length=255)),
|
||||||
|
('description', models.TextField(blank=True, help_text='Rule description', null=True)),
|
||||||
|
('trigger', models.CharField(choices=[('schedule', 'Schedule'), ('event', 'Event'), ('manual', 'Manual')], default='manual', max_length=50)),
|
||||||
|
('schedule', models.CharField(blank=True, help_text="Cron-like schedule string (e.g., '0 0 * * *' for daily at midnight)", max_length=100, null=True)),
|
||||||
|
('conditions', models.JSONField(default=list, help_text='List of conditions that must be met for rule to execute')),
|
||||||
|
('actions', models.JSONField(default=list, help_text='List of actions to execute when rule triggers')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Whether rule is active')),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('paused', 'Paused')], default='active', max_length=50)),
|
||||||
|
('last_executed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('execution_count', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('metadata', models.JSONField(default=dict, help_text='Additional metadata')),
|
||||||
|
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
|
||||||
|
('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='igny8_core_auth.site')),
|
||||||
|
('sector', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='igny8_core_auth.sector')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'igny8_automation_rules',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'verbose_name': 'Automation Rule',
|
||||||
|
'verbose_name_plural': 'Automation Rules',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScheduledTask',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('scheduled_at', models.DateTimeField(help_text='When the task is scheduled to run')),
|
||||||
|
('executed_at', models.DateTimeField(blank=True, help_text='When the task was actually executed', null=True)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=50)),
|
||||||
|
('result', models.JSONField(default=dict, help_text='Execution result data')),
|
||||||
|
('error_message', models.TextField(blank=True, help_text='Error message if execution failed', null=True)),
|
||||||
|
('metadata', models.JSONField(default=dict, help_text='Additional metadata')),
|
||||||
|
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
|
||||||
|
('automation_rule', models.ForeignKey(help_text='The automation rule this task belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_tasks', to='automation.automationrule')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'igny8_scheduled_tasks',
|
||||||
|
'ordering': ['-scheduled_at'],
|
||||||
|
'verbose_name': 'Scheduled Task',
|
||||||
|
'verbose_name_plural': 'Scheduled Tasks',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='automationrule',
|
||||||
|
index=models.Index(fields=['trigger', 'is_active'], name='igny8_autom_trigger_123abc_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='automationrule',
|
||||||
|
index=models.Index(fields=['status'], name='igny8_autom_status_456def_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='automationrule',
|
||||||
|
index=models.Index(fields=['site', 'sector'], name='igny8_autom_site_id_789ghi_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='automationrule',
|
||||||
|
index=models.Index(fields=['trigger', 'is_active', 'status'], name='igny8_autom_trigger_0abjkl_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduledtask',
|
||||||
|
index=models.Index(fields=['automation_rule', 'status'], name='igny8_sched_automation_123abc_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduledtask',
|
||||||
|
index=models.Index(fields=['scheduled_at', 'status'], name='igny8_sched_scheduled_456def_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduledtask',
|
||||||
|
index=models.Index(fields=['account', 'status'], name='igny8_sched_account_789ghi_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduledtask',
|
||||||
|
index=models.Index(fields=['status', 'scheduled_at'], name='igny8_sched_status_0abjkl_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
143
backend/igny8_core/business/automation/models.py
Normal file
143
backend/igny8_core/business/automation/models.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Automation Models
|
||||||
|
Phase 2: Automation System
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from igny8_core.auth.models import SiteSectorBaseModel, AccountBaseModel
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationRule(SiteSectorBaseModel):
|
||||||
|
"""
|
||||||
|
Automation Rule model for defining automated workflows.
|
||||||
|
|
||||||
|
Rules can be triggered by:
|
||||||
|
- schedule: Time-based triggers (cron-like)
|
||||||
|
- event: Event-based triggers (content created, keyword added, etc.)
|
||||||
|
- manual: Manual execution only
|
||||||
|
"""
|
||||||
|
|
||||||
|
TRIGGER_CHOICES = [
|
||||||
|
('schedule', 'Schedule'),
|
||||||
|
('event', 'Event'),
|
||||||
|
('manual', 'Manual'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('active', 'Active'),
|
||||||
|
('inactive', 'Inactive'),
|
||||||
|
('paused', 'Paused'),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255, help_text="Rule name")
|
||||||
|
description = models.TextField(blank=True, null=True, help_text="Rule description")
|
||||||
|
|
||||||
|
# Trigger configuration
|
||||||
|
trigger = models.CharField(max_length=50, choices=TRIGGER_CHOICES, default='manual')
|
||||||
|
|
||||||
|
# Schedule configuration (for schedule triggers)
|
||||||
|
# Stored as cron-like string: "0 0 * * *" (daily at midnight)
|
||||||
|
schedule = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="Cron-like schedule string (e.g., '0 0 * * *' for daily at midnight)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Conditions (JSON field)
|
||||||
|
# Format: [{"field": "content.status", "operator": "equals", "value": "draft"}, ...]
|
||||||
|
conditions = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="List of conditions that must be met for rule to execute"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actions (JSON field)
|
||||||
|
# Format: [{"type": "generate_content", "params": {...}}, ...]
|
||||||
|
actions = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="List of actions to execute when rule triggers"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True, help_text="Whether rule is active")
|
||||||
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='active')
|
||||||
|
|
||||||
|
# Execution tracking
|
||||||
|
last_executed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
execution_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
metadata = models.JSONField(default=dict, help_text="Additional metadata")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'automation'
|
||||||
|
db_table = 'igny8_automation_rules'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Automation Rule'
|
||||||
|
verbose_name_plural = 'Automation Rules'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['trigger', 'is_active']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['site', 'sector']),
|
||||||
|
models.Index(fields=['trigger', 'is_active', 'status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_trigger_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTask(AccountBaseModel):
|
||||||
|
"""
|
||||||
|
Scheduled Task model for tracking scheduled automation rule executions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('running', 'Running'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
automation_rule = models.ForeignKey(
|
||||||
|
AutomationRule,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='scheduled_tasks',
|
||||||
|
help_text="The automation rule this task belongs to"
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_at = models.DateTimeField(help_text="When the task is scheduled to run")
|
||||||
|
executed_at = models.DateTimeField(null=True, blank=True, help_text="When the task was actually executed")
|
||||||
|
|
||||||
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='pending')
|
||||||
|
|
||||||
|
# Execution results
|
||||||
|
result = models.JSONField(default=dict, help_text="Execution result data")
|
||||||
|
error_message = models.TextField(blank=True, null=True, help_text="Error message if execution failed")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
metadata = models.JSONField(default=dict, help_text="Additional metadata")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'automation'
|
||||||
|
db_table = 'igny8_scheduled_tasks'
|
||||||
|
ordering = ['-scheduled_at']
|
||||||
|
verbose_name = 'Scheduled Task'
|
||||||
|
verbose_name_plural = 'Scheduled Tasks'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['automation_rule', 'status']),
|
||||||
|
models.Index(fields=['scheduled_at', 'status']),
|
||||||
|
models.Index(fields=['account', 'status']),
|
||||||
|
models.Index(fields=['status', 'scheduled_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Scheduled task for {self.automation_rule.name} at {self.scheduled_at}"
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Automation services
|
||||||
|
"""
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Action Executor
|
||||||
|
Executes rule actions
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from igny8_core.business.planning.services.clustering_service import ClusteringService
|
||||||
|
from igny8_core.business.planning.services.ideas_service import IdeasService
|
||||||
|
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionExecutor:
|
||||||
|
"""Executes rule actions"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.clustering_service = ClusteringService()
|
||||||
|
self.ideas_service = IdeasService()
|
||||||
|
self.content_service = ContentGenerationService()
|
||||||
|
|
||||||
|
def execute(self, action, context, rule):
|
||||||
|
"""
|
||||||
|
Execute a single action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: Action dict with 'type' and 'params'
|
||||||
|
context: Context dict
|
||||||
|
rule: AutomationRule instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Action execution result
|
||||||
|
"""
|
||||||
|
action_type = action.get('type')
|
||||||
|
params = action.get('params', {})
|
||||||
|
|
||||||
|
if action_type == 'cluster_keywords':
|
||||||
|
return self._execute_cluster_keywords(params, rule)
|
||||||
|
elif action_type == 'generate_ideas':
|
||||||
|
return self._execute_generate_ideas(params, rule)
|
||||||
|
elif action_type == 'generate_content':
|
||||||
|
return self._execute_generate_content(params, rule)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown action type: {action_type}")
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f'Unknown action type: {action_type}'
|
||||||
|
}
|
||||||
|
|
||||||
|
def _execute_cluster_keywords(self, params, rule):
|
||||||
|
"""Execute cluster keywords action"""
|
||||||
|
keyword_ids = params.get('keyword_ids', [])
|
||||||
|
sector_id = params.get('sector_id') or (rule.sector.id if rule.sector else None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.clustering_service.cluster_keywords(
|
||||||
|
keyword_ids=keyword_ids,
|
||||||
|
account=rule.account,
|
||||||
|
sector_id=sector_id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clustering keywords: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _execute_generate_ideas(self, params, rule):
|
||||||
|
"""Execute generate ideas action"""
|
||||||
|
cluster_ids = params.get('cluster_ids', [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.ideas_service.generate_ideas(
|
||||||
|
cluster_ids=cluster_ids,
|
||||||
|
account=rule.account
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating ideas: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _execute_generate_content(self, params, rule):
|
||||||
|
"""Execute generate content action"""
|
||||||
|
task_ids = params.get('task_ids', [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.content_service.generate_content(
|
||||||
|
task_ids=task_ids,
|
||||||
|
account=rule.account
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating content: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
Automation Service
|
||||||
|
Main service for executing automation rules
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from django.utils import timezone
|
||||||
|
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
||||||
|
from igny8_core.business.automation.services.rule_engine import RuleEngine
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationService:
|
||||||
|
"""Service for executing automation rules"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rule_engine = RuleEngine()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def execute_rule(self, rule, context=None):
|
||||||
|
"""
|
||||||
|
Execute an automation rule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rule: AutomationRule instance
|
||||||
|
context: Optional context dict for condition evaluation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Execution result with status and data
|
||||||
|
"""
|
||||||
|
if not rule.is_active or rule.status != 'active':
|
||||||
|
return {
|
||||||
|
'status': 'skipped',
|
||||||
|
'reason': 'Rule is inactive',
|
||||||
|
'rule_id': rule.id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check credits (estimate based on actions)
|
||||||
|
estimated_credits = self._estimate_credits(rule)
|
||||||
|
try:
|
||||||
|
self.credit_service.check_credits_legacy(rule.account, estimated_credits)
|
||||||
|
except InsufficientCreditsError as e:
|
||||||
|
logger.warning(f"Rule {rule.id} skipped: {str(e)}")
|
||||||
|
return {
|
||||||
|
'status': 'skipped',
|
||||||
|
'reason': f'Insufficient credits: {str(e)}',
|
||||||
|
'rule_id': rule.id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute via rule engine
|
||||||
|
try:
|
||||||
|
result = self.rule_engine.execute(rule, context or {})
|
||||||
|
|
||||||
|
# Update rule tracking
|
||||||
|
rule.last_executed_at = timezone.now()
|
||||||
|
rule.execution_count += 1
|
||||||
|
rule.save(update_fields=['last_executed_at', 'execution_count'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'completed',
|
||||||
|
'rule_id': rule.id,
|
||||||
|
'result': result
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error executing rule {rule.id}: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'status': 'failed',
|
||||||
|
'reason': str(e),
|
||||||
|
'rule_id': rule.id
|
||||||
|
}
|
||||||
|
|
||||||
|
def _estimate_credits(self, rule):
|
||||||
|
"""Estimate credits needed for rule execution"""
|
||||||
|
# Simple estimation based on action types
|
||||||
|
estimated = 0
|
||||||
|
for action in rule.actions:
|
||||||
|
action_type = action.get('type', '')
|
||||||
|
if 'cluster' in action_type:
|
||||||
|
estimated += 10
|
||||||
|
elif 'idea' in action_type:
|
||||||
|
estimated += 15
|
||||||
|
elif 'content' in action_type:
|
||||||
|
estimated += 50 # Conservative estimate
|
||||||
|
else:
|
||||||
|
estimated += 5 # Default
|
||||||
|
return max(estimated, 10) # Minimum 10 credits
|
||||||
|
|
||||||
|
def execute_scheduled_rules(self):
|
||||||
|
"""
|
||||||
|
Execute all scheduled rules that are due.
|
||||||
|
Called by Celery Beat task.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Summary of executions
|
||||||
|
"""
|
||||||
|
from django.utils import timezone
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
# Get active scheduled rules
|
||||||
|
rules = AutomationRule.objects.filter(
|
||||||
|
trigger='schedule',
|
||||||
|
is_active=True,
|
||||||
|
status='active'
|
||||||
|
)
|
||||||
|
|
||||||
|
executed = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
# Check if rule should execute based on schedule
|
||||||
|
if self._should_execute_schedule(rule, now):
|
||||||
|
result = self.execute_rule(rule)
|
||||||
|
if result['status'] == 'completed':
|
||||||
|
executed += 1
|
||||||
|
elif result['status'] == 'skipped':
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
'executed': executed,
|
||||||
|
'skipped': skipped,
|
||||||
|
'failed': failed,
|
||||||
|
'total': len(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _should_execute_schedule(self, rule, now):
|
||||||
|
"""
|
||||||
|
Check if a scheduled rule should execute now.
|
||||||
|
Simple implementation - can be enhanced with proper cron parsing.
|
||||||
|
"""
|
||||||
|
if not rule.schedule:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# For now, simple check - can be enhanced with cron parser
|
||||||
|
# This is a placeholder - proper implementation would parse cron string
|
||||||
|
return True # Simplified for now
|
||||||
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
Condition Evaluator
|
||||||
|
Evaluates rule conditions
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionEvaluator:
|
||||||
|
"""Evaluates rule conditions"""
|
||||||
|
|
||||||
|
OPERATORS = {
|
||||||
|
'equals': lambda a, b: a == b,
|
||||||
|
'not_equals': lambda a, b: a != b,
|
||||||
|
'greater_than': lambda a, b: a > b,
|
||||||
|
'greater_than_or_equal': lambda a, b: a >= b,
|
||||||
|
'less_than': lambda a, b: a < b,
|
||||||
|
'less_than_or_equal': lambda a, b: a <= b,
|
||||||
|
'in': lambda a, b: a in b,
|
||||||
|
'contains': lambda a, b: b in a if isinstance(a, str) else a in b,
|
||||||
|
'is_empty': lambda a, b: not a or (isinstance(a, str) and not a.strip()),
|
||||||
|
'is_not_empty': lambda a, b: a and (not isinstance(a, str) or a.strip()),
|
||||||
|
}
|
||||||
|
|
||||||
|
def evaluate(self, conditions, context):
|
||||||
|
"""
|
||||||
|
Evaluate a list of conditions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conditions: List of condition dicts
|
||||||
|
context: Context dict for field resolution
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if all conditions are met
|
||||||
|
"""
|
||||||
|
if not conditions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for condition in conditions:
|
||||||
|
if not self._evaluate_condition(condition, context):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _evaluate_condition(self, condition, context):
|
||||||
|
"""
|
||||||
|
Evaluate a single condition.
|
||||||
|
|
||||||
|
Condition format:
|
||||||
|
{
|
||||||
|
"field": "content.status",
|
||||||
|
"operator": "equals",
|
||||||
|
"value": "draft"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
field_path = condition.get('field')
|
||||||
|
operator = condition.get('operator', 'equals')
|
||||||
|
expected_value = condition.get('value')
|
||||||
|
|
||||||
|
if not field_path:
|
||||||
|
logger.warning("Condition missing 'field'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Resolve field value from context
|
||||||
|
actual_value = self._resolve_field(field_path, context)
|
||||||
|
|
||||||
|
# Get operator function
|
||||||
|
op_func = self.OPERATORS.get(operator)
|
||||||
|
if not op_func:
|
||||||
|
logger.warning(f"Unknown operator: {operator}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Evaluate
|
||||||
|
try:
|
||||||
|
return op_func(actual_value, expected_value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error evaluating condition: {str(e)}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _resolve_field(self, field_path, context):
|
||||||
|
"""
|
||||||
|
Resolve a field path from context.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "content.status" -> context['content']['status']
|
||||||
|
- "count" -> context['count']
|
||||||
|
"""
|
||||||
|
parts = field_path.split('.')
|
||||||
|
value = context
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get(part)
|
||||||
|
elif hasattr(value, part):
|
||||||
|
value = getattr(value, part)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Rule Engine
|
||||||
|
Orchestrates rule execution
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from igny8_core.business.automation.services.condition_evaluator import ConditionEvaluator
|
||||||
|
from igny8_core.business.automation.services.action_executor import ActionExecutor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RuleEngine:
|
||||||
|
"""Orchestrates rule execution"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.condition_evaluator = ConditionEvaluator()
|
||||||
|
self.action_executor = ActionExecutor()
|
||||||
|
|
||||||
|
def execute(self, rule, context):
|
||||||
|
"""
|
||||||
|
Execute a rule by evaluating conditions and executing actions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rule: AutomationRule instance
|
||||||
|
context: Context dict for evaluation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Execution results
|
||||||
|
"""
|
||||||
|
# Evaluate conditions
|
||||||
|
if rule.conditions:
|
||||||
|
conditions_met = self.condition_evaluator.evaluate(rule.conditions, context)
|
||||||
|
if not conditions_met:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'reason': 'Conditions not met'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute actions
|
||||||
|
action_results = []
|
||||||
|
for action in rule.actions:
|
||||||
|
try:
|
||||||
|
result = self.action_executor.execute(action, context, rule)
|
||||||
|
action_results.append({
|
||||||
|
'action': action,
|
||||||
|
'success': True,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Action execution failed: {str(e)}", exc_info=True)
|
||||||
|
action_results.append({
|
||||||
|
'action': action,
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'actions': action_results
|
||||||
|
}
|
||||||
|
|
||||||
28
backend/igny8_core/business/automation/tasks.py
Normal file
28
backend/igny8_core/business/automation/tasks.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Automation Celery Tasks
|
||||||
|
"""
|
||||||
|
from celery import shared_task
|
||||||
|
import logging
|
||||||
|
from igny8_core.business.automation.services.automation_service import AutomationService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name='igny8_core.business.automation.tasks.execute_scheduled_automation_rules')
|
||||||
|
def execute_scheduled_automation_rules():
|
||||||
|
"""
|
||||||
|
Execute all scheduled automation rules.
|
||||||
|
Called by Celery Beat.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = AutomationService()
|
||||||
|
result = service.execute_scheduled_rules()
|
||||||
|
logger.info(f"Executed scheduled automation rules: {result}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error executing scheduled automation rules: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
4
backend/igny8_core/business/billing/__init__.py
Normal file
4
backend/igny8_core/business/billing/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Billing business logic - CreditTransaction, CreditUsageLog models and services
|
||||||
|
"""
|
||||||
|
|
||||||
21
backend/igny8_core/business/billing/constants.py
Normal file
21
backend/igny8_core/business/billing/constants.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
Credit Cost Constants
|
||||||
|
Phase 0: Credit-only system costs per operation
|
||||||
|
"""
|
||||||
|
CREDIT_COSTS = {
|
||||||
|
'clustering': 10, # Per clustering request
|
||||||
|
'idea_generation': 15, # Per cluster → ideas request
|
||||||
|
'content_generation': 1, # Per 100 words
|
||||||
|
'image_prompt_extraction': 2, # Per content piece
|
||||||
|
'image_generation': 5, # Per image
|
||||||
|
'linking': 8, # Per content piece (NEW)
|
||||||
|
'optimization': 1, # Per 200 words (NEW)
|
||||||
|
'site_structure_generation': 50, # Per site blueprint (NEW)
|
||||||
|
'site_page_generation': 20, # Per page (NEW)
|
||||||
|
# Legacy operation types (for backward compatibility)
|
||||||
|
'ideas': 15, # Alias for idea_generation
|
||||||
|
'content': 3, # Legacy: 3 credits per content piece
|
||||||
|
'images': 5, # Alias for image_generation
|
||||||
|
'reparse': 1, # Per reparse
|
||||||
|
}
|
||||||
|
|
||||||
14
backend/igny8_core/business/billing/exceptions.py
Normal file
14
backend/igny8_core/business/billing/exceptions.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Billing Exceptions
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientCreditsError(Exception):
|
||||||
|
"""Raised when account doesn't have enough credits"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CreditCalculationError(Exception):
|
||||||
|
"""Raised when credit calculation fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
77
backend/igny8_core/business/billing/models.py
Normal file
77
backend/igny8_core/business/billing/models.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Billing Models for Credit System
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from igny8_core.auth.models import AccountBaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreditTransaction(AccountBaseModel):
|
||||||
|
"""Track all credit transactions (additions, deductions)"""
|
||||||
|
TRANSACTION_TYPE_CHOICES = [
|
||||||
|
('purchase', 'Purchase'),
|
||||||
|
('subscription', 'Subscription Renewal'),
|
||||||
|
('refund', 'Refund'),
|
||||||
|
('deduction', 'Usage Deduction'),
|
||||||
|
('adjustment', 'Manual Adjustment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
transaction_type = models.CharField(max_length=20, choices=TRANSACTION_TYPE_CHOICES, db_index=True)
|
||||||
|
amount = models.IntegerField(help_text="Positive for additions, negative for deductions")
|
||||||
|
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
|
||||||
|
description = models.CharField(max_length=255)
|
||||||
|
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'billing'
|
||||||
|
db_table = 'igny8_credit_transactions'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['account', 'transaction_type']),
|
||||||
|
models.Index(fields=['account', 'created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
account = getattr(self, 'account', None)
|
||||||
|
return f"{self.get_transaction_type_display()} - {self.amount} credits - {account.name if account else 'No Account'}"
|
||||||
|
|
||||||
|
|
||||||
|
class CreditUsageLog(AccountBaseModel):
|
||||||
|
"""Detailed log of credit usage per AI operation"""
|
||||||
|
OPERATION_TYPE_CHOICES = [
|
||||||
|
('clustering', 'Keyword Clustering'),
|
||||||
|
('idea_generation', 'Content Ideas Generation'),
|
||||||
|
('content_generation', 'Content Generation'),
|
||||||
|
('image_generation', 'Image Generation'),
|
||||||
|
('reparse', 'Content Reparse'),
|
||||||
|
('ideas', 'Content Ideas Generation'), # Legacy
|
||||||
|
('content', 'Content Generation'), # Legacy
|
||||||
|
('images', 'Image Generation'), # Legacy
|
||||||
|
]
|
||||||
|
|
||||||
|
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
|
||||||
|
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
|
||||||
|
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
|
||||||
|
model_used = models.CharField(max_length=100, blank=True)
|
||||||
|
tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
|
||||||
|
tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
|
||||||
|
related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task'
|
||||||
|
related_object_id = models.IntegerField(null=True, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'billing'
|
||||||
|
db_table = 'igny8_credit_usage_logs'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['account', 'operation_type']),
|
||||||
|
models.Index(fields=['account', 'created_at']),
|
||||||
|
models.Index(fields=['account', 'operation_type', 'created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
account = getattr(self, 'account', None)
|
||||||
|
return f"{self.get_operation_type_display()} - {self.credits_used} credits - {account.name if account else 'No Account'}"
|
||||||
|
|
||||||
4
backend/igny8_core/business/billing/services/__init__.py
Normal file
4
backend/igny8_core/business/billing/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Billing services
|
||||||
|
"""
|
||||||
|
|
||||||
264
backend/igny8_core/business/billing/services/credit_service.py
Normal file
264
backend/igny8_core/business/billing/services/credit_service.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
Credit Service for managing credit transactions and deductions
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||||
|
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class CreditService:
|
||||||
|
"""Service for managing credits"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_credit_cost(operation_type, amount=None):
|
||||||
|
"""
|
||||||
|
Get credit cost for operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation_type: Type of operation (from CREDIT_COSTS)
|
||||||
|
amount: Optional amount (word count, image count, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of credits required
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CreditCalculationError: If operation type is unknown
|
||||||
|
"""
|
||||||
|
base_cost = CREDIT_COSTS.get(operation_type, 0)
|
||||||
|
if base_cost == 0:
|
||||||
|
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
||||||
|
|
||||||
|
# Variable cost operations
|
||||||
|
if operation_type == 'content_generation' and amount:
|
||||||
|
# Per 100 words
|
||||||
|
return max(1, int(base_cost * (amount / 100)))
|
||||||
|
elif operation_type == 'optimization' and amount:
|
||||||
|
# Per 200 words
|
||||||
|
return max(1, int(base_cost * (amount / 200)))
|
||||||
|
elif operation_type == 'image_generation' and amount:
|
||||||
|
# Per image
|
||||||
|
return base_cost * amount
|
||||||
|
elif operation_type == 'idea_generation' and amount:
|
||||||
|
# Per idea
|
||||||
|
return base_cost * amount
|
||||||
|
|
||||||
|
# Fixed cost operations
|
||||||
|
return base_cost
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_credits(account, operation_type, amount=None):
|
||||||
|
"""
|
||||||
|
Check if account has sufficient credits for an operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account instance
|
||||||
|
operation_type: Type of operation
|
||||||
|
amount: Optional amount (word count, image count, etc.)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
required = CreditService.get_credit_cost(operation_type, amount)
|
||||||
|
if account.credits < required:
|
||||||
|
raise InsufficientCreditsError(
|
||||||
|
f"Insufficient credits. Required: {required}, Available: {account.credits}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_credits_legacy(account, required_credits):
|
||||||
|
"""
|
||||||
|
Legacy method: Check if account has enough credits (for backward compatibility).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account instance
|
||||||
|
required_credits: Number of credits required
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
if account.credits < required_credits:
|
||||||
|
raise InsufficientCreditsError(
|
||||||
|
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||||
|
"""
|
||||||
|
Deduct credits and log transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account instance
|
||||||
|
amount: Number of credits to deduct
|
||||||
|
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
|
||||||
|
description: Description of the transaction
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
# Check sufficient credits (legacy: amount is already calculated)
|
||||||
|
CreditService.check_credits_legacy(account, amount)
|
||||||
|
|
||||||
|
# Deduct from account.credits
|
||||||
|
account.credits -= amount
|
||||||
|
account.save(update_fields=['credits'])
|
||||||
|
|
||||||
|
# Create CreditTransaction
|
||||||
|
CreditTransaction.objects.create(
|
||||||
|
account=account,
|
||||||
|
transaction_type='deduction',
|
||||||
|
amount=-amount, # Negative for deduction
|
||||||
|
balance_after=account.credits,
|
||||||
|
description=description,
|
||||||
|
metadata=metadata or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create CreditUsageLog
|
||||||
|
CreditUsageLog.objects.create(
|
||||||
|
account=account,
|
||||||
|
operation_type=operation_type,
|
||||||
|
credits_used=amount,
|
||||||
|
cost_usd=cost_usd,
|
||||||
|
model_used=model_used or '',
|
||||||
|
tokens_input=tokens_input,
|
||||||
|
tokens_output=tokens_output,
|
||||||
|
related_object_type=related_object_type or '',
|
||||||
|
related_object_id=related_object_id,
|
||||||
|
metadata=metadata or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
return account.credits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||||
|
"""
|
||||||
|
Deduct credits for an operation (convenience method that calculates cost automatically).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account instance
|
||||||
|
operation_type: Type of operation
|
||||||
|
amount: Optional amount (word count, image count, etc.)
|
||||||
|
description: Optional description (auto-generated if not provided)
|
||||||
|
metadata: Optional metadata dict
|
||||||
|
cost_usd: Optional cost in USD
|
||||||
|
model_used: Optional AI model used
|
||||||
|
tokens_input: Optional input tokens
|
||||||
|
tokens_output: Optional output tokens
|
||||||
|
related_object_type: Optional related object type
|
||||||
|
related_object_id: Optional related object ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: New credit balance
|
||||||
|
"""
|
||||||
|
# Calculate credit cost
|
||||||
|
credits_required = CreditService.get_credit_cost(operation_type, amount)
|
||||||
|
|
||||||
|
# Check sufficient credits
|
||||||
|
CreditService.check_credits(account, operation_type, amount)
|
||||||
|
|
||||||
|
# Auto-generate description if not provided
|
||||||
|
if not description:
|
||||||
|
if operation_type == 'clustering':
|
||||||
|
description = f"Clustering operation"
|
||||||
|
elif operation_type == 'idea_generation':
|
||||||
|
description = f"Generated {amount or 1} idea(s)"
|
||||||
|
elif operation_type == 'content_generation':
|
||||||
|
description = f"Generated content ({amount or 0} words)"
|
||||||
|
elif operation_type == 'image_generation':
|
||||||
|
description = f"Generated {amount or 1} image(s)"
|
||||||
|
else:
|
||||||
|
description = f"{operation_type} operation"
|
||||||
|
|
||||||
|
return CreditService.deduct_credits(
|
||||||
|
account=account,
|
||||||
|
amount=credits_required,
|
||||||
|
operation_type=operation_type,
|
||||||
|
description=description,
|
||||||
|
metadata=metadata,
|
||||||
|
cost_usd=cost_usd,
|
||||||
|
model_used=model_used,
|
||||||
|
tokens_input=tokens_input,
|
||||||
|
tokens_output=tokens_output,
|
||||||
|
related_object_type=related_object_type,
|
||||||
|
related_object_id=related_object_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def add_credits(account, amount, transaction_type, description, metadata=None):
|
||||||
|
"""
|
||||||
|
Add credits (purchase, subscription, etc.).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account instance
|
||||||
|
amount: Number of credits to add
|
||||||
|
transaction_type: Type of transaction (from CreditTransaction.TRANSACTION_TYPE_CHOICES)
|
||||||
|
description: Description of the transaction
|
||||||
|
metadata: Optional metadata dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: New credit balance
|
||||||
|
"""
|
||||||
|
# Add to account.credits
|
||||||
|
account.credits += amount
|
||||||
|
account.save(update_fields=['credits'])
|
||||||
|
|
||||||
|
# Create CreditTransaction
|
||||||
|
CreditTransaction.objects.create(
|
||||||
|
account=account,
|
||||||
|
transaction_type=transaction_type,
|
||||||
|
amount=amount, # Positive for addition
|
||||||
|
balance_after=account.credits,
|
||||||
|
description=description,
|
||||||
|
metadata=metadata or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
return account.credits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_credits_for_operation(operation_type, **kwargs):
|
||||||
|
"""
|
||||||
|
Calculate credits needed for an operation.
|
||||||
|
Legacy method - use get_credit_cost() instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation_type: Type of operation
|
||||||
|
**kwargs: Operation-specific parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of credits required
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CreditCalculationError: If calculation fails
|
||||||
|
"""
|
||||||
|
# Map legacy operation types
|
||||||
|
if operation_type == 'ideas':
|
||||||
|
operation_type = 'idea_generation'
|
||||||
|
elif operation_type == 'content':
|
||||||
|
operation_type = 'content_generation'
|
||||||
|
elif operation_type == 'images':
|
||||||
|
operation_type = 'image_generation'
|
||||||
|
|
||||||
|
# Extract amount from kwargs
|
||||||
|
amount = None
|
||||||
|
if 'word_count' in kwargs:
|
||||||
|
amount = kwargs.get('word_count')
|
||||||
|
elif 'image_count' in kwargs:
|
||||||
|
amount = kwargs.get('image_count')
|
||||||
|
elif 'idea_count' in kwargs:
|
||||||
|
amount = kwargs.get('idea_count')
|
||||||
|
|
||||||
|
return CreditService.get_credit_cost(operation_type, amount)
|
||||||
|
|
||||||
4
backend/igny8_core/business/content/__init__.py
Normal file
4
backend/igny8_core/business/content/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Content business logic - Content, Tasks, Images models and services
|
||||||
|
"""
|
||||||
|
|
||||||
252
backend/igny8_core/business/content/models.py
Normal file
252
backend/igny8_core/business/content/models.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from igny8_core.auth.models import SiteSectorBaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Tasks(SiteSectorBaseModel):
|
||||||
|
"""Tasks model for content generation queue"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('queued', 'Queued'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTENT_STRUCTURE_CHOICES = [
|
||||||
|
('cluster_hub', 'Cluster Hub'),
|
||||||
|
('landing_page', 'Landing Page'),
|
||||||
|
('pillar_page', 'Pillar Page'),
|
||||||
|
('supporting_page', 'Supporting Page'),
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTENT_TYPE_CHOICES = [
|
||||||
|
('blog_post', 'Blog Post'),
|
||||||
|
('article', 'Article'),
|
||||||
|
('guide', 'Guide'),
|
||||||
|
('tutorial', 'Tutorial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255, db_index=True)
|
||||||
|
description = models.TextField(blank=True, null=True)
|
||||||
|
keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
|
||||||
|
cluster = models.ForeignKey(
|
||||||
|
'planner.Clusters',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='tasks',
|
||||||
|
limit_choices_to={'sector': models.F('sector')}
|
||||||
|
)
|
||||||
|
keyword_objects = models.ManyToManyField(
|
||||||
|
'planner.Keywords',
|
||||||
|
blank=True,
|
||||||
|
related_name='tasks',
|
||||||
|
help_text="Individual keywords linked to this task"
|
||||||
|
)
|
||||||
|
idea = models.ForeignKey(
|
||||||
|
'planner.ContentIdeas',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='tasks'
|
||||||
|
)
|
||||||
|
content_structure = models.CharField(max_length=50, choices=CONTENT_STRUCTURE_CHOICES, default='blog_post')
|
||||||
|
content_type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='blog_post')
|
||||||
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='queued')
|
||||||
|
|
||||||
|
# Content fields
|
||||||
|
content = models.TextField(blank=True, null=True) # Generated content
|
||||||
|
word_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
# SEO fields
|
||||||
|
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
meta_description = models.TextField(blank=True, null=True)
|
||||||
|
# WordPress integration
|
||||||
|
assigned_post_id = models.IntegerField(null=True, blank=True) # WordPress post ID if published
|
||||||
|
post_url = models.URLField(blank=True, null=True) # WordPress post URL
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'writer'
|
||||||
|
db_table = 'igny8_tasks'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Task'
|
||||||
|
verbose_name_plural = 'Tasks'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['title']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['cluster']),
|
||||||
|
models.Index(fields=['content_type']),
|
||||||
|
models.Index(fields=['site', 'sector']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class Content(SiteSectorBaseModel):
|
||||||
|
"""
|
||||||
|
Content model for storing final AI-generated article content.
|
||||||
|
Separated from Task for content versioning and storage optimization.
|
||||||
|
"""
|
||||||
|
task = models.OneToOneField(
|
||||||
|
Tasks,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='content_record',
|
||||||
|
help_text="The task this content belongs to"
|
||||||
|
)
|
||||||
|
html_content = models.TextField(help_text="Final AI-generated HTML content")
|
||||||
|
word_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
|
metadata = models.JSONField(default=dict, help_text="Additional metadata (SEO, structure, etc.)")
|
||||||
|
title = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
meta_description = models.TextField(blank=True, null=True)
|
||||||
|
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
|
||||||
|
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
|
||||||
|
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('review', 'Review'),
|
||||||
|
('publish', 'Publish'),
|
||||||
|
]
|
||||||
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='draft', help_text="Content workflow status (draft, review, publish)")
|
||||||
|
generated_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# Phase 4: Source tracking
|
||||||
|
SOURCE_CHOICES = [
|
||||||
|
('igny8', 'IGNY8 Generated'),
|
||||||
|
('wordpress', 'WordPress Synced'),
|
||||||
|
('shopify', 'Shopify Synced'),
|
||||||
|
('custom', 'Custom API Synced'),
|
||||||
|
]
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=SOURCE_CHOICES,
|
||||||
|
default='igny8',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Source of the content"
|
||||||
|
)
|
||||||
|
|
||||||
|
SYNC_STATUS_CHOICES = [
|
||||||
|
('native', 'Native IGNY8 Content'),
|
||||||
|
('imported', 'Imported from External'),
|
||||||
|
('synced', 'Synced from External'),
|
||||||
|
]
|
||||||
|
sync_status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=SYNC_STATUS_CHOICES,
|
||||||
|
default='native',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Sync status of the content"
|
||||||
|
)
|
||||||
|
|
||||||
|
# External reference fields
|
||||||
|
external_id = models.CharField(max_length=255, blank=True, null=True, help_text="External platform ID")
|
||||||
|
external_url = models.URLField(blank=True, null=True, help_text="External platform URL")
|
||||||
|
sync_metadata = models.JSONField(default=dict, blank=True, help_text="Platform-specific sync metadata")
|
||||||
|
|
||||||
|
# Phase 4: Linking fields
|
||||||
|
internal_links = models.JSONField(default=list, blank=True, help_text="Internal links added by linker")
|
||||||
|
linker_version = models.IntegerField(default=0, help_text="Version of linker processing")
|
||||||
|
|
||||||
|
# Phase 4: Optimization fields
|
||||||
|
optimizer_version = models.IntegerField(default=0, help_text="Version of optimizer processing")
|
||||||
|
optimization_scores = models.JSONField(default=dict, blank=True, help_text="Optimization scores (SEO, readability, engagement)")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'writer'
|
||||||
|
db_table = 'igny8_content'
|
||||||
|
ordering = ['-generated_at']
|
||||||
|
verbose_name = 'Content'
|
||||||
|
verbose_name_plural = 'Contents'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['task']),
|
||||||
|
models.Index(fields=['generated_at']),
|
||||||
|
models.Index(fields=['source']),
|
||||||
|
models.Index(fields=['sync_status']),
|
||||||
|
models.Index(fields=['source', 'sync_status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Automatically set account, site, and sector from task"""
|
||||||
|
if self.task:
|
||||||
|
self.account = self.task.account
|
||||||
|
self.site = self.task.site
|
||||||
|
self.sector = self.task.sector
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Content for {self.task.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class Images(SiteSectorBaseModel):
|
||||||
|
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
|
||||||
|
|
||||||
|
IMAGE_TYPE_CHOICES = [
|
||||||
|
('featured', 'Featured Image'),
|
||||||
|
('desktop', 'Desktop Image'),
|
||||||
|
('mobile', 'Mobile Image'),
|
||||||
|
('in_article', 'In-Article Image'),
|
||||||
|
]
|
||||||
|
|
||||||
|
content = models.ForeignKey(
|
||||||
|
Content,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='images',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="The content this image belongs to (preferred)"
|
||||||
|
)
|
||||||
|
task = models.ForeignKey(
|
||||||
|
Tasks,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='images',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="The task this image belongs to (legacy, use content instead)"
|
||||||
|
)
|
||||||
|
image_type = models.CharField(max_length=50, choices=IMAGE_TYPE_CHOICES, default='featured')
|
||||||
|
image_url = models.CharField(max_length=500, blank=True, null=True, help_text="URL of the generated/stored image")
|
||||||
|
image_path = models.CharField(max_length=500, blank=True, null=True, help_text="Local path if stored locally")
|
||||||
|
prompt = models.TextField(blank=True, null=True, help_text="Image generation prompt used")
|
||||||
|
status = models.CharField(max_length=50, default='pending', help_text="Status: pending, generated, failed")
|
||||||
|
position = models.IntegerField(default=0, help_text="Position for in-article images ordering")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'writer'
|
||||||
|
db_table = 'igny8_images'
|
||||||
|
ordering = ['content', 'position', '-created_at']
|
||||||
|
verbose_name = 'Image'
|
||||||
|
verbose_name_plural = 'Images'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['content', 'image_type']),
|
||||||
|
models.Index(fields=['task', 'image_type']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['content', 'position']),
|
||||||
|
models.Index(fields=['task', 'position']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Automatically set account, site, and sector from content or task"""
|
||||||
|
# Prefer content over task
|
||||||
|
if self.content:
|
||||||
|
self.account = self.content.account
|
||||||
|
self.site = self.content.site
|
||||||
|
self.sector = self.content.sector
|
||||||
|
elif self.task:
|
||||||
|
self.account = self.task.account
|
||||||
|
self.site = self.task.site
|
||||||
|
self.sector = self.task.sector
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
content_title = self.content.title if self.content else None
|
||||||
|
task_title = self.task.title if self.task else None
|
||||||
|
title = content_title or task_title or 'Unknown'
|
||||||
|
return f"{title} - {self.image_type}"
|
||||||
|
|
||||||
8
backend/igny8_core/business/content/services/__init__.py
Normal file
8
backend/igny8_core/business/content/services/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Content Services
|
||||||
|
"""
|
||||||
|
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||||
|
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||||
|
|
||||||
|
__all__ = ['ContentGenerationService', 'ContentPipelineService']
|
||||||
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Content Generation Service
|
||||||
|
Handles content generation business logic
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from igny8_core.business.content.models import Tasks
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentGenerationService:
|
||||||
|
"""Service for content generation operations"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def generate_content(self, task_ids, account):
|
||||||
|
"""
|
||||||
|
Generate content for tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_ids: List of task IDs
|
||||||
|
account: Account instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result with success status and data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
# Get tasks
|
||||||
|
tasks = Tasks.objects.filter(id__in=task_ids, account=account)
|
||||||
|
|
||||||
|
# Calculate estimated credits needed
|
||||||
|
total_word_count = sum(task.word_count or 1000 for task in tasks)
|
||||||
|
|
||||||
|
# Check credits
|
||||||
|
try:
|
||||||
|
self.credit_service.check_credits(account, 'content_generation', total_word_count)
|
||||||
|
except InsufficientCreditsError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Delegate to AI task (actual generation happens in Celery)
|
||||||
|
from igny8_core.ai.tasks import run_ai_task
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(run_ai_task, 'delay'):
|
||||||
|
# Celery available - queue async
|
||||||
|
task = run_ai_task.delay(
|
||||||
|
function_name='generate_content',
|
||||||
|
payload={'ids': task_ids},
|
||||||
|
account_id=account.id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'task_id': str(task.id),
|
||||||
|
'message': 'Content generation started'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Celery not available - execute synchronously
|
||||||
|
result = run_ai_task(
|
||||||
|
function_name='generate_content',
|
||||||
|
payload={'ids': task_ids},
|
||||||
|
account_id=account.id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in generate_content: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
Content Pipeline Service
|
||||||
|
Orchestrates content processing pipeline: Writer → Linker → Optimizer
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
from igny8_core.business.linking.services.linker_service import LinkerService
|
||||||
|
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPipelineService:
|
||||||
|
"""Orchestrates content processing pipeline"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.linker_service = LinkerService()
|
||||||
|
self.optimizer_service = OptimizerService()
|
||||||
|
|
||||||
|
def process_writer_content(
|
||||||
|
self,
|
||||||
|
content_id: int,
|
||||||
|
stages: Optional[List[str]] = None
|
||||||
|
) -> Content:
|
||||||
|
"""
|
||||||
|
Writer → Linker → Optimizer pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID from Writer
|
||||||
|
stages: List of stages to run: ['linking', 'optimization'] (default: both)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed Content instance
|
||||||
|
"""
|
||||||
|
if stages is None:
|
||||||
|
stages = ['linking', 'optimization']
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id, source='igny8')
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"IGNY8 content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
# Stage 1: Linking
|
||||||
|
if 'linking' in stages:
|
||||||
|
try:
|
||||||
|
content = self.linker_service.process(content.id)
|
||||||
|
logger.info(f"Linked content {content_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in linking stage for content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
# Continue to next stage even if linking fails
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Stage 2: Optimization
|
||||||
|
if 'optimization' in stages:
|
||||||
|
try:
|
||||||
|
content = self.optimizer_service.optimize_from_writer(content.id)
|
||||||
|
logger.info(f"Optimized content {content_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in optimization stage for content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
# Don't fail the whole pipeline
|
||||||
|
pass
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def process_synced_content(
|
||||||
|
self,
|
||||||
|
content_id: int,
|
||||||
|
stages: Optional[List[str]] = None
|
||||||
|
) -> Content:
|
||||||
|
"""
|
||||||
|
Synced Content → Optimizer pipeline (skip linking if needed).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID from sync (WordPress, Shopify, etc.)
|
||||||
|
stages: List of stages to run: ['optimization'] (default: optimization only)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed Content instance
|
||||||
|
"""
|
||||||
|
if stages is None:
|
||||||
|
stages = ['optimization']
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"Content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
# Stage: Optimization (skip linking for synced content by default)
|
||||||
|
if 'optimization' in stages:
|
||||||
|
try:
|
||||||
|
if content.source == 'wordpress':
|
||||||
|
content = self.optimizer_service.optimize_from_wordpress_sync(content.id)
|
||||||
|
elif content.source in ['shopify', 'custom']:
|
||||||
|
content = self.optimizer_service.optimize_from_external_sync(content.id)
|
||||||
|
else:
|
||||||
|
content = self.optimizer_service.optimize_manual(content.id)
|
||||||
|
|
||||||
|
logger.info(f"Optimized synced content {content_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in optimization stage for content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def batch_process_writer_content(
|
||||||
|
self,
|
||||||
|
content_ids: List[int],
|
||||||
|
stages: Optional[List[str]] = None
|
||||||
|
) -> List[Content]:
|
||||||
|
"""
|
||||||
|
Batch process multiple Writer content items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_ids: List of content IDs
|
||||||
|
stages: List of stages to run
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of processed Content instances
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for content_id in content_ids:
|
||||||
|
try:
|
||||||
|
result = self.process_writer_content(content_id, stages)
|
||||||
|
results.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
# Continue with other items
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
6
backend/igny8_core/business/linking/__init__.py
Normal file
6
backend/igny8_core/business/linking/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Linking Business Logic
|
||||||
|
Phase 4: Linker & Optimizer
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
5
backend/igny8_core/business/linking/services/__init__.py
Normal file
5
backend/igny8_core/business/linking/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Linking Services
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
117
backend/igny8_core/business/linking/services/candidate_engine.py
Normal file
117
backend/igny8_core/business/linking/services/candidate_engine.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Link Candidate Engine
|
||||||
|
Finds relevant content for internal linking
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict
|
||||||
|
from django.db import models
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateEngine:
|
||||||
|
"""Finds link candidates for content"""
|
||||||
|
|
||||||
|
def find_candidates(self, content: Content, max_candidates: int = 10) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Find link candidates for a piece of content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance to find links for
|
||||||
|
max_candidates: Maximum number of candidates to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of candidate dicts with: {'content_id', 'title', 'url', 'relevance_score', 'anchor_text'}
|
||||||
|
"""
|
||||||
|
if not content or not content.html_content:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Find relevant content from same account/site/sector
|
||||||
|
relevant_content = self._find_relevant_content(content)
|
||||||
|
|
||||||
|
# Score candidates based on relevance
|
||||||
|
candidates = self._score_candidates(content, relevant_content)
|
||||||
|
|
||||||
|
# Sort by score and return top candidates
|
||||||
|
candidates.sort(key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||||
|
|
||||||
|
return candidates[:max_candidates]
|
||||||
|
|
||||||
|
def _find_relevant_content(self, content: Content) -> List[Content]:
|
||||||
|
"""Find relevant content from same account/site/sector"""
|
||||||
|
# Get content from same account, site, and sector
|
||||||
|
queryset = Content.objects.filter(
|
||||||
|
account=content.account,
|
||||||
|
site=content.site,
|
||||||
|
sector=content.sector,
|
||||||
|
status__in=['draft', 'review', 'publish']
|
||||||
|
).exclude(id=content.id)
|
||||||
|
|
||||||
|
# Filter by keywords if available
|
||||||
|
if content.primary_keyword:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
models.Q(primary_keyword__icontains=content.primary_keyword) |
|
||||||
|
models.Q(secondary_keywords__icontains=content.primary_keyword)
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(queryset[:50]) # Limit initial query
|
||||||
|
|
||||||
|
def _score_candidates(self, content: Content, candidates: List[Content]) -> List[Dict]:
|
||||||
|
"""Score candidates based on relevance"""
|
||||||
|
scored = []
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Keyword overlap (higher weight)
|
||||||
|
if content.primary_keyword and candidate.primary_keyword:
|
||||||
|
if content.primary_keyword.lower() in candidate.primary_keyword.lower():
|
||||||
|
score += 30
|
||||||
|
if candidate.primary_keyword.lower() in content.primary_keyword.lower():
|
||||||
|
score += 30
|
||||||
|
|
||||||
|
# Secondary keywords overlap
|
||||||
|
if content.secondary_keywords and candidate.secondary_keywords:
|
||||||
|
overlap = set(content.secondary_keywords) & set(candidate.secondary_keywords)
|
||||||
|
score += len(overlap) * 10
|
||||||
|
|
||||||
|
# Category overlap
|
||||||
|
if content.categories and candidate.categories:
|
||||||
|
overlap = set(content.categories) & set(candidate.categories)
|
||||||
|
score += len(overlap) * 5
|
||||||
|
|
||||||
|
# Tag overlap
|
||||||
|
if content.tags and candidate.tags:
|
||||||
|
overlap = set(content.tags) & set(candidate.tags)
|
||||||
|
score += len(overlap) * 3
|
||||||
|
|
||||||
|
# Recency bonus (newer content gets slight boost)
|
||||||
|
if candidate.generated_at:
|
||||||
|
days_old = (content.generated_at - candidate.generated_at).days
|
||||||
|
if days_old < 30:
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
if score > 0:
|
||||||
|
scored.append({
|
||||||
|
'content_id': candidate.id,
|
||||||
|
'title': candidate.title or candidate.task.title if candidate.task else 'Untitled',
|
||||||
|
'url': f"/content/{candidate.id}/", # Placeholder - actual URL depends on routing
|
||||||
|
'relevance_score': score,
|
||||||
|
'anchor_text': self._generate_anchor_text(candidate, content)
|
||||||
|
})
|
||||||
|
|
||||||
|
return scored
|
||||||
|
|
||||||
|
def _generate_anchor_text(self, candidate: Content, source_content: Content) -> str:
|
||||||
|
"""Generate anchor text for link"""
|
||||||
|
# Use primary keyword if available, otherwise use title
|
||||||
|
if candidate.primary_keyword:
|
||||||
|
return candidate.primary_keyword
|
||||||
|
elif candidate.title:
|
||||||
|
return candidate.title
|
||||||
|
elif candidate.task and candidate.task.title:
|
||||||
|
return candidate.task.title
|
||||||
|
else:
|
||||||
|
return "Learn more"
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Link Injection Engine
|
||||||
|
Injects internal links into content HTML
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import List, Dict
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InjectionEngine:
|
||||||
|
"""Injects links into content HTML"""
|
||||||
|
|
||||||
|
def inject_links(self, content: Content, candidates: List[Dict], max_links: int = 5) -> Dict:
|
||||||
|
"""
|
||||||
|
Inject links into content HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance
|
||||||
|
candidates: List of link candidates from CandidateEngine
|
||||||
|
max_links: Maximum number of links to inject
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with: {'html_content', 'links', 'links_added'}
|
||||||
|
"""
|
||||||
|
if not content.html_content or not candidates:
|
||||||
|
return {
|
||||||
|
'html_content': content.html_content,
|
||||||
|
'links': [],
|
||||||
|
'links_added': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
html = content.html_content
|
||||||
|
links_added = []
|
||||||
|
links_used = set() # Track which candidates we've used
|
||||||
|
|
||||||
|
# Sort candidates by relevance score
|
||||||
|
sorted_candidates = sorted(candidates, key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||||
|
|
||||||
|
# Inject links (limit to max_links)
|
||||||
|
for candidate in sorted_candidates[:max_links]:
|
||||||
|
if candidate['content_id'] in links_used:
|
||||||
|
continue
|
||||||
|
|
||||||
|
anchor_text = candidate.get('anchor_text', 'Learn more')
|
||||||
|
url = candidate.get('url', f"/content/{candidate['content_id']}/")
|
||||||
|
|
||||||
|
# Find first occurrence of anchor text in HTML (case-insensitive)
|
||||||
|
pattern = re.compile(re.escape(anchor_text), re.IGNORECASE)
|
||||||
|
match = pattern.search(html)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
# Replace with link
|
||||||
|
link_html = f'<a href="{url}" class="internal-link">{anchor_text}</a>'
|
||||||
|
html = html[:match.start()] + link_html + html[match.end():]
|
||||||
|
|
||||||
|
links_added.append({
|
||||||
|
'content_id': candidate['content_id'],
|
||||||
|
'anchor_text': anchor_text,
|
||||||
|
'url': url,
|
||||||
|
'position': match.start()
|
||||||
|
})
|
||||||
|
links_used.add(candidate['content_id'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'html_content': html,
|
||||||
|
'links': links_added,
|
||||||
|
'links_added': len(links_added)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
101
backend/igny8_core/business/linking/services/linker_service.py
Normal file
101
backend/igny8_core/business/linking/services/linker_service.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Linker Service
|
||||||
|
Main service for processing content for internal linking
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
from igny8_core.business.linking.services.candidate_engine import CandidateEngine
|
||||||
|
from igny8_core.business.linking.services.injection_engine import InjectionEngine
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkerService:
|
||||||
|
"""Service for processing content for internal linking"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.candidate_engine = CandidateEngine()
|
||||||
|
self.injection_engine = InjectionEngine()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def process(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Process content for linking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Content instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"Content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
account = content.account
|
||||||
|
|
||||||
|
# Check credits
|
||||||
|
try:
|
||||||
|
self.credit_service.check_credits(account, 'linking')
|
||||||
|
except InsufficientCreditsError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Find link candidates
|
||||||
|
candidates = self.candidate_engine.find_candidates(content)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
logger.info(f"No link candidates found for content {content_id}")
|
||||||
|
return content
|
||||||
|
|
||||||
|
# Inject links
|
||||||
|
result = self.injection_engine.inject_links(content, candidates)
|
||||||
|
|
||||||
|
# Update content
|
||||||
|
content.html_content = result['html_content']
|
||||||
|
content.internal_links = result['links']
|
||||||
|
content.linker_version += 1
|
||||||
|
content.save(update_fields=['html_content', 'internal_links', 'linker_version'])
|
||||||
|
|
||||||
|
# Deduct credits
|
||||||
|
self.credit_service.deduct_credits_for_operation(
|
||||||
|
account=account,
|
||||||
|
operation_type='linking',
|
||||||
|
description=f"Internal linking for content: {content.title or 'Untitled'}",
|
||||||
|
related_object_type='content',
|
||||||
|
related_object_id=content.id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Linked content {content_id}: {result['links_added']} links added")
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def batch_process(self, content_ids: List[int]) -> List[Content]:
|
||||||
|
"""
|
||||||
|
Process multiple content items for linking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_ids: List of content IDs to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of updated Content instances
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for content_id in content_ids:
|
||||||
|
try:
|
||||||
|
result = self.process(content_id)
|
||||||
|
results.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
# Continue with other items
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
6
backend/igny8_core/business/optimization/__init__.py
Normal file
6
backend/igny8_core/business/optimization/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Optimization Business Logic
|
||||||
|
Phase 4: Linker & Optimizer
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
77
backend/igny8_core/business/optimization/models.py
Normal file
77
backend/igny8_core/business/optimization/models.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Optimization Models
|
||||||
|
Phase 4: Linker & Optimizer
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from igny8_core.auth.models import AccountBaseModel
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationTask(AccountBaseModel):
|
||||||
|
"""
|
||||||
|
Optimization Task model for tracking content optimization runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('running', 'Running'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
content = models.ForeignKey(
|
||||||
|
Content,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='optimization_tasks',
|
||||||
|
help_text="The content being optimized"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scores before and after optimization
|
||||||
|
scores_before = models.JSONField(default=dict, help_text="Optimization scores before")
|
||||||
|
scores_after = models.JSONField(default=dict, help_text="Optimization scores after")
|
||||||
|
|
||||||
|
# Content before and after (for comparison)
|
||||||
|
html_before = models.TextField(blank=True, help_text="HTML content before optimization")
|
||||||
|
html_after = models.TextField(blank=True, help_text="HTML content after optimization")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='pending',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Optimization task status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Credits used
|
||||||
|
credits_used = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Credits used for optimization")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'optimization'
|
||||||
|
db_table = 'igny8_optimization_tasks'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Optimization Task'
|
||||||
|
verbose_name_plural = 'Optimization Tasks'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['content', 'status']),
|
||||||
|
models.Index(fields=['account', 'status']),
|
||||||
|
models.Index(fields=['status', 'created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Automatically set account from content"""
|
||||||
|
if self.content:
|
||||||
|
self.account = self.content.account
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Optimization for {self.content.title or 'Content'} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Optimization Services
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
184
backend/igny8_core/business/optimization/services/analyzer.py
Normal file
184
backend/igny8_core/business/optimization/services/analyzer.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Content Analyzer
|
||||||
|
Analyzes content quality and calculates optimization scores
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Dict
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentAnalyzer:
|
||||||
|
"""Analyzes content quality"""
|
||||||
|
|
||||||
|
def analyze(self, content: Content) -> Dict:
|
||||||
|
"""
|
||||||
|
Analyze content and return scores.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance to analyze
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with scores: {'seo_score', 'readability_score', 'engagement_score', 'overall_score'}
|
||||||
|
"""
|
||||||
|
if not content or not content.html_content:
|
||||||
|
return {
|
||||||
|
'seo_score': 0,
|
||||||
|
'readability_score': 0,
|
||||||
|
'engagement_score': 0,
|
||||||
|
'overall_score': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
seo_score = self._calculate_seo_score(content)
|
||||||
|
readability_score = self._calculate_readability_score(content)
|
||||||
|
engagement_score = self._calculate_engagement_score(content)
|
||||||
|
|
||||||
|
# Overall score is weighted average
|
||||||
|
overall_score = (
|
||||||
|
seo_score * 0.4 +
|
||||||
|
readability_score * 0.3 +
|
||||||
|
engagement_score * 0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'seo_score': round(seo_score, 2),
|
||||||
|
'readability_score': round(readability_score, 2),
|
||||||
|
'engagement_score': round(engagement_score, 2),
|
||||||
|
'overall_score': round(overall_score, 2),
|
||||||
|
'word_count': content.word_count or 0,
|
||||||
|
'has_meta_title': bool(content.meta_title),
|
||||||
|
'has_meta_description': bool(content.meta_description),
|
||||||
|
'has_primary_keyword': bool(content.primary_keyword),
|
||||||
|
'internal_links_count': len(content.internal_links) if content.internal_links else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_seo_score(self, content: Content) -> float:
|
||||||
|
"""Calculate SEO score (0-100)"""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Meta title (20 points)
|
||||||
|
if content.meta_title:
|
||||||
|
if len(content.meta_title) >= 30 and len(content.meta_title) <= 60:
|
||||||
|
score += 20
|
||||||
|
elif len(content.meta_title) > 0:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Meta description (20 points)
|
||||||
|
if content.meta_description:
|
||||||
|
if len(content.meta_description) >= 120 and len(content.meta_description) <= 160:
|
||||||
|
score += 20
|
||||||
|
elif len(content.meta_description) > 0:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Primary keyword (20 points)
|
||||||
|
if content.primary_keyword:
|
||||||
|
score += 20
|
||||||
|
|
||||||
|
# Word count (20 points) - optimal range 1000-2500 words
|
||||||
|
word_count = content.word_count or 0
|
||||||
|
if 1000 <= word_count <= 2500:
|
||||||
|
score += 20
|
||||||
|
elif 500 <= word_count < 1000 or 2500 < word_count <= 3000:
|
||||||
|
score += 15
|
||||||
|
elif word_count > 0:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Internal links (20 points)
|
||||||
|
internal_links = content.internal_links or []
|
||||||
|
if len(internal_links) >= 3:
|
||||||
|
score += 20
|
||||||
|
elif len(internal_links) >= 1:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
return min(score, 100)
|
||||||
|
|
||||||
|
def _calculate_readability_score(self, content: Content) -> float:
|
||||||
|
"""Calculate readability score (0-100)"""
|
||||||
|
if not content.html_content:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Simple readability metrics
|
||||||
|
html = content.html_content
|
||||||
|
|
||||||
|
# Remove HTML tags for text analysis
|
||||||
|
text = re.sub(r'<[^>]+>', '', html)
|
||||||
|
sentences = re.split(r'[.!?]+', text)
|
||||||
|
words = text.split()
|
||||||
|
|
||||||
|
if not words:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Average sentence length (optimal: 15-20 words)
|
||||||
|
avg_sentence_length = len(words) / max(len(sentences), 1)
|
||||||
|
if 15 <= avg_sentence_length <= 20:
|
||||||
|
sentence_score = 40
|
||||||
|
elif 10 <= avg_sentence_length < 15 or 20 < avg_sentence_length <= 25:
|
||||||
|
sentence_score = 30
|
||||||
|
else:
|
||||||
|
sentence_score = 20
|
||||||
|
|
||||||
|
# Average word length (optimal: 4-5 characters)
|
||||||
|
avg_word_length = sum(len(word) for word in words) / len(words)
|
||||||
|
if 4 <= avg_word_length <= 5:
|
||||||
|
word_score = 30
|
||||||
|
elif 3 <= avg_word_length < 4 or 5 < avg_word_length <= 6:
|
||||||
|
word_score = 20
|
||||||
|
else:
|
||||||
|
word_score = 10
|
||||||
|
|
||||||
|
# Paragraph structure (30 points)
|
||||||
|
paragraphs = html.count('<p>') + html.count('<div>')
|
||||||
|
if paragraphs >= 3:
|
||||||
|
paragraph_score = 30
|
||||||
|
elif paragraphs >= 1:
|
||||||
|
paragraph_score = 20
|
||||||
|
else:
|
||||||
|
paragraph_score = 10
|
||||||
|
|
||||||
|
return min(sentence_score + word_score + paragraph_score, 100)
|
||||||
|
|
||||||
|
def _calculate_engagement_score(self, content: Content) -> float:
|
||||||
|
"""Calculate engagement score (0-100)"""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Headings (30 points)
|
||||||
|
if content.html_content:
|
||||||
|
h1_count = content.html_content.count('<h1>')
|
||||||
|
h2_count = content.html_content.count('<h2>')
|
||||||
|
h3_count = content.html_content.count('<h3>')
|
||||||
|
|
||||||
|
if h1_count >= 1 and h2_count >= 2:
|
||||||
|
score += 30
|
||||||
|
elif h1_count >= 1 or h2_count >= 1:
|
||||||
|
score += 20
|
||||||
|
elif h3_count >= 1:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Images (30 points)
|
||||||
|
if hasattr(content, 'images'):
|
||||||
|
image_count = content.images.count()
|
||||||
|
if image_count >= 3:
|
||||||
|
score += 30
|
||||||
|
elif image_count >= 1:
|
||||||
|
score += 20
|
||||||
|
|
||||||
|
# Lists (20 points)
|
||||||
|
if content.html_content:
|
||||||
|
list_count = content.html_content.count('<ul>') + content.html_content.count('<ol>')
|
||||||
|
if list_count >= 2:
|
||||||
|
score += 20
|
||||||
|
elif list_count >= 1:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Internal links (20 points)
|
||||||
|
internal_links = content.internal_links or []
|
||||||
|
if len(internal_links) >= 3:
|
||||||
|
score += 20
|
||||||
|
elif len(internal_links) >= 1:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
return min(score, 100)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Optimizer Service
|
||||||
|
Main service for content optimization with multiple entry points
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
from igny8_core.business.optimization.models import OptimizationTask
|
||||||
|
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizerService:
|
||||||
|
"""Service for content optimization with multiple entry points"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.analyzer = ContentAnalyzer()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def optimize_from_writer(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Entry Point 1: Writer → Optimizer
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID from Writer module
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id, source='igny8')
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"IGNY8 content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.optimize(content)
|
||||||
|
|
||||||
|
def optimize_from_wordpress_sync(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Entry Point 2: WordPress Sync → Optimizer
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID synced from WordPress
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id, source='wordpress')
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"WordPress content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.optimize(content)
|
||||||
|
|
||||||
|
def optimize_from_external_sync(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Entry Point 3: External Sync → Optimizer (Shopify, custom APIs)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID synced from external source
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id, source__in=['shopify', 'custom'])
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"External content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.optimize(content)
|
||||||
|
|
||||||
|
def optimize_manual(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Entry Point 4: Manual Selection → Optimizer
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID selected manually
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"Content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.optimize(content)
|
||||||
|
|
||||||
|
def optimize(self, content: Content) -> Content:
|
||||||
|
"""
|
||||||
|
Unified optimization logic (used by all entry points).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance to optimize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
account = content.account
|
||||||
|
word_count = content.word_count or 0
|
||||||
|
|
||||||
|
# Check credits
|
||||||
|
try:
|
||||||
|
self.credit_service.check_credits(account, 'optimization', word_count)
|
||||||
|
except InsufficientCreditsError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Analyze content before optimization
|
||||||
|
scores_before = self.analyzer.analyze(content)
|
||||||
|
html_before = content.html_content
|
||||||
|
|
||||||
|
# Create optimization task
|
||||||
|
task = OptimizationTask.objects.create(
|
||||||
|
content=content,
|
||||||
|
scores_before=scores_before,
|
||||||
|
status='running',
|
||||||
|
html_before=html_before,
|
||||||
|
account=account
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delegate to AI function (actual optimization happens in Celery/AI task)
|
||||||
|
# For now, we'll do a simple optimization pass
|
||||||
|
# In production, this would call the AI function
|
||||||
|
optimized_content = self._optimize_content(content, scores_before)
|
||||||
|
|
||||||
|
# Analyze optimized content
|
||||||
|
scores_after = self.analyzer.analyze(optimized_content)
|
||||||
|
|
||||||
|
# Calculate credits used
|
||||||
|
credits_used = self.credit_service.get_credit_cost('optimization', word_count)
|
||||||
|
|
||||||
|
# Update optimization task
|
||||||
|
task.scores_after = scores_after
|
||||||
|
task.html_after = optimized_content.html_content
|
||||||
|
task.status = 'completed'
|
||||||
|
task.credits_used = credits_used
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# Update content
|
||||||
|
content.html_content = optimized_content.html_content
|
||||||
|
content.optimizer_version += 1
|
||||||
|
content.optimization_scores = scores_after
|
||||||
|
content.save(update_fields=['html_content', 'optimizer_version', 'optimization_scores'])
|
||||||
|
|
||||||
|
# Deduct credits
|
||||||
|
self.credit_service.deduct_credits_for_operation(
|
||||||
|
account=account,
|
||||||
|
operation_type='optimization',
|
||||||
|
amount=word_count,
|
||||||
|
description=f"Content optimization: {content.title or 'Untitled'}",
|
||||||
|
related_object_type='content',
|
||||||
|
related_object_id=content.id,
|
||||||
|
metadata={
|
||||||
|
'scores_before': scores_before,
|
||||||
|
'scores_after': scores_after,
|
||||||
|
'improvement': scores_after.get('overall_score', 0) - scores_before.get('overall_score', 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Optimized content {content.id}: {scores_before.get('overall_score', 0)} → {scores_after.get('overall_score', 0)}")
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error optimizing content {content.id}: {str(e)}", exc_info=True)
|
||||||
|
task.status = 'failed'
|
||||||
|
task.metadata = {'error': str(e)}
|
||||||
|
task.save()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _optimize_content(self, content: Content, scores_before: dict) -> Content:
|
||||||
|
"""
|
||||||
|
Internal method to optimize content.
|
||||||
|
This is a placeholder - in production, this would call the AI function.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content to optimize
|
||||||
|
scores_before: Scores before optimization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
# For now, return content as-is
|
||||||
|
# In production, this would:
|
||||||
|
# 1. Call OptimizeContentFunction AI function
|
||||||
|
# 2. Get optimized HTML
|
||||||
|
# 3. Update content
|
||||||
|
|
||||||
|
# Placeholder: We'll implement AI function call later
|
||||||
|
# For now, just return the content
|
||||||
|
return content
|
||||||
|
|
||||||
|
def analyze_only(self, content_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Analyze content without optimizing (for preview).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID to analyze
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Analysis scores dict
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"Content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.analyzer.analyze(content)
|
||||||
|
|
||||||
|
|
||||||
4
backend/igny8_core/business/planning/__init__.py
Normal file
4
backend/igny8_core/business/planning/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Planning business logic - Keywords, Clusters, ContentIdeas models and services
|
||||||
|
"""
|
||||||
|
|
||||||
198
backend/igny8_core/business/planning/models.py
Normal file
198
backend/igny8_core/business/planning/models.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
from django.db import models
|
||||||
|
from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
|
||||||
|
|
||||||
|
|
||||||
|
class Clusters(SiteSectorBaseModel):
|
||||||
|
"""Clusters model for keyword grouping"""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
|
description = models.TextField(blank=True, null=True)
|
||||||
|
keywords_count = models.IntegerField(default=0)
|
||||||
|
volume = models.IntegerField(default=0)
|
||||||
|
mapped_pages = models.IntegerField(default=0)
|
||||||
|
status = models.CharField(max_length=50, default='active')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'planner'
|
||||||
|
db_table = 'igny8_clusters'
|
||||||
|
ordering = ['name']
|
||||||
|
verbose_name = 'Cluster'
|
||||||
|
verbose_name_plural = 'Clusters'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['name']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['site', 'sector']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Keywords(SiteSectorBaseModel):
|
||||||
|
"""
|
||||||
|
Keywords model for SEO keyword management.
|
||||||
|
Site-specific instances that reference global SeedKeywords.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('active', 'Active'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('archived', 'Archived'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Required: Link to global SeedKeyword
|
||||||
|
seed_keyword = models.ForeignKey(
|
||||||
|
SeedKeyword,
|
||||||
|
on_delete=models.PROTECT, # Prevent deletion if Keywords reference it
|
||||||
|
related_name='site_keywords',
|
||||||
|
help_text="Reference to the global seed keyword"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Site-specific overrides (optional)
|
||||||
|
volume_override = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Site-specific volume override (uses seed_keyword.volume if not set)"
|
||||||
|
)
|
||||||
|
difficulty_override = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Site-specific difficulty override (uses seed_keyword.difficulty if not set)"
|
||||||
|
)
|
||||||
|
|
||||||
|
cluster = models.ForeignKey(
|
||||||
|
'Clusters',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='keywords',
|
||||||
|
limit_choices_to={'sector': models.F('sector')} # Cluster must be in same sector
|
||||||
|
)
|
||||||
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='pending')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'planner'
|
||||||
|
db_table = 'igny8_keywords'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Keyword'
|
||||||
|
verbose_name_plural = 'Keywords'
|
||||||
|
unique_together = [['seed_keyword', 'site', 'sector']] # One keyword per site/sector
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['seed_keyword']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['cluster']),
|
||||||
|
models.Index(fields=['site', 'sector']),
|
||||||
|
models.Index(fields=['seed_keyword', 'site', 'sector']),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def keyword(self):
|
||||||
|
"""Get keyword text from seed_keyword"""
|
||||||
|
return self.seed_keyword.keyword if self.seed_keyword else ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume(self):
|
||||||
|
"""Get volume from override or seed_keyword"""
|
||||||
|
return self.volume_override if self.volume_override is not None else (self.seed_keyword.volume if self.seed_keyword else 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def difficulty(self):
|
||||||
|
"""Get difficulty from override or seed_keyword"""
|
||||||
|
return self.difficulty_override if self.difficulty_override is not None else (self.seed_keyword.difficulty if self.seed_keyword else 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def intent(self):
|
||||||
|
"""Get intent from seed_keyword"""
|
||||||
|
return self.seed_keyword.intent if self.seed_keyword else 'informational'
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Validate that seed_keyword's industry/sector matches site's industry/sector"""
|
||||||
|
if self.seed_keyword and self.site and self.sector:
|
||||||
|
# Validate industry match
|
||||||
|
if self.site.industry != self.seed_keyword.industry:
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
raise ValidationError(
|
||||||
|
f"SeedKeyword industry ({self.seed_keyword.industry.name}) must match site industry ({self.site.industry.name})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate sector match (site sector's industry_sector must match seed_keyword's sector)
|
||||||
|
if self.sector.industry_sector != self.seed_keyword.sector:
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
raise ValidationError(
|
||||||
|
f"SeedKeyword sector ({self.seed_keyword.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.keyword
|
||||||
|
|
||||||
|
|
||||||
|
class ContentIdeas(SiteSectorBaseModel):
|
||||||
|
"""Content Ideas model for planning content based on keyword clusters"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('scheduled', 'Scheduled'),
|
||||||
|
('published', 'Published'),
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTENT_STRUCTURE_CHOICES = [
|
||||||
|
('cluster_hub', 'Cluster Hub'),
|
||||||
|
('landing_page', 'Landing Page'),
|
||||||
|
('pillar_page', 'Pillar Page'),
|
||||||
|
('supporting_page', 'Supporting Page'),
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTENT_TYPE_CHOICES = [
|
||||||
|
('blog_post', 'Blog Post'),
|
||||||
|
('article', 'Article'),
|
||||||
|
('guide', 'Guide'),
|
||||||
|
('tutorial', 'Tutorial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
idea_title = models.CharField(max_length=255, db_index=True)
|
||||||
|
description = models.TextField(blank=True, null=True)
|
||||||
|
content_structure = models.CharField(max_length=50, choices=CONTENT_STRUCTURE_CHOICES, default='blog_post')
|
||||||
|
content_type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='blog_post')
|
||||||
|
target_keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
|
||||||
|
keyword_objects = models.ManyToManyField(
|
||||||
|
'Keywords',
|
||||||
|
blank=True,
|
||||||
|
related_name='content_ideas',
|
||||||
|
help_text="Individual keywords linked to this content idea"
|
||||||
|
)
|
||||||
|
keyword_cluster = models.ForeignKey(
|
||||||
|
Clusters,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='ideas',
|
||||||
|
limit_choices_to={'sector': models.F('sector')}
|
||||||
|
)
|
||||||
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
|
||||||
|
estimated_word_count = models.IntegerField(default=1000)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'planner'
|
||||||
|
db_table = 'igny8_content_ideas'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Content Idea'
|
||||||
|
verbose_name_plural = 'Content Ideas'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['idea_title']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['keyword_cluster']),
|
||||||
|
models.Index(fields=['content_structure']),
|
||||||
|
models.Index(fields=['site', 'sector']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.idea_title
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Planning services
|
||||||
|
"""
|
||||||
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Clustering Service
|
||||||
|
Handles keyword clustering business logic
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from igny8_core.business.planning.models import Keywords
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ClusteringService:
|
||||||
|
"""Service for keyword clustering operations"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def cluster_keywords(self, keyword_ids, account, sector_id=None):
|
||||||
|
"""
|
||||||
|
Cluster keywords using AI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keyword_ids: List of keyword IDs
|
||||||
|
account: Account instance
|
||||||
|
sector_id: Optional sector ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result with success status and data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
# Validate input
|
||||||
|
if not keyword_ids:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'No keyword IDs provided'
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keyword_ids) > 20:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'Maximum 20 keywords allowed for clustering'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check credits (fixed cost per clustering operation)
|
||||||
|
try:
|
||||||
|
self.credit_service.check_credits(account, 'clustering')
|
||||||
|
except InsufficientCreditsError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Delegate to AI task
|
||||||
|
from igny8_core.ai.tasks import run_ai_task
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'ids': keyword_ids,
|
||||||
|
'sector_id': sector_id
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(run_ai_task, 'delay'):
|
||||||
|
# Celery available - queue async
|
||||||
|
task = run_ai_task.delay(
|
||||||
|
function_name='auto_cluster',
|
||||||
|
payload=payload,
|
||||||
|
account_id=account.id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'task_id': str(task.id),
|
||||||
|
'message': 'Clustering started'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Celery not available - execute synchronously
|
||||||
|
result = run_ai_task(
|
||||||
|
function_name='auto_cluster',
|
||||||
|
payload=payload,
|
||||||
|
account_id=account.id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in cluster_keywords: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Ideas Service
|
||||||
|
Handles content ideas generation business logic
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from igny8_core.business.planning.models import Clusters
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IdeasService:
|
||||||
|
"""Service for content ideas generation operations"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def generate_ideas(self, cluster_ids, account):
|
||||||
|
"""
|
||||||
|
Generate content ideas from clusters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cluster_ids: List of cluster IDs
|
||||||
|
account: Account instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result with success status and data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
# Validate input
|
||||||
|
if not cluster_ids:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'No cluster IDs provided'
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cluster_ids) > 10:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'Maximum 10 clusters allowed for idea generation'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get clusters to count ideas
|
||||||
|
clusters = Clusters.objects.filter(id__in=cluster_ids, account=account)
|
||||||
|
idea_count = len(cluster_ids)
|
||||||
|
|
||||||
|
# Check credits
|
||||||
|
try:
|
||||||
|
self.credit_service.check_credits(account, 'idea_generation', idea_count)
|
||||||
|
except InsufficientCreditsError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Delegate to AI task
|
||||||
|
from igny8_core.ai.tasks import run_ai_task
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'ids': cluster_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(run_ai_task, 'delay'):
|
||||||
|
# Celery available - queue async
|
||||||
|
task = run_ai_task.delay(
|
||||||
|
function_name='auto_generate_ideas',
|
||||||
|
payload=payload,
|
||||||
|
account_id=account.id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'task_id': str(task.id),
|
||||||
|
'message': 'Idea generation started'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Celery not available - execute synchronously
|
||||||
|
result = run_ai_task(
|
||||||
|
function_name='auto_generate_ideas',
|
||||||
|
payload=payload,
|
||||||
|
account_id=account.id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in generate_ideas: {str(e)}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
6
backend/igny8_core/business/site_building/__init__.py
Normal file
6
backend/igny8_core/business/site_building/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Site Building Business Logic
|
||||||
|
Phase 3: Site Builder
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
168
backend/igny8_core/business/site_building/models.py
Normal file
168
backend/igny8_core/business/site_building/models.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Site Builder Models
|
||||||
|
Phase 3: Site Builder
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from igny8_core.auth.models import SiteSectorBaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SiteBlueprint(SiteSectorBaseModel):
|
||||||
|
"""
|
||||||
|
Site Blueprint model for storing AI-generated site structures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('generating', 'Generating'),
|
||||||
|
('ready', 'Ready'),
|
||||||
|
('deployed', 'Deployed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
HOSTING_TYPE_CHOICES = [
|
||||||
|
('igny8_sites', 'IGNY8 Sites'),
|
||||||
|
('wordpress', 'WordPress'),
|
||||||
|
('shopify', 'Shopify'),
|
||||||
|
('multi', 'Multiple Destinations'),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255, help_text="Site name")
|
||||||
|
description = models.TextField(blank=True, null=True, help_text="Site description")
|
||||||
|
|
||||||
|
# Site configuration (from wizard)
|
||||||
|
config_json = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
help_text="Wizard configuration: business_type, style, objectives, etc."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generated structure (from AI)
|
||||||
|
structure_json = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
help_text="AI-generated structure: pages, layout, theme, etc."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status tracking
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='draft',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Blueprint status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hosting configuration
|
||||||
|
hosting_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=HOSTING_TYPE_CHOICES,
|
||||||
|
default='igny8_sites',
|
||||||
|
help_text="Target hosting platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Version tracking
|
||||||
|
version = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Blueprint version")
|
||||||
|
deployed_version = models.IntegerField(null=True, blank=True, help_text="Currently deployed version")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'site_building'
|
||||||
|
db_table = 'igny8_site_blueprints'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Site Blueprint'
|
||||||
|
verbose_name_plural = 'Site Blueprints'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['hosting_type']),
|
||||||
|
models.Index(fields=['site', 'sector']),
|
||||||
|
models.Index(fields=['account', 'status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class PageBlueprint(SiteSectorBaseModel):
|
||||||
|
"""
|
||||||
|
Page Blueprint model for storing individual page definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGE_TYPE_CHOICES = [
|
||||||
|
('home', 'Home'),
|
||||||
|
('about', 'About'),
|
||||||
|
('services', 'Services'),
|
||||||
|
('products', 'Products'),
|
||||||
|
('blog', 'Blog'),
|
||||||
|
('contact', 'Contact'),
|
||||||
|
('custom', 'Custom'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('generating', 'Generating'),
|
||||||
|
('ready', 'Ready'),
|
||||||
|
]
|
||||||
|
|
||||||
|
site_blueprint = models.ForeignKey(
|
||||||
|
SiteBlueprint,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='pages',
|
||||||
|
help_text="The site blueprint this page belongs to"
|
||||||
|
)
|
||||||
|
slug = models.SlugField(max_length=255, help_text="Page URL slug")
|
||||||
|
title = models.CharField(max_length=255, help_text="Page title")
|
||||||
|
|
||||||
|
# Page type
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=PAGE_TYPE_CHOICES,
|
||||||
|
default='custom',
|
||||||
|
help_text="Page type"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Page content (blocks)
|
||||||
|
blocks_json = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="Page content blocks: [{'type': 'hero', 'data': {...}}, ...]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='draft',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Page status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order
|
||||||
|
order = models.IntegerField(default=0, help_text="Page order in navigation")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'site_building'
|
||||||
|
db_table = 'igny8_page_blueprints'
|
||||||
|
ordering = ['order', 'created_at']
|
||||||
|
verbose_name = 'Page Blueprint'
|
||||||
|
verbose_name_plural = 'Page Blueprints'
|
||||||
|
unique_together = [['site_blueprint', 'slug']]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['site_blueprint', 'status']),
|
||||||
|
models.Index(fields=['type']),
|
||||||
|
models.Index(fields=['site_blueprint', 'order']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Automatically set account, site, and sector from site_blueprint"""
|
||||||
|
if self.site_blueprint:
|
||||||
|
self.account = self.site_blueprint.account
|
||||||
|
self.site = self.site_blueprint.site
|
||||||
|
self.sector = self.site_blueprint.sector
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} ({self.site_blueprint.name})"
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Site Building Services
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
Site File Management Service
|
||||||
|
Manages file uploads, deletions, and access control for site assets
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from igny8_core.auth.models import User, Site
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Base path for site files
|
||||||
|
SITES_DATA_BASE = Path('/data/app/sites-data/clients')
|
||||||
|
|
||||||
|
|
||||||
|
class SiteBuilderFileService:
|
||||||
|
"""Service for managing site files and assets"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_path = SITES_DATA_BASE
|
||||||
|
self.max_file_size = 10 * 1024 * 1024 # 10MB per file
|
||||||
|
self.max_storage_per_site = 100 * 1024 * 1024 # 100MB per site
|
||||||
|
|
||||||
|
def get_user_accessible_sites(self, user: User) -> List[Site]:
|
||||||
|
"""
|
||||||
|
Get sites user can access for file management.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Site instances user can access
|
||||||
|
"""
|
||||||
|
# Owner/Admin: Full access to all account sites
|
||||||
|
if user.is_owner_or_admin():
|
||||||
|
return Site.objects.filter(account=user.account, is_active=True)
|
||||||
|
|
||||||
|
# Editor/Viewer: Access to granted sites (via SiteUserAccess)
|
||||||
|
# TODO: Implement SiteUserAccess check when available
|
||||||
|
return Site.objects.filter(account=user.account, is_active=True)
|
||||||
|
|
||||||
|
def check_file_access(self, user: User, site_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user can access site's files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
site_id: Site ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has access, False otherwise
|
||||||
|
"""
|
||||||
|
accessible_sites = self.get_user_accessible_sites(user)
|
||||||
|
return any(site.id == site_id for site in accessible_sites)
|
||||||
|
|
||||||
|
def get_site_files_path(self, site_id: int, version: int = 1) -> Path:
|
||||||
|
"""
|
||||||
|
Get site's files directory path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID
|
||||||
|
version: Site version (default: 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path object for site files directory
|
||||||
|
"""
|
||||||
|
return self.base_path / str(site_id) / f"v{version}" / "assets"
|
||||||
|
|
||||||
|
def check_storage_quota(self, site_id: int, file_size: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if site has enough storage quota.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID
|
||||||
|
file_size: Size of file to upload in bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if quota available, False otherwise
|
||||||
|
"""
|
||||||
|
site_path = self.get_site_files_path(site_id)
|
||||||
|
|
||||||
|
# Calculate current storage usage
|
||||||
|
current_usage = self._calculate_storage_usage(site_path)
|
||||||
|
|
||||||
|
# Check if adding file would exceed quota
|
||||||
|
return (current_usage + file_size) <= self.max_storage_per_site
|
||||||
|
|
||||||
|
def _calculate_storage_usage(self, site_path: Path) -> int:
|
||||||
|
"""Calculate current storage usage for a site"""
|
||||||
|
if not site_path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for file_path in site_path.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
total_size += file_path.stat().st_size
|
||||||
|
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
def upload_file(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
site_id: int,
|
||||||
|
file,
|
||||||
|
folder: str = 'images',
|
||||||
|
version: int = 1
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Upload file to site's assets folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
site_id: Site ID
|
||||||
|
file: Django UploadedFile instance
|
||||||
|
folder: Subfolder name (images, documents, media)
|
||||||
|
version: Site version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with file_path, file_url, file_size
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionDenied: If user doesn't have access
|
||||||
|
ValidationError: If file size exceeds limit or quota exceeded
|
||||||
|
"""
|
||||||
|
# Check access
|
||||||
|
if not self.check_file_access(user, site_id):
|
||||||
|
raise PermissionDenied("No access to this site")
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
if file.size > self.max_file_size:
|
||||||
|
raise ValidationError(f"File size exceeds maximum of {self.max_file_size / 1024 / 1024}MB")
|
||||||
|
|
||||||
|
# Check storage quota
|
||||||
|
if not self.check_storage_quota(site_id, file.size):
|
||||||
|
raise ValidationError("Storage quota exceeded")
|
||||||
|
|
||||||
|
# Get target directory
|
||||||
|
site_path = self.get_site_files_path(site_id, version)
|
||||||
|
target_dir = site_path / folder
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
file_path = target_dir / file.name
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
for chunk in file.chunks():
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
# Generate file URL (relative to site assets)
|
||||||
|
file_url = f"/sites/{site_id}/v{version}/assets/{folder}/{file.name}"
|
||||||
|
|
||||||
|
logger.info(f"Uploaded file {file.name} to site {site_id}/{folder}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'file_path': str(file_path),
|
||||||
|
'file_url': file_url,
|
||||||
|
'file_size': file.size,
|
||||||
|
'folder': folder
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_file(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
site_id: int,
|
||||||
|
file_path: str,
|
||||||
|
version: int = 1
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete file from site's assets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
site_id: Site ID
|
||||||
|
file_path: Relative file path (e.g., 'images/photo.jpg')
|
||||||
|
version: Site version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False otherwise
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionDenied: If user doesn't have access
|
||||||
|
"""
|
||||||
|
# Check access
|
||||||
|
if not self.check_file_access(user, site_id):
|
||||||
|
raise PermissionDenied("No access to this site")
|
||||||
|
|
||||||
|
# Get full file path
|
||||||
|
site_path = self.get_site_files_path(site_id, version)
|
||||||
|
full_path = site_path / file_path
|
||||||
|
|
||||||
|
# Check if file exists and is within site directory
|
||||||
|
if not full_path.exists() or not str(full_path).startswith(str(site_path)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete file
|
||||||
|
full_path.unlink()
|
||||||
|
|
||||||
|
logger.info(f"Deleted file {file_path} from site {site_id}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_files(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
site_id: int,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
version: int = 1
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
List files in site's assets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
site_id: Site ID
|
||||||
|
folder: Optional folder to list (None = all folders)
|
||||||
|
version: Site version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of file dicts with: name, path, size, folder, url
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionDenied: If user doesn't have access
|
||||||
|
"""
|
||||||
|
# Check access
|
||||||
|
if not self.check_file_access(user, site_id):
|
||||||
|
raise PermissionDenied("No access to this site")
|
||||||
|
|
||||||
|
site_path = self.get_site_files_path(site_id, version)
|
||||||
|
|
||||||
|
if not site_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
files = []
|
||||||
|
|
||||||
|
# List files in specified folder or all folders
|
||||||
|
if folder:
|
||||||
|
folder_path = site_path / folder
|
||||||
|
if folder_path.exists():
|
||||||
|
files.extend(self._list_directory(folder_path, folder, site_id, version))
|
||||||
|
else:
|
||||||
|
# List all folders
|
||||||
|
for folder_dir in site_path.iterdir():
|
||||||
|
if folder_dir.is_dir():
|
||||||
|
files.extend(self._list_directory(folder_dir, folder_dir.name, site_id, version))
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def _list_directory(self, directory: Path, folder_name: str, site_id: int, version: int) -> List[Dict]:
|
||||||
|
"""List files in a directory"""
|
||||||
|
files = []
|
||||||
|
for file_path in directory.iterdir():
|
||||||
|
if file_path.is_file():
|
||||||
|
file_url = f"/sites/{site_id}/v{version}/assets/{folder_name}/{file_path.name}"
|
||||||
|
files.append({
|
||||||
|
'name': file_path.name,
|
||||||
|
'path': f"{folder_name}/{file_path.name}",
|
||||||
|
'size': file_path.stat().st_size,
|
||||||
|
'folder': folder_name,
|
||||||
|
'url': file_url
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ 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')
|
||||||
@@ -18,6 +19,17 @@ 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
|
||||||
|
},
|
||||||
|
'execute-scheduled-automation-rules': {
|
||||||
|
'task': 'igny8_core.business.automation.tasks.execute_scheduled_automation_rules',
|
||||||
|
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@app.task(bind=True, ignore_result=True)
|
@app.task(bind=True, ignore_result=True)
|
||||||
def debug_task(self):
|
def debug_task(self):
|
||||||
|
|||||||
5
backend/igny8_core/modules/automation/__init__.py
Normal file
5
backend/igny8_core/modules/automation/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Automation Module - API Layer
|
||||||
|
Business logic is in business/automation/
|
||||||
|
"""
|
||||||
|
|
||||||
13
backend/igny8_core/modules/automation/apps.py
Normal file
13
backend/igny8_core/modules/automation/apps.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
Automation App Configuration
|
||||||
|
"""
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationConfig(AppConfig):
|
||||||
|
"""Configuration for automation module"""
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'igny8_core.modules.automation'
|
||||||
|
label = 'automation'
|
||||||
|
verbose_name = 'Automation'
|
||||||
|
|
||||||
100
backend/igny8_core/modules/automation/migrations/0001_initial.py
Normal file
100
backend/igny8_core/modules/automation/migrations/0001_initial.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Generated manually for Phase 2: Automation System
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AutomationRule',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(help_text='Rule name', max_length=255)),
|
||||||
|
('description', models.TextField(blank=True, help_text='Rule description', null=True)),
|
||||||
|
('trigger', models.CharField(choices=[('schedule', 'Schedule'), ('event', 'Event'), ('manual', 'Manual')], default='manual', max_length=50)),
|
||||||
|
('schedule', models.CharField(blank=True, help_text="Cron-like schedule string (e.g., '0 0 * * *' for daily at midnight)", max_length=100, null=True)),
|
||||||
|
('conditions', models.JSONField(default=list, help_text='List of conditions that must be met for rule to execute')),
|
||||||
|
('actions', models.JSONField(default=list, help_text='List of actions to execute when rule triggers')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Whether rule is active')),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('paused', 'Paused')], default='active', max_length=50)),
|
||||||
|
('last_executed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('execution_count', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('metadata', models.JSONField(default=dict, help_text='Additional metadata')),
|
||||||
|
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
|
||||||
|
('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='igny8_core_auth.site')),
|
||||||
|
('sector', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='igny8_core_auth.sector')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'igny8_automation_rules',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'verbose_name': 'Automation Rule',
|
||||||
|
'verbose_name_plural': 'Automation Rules',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScheduledTask',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('scheduled_at', models.DateTimeField(help_text='When the task is scheduled to run')),
|
||||||
|
('executed_at', models.DateTimeField(blank=True, help_text='When the task was actually executed', null=True)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=50)),
|
||||||
|
('result', models.JSONField(default=dict, help_text='Execution result data')),
|
||||||
|
('error_message', models.TextField(blank=True, help_text='Error message if execution failed', null=True)),
|
||||||
|
('metadata', models.JSONField(default=dict, help_text='Additional metadata')),
|
||||||
|
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
|
||||||
|
('automation_rule', models.ForeignKey(help_text='The automation rule this task belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_tasks', to='automation.automationrule')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'igny8_scheduled_tasks',
|
||||||
|
'ordering': ['-scheduled_at'],
|
||||||
|
'verbose_name': 'Scheduled Task',
|
||||||
|
'verbose_name_plural': 'Scheduled Tasks',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='automationrule',
|
||||||
|
index=models.Index(fields=['trigger', 'is_active'], name='igny8_autom_trigger_123abc_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='automationrule',
|
||||||
|
index=models.Index(fields=['status'], name='igny8_autom_status_456def_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='automationrule',
|
||||||
|
index=models.Index(fields=['site', 'sector'], name='igny8_autom_site_id_789ghi_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='automationrule',
|
||||||
|
index=models.Index(fields=['trigger', 'is_active', 'status'], name='igny8_autom_trigger_0abjkl_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduledtask',
|
||||||
|
index=models.Index(fields=['automation_rule', 'status'], name='igny8_sched_automation_123abc_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduledtask',
|
||||||
|
index=models.Index(fields=['scheduled_at', 'status'], name='igny8_sched_scheduled_456def_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduledtask',
|
||||||
|
index=models.Index(fields=['account', 'status'], name='igny8_sched_account_789ghi_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduledtask',
|
||||||
|
index=models.Index(fields=['status', 'scheduled_at'], name='igny8_sched_status_0abjkl_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
5
backend/igny8_core/modules/automation/models.py
Normal file
5
backend/igny8_core/modules/automation/models.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Backward compatibility alias - models moved to business/automation/
|
||||||
|
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
||||||
|
|
||||||
|
__all__ = ['AutomationRule', 'ScheduledTask']
|
||||||
|
|
||||||
36
backend/igny8_core/modules/automation/serializers.py
Normal file
36
backend/igny8_core/modules/automation/serializers.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Serializers for Automation Models
|
||||||
|
"""
|
||||||
|
from rest_framework import serializers
|
||||||
|
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationRuleSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for AutomationRule model"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AutomationRule
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'description', 'trigger', 'schedule',
|
||||||
|
'conditions', 'actions', 'is_active', 'status',
|
||||||
|
'last_executed_at', 'execution_count',
|
||||||
|
'metadata', 'created_at', 'updated_at',
|
||||||
|
'account', 'site', 'sector'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at', 'last_executed_at', 'execution_count']
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTaskSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for ScheduledTask model"""
|
||||||
|
automation_rule_name = serializers.CharField(source='automation_rule.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ScheduledTask
|
||||||
|
fields = [
|
||||||
|
'id', 'automation_rule', 'automation_rule_name',
|
||||||
|
'scheduled_at', 'executed_at', 'status',
|
||||||
|
'result', 'error_message', 'metadata',
|
||||||
|
'created_at', 'updated_at', 'account'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at', 'executed_at']
|
||||||
|
|
||||||
15
backend/igny8_core/modules/automation/urls.py
Normal file
15
backend/igny8_core/modules/automation/urls.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
URL patterns for automation module.
|
||||||
|
"""
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import AutomationRuleViewSet, ScheduledTaskViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'rules', AutomationRuleViewSet, basename='automation-rule')
|
||||||
|
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduled-task')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
92
backend/igny8_core/modules/automation/views.py
Normal file
92
backend/igny8_core/modules/automation/views.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
ViewSets for Automation Models
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
|
"""
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework import filters
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
from igny8_core.api.base import SiteSectorModelViewSet, AccountModelViewSet
|
||||||
|
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||||
|
from igny8_core.api.response import success_response, error_response
|
||||||
|
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||||
|
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove
|
||||||
|
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
||||||
|
from igny8_core.business.automation.services.automation_service import AutomationService
|
||||||
|
from .serializers import AutomationRuleSerializer, ScheduledTaskSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=['Automation']),
|
||||||
|
create=extend_schema(tags=['Automation']),
|
||||||
|
retrieve=extend_schema(tags=['Automation']),
|
||||||
|
update=extend_schema(tags=['Automation']),
|
||||||
|
partial_update=extend_schema(tags=['Automation']),
|
||||||
|
destroy=extend_schema(tags=['Automation']),
|
||||||
|
)
|
||||||
|
class AutomationRuleViewSet(SiteSectorModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing automation rules
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
|
"""
|
||||||
|
queryset = AutomationRule.objects.all()
|
||||||
|
serializer_class = AutomationRuleSerializer
|
||||||
|
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'automation'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering_fields = ['name', 'created_at', 'last_executed_at', 'execution_count']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
filterset_fields = ['trigger', 'is_active', 'status']
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='execute', url_name='execute')
|
||||||
|
def execute(self, request, pk=None):
|
||||||
|
"""Manually execute an automation rule"""
|
||||||
|
rule = self.get_object()
|
||||||
|
service = AutomationService()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = service.execute_rule(rule, context=request.data.get('context', {}))
|
||||||
|
return success_response(
|
||||||
|
data=result,
|
||||||
|
message='Rule executed successfully',
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=['Automation']),
|
||||||
|
create=extend_schema(tags=['Automation']),
|
||||||
|
retrieve=extend_schema(tags=['Automation']),
|
||||||
|
update=extend_schema(tags=['Automation']),
|
||||||
|
partial_update=extend_schema(tags=['Automation']),
|
||||||
|
destroy=extend_schema(tags=['Automation']),
|
||||||
|
)
|
||||||
|
class ScheduledTaskViewSet(AccountModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing scheduled tasks
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
|
"""
|
||||||
|
queryset = ScheduledTask.objects.select_related('automation_rule')
|
||||||
|
serializer_class = ScheduledTaskSerializer
|
||||||
|
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'automation'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
ordering_fields = ['scheduled_at', 'executed_at', 'status', 'created_at']
|
||||||
|
ordering = ['-scheduled_at']
|
||||||
|
filterset_fields = ['automation_rule', 'status']
|
||||||
|
|
||||||
@@ -1,22 +1,4 @@
|
|||||||
"""
|
# Backward compatibility alias - constants moved to business/billing/
|
||||||
Credit Cost Constants
|
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||||
"""
|
|
||||||
CREDIT_COSTS = {
|
|
||||||
'clustering': {
|
|
||||||
'base': 1, # 1 credit per 30 keywords
|
|
||||||
'per_keyword': 1 / 30,
|
|
||||||
},
|
|
||||||
'ideas': {
|
|
||||||
'base': 1, # 1 credit per idea
|
|
||||||
},
|
|
||||||
'content': {
|
|
||||||
'base': 3, # 3 credits per full blog post
|
|
||||||
},
|
|
||||||
'images': {
|
|
||||||
'base': 1, # 1 credit per image
|
|
||||||
},
|
|
||||||
'reparse': {
|
|
||||||
'base': 1, # 1 credit per reparse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
__all__ = ['CREDIT_COSTS']
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
"""
|
# Backward compatibility aliases - exceptions moved to business/billing/
|
||||||
Billing Exceptions
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class InsufficientCreditsError(Exception):
|
|
||||||
"""Raised when account doesn't have enough credits"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CreditCalculationError(Exception):
|
|
||||||
"""Raised when credit calculation fails"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
__all__ = ['InsufficientCreditsError', 'CreditCalculationError']
|
||||||
|
|||||||
@@ -1,72 +1,4 @@
|
|||||||
"""
|
# Backward compatibility aliases - models moved to business/billing/
|
||||||
Billing Models for Credit System
|
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||||
"""
|
|
||||||
from django.db import models
|
|
||||||
from django.core.validators import MinValueValidator
|
|
||||||
from igny8_core.auth.models import AccountBaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class CreditTransaction(AccountBaseModel):
|
|
||||||
"""Track all credit transactions (additions, deductions)"""
|
|
||||||
TRANSACTION_TYPE_CHOICES = [
|
|
||||||
('purchase', 'Purchase'),
|
|
||||||
('subscription', 'Subscription Renewal'),
|
|
||||||
('refund', 'Refund'),
|
|
||||||
('deduction', 'Usage Deduction'),
|
|
||||||
('adjustment', 'Manual Adjustment'),
|
|
||||||
]
|
|
||||||
|
|
||||||
transaction_type = models.CharField(max_length=20, choices=TRANSACTION_TYPE_CHOICES, db_index=True)
|
|
||||||
amount = models.IntegerField(help_text="Positive for additions, negative for deductions")
|
|
||||||
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
|
|
||||||
description = models.CharField(max_length=255)
|
|
||||||
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'igny8_credit_transactions'
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['account', 'transaction_type']),
|
|
||||||
models.Index(fields=['account', 'created_at']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
account = getattr(self, 'account', None)
|
|
||||||
return f"{self.get_transaction_type_display()} - {self.amount} credits - {account.name if account else 'No Account'}"
|
|
||||||
|
|
||||||
|
|
||||||
class CreditUsageLog(AccountBaseModel):
|
|
||||||
"""Detailed log of credit usage per AI operation"""
|
|
||||||
OPERATION_TYPE_CHOICES = [
|
|
||||||
('clustering', 'Keyword Clustering'),
|
|
||||||
('ideas', 'Content Ideas Generation'),
|
|
||||||
('content', 'Content Generation'),
|
|
||||||
('images', 'Image Generation'),
|
|
||||||
('reparse', 'Content Reparse'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
|
|
||||||
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
|
|
||||||
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
|
|
||||||
model_used = models.CharField(max_length=100, blank=True)
|
|
||||||
tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
|
|
||||||
tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
|
|
||||||
related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task'
|
|
||||||
related_object_id = models.IntegerField(null=True, blank=True)
|
|
||||||
metadata = models.JSONField(default=dict)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'igny8_credit_usage_logs'
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['account', 'operation_type']),
|
|
||||||
models.Index(fields=['account', 'created_at']),
|
|
||||||
models.Index(fields=['account', 'operation_type', 'created_at']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
account = getattr(self, 'account', None)
|
|
||||||
return f"{self.get_operation_type_display()} - {self.credits_used} credits - {account.name if account else 'No Account'}"
|
|
||||||
|
|
||||||
|
__all__ = ['CreditTransaction', 'CreditUsageLog']
|
||||||
|
|||||||
@@ -1,161 +1,4 @@
|
|||||||
"""
|
# Backward compatibility alias - service moved to business/billing/services/
|
||||||
Credit Service for managing credit transactions and deductions
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
"""
|
|
||||||
from django.db import transaction
|
|
||||||
from django.utils import timezone
|
|
||||||
from .models import CreditTransaction, CreditUsageLog
|
|
||||||
from .constants import CREDIT_COSTS
|
|
||||||
from .exceptions import InsufficientCreditsError, CreditCalculationError
|
|
||||||
from igny8_core.auth.models import Account
|
|
||||||
|
|
||||||
|
|
||||||
class CreditService:
|
|
||||||
"""Service for managing credits"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def check_credits(account, required_credits):
|
|
||||||
"""
|
|
||||||
Check if account has enough credits.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: Account instance
|
|
||||||
required_credits: Number of credits required
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
InsufficientCreditsError: If account doesn't have enough credits
|
|
||||||
"""
|
|
||||||
if account.credits < required_credits:
|
|
||||||
raise InsufficientCreditsError(
|
|
||||||
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@transaction.atomic
|
|
||||||
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
|
||||||
"""
|
|
||||||
Deduct credits and log transaction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: Account instance
|
|
||||||
amount: Number of credits to deduct
|
|
||||||
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
|
|
||||||
description: Description of the transaction
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
# Check sufficient credits
|
|
||||||
CreditService.check_credits(account, amount)
|
|
||||||
|
|
||||||
# Deduct from account.credits
|
|
||||||
account.credits -= amount
|
|
||||||
account.save(update_fields=['credits'])
|
|
||||||
|
|
||||||
# Create CreditTransaction
|
|
||||||
CreditTransaction.objects.create(
|
|
||||||
account=account,
|
|
||||||
transaction_type='deduction',
|
|
||||||
amount=-amount, # Negative for deduction
|
|
||||||
balance_after=account.credits,
|
|
||||||
description=description,
|
|
||||||
metadata=metadata or {}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create CreditUsageLog
|
|
||||||
CreditUsageLog.objects.create(
|
|
||||||
account=account,
|
|
||||||
operation_type=operation_type,
|
|
||||||
credits_used=amount,
|
|
||||||
cost_usd=cost_usd,
|
|
||||||
model_used=model_used or '',
|
|
||||||
tokens_input=tokens_input,
|
|
||||||
tokens_output=tokens_output,
|
|
||||||
related_object_type=related_object_type or '',
|
|
||||||
related_object_id=related_object_id,
|
|
||||||
metadata=metadata or {}
|
|
||||||
)
|
|
||||||
|
|
||||||
return account.credits
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@transaction.atomic
|
|
||||||
def add_credits(account, amount, transaction_type, description, metadata=None):
|
|
||||||
"""
|
|
||||||
Add credits (purchase, subscription, etc.).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account: Account instance
|
|
||||||
amount: Number of credits to add
|
|
||||||
transaction_type: Type of transaction (from CreditTransaction.TRANSACTION_TYPE_CHOICES)
|
|
||||||
description: Description of the transaction
|
|
||||||
metadata: Optional metadata dict
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: New credit balance
|
|
||||||
"""
|
|
||||||
# Add to account.credits
|
|
||||||
account.credits += amount
|
|
||||||
account.save(update_fields=['credits'])
|
|
||||||
|
|
||||||
# Create CreditTransaction
|
|
||||||
CreditTransaction.objects.create(
|
|
||||||
account=account,
|
|
||||||
transaction_type=transaction_type,
|
|
||||||
amount=amount, # Positive for addition
|
|
||||||
balance_after=account.credits,
|
|
||||||
description=description,
|
|
||||||
metadata=metadata or {}
|
|
||||||
)
|
|
||||||
|
|
||||||
return account.credits
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def calculate_credits_for_operation(operation_type, **kwargs):
|
|
||||||
"""
|
|
||||||
Calculate credits needed for an operation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation_type: Type of operation
|
|
||||||
**kwargs: Operation-specific parameters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Number of credits required
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
CreditCalculationError: If calculation fails
|
|
||||||
"""
|
|
||||||
if operation_type not in CREDIT_COSTS:
|
|
||||||
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
|
||||||
|
|
||||||
cost_config = CREDIT_COSTS[operation_type]
|
|
||||||
|
|
||||||
if operation_type == 'clustering':
|
|
||||||
# 1 credit per 30 keywords
|
|
||||||
keyword_count = kwargs.get('keyword_count', 0)
|
|
||||||
credits = max(1, int(keyword_count * cost_config['per_keyword']))
|
|
||||||
return credits
|
|
||||||
elif operation_type == 'ideas':
|
|
||||||
# 1 credit per idea
|
|
||||||
idea_count = kwargs.get('idea_count', 1)
|
|
||||||
return cost_config['base'] * idea_count
|
|
||||||
elif operation_type == 'content':
|
|
||||||
# 3 credits per content piece
|
|
||||||
content_count = kwargs.get('content_count', 1)
|
|
||||||
return cost_config['base'] * content_count
|
|
||||||
elif operation_type == 'images':
|
|
||||||
# 1 credit per image
|
|
||||||
image_count = kwargs.get('image_count', 1)
|
|
||||||
return cost_config['base'] * image_count
|
|
||||||
elif operation_type == 'reparse':
|
|
||||||
# 1 credit per reparse
|
|
||||||
return cost_config['base']
|
|
||||||
|
|
||||||
return cost_config['base']
|
|
||||||
|
|
||||||
|
__all__ = ['CreditService']
|
||||||
|
|||||||
99
backend/igny8_core/modules/billing/tasks.py
Normal file
99
backend/igny8_core/modules/billing/tasks.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
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
|
# Get plan credits per month (use get_effective_credits_per_month for Phase 0 compatibility)
|
||||||
plan_credits_per_month = account.plan.credits_per_month if account.plan else 0
|
plan_credits_per_month = account.plan.get_effective_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,7 +207,10 @@ 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)
|
||||||
|
|
||||||
@@ -225,13 +228,7 @@ 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)
|
||||||
|
|
||||||
@@ -241,115 +238,16 @@ 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 = []
|
||||||
|
|
||||||
# Planner Limits
|
# Credit Usage (Phase 0: Credit-only system)
|
||||||
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
|
||||||
@@ -358,64 +256,89 @@ 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='clustering',
|
operation_type__in=['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='content',
|
operation_type__in=['content', 'content_generation'],
|
||||||
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='image',
|
operation_type__in=['images', 'image_generation', 'image_prompt_extraction'],
|
||||||
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
|
||||||
|
|
||||||
plan_credits = plan.monthly_ai_credit_limit or plan.credits_per_month or 0
|
idea_credits = CreditUsageLog.objects.filter(
|
||||||
|
account=account,
|
||||||
|
operation_type__in=['ideas', 'idea_generation'],
|
||||||
|
created_at__gte=start_of_month
|
||||||
|
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||||
|
|
||||||
|
# Use included_credits from plan (Phase 0: Credit-only)
|
||||||
|
plan_credits = plan.included_credits or plan.credits_per_month or 0
|
||||||
|
|
||||||
limits_data.extend([
|
limits_data.extend([
|
||||||
{
|
{
|
||||||
'title': 'Monthly AI Credits',
|
'title': 'Monthly 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': 'ai',
|
'category': 'credits',
|
||||||
'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': 'Content AI Credits',
|
'title': 'Current Balance',
|
||||||
'limit': plan.monthly_content_ai_credits or 0,
|
'limit': None, # No limit - shows current balance
|
||||||
'used': content_credits,
|
'used': None,
|
||||||
'available': max(0, (plan.monthly_content_ai_credits or 0) - content_credits),
|
'available': account.credits,
|
||||||
'unit': 'credits',
|
'unit': 'credits',
|
||||||
'category': 'ai',
|
'category': 'credits',
|
||||||
'percentage': (content_credits / (plan.monthly_content_ai_credits or 1)) * 100 if plan.monthly_content_ai_credits else 0
|
'percentage': None
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Image AI Credits',
|
'title': 'Clustering Credits',
|
||||||
'limit': plan.monthly_image_ai_credits or 0,
|
'limit': None,
|
||||||
'used': image_credits,
|
|
||||||
'available': max(0, (plan.monthly_image_ai_credits or 0) - image_credits),
|
|
||||||
'unit': 'credits',
|
|
||||||
'category': 'ai',
|
|
||||||
'percentage': (image_credits / (plan.monthly_image_ai_credits or 1)) * 100 if plan.monthly_image_ai_credits else 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'title': 'Cluster AI Credits',
|
|
||||||
'limit': plan.monthly_cluster_ai_credits or 0,
|
|
||||||
'used': cluster_credits,
|
'used': cluster_credits,
|
||||||
'available': max(0, (plan.monthly_cluster_ai_credits or 0) - cluster_credits),
|
'available': None,
|
||||||
'unit': 'credits',
|
'unit': 'credits',
|
||||||
'category': 'ai',
|
'category': 'credits',
|
||||||
'percentage': (cluster_credits / (plan.monthly_cluster_ai_credits or 1)) * 100 if plan.monthly_cluster_ai_credits else 0
|
'percentage': None
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Content Generation Credits',
|
||||||
|
'limit': None,
|
||||||
|
'used': content_credits,
|
||||||
|
'available': None,
|
||||||
|
'unit': 'credits',
|
||||||
|
'category': 'credits',
|
||||||
|
'percentage': None
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Image Generation Credits',
|
||||||
|
'limit': None,
|
||||||
|
'used': image_credits,
|
||||||
|
'available': None,
|
||||||
|
'unit': 'credits',
|
||||||
|
'category': 'credits',
|
||||||
|
'percentage': None
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Idea Generation Credits',
|
||||||
|
'limit': None,
|
||||||
|
'used': idea_credits,
|
||||||
|
'available': None,
|
||||||
|
'unit': 'credits',
|
||||||
|
'category': 'credits',
|
||||||
|
'percentage': None
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
# General Limits
|
# Account Management Limits (kept - not operation 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()
|
||||||
|
|
||||||
@@ -426,7 +349,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': 'general',
|
'category': 'account',
|
||||||
'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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -435,7 +358,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': 'general',
|
'category': 'account',
|
||||||
'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,194 +1,4 @@
|
|||||||
from django.db import models
|
# Backward compatibility aliases - models moved to business/planning/
|
||||||
from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
|
from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas
|
||||||
|
|
||||||
|
__all__ = ['Keywords', 'Clusters', 'ContentIdeas']
|
||||||
class Clusters(SiteSectorBaseModel):
|
|
||||||
"""Clusters model for keyword grouping"""
|
|
||||||
|
|
||||||
name = models.CharField(max_length=255, unique=True, db_index=True)
|
|
||||||
description = models.TextField(blank=True, null=True)
|
|
||||||
keywords_count = models.IntegerField(default=0)
|
|
||||||
volume = models.IntegerField(default=0)
|
|
||||||
mapped_pages = models.IntegerField(default=0)
|
|
||||||
status = models.CharField(max_length=50, default='active')
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'igny8_clusters'
|
|
||||||
ordering = ['name']
|
|
||||||
verbose_name = 'Cluster'
|
|
||||||
verbose_name_plural = 'Clusters'
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['name']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['site', 'sector']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class Keywords(SiteSectorBaseModel):
|
|
||||||
"""
|
|
||||||
Keywords model for SEO keyword management.
|
|
||||||
Site-specific instances that reference global SeedKeywords.
|
|
||||||
"""
|
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('active', 'Active'),
|
|
||||||
('pending', 'Pending'),
|
|
||||||
('archived', 'Archived'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Required: Link to global SeedKeyword
|
|
||||||
seed_keyword = models.ForeignKey(
|
|
||||||
SeedKeyword,
|
|
||||||
on_delete=models.PROTECT, # Prevent deletion if Keywords reference it
|
|
||||||
related_name='site_keywords',
|
|
||||||
help_text="Reference to the global seed keyword"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Site-specific overrides (optional)
|
|
||||||
volume_override = models.IntegerField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="Site-specific volume override (uses seed_keyword.volume if not set)"
|
|
||||||
)
|
|
||||||
difficulty_override = models.IntegerField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="Site-specific difficulty override (uses seed_keyword.difficulty if not set)"
|
|
||||||
)
|
|
||||||
|
|
||||||
cluster = models.ForeignKey(
|
|
||||||
Clusters,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='keywords',
|
|
||||||
limit_choices_to={'sector': models.F('sector')} # Cluster must be in same sector
|
|
||||||
)
|
|
||||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='pending')
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'igny8_keywords'
|
|
||||||
ordering = ['-created_at']
|
|
||||||
verbose_name = 'Keyword'
|
|
||||||
verbose_name_plural = 'Keywords'
|
|
||||||
unique_together = [['seed_keyword', 'site', 'sector']] # One keyword per site/sector
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['seed_keyword']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['cluster']),
|
|
||||||
models.Index(fields=['site', 'sector']),
|
|
||||||
models.Index(fields=['seed_keyword', 'site', 'sector']),
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def keyword(self):
|
|
||||||
"""Get keyword text from seed_keyword"""
|
|
||||||
return self.seed_keyword.keyword if self.seed_keyword else ''
|
|
||||||
|
|
||||||
@property
|
|
||||||
def volume(self):
|
|
||||||
"""Get volume from override or seed_keyword"""
|
|
||||||
return self.volume_override if self.volume_override is not None else (self.seed_keyword.volume if self.seed_keyword else 0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def difficulty(self):
|
|
||||||
"""Get difficulty from override or seed_keyword"""
|
|
||||||
return self.difficulty_override if self.difficulty_override is not None else (self.seed_keyword.difficulty if self.seed_keyword else 0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def intent(self):
|
|
||||||
"""Get intent from seed_keyword"""
|
|
||||||
return self.seed_keyword.intent if self.seed_keyword else 'informational'
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
"""Validate that seed_keyword's industry/sector matches site's industry/sector"""
|
|
||||||
if self.seed_keyword and self.site and self.sector:
|
|
||||||
# Validate industry match
|
|
||||||
if self.site.industry != self.seed_keyword.industry:
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
raise ValidationError(
|
|
||||||
f"SeedKeyword industry ({self.seed_keyword.industry.name}) must match site industry ({self.site.industry.name})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate sector match (site sector's industry_sector must match seed_keyword's sector)
|
|
||||||
if self.sector.industry_sector != self.seed_keyword.sector:
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
raise ValidationError(
|
|
||||||
f"SeedKeyword sector ({self.seed_keyword.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})"
|
|
||||||
)
|
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.keyword
|
|
||||||
|
|
||||||
|
|
||||||
class ContentIdeas(SiteSectorBaseModel):
|
|
||||||
"""Content Ideas model for planning content based on keyword clusters"""
|
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('new', 'New'),
|
|
||||||
('scheduled', 'Scheduled'),
|
|
||||||
('published', 'Published'),
|
|
||||||
]
|
|
||||||
|
|
||||||
CONTENT_STRUCTURE_CHOICES = [
|
|
||||||
('cluster_hub', 'Cluster Hub'),
|
|
||||||
('landing_page', 'Landing Page'),
|
|
||||||
('pillar_page', 'Pillar Page'),
|
|
||||||
('supporting_page', 'Supporting Page'),
|
|
||||||
]
|
|
||||||
|
|
||||||
CONTENT_TYPE_CHOICES = [
|
|
||||||
('blog_post', 'Blog Post'),
|
|
||||||
('article', 'Article'),
|
|
||||||
('guide', 'Guide'),
|
|
||||||
('tutorial', 'Tutorial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
idea_title = models.CharField(max_length=255, db_index=True)
|
|
||||||
description = models.TextField(blank=True, null=True)
|
|
||||||
content_structure = models.CharField(max_length=50, choices=CONTENT_STRUCTURE_CHOICES, default='blog_post')
|
|
||||||
content_type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='blog_post')
|
|
||||||
target_keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
|
|
||||||
keyword_objects = models.ManyToManyField(
|
|
||||||
'Keywords',
|
|
||||||
blank=True,
|
|
||||||
related_name='content_ideas',
|
|
||||||
help_text="Individual keywords linked to this content idea"
|
|
||||||
)
|
|
||||||
keyword_cluster = models.ForeignKey(
|
|
||||||
Clusters,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='ideas',
|
|
||||||
limit_choices_to={'sector': models.F('sector')}
|
|
||||||
)
|
|
||||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
|
|
||||||
estimated_word_count = models.IntegerField(default=1000)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'igny8_content_ideas'
|
|
||||||
ordering = ['-created_at']
|
|
||||||
verbose_name = 'Content Idea'
|
|
||||||
verbose_name_plural = 'Content Ideas'
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['idea_title']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['keyword_cluster']),
|
|
||||||
models.Index(fields=['content_structure']),
|
|
||||||
models.Index(fields=['site', 'sector']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.idea_title
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove
|
|||||||
from .models import Keywords, Clusters, ContentIdeas
|
from .models import Keywords, Clusters, ContentIdeas
|
||||||
from .serializers import KeywordSerializer, ContentIdeasSerializer
|
from .serializers import KeywordSerializer, ContentIdeasSerializer
|
||||||
from .cluster_serializers import ClusterSerializer
|
from .cluster_serializers import ClusterSerializer
|
||||||
|
from igny8_core.business.planning.services.clustering_service import ClusteringService
|
||||||
|
from igny8_core.business.planning.services.ideas_service import IdeasService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
@@ -568,93 +571,55 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster')
|
@action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster')
|
||||||
def auto_cluster(self, request):
|
def auto_cluster(self, request):
|
||||||
"""Auto-cluster keywords using AI - New unified framework"""
|
"""Auto-cluster keywords using ClusteringService"""
|
||||||
import logging
|
import logging
|
||||||
from igny8_core.ai.tasks import run_ai_task
|
|
||||||
from kombu.exceptions import OperationalError as KombuOperationalError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
keyword_ids = request.data.get('ids', [])
|
||||||
|
sector_id = request.data.get('sector_id')
|
||||||
|
|
||||||
# Get account
|
# Get account
|
||||||
account = getattr(request, 'account', None)
|
account = getattr(request, 'account', None)
|
||||||
account_id = account.id if account else None
|
if not account:
|
||||||
|
|
||||||
# Prepare payload
|
|
||||||
payload = {
|
|
||||||
'ids': request.data.get('ids', []),
|
|
||||||
'sector_id': request.data.get('sector_id')
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"auto_cluster called with ids={payload['ids']}, sector_id={payload.get('sector_id')}")
|
|
||||||
|
|
||||||
# Validate basic input
|
|
||||||
if not payload['ids']:
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error='No IDs provided',
|
error='Account is required',
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(payload['ids']) > 20:
|
# Use service to cluster keywords
|
||||||
return error_response(
|
service = ClusteringService()
|
||||||
error='Maximum 20 keywords allowed for clustering',
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to queue Celery task
|
|
||||||
try:
|
try:
|
||||||
if hasattr(run_ai_task, 'delay'):
|
result = service.cluster_keywords(keyword_ids, account, sector_id)
|
||||||
task = run_ai_task.delay(
|
|
||||||
function_name='auto_cluster',
|
if result.get('success'):
|
||||||
payload=payload,
|
if 'task_id' in result:
|
||||||
account_id=account_id
|
# Async task queued
|
||||||
)
|
return success_response(
|
||||||
logger.info(f"Task queued: {task.id}")
|
data={'task_id': result['task_id']},
|
||||||
return success_response(
|
message=result.get('message', 'Clustering started'),
|
||||||
data={'task_id': str(task.id)},
|
request=request
|
||||||
message='Clustering started',
|
)
|
||||||
request=request
|
else:
|
||||||
)
|
# Synchronous execution
|
||||||
else:
|
|
||||||
# Celery not available - execute synchronously
|
|
||||||
logger.warning("Celery not available, executing synchronously")
|
|
||||||
result = run_ai_task(
|
|
||||||
function_name='auto_cluster',
|
|
||||||
payload=payload,
|
|
||||||
account_id=account_id
|
|
||||||
)
|
|
||||||
if result.get('success'):
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data=result,
|
data=result,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
return error_response(
|
|
||||||
error=result.get('error', 'Clustering failed'),
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
except (KombuOperationalError, ConnectionError) as e:
|
|
||||||
# Broker connection failed - fall back to synchronous execution
|
|
||||||
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
|
|
||||||
result = run_ai_task(
|
|
||||||
function_name='auto_cluster',
|
|
||||||
payload=payload,
|
|
||||||
account_id=account_id
|
|
||||||
)
|
|
||||||
if result.get('success'):
|
|
||||||
return success_response(
|
|
||||||
data=result,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return error_response(
|
return error_response(
|
||||||
error=result.get('error', 'Clustering failed'),
|
error=result.get('error', 'Clustering failed'),
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
except InsufficientCreditsError as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True)
|
logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True)
|
||||||
return error_response(
|
return error_response(
|
||||||
@@ -843,92 +808,54 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
|
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
|
||||||
def auto_generate_ideas(self, request):
|
def auto_generate_ideas(self, request):
|
||||||
"""Auto-generate ideas for clusters using AI - New unified framework"""
|
"""Auto-generate ideas for clusters using IdeasService"""
|
||||||
import logging
|
import logging
|
||||||
from igny8_core.ai.tasks import run_ai_task
|
|
||||||
from kombu.exceptions import OperationalError as KombuOperationalError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
cluster_ids = request.data.get('ids', [])
|
||||||
|
|
||||||
# Get account
|
# Get account
|
||||||
account = getattr(request, 'account', None)
|
account = getattr(request, 'account', None)
|
||||||
account_id = account.id if account else None
|
if not account:
|
||||||
|
|
||||||
# Prepare payload
|
|
||||||
payload = {
|
|
||||||
'ids': request.data.get('ids', [])
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"auto_generate_ideas called with ids={payload['ids']}")
|
|
||||||
|
|
||||||
# Validate basic input
|
|
||||||
if not payload['ids']:
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error='No IDs provided',
|
error='Account is required',
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(payload['ids']) > 10:
|
# Use service to generate ideas
|
||||||
return error_response(
|
service = IdeasService()
|
||||||
error='Maximum 10 clusters allowed for idea generation',
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to queue Celery task
|
|
||||||
try:
|
try:
|
||||||
if hasattr(run_ai_task, 'delay'):
|
result = service.generate_ideas(cluster_ids, account)
|
||||||
task = run_ai_task.delay(
|
|
||||||
function_name='auto_generate_ideas',
|
if result.get('success'):
|
||||||
payload=payload,
|
if 'task_id' in result:
|
||||||
account_id=account_id
|
# Async task queued
|
||||||
)
|
return success_response(
|
||||||
logger.info(f"Task queued: {task.id}")
|
data={'task_id': result['task_id']},
|
||||||
return success_response(
|
message=result.get('message', 'Idea generation started'),
|
||||||
data={'task_id': str(task.id)},
|
request=request
|
||||||
message='Idea generation started',
|
)
|
||||||
request=request
|
else:
|
||||||
)
|
# Synchronous execution
|
||||||
else:
|
|
||||||
# Celery not available - execute synchronously
|
|
||||||
logger.warning("Celery not available, executing synchronously")
|
|
||||||
result = run_ai_task(
|
|
||||||
function_name='auto_generate_ideas',
|
|
||||||
payload=payload,
|
|
||||||
account_id=account_id
|
|
||||||
)
|
|
||||||
if result.get('success'):
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data=result,
|
data=result,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
return error_response(
|
|
||||||
error=result.get('error', 'Idea generation failed'),
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
except (KombuOperationalError, ConnectionError) as e:
|
|
||||||
# Broker connection failed - fall back to synchronous execution
|
|
||||||
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
|
|
||||||
result = run_ai_task(
|
|
||||||
function_name='auto_generate_ideas',
|
|
||||||
payload=payload,
|
|
||||||
account_id=account_id
|
|
||||||
)
|
|
||||||
if result.get('success'):
|
|
||||||
return success_response(
|
|
||||||
data=result,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return error_response(
|
return error_response(
|
||||||
error=result.get('error', 'Idea generation failed'),
|
error=result.get('error', 'Idea generation failed'),
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
except InsufficientCreditsError as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True)
|
logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True)
|
||||||
return error_response(
|
return error_response(
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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, AISettings
|
SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, 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, AISettings
|
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SystemSettings)
|
@admin.register(SystemSettings)
|
||||||
|
|||||||
@@ -92,6 +92,46 @@ class ModuleSettings(BaseSettings):
|
|||||||
return f"ModuleSetting: {self.module_name} - {self.key}"
|
return f"ModuleSetting: {self.module_name} - {self.key}"
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleEnableSettings(AccountBaseModel):
|
||||||
|
"""Module enable/disable settings per account"""
|
||||||
|
planner_enabled = models.BooleanField(default=True, help_text="Enable Planner module")
|
||||||
|
writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module")
|
||||||
|
thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module")
|
||||||
|
automation_enabled = models.BooleanField(default=True, help_text="Enable Automation module")
|
||||||
|
site_builder_enabled = models.BooleanField(default=True, help_text="Enable Site Builder module")
|
||||||
|
linker_enabled = models.BooleanField(default=True, help_text="Enable Linker module")
|
||||||
|
optimizer_enabled = models.BooleanField(default=True, help_text="Enable Optimizer module")
|
||||||
|
publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'igny8_module_enable_settings'
|
||||||
|
unique_together = [['account']] # One record per account
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
account = getattr(self, 'account', None)
|
||||||
|
return f"ModuleEnableSettings: {account.name if account else 'No Account'}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create_for_account(cls, account):
|
||||||
|
"""Get or create module enable settings for an account"""
|
||||||
|
settings, created = cls.objects.get_or_create(account=account)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def is_module_enabled(self, module_name):
|
||||||
|
"""Check if a module is enabled"""
|
||||||
|
mapping = {
|
||||||
|
'planner': self.planner_enabled,
|
||||||
|
'writer': self.writer_enabled,
|
||||||
|
'thinker': self.thinker_enabled,
|
||||||
|
'automation': self.automation_enabled,
|
||||||
|
'site_builder': self.site_builder_enabled,
|
||||||
|
'linker': self.linker_enabled,
|
||||||
|
'optimizer': self.optimizer_enabled,
|
||||||
|
'publisher': self.publisher_enabled,
|
||||||
|
}
|
||||||
|
return mapping.get(module_name, True) # Default to enabled if unknown
|
||||||
|
|
||||||
|
|
||||||
# AISettings extends IntegrationSettings (which already exists)
|
# AISettings extends IntegrationSettings (which already exists)
|
||||||
# We'll create it as a separate model that can reference IntegrationSettings
|
# We'll create it as a separate model that can reference IntegrationSettings
|
||||||
class AISettings(AccountBaseModel):
|
class AISettings(AccountBaseModel):
|
||||||
|
|||||||
@@ -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, AISettings
|
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||||
from .validators import validate_settings_schema
|
from .validators import validate_settings_schema
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +58,17 @@ class ModuleSettingsSerializer(serializers.ModelSerializer):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleEnableSettingsSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ModuleEnableSettings
|
||||||
|
fields = [
|
||||||
|
'id', 'planner_enabled', 'writer_enabled', 'thinker_enabled',
|
||||||
|
'automation_enabled', 'site_builder_enabled', 'linker_enabled',
|
||||||
|
'optimizer_enabled', 'publisher_enabled', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['created_at', 'updated_at', 'account']
|
||||||
|
|
||||||
|
|
||||||
class AISettingsSerializer(serializers.ModelSerializer):
|
class AISettingsSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AISettings
|
model = AISettings
|
||||||
|
|||||||
@@ -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, AISettings
|
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||||
from .settings_serializers import (
|
from .settings_serializers import (
|
||||||
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
||||||
ModuleSettingsSerializer, AISettingsSerializer
|
ModuleSettingsSerializer, ModuleEnableSettingsSerializer, AISettingsSerializer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -235,6 +235,15 @@ 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
|
||||||
@@ -276,6 +285,171 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
|||||||
serializer.save(account=account)
|
serializer.save(account=account)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=['System']),
|
||||||
|
retrieve=extend_schema(tags=['System']),
|
||||||
|
update=extend_schema(tags=['System']),
|
||||||
|
partial_update=extend_schema(tags=['System']),
|
||||||
|
)
|
||||||
|
class ModuleEnableSettingsViewSet(AccountModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing module enable/disable settings
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
|
One record per account
|
||||||
|
Read access: All authenticated users
|
||||||
|
Write access: Admins/Owners only
|
||||||
|
"""
|
||||||
|
queryset = ModuleEnableSettings.objects.all()
|
||||||
|
serializer_class = ModuleEnableSettingsSerializer
|
||||||
|
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||||
|
throttle_scope = 'system'
|
||||||
|
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):
|
||||||
|
"""Get module enable settings for current account"""
|
||||||
|
# Return queryset filtered by account - but list() will handle get_or_create
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
# Filter by account if available
|
||||||
|
account = getattr(self.request, 'account', None)
|
||||||
|
if not account:
|
||||||
|
user = getattr(self.request, 'user', None)
|
||||||
|
if user:
|
||||||
|
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:
|
||||||
|
return error_response(
|
||||||
|
error='Account not found',
|
||||||
|
status_code=status.HTTP_400_BAD_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']),
|
||||||
create=extend_schema(tags=['System']),
|
create=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, AISettingsViewSet
|
ModuleSettingsViewSet, ModuleEnableSettingsViewSet, AISettingsViewSet
|
||||||
)
|
)
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'prompts', AIPromptViewSet, basename='prompts')
|
router.register(r'prompts', AIPromptViewSet, basename='prompts')
|
||||||
@@ -16,6 +16,7 @@ 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/ai', AISettingsViewSet, basename='ai-settings')
|
router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings')
|
||||||
|
|
||||||
@@ -49,7 +50,20 @@ 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'),
|
||||||
|
|||||||
@@ -1,206 +1,4 @@
|
|||||||
from django.db import models
|
# Backward compatibility aliases - models moved to business/content/
|
||||||
from django.core.validators import MinValueValidator
|
from igny8_core.business.content.models import Tasks, Content, Images
|
||||||
from igny8_core.auth.models import SiteSectorBaseModel
|
|
||||||
from igny8_core.modules.planner.models import Clusters, ContentIdeas, Keywords
|
|
||||||
|
|
||||||
|
|
||||||
class Tasks(SiteSectorBaseModel):
|
|
||||||
"""Tasks model for content generation queue"""
|
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('queued', 'Queued'),
|
|
||||||
('completed', 'Completed'),
|
|
||||||
]
|
|
||||||
|
|
||||||
CONTENT_STRUCTURE_CHOICES = [
|
|
||||||
('cluster_hub', 'Cluster Hub'),
|
|
||||||
('landing_page', 'Landing Page'),
|
|
||||||
('pillar_page', 'Pillar Page'),
|
|
||||||
('supporting_page', 'Supporting Page'),
|
|
||||||
]
|
|
||||||
|
|
||||||
CONTENT_TYPE_CHOICES = [
|
|
||||||
('blog_post', 'Blog Post'),
|
|
||||||
('article', 'Article'),
|
|
||||||
('guide', 'Guide'),
|
|
||||||
('tutorial', 'Tutorial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
title = models.CharField(max_length=255, db_index=True)
|
|
||||||
description = models.TextField(blank=True, null=True)
|
|
||||||
keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
|
|
||||||
cluster = models.ForeignKey(
|
|
||||||
Clusters,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='tasks',
|
|
||||||
limit_choices_to={'sector': models.F('sector')}
|
|
||||||
)
|
|
||||||
keyword_objects = models.ManyToManyField(
|
|
||||||
Keywords,
|
|
||||||
blank=True,
|
|
||||||
related_name='tasks',
|
|
||||||
help_text="Individual keywords linked to this task"
|
|
||||||
)
|
|
||||||
idea = models.ForeignKey(
|
|
||||||
ContentIdeas,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='tasks'
|
|
||||||
)
|
|
||||||
content_structure = models.CharField(max_length=50, choices=CONTENT_STRUCTURE_CHOICES, default='blog_post')
|
|
||||||
content_type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='blog_post')
|
|
||||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='queued')
|
|
||||||
|
|
||||||
# Content fields
|
|
||||||
content = models.TextField(blank=True, null=True) # Generated content
|
|
||||||
word_count = models.IntegerField(default=0)
|
|
||||||
|
|
||||||
# SEO fields
|
|
||||||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
meta_description = models.TextField(blank=True, null=True)
|
|
||||||
# WordPress integration
|
|
||||||
assigned_post_id = models.IntegerField(null=True, blank=True) # WordPress post ID if published
|
|
||||||
post_url = models.URLField(blank=True, null=True) # WordPress post URL
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'igny8_tasks'
|
|
||||||
ordering = ['-created_at']
|
|
||||||
verbose_name = 'Task'
|
|
||||||
verbose_name_plural = 'Tasks'
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['title']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['cluster']),
|
|
||||||
models.Index(fields=['content_type']),
|
|
||||||
models.Index(fields=['site', 'sector']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.title
|
|
||||||
|
|
||||||
|
|
||||||
class Content(SiteSectorBaseModel):
|
|
||||||
"""
|
|
||||||
Content model for storing final AI-generated article content.
|
|
||||||
Separated from Task for content versioning and storage optimization.
|
|
||||||
"""
|
|
||||||
task = models.OneToOneField(
|
|
||||||
Tasks,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='content_record',
|
|
||||||
help_text="The task this content belongs to"
|
|
||||||
)
|
|
||||||
html_content = models.TextField(help_text="Final AI-generated HTML content")
|
|
||||||
word_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
|
||||||
metadata = models.JSONField(default=dict, help_text="Additional metadata (SEO, structure, etc.)")
|
|
||||||
title = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
meta_description = models.TextField(blank=True, null=True)
|
|
||||||
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
|
|
||||||
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
|
|
||||||
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('draft', 'Draft'),
|
|
||||||
('review', 'Review'),
|
|
||||||
('publish', 'Publish'),
|
|
||||||
]
|
|
||||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='draft', help_text="Content workflow status (draft, review, publish)")
|
|
||||||
generated_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'igny8_content'
|
|
||||||
ordering = ['-generated_at']
|
|
||||||
verbose_name = 'Content'
|
|
||||||
verbose_name_plural = 'Contents'
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['task']),
|
|
||||||
models.Index(fields=['generated_at']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
"""Automatically set account, site, and sector from task"""
|
|
||||||
if self.task:
|
|
||||||
self.account = self.task.account
|
|
||||||
self.site = self.task.site
|
|
||||||
self.sector = self.task.sector
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Content for {self.task.title}"
|
|
||||||
|
|
||||||
|
|
||||||
class Images(SiteSectorBaseModel):
|
|
||||||
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
|
|
||||||
|
|
||||||
IMAGE_TYPE_CHOICES = [
|
|
||||||
('featured', 'Featured Image'),
|
|
||||||
('desktop', 'Desktop Image'),
|
|
||||||
('mobile', 'Mobile Image'),
|
|
||||||
('in_article', 'In-Article Image'),
|
|
||||||
]
|
|
||||||
|
|
||||||
content = models.ForeignKey(
|
|
||||||
Content,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='images',
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="The content this image belongs to (preferred)"
|
|
||||||
)
|
|
||||||
task = models.ForeignKey(
|
|
||||||
Tasks,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='images',
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="The task this image belongs to (legacy, use content instead)"
|
|
||||||
)
|
|
||||||
image_type = models.CharField(max_length=50, choices=IMAGE_TYPE_CHOICES, default='featured')
|
|
||||||
image_url = models.CharField(max_length=500, blank=True, null=True, help_text="URL of the generated/stored image")
|
|
||||||
image_path = models.CharField(max_length=500, blank=True, null=True, help_text="Local path if stored locally")
|
|
||||||
prompt = models.TextField(blank=True, null=True, help_text="Image generation prompt used")
|
|
||||||
status = models.CharField(max_length=50, default='pending', help_text="Status: pending, generated, failed")
|
|
||||||
position = models.IntegerField(default=0, help_text="Position for in-article images ordering")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'igny8_images'
|
|
||||||
ordering = ['content', 'position', '-created_at']
|
|
||||||
verbose_name = 'Image'
|
|
||||||
verbose_name_plural = 'Images'
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['content', 'image_type']),
|
|
||||||
models.Index(fields=['task', 'image_type']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['content', 'position']),
|
|
||||||
models.Index(fields=['task', 'position']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
"""Automatically set account, site, and sector from content or task"""
|
|
||||||
# Prefer content over task
|
|
||||||
if self.content:
|
|
||||||
self.account = self.content.account
|
|
||||||
self.site = self.content.site
|
|
||||||
self.sector = self.content.sector
|
|
||||||
elif self.task:
|
|
||||||
self.account = self.task.account
|
|
||||||
self.site = self.task.site
|
|
||||||
self.sector = self.task.sector
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
content_title = self.content.title if self.content else None
|
|
||||||
task_title = self.task.title if self.task else None
|
|
||||||
title = content_title or task_title or 'Unknown'
|
|
||||||
return f"{title} - {self.image_type}"
|
|
||||||
|
|
||||||
|
__all__ = ['Tasks', 'Content', 'Images']
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from .models import Tasks, Images, Content
|
from .models import Tasks, Images, Content
|
||||||
from igny8_core.modules.planner.models import Clusters, ContentIdeas
|
from igny8_core.business.planning.models import Clusters, ContentIdeas
|
||||||
|
|
||||||
|
|
||||||
class TasksSerializer(serializers.ModelSerializer):
|
class TasksSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from igny8_core.api.throttles import DebugScopedRateThrottle
|
|||||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove
|
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove
|
||||||
from .models import Tasks, Images, Content
|
from .models import Tasks, Images, Content
|
||||||
from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
|
from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
|
||||||
|
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
@@ -137,17 +139,14 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content')
|
@action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content')
|
||||||
def auto_generate_content(self, request):
|
def auto_generate_content(self, request):
|
||||||
"""Auto-generate content for tasks using AI"""
|
"""Auto-generate content for tasks using ContentGenerationService"""
|
||||||
import logging
|
import logging
|
||||||
from django.db import OperationalError, DatabaseError, IntegrityError
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ids = request.data.get('ids', [])
|
ids = request.data.get('ids', [])
|
||||||
if not ids:
|
if not ids:
|
||||||
logger.warning("auto_generate_content: No IDs provided")
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error='No IDs provided',
|
error='No IDs provided',
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -155,229 +154,77 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if len(ids) > 10:
|
if len(ids) > 10:
|
||||||
logger.warning(f"auto_generate_content: Too many IDs provided: {len(ids)}")
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Maximum 10 tasks allowed for content generation',
|
error='Maximum 10 tasks allowed for content generation',
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}")
|
|
||||||
|
|
||||||
# Get account
|
# Get account
|
||||||
account = getattr(request, 'account', None)
|
account = getattr(request, 'account', None)
|
||||||
account_id = account.id if account else None
|
if not account:
|
||||||
logger.info(f"auto_generate_content: Account ID: {account_id}")
|
|
||||||
|
|
||||||
# Validate task IDs exist in database before proceeding
|
|
||||||
try:
|
|
||||||
queryset = self.get_queryset()
|
|
||||||
existing_tasks = queryset.filter(id__in=ids)
|
|
||||||
existing_count = existing_tasks.count()
|
|
||||||
existing_ids = list(existing_tasks.values_list('id', flat=True))
|
|
||||||
|
|
||||||
logger.info(f"auto_generate_content: Found {existing_count} existing tasks out of {len(ids)} requested")
|
|
||||||
logger.info(f"auto_generate_content: Existing task IDs: {existing_ids}")
|
|
||||||
|
|
||||||
if existing_count == 0:
|
|
||||||
logger.error(f"auto_generate_content: No tasks found for IDs: {ids}")
|
|
||||||
return error_response(
|
|
||||||
error=f'No tasks found for the provided IDs: {ids}',
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_count < len(ids):
|
|
||||||
missing_ids = set(ids) - set(existing_ids)
|
|
||||||
logger.warning(f"auto_generate_content: Some task IDs not found: {missing_ids}")
|
|
||||||
# Continue with existing tasks, but log warning
|
|
||||||
|
|
||||||
except (OperationalError, DatabaseError) as db_error:
|
|
||||||
logger.error("=" * 80)
|
|
||||||
logger.error("DATABASE ERROR: Failed to query tasks")
|
|
||||||
logger.error(f" - Error type: {type(db_error).__name__}")
|
|
||||||
logger.error(f" - Error message: {str(db_error)}")
|
|
||||||
logger.error(f" - Requested IDs: {ids}")
|
|
||||||
logger.error(f" - Account ID: {account_id}")
|
|
||||||
logger.error("=" * 80, exc_info=True)
|
|
||||||
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error=f'Database error while querying tasks: {str(db_error)}',
|
error='Account is required',
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to queue Celery task, fall back to synchronous if Celery not available
|
|
||||||
try:
|
|
||||||
from igny8_core.ai.tasks import run_ai_task
|
|
||||||
from kombu.exceptions import OperationalError as KombuOperationalError
|
|
||||||
|
|
||||||
if hasattr(run_ai_task, 'delay'):
|
|
||||||
# Celery is available - queue async task
|
|
||||||
logger.info(f"auto_generate_content: Queuing Celery task for {len(ids)} tasks")
|
|
||||||
try:
|
|
||||||
task = run_ai_task.delay(
|
|
||||||
function_name='generate_content',
|
|
||||||
payload={'ids': ids},
|
|
||||||
account_id=account_id
|
|
||||||
)
|
|
||||||
logger.info(f"auto_generate_content: Celery task queued successfully: {task.id}")
|
|
||||||
return success_response(
|
|
||||||
data={'task_id': str(task.id)},
|
|
||||||
message='Content generation started',
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
except KombuOperationalError as celery_error:
|
|
||||||
logger.error("=" * 80)
|
|
||||||
logger.error("CELERY ERROR: Failed to queue task")
|
|
||||||
logger.error(f" - Error type: {type(celery_error).__name__}")
|
|
||||||
logger.error(f" - Error message: {str(celery_error)}")
|
|
||||||
logger.error(f" - Task IDs: {ids}")
|
|
||||||
logger.error(f" - Account ID: {account_id}")
|
|
||||||
logger.error("=" * 80, exc_info=True)
|
|
||||||
|
|
||||||
return error_response(
|
|
||||||
error='Task queue unavailable. Please try again.',
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
except Exception as celery_error:
|
|
||||||
logger.error("=" * 80)
|
|
||||||
logger.error("CELERY ERROR: Failed to queue task")
|
|
||||||
logger.error(f" - Error type: {type(celery_error).__name__}")
|
|
||||||
logger.error(f" - Error message: {str(celery_error)}")
|
|
||||||
logger.error(f" - Task IDs: {ids}")
|
|
||||||
logger.error(f" - Account ID: {account_id}")
|
|
||||||
logger.error("=" * 80, exc_info=True)
|
|
||||||
|
|
||||||
# Fall back to synchronous execution
|
|
||||||
logger.info("auto_generate_content: Falling back to synchronous execution")
|
|
||||||
result = run_ai_task(
|
|
||||||
function_name='generate_content',
|
|
||||||
payload={'ids': ids},
|
|
||||||
account_id=account_id
|
|
||||||
)
|
|
||||||
if result.get('success'):
|
|
||||||
return success_response(
|
|
||||||
data={'tasks_updated': result.get('count', 0)},
|
|
||||||
message='Content generated successfully (synchronous)',
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return error_response(
|
|
||||||
error=result.get('error', 'Content generation failed'),
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Celery not available - execute synchronously
|
|
||||||
logger.info(f"auto_generate_content: Executing synchronously (Celery not available)")
|
|
||||||
result = run_ai_task(
|
|
||||||
function_name='generate_content',
|
|
||||||
payload={'ids': ids},
|
|
||||||
account_id=account_id
|
|
||||||
)
|
|
||||||
if result.get('success'):
|
|
||||||
logger.info(f"auto_generate_content: Synchronous execution successful: {result.get('count', 0)} tasks updated")
|
|
||||||
return success_response(
|
|
||||||
data={'tasks_updated': result.get('count', 0)},
|
|
||||||
message='Content generated successfully',
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.error(f"auto_generate_content: Synchronous execution failed: {result.get('error', 'Unknown error')}")
|
|
||||||
return error_response(
|
|
||||||
error=result.get('error', 'Content generation failed'),
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except ImportError as import_error:
|
|
||||||
logger.error(f"auto_generate_content: ImportError - tasks module not available: {str(import_error)}")
|
|
||||||
# Tasks module not available - update status only
|
|
||||||
try:
|
|
||||||
queryset = self.get_queryset()
|
|
||||||
tasks = queryset.filter(id__in=ids, status='queued')
|
|
||||||
updated_count = tasks.update(status='completed', content='[AI content generation not available]')
|
|
||||||
|
|
||||||
logger.info(f"auto_generate_content: Updated {updated_count} tasks (AI generation not available)")
|
|
||||||
return success_response(
|
|
||||||
data={'updated_count': updated_count},
|
|
||||||
message='Tasks updated (AI generation not available)',
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
except (OperationalError, DatabaseError) as db_error:
|
|
||||||
logger.error("=" * 80)
|
|
||||||
logger.error("DATABASE ERROR: Failed to update tasks")
|
|
||||||
logger.error(f" - Error type: {type(db_error).__name__}")
|
|
||||||
logger.error(f" - Error message: {str(db_error)}")
|
|
||||||
logger.error("=" * 80, exc_info=True)
|
|
||||||
return error_response(
|
|
||||||
error=f'Database error while updating tasks: {str(db_error)}',
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except (OperationalError, DatabaseError) as db_error:
|
|
||||||
logger.error("=" * 80)
|
|
||||||
logger.error("DATABASE ERROR: Failed during task execution")
|
|
||||||
logger.error(f" - Error type: {type(db_error).__name__}")
|
|
||||||
logger.error(f" - Error message: {str(db_error)}")
|
|
||||||
logger.error(f" - Task IDs: {ids}")
|
|
||||||
logger.error(f" - Account ID: {account_id}")
|
|
||||||
logger.error("=" * 80, exc_info=True)
|
|
||||||
|
|
||||||
return error_response(
|
|
||||||
error=f'Database error during content generation: {str(db_error)}',
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except IntegrityError as integrity_error:
|
|
||||||
logger.error("=" * 80)
|
|
||||||
logger.error("INTEGRITY ERROR: Data integrity violation")
|
|
||||||
logger.error(f" - Error message: {str(integrity_error)}")
|
|
||||||
logger.error(f" - Task IDs: {ids}")
|
|
||||||
logger.error("=" * 80, exc_info=True)
|
|
||||||
|
|
||||||
return error_response(
|
|
||||||
error=f'Data integrity error: {str(integrity_error)}',
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValidationError as validation_error:
|
|
||||||
logger.error(f"auto_generate_content: ValidationError: {str(validation_error)}")
|
|
||||||
return error_response(
|
|
||||||
error=f'Validation error: {str(validation_error)}',
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
# Validate task IDs exist
|
||||||
logger.error("=" * 80)
|
queryset = self.get_queryset()
|
||||||
logger.error("UNEXPECTED ERROR in auto_generate_content")
|
existing_tasks = queryset.filter(id__in=ids, account=account)
|
||||||
logger.error(f" - Error type: {type(e).__name__}")
|
existing_count = existing_tasks.count()
|
||||||
logger.error(f" - Error message: {str(e)}")
|
|
||||||
logger.error(f" - Task IDs: {ids}")
|
if existing_count == 0:
|
||||||
logger.error(f" - Account ID: {account_id}")
|
|
||||||
logger.error("=" * 80, exc_info=True)
|
|
||||||
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error=f'Unexpected error: {str(e)}',
|
error=f'No tasks found for the provided IDs: {ids}',
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use service to generate content
|
||||||
|
service = ContentGenerationService()
|
||||||
|
try:
|
||||||
|
result = service.generate_content(ids, account)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
if 'task_id' in result:
|
||||||
|
# Async task queued
|
||||||
|
return success_response(
|
||||||
|
data={'task_id': result['task_id']},
|
||||||
|
message=result.get('message', 'Content generation started'),
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Synchronous execution
|
||||||
|
return success_response(
|
||||||
|
data=result,
|
||||||
|
message='Content generated successfully',
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return error_response(
|
||||||
|
error=result.get('error', 'Content generation failed'),
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except InsufficientCreditsError as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in auto_generate_content: {str(e)}", exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error=f'Content generation failed: {str(e)}',
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as outer_error:
|
except Exception as e:
|
||||||
logger.error("=" * 80)
|
logger.error(f"Unexpected error in auto_generate_content: {str(e)}", exc_info=True)
|
||||||
logger.error("CRITICAL ERROR: Outer exception handler")
|
|
||||||
logger.error(f" - Error type: {type(outer_error).__name__}")
|
|
||||||
logger.error(f" - Error message: {str(outer_error)}")
|
|
||||||
logger.error("=" * 80, exc_info=True)
|
|
||||||
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error=f'Critical error: {str(outer_error)}',
|
error=f'Unexpected error: {str(e)}',
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ INSTALLED_APPS = [
|
|||||||
'igny8_core.modules.writer.apps.WriterConfig',
|
'igny8_core.modules.writer.apps.WriterConfig',
|
||||||
'igny8_core.modules.system.apps.SystemConfig',
|
'igny8_core.modules.system.apps.SystemConfig',
|
||||||
'igny8_core.modules.billing.apps.BillingConfig',
|
'igny8_core.modules.billing.apps.BillingConfig',
|
||||||
|
'igny8_core.modules.automation.apps.AutomationConfig',
|
||||||
]
|
]
|
||||||
|
|
||||||
# System module needs explicit registration for admin
|
# System module needs explicit registration for admin
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ urlpatterns = [
|
|||||||
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
|
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
|
||||||
path('api/v1/system/', include('igny8_core.modules.system.urls')),
|
path('api/v1/system/', include('igny8_core.modules.system.urls')),
|
||||||
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
|
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
|
||||||
|
path('api/v1/automation/', include('igny8_core.modules.automation.urls')), # Automation endpoints
|
||||||
# OpenAPI Schema and Documentation
|
# OpenAPI Schema and Documentation
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||||
|
|||||||
@@ -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 & Schedules */}
|
{/* Automation */}
|
||||||
<Route path="/automation" element={<AutomationDashboard />} />
|
<Route path="/automation" element={<AutomationDashboard />} />
|
||||||
<Route path="/schedules" element={<Schedules />} />
|
{/* Note: Schedules functionality is integrated into Automation Dashboard */}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={<GeneralSettings />} />
|
<Route path="/settings" element={<GeneralSettings />} />
|
||||||
|
|||||||
@@ -644,9 +644,12 @@ 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"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
273
docs/DIAGNOSIS-AUTHENTICATION-ISSUE.md
Normal file
273
docs/DIAGNOSIS-AUTHENTICATION-ISSUE.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# Authentication & Account Context Diagnosis
|
||||||
|
|
||||||
|
## Issue Summary
|
||||||
|
**Problem**: Wrong user showing without proper rights - authentication/account context mismatch
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
```
|
||||||
|
1. Request arrives
|
||||||
|
↓
|
||||||
|
2. Django Middleware Stack (settings.py:74-88)
|
||||||
|
- SecurityMiddleware
|
||||||
|
- WhiteNoiseMiddleware
|
||||||
|
- CorsMiddleware
|
||||||
|
- SessionMiddleware
|
||||||
|
- CommonMiddleware
|
||||||
|
- CsrfViewMiddleware
|
||||||
|
- AuthenticationMiddleware (sets request.user from session)
|
||||||
|
↓
|
||||||
|
3. AccountContextMiddleware (line 83)
|
||||||
|
- Extracts account from JWT token OR session
|
||||||
|
- Sets request.account
|
||||||
|
↓
|
||||||
|
4. DRF Authentication Classes (settings.py:210-214)
|
||||||
|
- JWTAuthentication (runs first)
|
||||||
|
- CSRFExemptSessionAuthentication
|
||||||
|
- BasicAuthentication
|
||||||
|
↓
|
||||||
|
5. View/ViewSet
|
||||||
|
- Uses request.user (from DRF auth)
|
||||||
|
- Uses request.account (from middleware OR JWTAuthentication)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Issues Found
|
||||||
|
|
||||||
|
### Issue #1: Duplicate Account Setting Logic
|
||||||
|
**Location**: Two places set `request.account` with different logic
|
||||||
|
|
||||||
|
1. **AccountContextMiddleware** (`backend/igny8_core/auth/middleware.py:99-106`)
|
||||||
|
```python
|
||||||
|
if account_id:
|
||||||
|
account = Account.objects.get(id=account_id)
|
||||||
|
# If user's account changed, use the new one from user object
|
||||||
|
if user.account and user.account.id != account_id:
|
||||||
|
request.account = user.account # Prioritizes user's current account
|
||||||
|
else:
|
||||||
|
request.account = account # Uses token's account
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **JWTAuthentication** (`backend/igny8_core/api/authentication.py:64-80`)
|
||||||
|
```python
|
||||||
|
account_id = payload.get('account_id')
|
||||||
|
account = None
|
||||||
|
if account_id:
|
||||||
|
account = Account.objects.get(id=account_id) # Always uses token's account
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
account = getattr(user, 'account', None) # Fallback only if no account_id
|
||||||
|
|
||||||
|
request.account = account # OVERWRITES middleware's account
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Middleware validates if user's account changed and prioritizes `user.account`
|
||||||
|
- JWTAuthentication runs AFTER middleware and OVERWRITES `request.account` without validation
|
||||||
|
- This means middleware's validation is ignored
|
||||||
|
|
||||||
|
### Issue #2: User Object Loading Mismatch
|
||||||
|
**Location**: Different user loading strategies
|
||||||
|
|
||||||
|
1. **AccountContextMiddleware** (line 98)
|
||||||
|
```python
|
||||||
|
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
||||||
|
```
|
||||||
|
- Loads user WITH account relationship (efficient, has account data)
|
||||||
|
|
||||||
|
2. **JWTAuthentication** (line 58)
|
||||||
|
```python
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
```
|
||||||
|
- Does NOT load account relationship
|
||||||
|
- When checking `user.account`, it triggers a separate DB query
|
||||||
|
- If account relationship is stale or missing, this can fail
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- JWTAuthentication's user object doesn't have account relationship loaded
|
||||||
|
- When `/me` endpoint uses `request.user.id` and then serializes with `UserSerializer`, it tries to access `user.account`
|
||||||
|
- This might trigger lazy loading which could return wrong/stale data
|
||||||
|
|
||||||
|
### Issue #3: Middleware Updates request.user (Session Auth)
|
||||||
|
**Location**: `backend/igny8_core/auth/middleware.py:32-46`
|
||||||
|
|
||||||
|
```python
|
||||||
|
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||||
|
user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
||||||
|
request.user = user # OVERWRITES request.user
|
||||||
|
request.account = user_account
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Middleware is setting `request.user` for session authentication
|
||||||
|
- But then JWTAuthentication runs and might set a DIFFERENT user (from JWT token)
|
||||||
|
- This creates a conflict where middleware's user is overwritten
|
||||||
|
|
||||||
|
### Issue #4: Token Account vs User Account Mismatch
|
||||||
|
**Location**: Token generation vs user's current account
|
||||||
|
|
||||||
|
**Token Generation** (`backend/igny8_core/auth/utils.py:30-57`):
|
||||||
|
```python
|
||||||
|
def generate_access_token(user, account=None):
|
||||||
|
if account is None:
|
||||||
|
account = getattr(user, 'account', None)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'user_id': user.id,
|
||||||
|
'account_id': account.id if account else None, # Token stores account_id at login time
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Token is generated at login with `user.account` at that moment
|
||||||
|
- If user's account changes AFTER login (e.g., admin moves user to different account), token still has old `account_id`
|
||||||
|
- Middleware tries to handle this (line 103-104), but JWTAuthentication overwrites it
|
||||||
|
|
||||||
|
### Issue #5: /me Endpoint Uses request.user Without Account Relationship
|
||||||
|
**Location**: `backend/igny8_core/auth/urls.py:188-197`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get(self, request):
|
||||||
|
user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
||||||
|
serializer = UserSerializer(user)
|
||||||
|
return success_response(data={'user': serializer.data}, request=request)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- `/me` endpoint correctly loads user with account relationship
|
||||||
|
- BUT `request.user` (from JWTAuthentication) doesn't have account relationship loaded
|
||||||
|
- If other code uses `request.user.account` directly, it might get wrong/stale data
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### Primary Root Cause
|
||||||
|
**JWTAuthentication overwrites `request.account` set by middleware without validating if user's account changed**
|
||||||
|
|
||||||
|
### Secondary Issues
|
||||||
|
1. JWTAuthentication doesn't load user with account relationship (inefficient + potential stale data)
|
||||||
|
2. Middleware sets `request.user` for session auth, but JWTAuthentication might overwrite it
|
||||||
|
3. Token's `account_id` can become stale if user's account changes after login
|
||||||
|
|
||||||
|
## Data Flow Problem
|
||||||
|
|
||||||
|
### Current Flow (BROKEN)
|
||||||
|
```
|
||||||
|
1. Request with JWT token arrives
|
||||||
|
↓
|
||||||
|
2. AccountContextMiddleware runs:
|
||||||
|
- Decodes JWT token
|
||||||
|
- Gets user_id=5, account_id=10
|
||||||
|
- Loads User(id=5) with account relationship
|
||||||
|
- Checks: user.account.id = 12 (user moved to account 12)
|
||||||
|
- Sets: request.account = Account(id=12) ✅ CORRECT
|
||||||
|
↓
|
||||||
|
3. JWTAuthentication runs:
|
||||||
|
- Decodes JWT token (again)
|
||||||
|
- Gets user_id=5, account_id=10
|
||||||
|
- Loads User(id=5) WITHOUT account relationship
|
||||||
|
- Gets Account(id=10) from token
|
||||||
|
- Sets: request.account = Account(id=10) ❌ WRONG (overwrites middleware)
|
||||||
|
- Sets: request.user = User(id=5) (without account relationship)
|
||||||
|
↓
|
||||||
|
4. View uses request.account (WRONG - account 10 instead of 12)
|
||||||
|
5. View uses request.user.account (might trigger lazy load, could be stale)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Flow (CORRECT)
|
||||||
|
```
|
||||||
|
1. Request with JWT token arrives
|
||||||
|
↓
|
||||||
|
2. AccountContextMiddleware runs:
|
||||||
|
- Sets request.account based on token with validation
|
||||||
|
↓
|
||||||
|
3. JWTAuthentication runs:
|
||||||
|
- Sets request.user with account relationship loaded
|
||||||
|
- Does NOT overwrite request.account (respects middleware)
|
||||||
|
↓
|
||||||
|
4. View uses request.account (CORRECT - from middleware)
|
||||||
|
5. View uses request.user.account (CORRECT - loaded with relationship)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema Check
|
||||||
|
|
||||||
|
### User Model
|
||||||
|
- `User.account` = ForeignKey to Account, `db_column='tenant_id'`, nullable
|
||||||
|
- Relationship: User → Account (many-to-one)
|
||||||
|
|
||||||
|
### Account Model
|
||||||
|
- `Account.owner` = ForeignKey to User
|
||||||
|
- Relationship: Account → User (many-to-one, owner)
|
||||||
|
|
||||||
|
### Potential Database Issues
|
||||||
|
- If `User.account_id` (tenant_id column) doesn't match token's `account_id`, there's a mismatch
|
||||||
|
- If user's account was changed in DB but token wasn't refreshed, token has stale account_id
|
||||||
|
|
||||||
|
## Permission System Check
|
||||||
|
|
||||||
|
### HasTenantAccess Permission
|
||||||
|
**Location**: `backend/igny8_core/api/permissions.py:25-67`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
account = getattr(request, 'account', None)
|
||||||
|
|
||||||
|
# If no account in request, try to get from user
|
||||||
|
if not account and hasattr(request.user, 'account'):
|
||||||
|
account = request.user.account
|
||||||
|
|
||||||
|
# Check if user belongs to this account
|
||||||
|
if account:
|
||||||
|
user_account = request.user.account
|
||||||
|
return user_account == account or user_account.id == account.id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Permission checks `request.account` vs `request.user.account`
|
||||||
|
- If `request.account` is wrong (from JWTAuthentication overwrite), permission check fails
|
||||||
|
- User gets 403 Forbidden even though they should have access
|
||||||
|
|
||||||
|
## Recommendations (Diagnosis Only - No Code Changes)
|
||||||
|
|
||||||
|
### Fix Priority
|
||||||
|
|
||||||
|
1. **CRITICAL**: Make JWTAuthentication respect middleware's `request.account` OR remove duplicate logic
|
||||||
|
- Option A: JWTAuthentication should check if `request.account` already exists and not overwrite it
|
||||||
|
- Option B: Remove account setting from JWTAuthentication, let middleware handle it
|
||||||
|
|
||||||
|
2. **HIGH**: Load user with account relationship in JWTAuthentication
|
||||||
|
- Change `User.objects.get(id=user_id)` to `User.objects.select_related('account', 'account__plan').get(id=user_id)`
|
||||||
|
|
||||||
|
3. **MEDIUM**: Don't set `request.user` in middleware for JWT auth
|
||||||
|
- Middleware should only set `request.user` for session auth
|
||||||
|
- For JWT auth, let JWTAuthentication handle `request.user`
|
||||||
|
|
||||||
|
4. **LOW**: Add validation in token generation to ensure account_id matches user.account
|
||||||
|
- Or add token refresh mechanism when user's account changes
|
||||||
|
|
||||||
|
### Architecture Decision Needed
|
||||||
|
|
||||||
|
**Question**: Should `request.account` be set by:
|
||||||
|
- A) Middleware only (current middleware logic with validation)
|
||||||
|
- B) JWTAuthentication only (simpler, but loses validation)
|
||||||
|
- C) Both, but JWTAuthentication checks if middleware already set it
|
||||||
|
|
||||||
|
**Recommendation**: Option C - Middleware sets it with validation, JWTAuthentication only sets if not already set
|
||||||
|
|
||||||
|
## Files Involved
|
||||||
|
|
||||||
|
1. `backend/igny8_core/auth/middleware.py` - AccountContextMiddleware
|
||||||
|
2. `backend/igny8_core/api/authentication.py` - JWTAuthentication
|
||||||
|
3. `backend/igny8_core/auth/urls.py` - MeView endpoint
|
||||||
|
4. `backend/igny8_core/auth/utils.py` - Token generation
|
||||||
|
5. `backend/igny8_core/api/permissions.py` - HasTenantAccess permission
|
||||||
|
6. `backend/igny8_core/settings.py` - Middleware and authentication class order
|
||||||
|
|
||||||
|
## Testing Scenarios to Verify
|
||||||
|
|
||||||
|
1. **User with account_id in token matches user.account** → Should work
|
||||||
|
2. **User's account changed after login (token has old account_id)** → Currently broken
|
||||||
|
3. **User with no account in token** → Should fallback to user.account
|
||||||
|
4. **Developer/admin user** → Should bypass account checks
|
||||||
|
5. **Session auth vs JWT auth** → Both should work consistently
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
| Principle | Description | Implementation |
|
| Principle | Description | Implementation |
|
||||||
|-----------|-------------|----------------|
|
|-----------|-------------|----------------|
|
||||||
| **Domain-Driven Design** | Organize by business domains, not technical layers | `domain/` folder with content, planning, linking, optimization, publishing domains |
|
| **Domain-Driven Design** | Organize by business domains, not technical layers | `business/` folder with content, planning, linking, optimization, publishing domains |
|
||||||
| **Service Layer Pattern** | Business logic in services, not ViewSets | All modules delegate to domain services |
|
| **Service Layer Pattern** | Business logic in services, not ViewSets | All modules delegate to domain services |
|
||||||
| **Single Responsibility** | Each layer has one clear purpose | Core → Domain → Module → Infrastructure |
|
| **Single Responsibility** | Each layer has one clear purpose | Core → Domain → Module → Infrastructure |
|
||||||
| **No Duplication** | Reuse services across modules | ContentGenerationService used by Writer + Site Builder |
|
| **No Duplication** | Reuse services across modules | ContentGenerationService used by Writer + Site Builder |
|
||||||
@@ -108,7 +108,7 @@ backend/igny8_core/
|
|||||||
│ ├── permissions.py # IsAuthenticatedAndActive, HasTenantAccess
|
│ ├── permissions.py # IsAuthenticatedAndActive, HasTenantAccess
|
||||||
│ └── throttles.py # DebugScopedRateThrottle
|
│ └── throttles.py # DebugScopedRateThrottle
|
||||||
│
|
│
|
||||||
├── domain/ # DOMAIN LAYER (Business Logic)
|
├── business/ # BUSINESS LAYER (Business Logic)
|
||||||
│ ├── content/ # Content domain
|
│ ├── content/ # Content domain
|
||||||
│ │ ├── models.py # Content, Tasks, Images (unified, extended)
|
│ │ ├── models.py # Content, Tasks, Images (unified, extended)
|
||||||
│ │ ├── services/
|
│ │ ├── services/
|
||||||
@@ -248,20 +248,20 @@ backend/igny8_core/
|
|||||||
|
|
||||||
| Model | Current Location | New Location | Extensions Needed |
|
| Model | Current Location | New Location | Extensions Needed |
|
||||||
|-------|------------------|-------------|-------------------|
|
|-------|------------------|-------------|-------------------|
|
||||||
| `Content` | `modules/writer/models.py` | `domain/content/models.py` | Add: `entity_type`, `json_blocks`, `structure_data`, `linker_version`, `optimizer_version`, `internal_links`, `optimization_scores`, `published_to`, `external_ids`, `source`, `sync_status`, `external_id`, `external_url`, `sync_metadata` |
|
| `Content` | `modules/writer/models.py` | `business/content/models.py` | Add: `entity_type`, `json_blocks`, `structure_data`, `linker_version`, `optimizer_version`, `internal_links`, `optimization_scores`, `published_to`, `external_ids`, `source`, `sync_status`, `external_id`, `external_url`, `sync_metadata` |
|
||||||
| `Tasks` | `modules/writer/models.py` | `domain/content/models.py` | Add: `entity_type` choices (product, service, taxonomy, etc.) |
|
| `Tasks` | `modules/writer/models.py` | `business/content/models.py` | Add: `entity_type` choices (product, service, taxonomy, etc.) |
|
||||||
| `Keywords` | `modules/planner/models.py` | `domain/planning/models.py` | No changes |
|
| `Keywords` | `modules/planner/models.py` | `business/planning/models.py` | No changes |
|
||||||
| `Clusters` | `modules/planner/models.py` | `domain/planning/models.py` | No changes |
|
| `Clusters` | `modules/planner/models.py` | `business/planning/models.py` | No changes |
|
||||||
| `ContentIdeas` | `modules/planner/models.py` | `domain/planning/models.py` | Add: `entity_type` support |
|
| `ContentIdeas` | `modules/planner/models.py` | `business/planning/models.py` | Add: `entity_type` support |
|
||||||
| `InternalLinks` | - | `domain/linking/models.py` | NEW: `source_id`, `target_id`, `anchor`, `position`, `link_type` |
|
| `InternalLinks` | - | `business/linking/models.py` | NEW: `source_id`, `target_id`, `anchor`, `position`, `link_type` |
|
||||||
| `OptimizationTask` | - | `domain/optimization/models.py` | NEW: `content_id`, `type`, `target_keyword`, `scores_before`, `scores_after`, `html_before`, `html_after` |
|
| `OptimizationTask` | - | `business/optimization/models.py` | NEW: `content_id`, `type`, `target_keyword`, `scores_before`, `scores_after`, `html_before`, `html_after` |
|
||||||
| `SiteBlueprint` | - | `domain/site_building/models.py` | NEW: `tenant`, `site`, `config_json`, `structure_json`, `status`, `hosting_type` |
|
| `SiteBlueprint` | - | `business/site_building/models.py` | NEW: `tenant`, `site`, `config_json`, `structure_json`, `status`, `hosting_type` |
|
||||||
| `PageBlueprint` | - | `domain/site_building/models.py` | NEW: `site_blueprint`, `slug`, `type`, `blocks_json`, `status` |
|
| `PageBlueprint` | - | `business/site_building/models.py` | NEW: `site_blueprint`, `slug`, `type`, `blocks_json`, `status` |
|
||||||
| `SiteIntegration` | - | `domain/integration/models.py` | NEW: `site`, `platform`, `platform_type`, `config_json`, `credentials`, `is_active`, `sync_enabled` |
|
| `SiteIntegration` | - | `business/integration/models.py` | NEW: `site`, `platform`, `platform_type`, `config_json`, `credentials`, `is_active`, `sync_enabled` |
|
||||||
| `PublishingRecord` | - | `domain/publishing/models.py` | NEW: `content_id`, `destination`, `destination_type`, `status`, `external_id`, `published_at`, `sync_status` |
|
| `PublishingRecord` | - | `business/publishing/models.py` | NEW: `content_id`, `destination`, `destination_type`, `status`, `external_id`, `published_at`, `sync_status` |
|
||||||
| `DeploymentRecord` | - | `domain/publishing/models.py` | NEW: `site_blueprint`, `version`, `status`, `build_url`, `deployed_at`, `deployment_type` |
|
| `DeploymentRecord` | - | `business/publishing/models.py` | NEW: `site_blueprint`, `version`, `status`, `build_url`, `deployed_at`, `deployment_type` |
|
||||||
| `AutomationRule` | - | `domain/automation/models.py` | NEW: `name`, `trigger`, `conditions`, `actions`, `schedule`, `is_active` |
|
| `AutomationRule` | - | `business/automation/models.py` | NEW: `name`, `trigger`, `conditions`, `actions`, `schedule`, `is_active` |
|
||||||
| `ScheduledTask` | - | `domain/automation/models.py` | NEW: `automation_rule`, `scheduled_at`, `status`, `executed_at` |
|
| `ScheduledTask` | - | `business/automation/models.py` | NEW: `automation_rule`, `scheduled_at`, `status`, `executed_at` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -278,11 +278,10 @@ frontend/src/
|
|||||||
│ ├── Billing/ # Existing
|
│ ├── Billing/ # Existing
|
||||||
│ ├── Settings/ # Existing
|
│ ├── Settings/ # Existing
|
||||||
│ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT
|
│ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT
|
||||||
│ │ ├── Dashboard.tsx # Automation overview
|
│ │ ├── Dashboard.tsx # Automation overview (includes schedules functionality)
|
||||||
│ │ ├── 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
|
||||||
@@ -367,8 +366,8 @@ sites/src/
|
|||||||
|
|
||||||
| Component | Purpose | Implementation |
|
| Component | Purpose | Implementation |
|
||||||
|-----------|---------|----------------|
|
|-----------|---------|----------------|
|
||||||
| **AutomationRule Model** | Store automation rules | `domain/automation/models.py` |
|
| **AutomationRule Model** | Store automation rules | `business/automation/models.py` |
|
||||||
| **AutomationService** | Execute automation rules | `domain/automation/services/automation_service.py` |
|
| **AutomationService** | Execute automation rules | `business/automation/services/automation_service.py` |
|
||||||
| **Celery Beat Tasks** | Scheduled automation | `infrastructure/messaging/automation_tasks.py` |
|
| **Celery Beat Tasks** | Scheduled automation | `infrastructure/messaging/automation_tasks.py` |
|
||||||
| **Automation API** | CRUD for rules | `modules/automation/views.py` |
|
| **Automation API** | CRUD for rules | `modules/automation/views.py` |
|
||||||
| **Automation UI** | Manage rules | `frontend/src/pages/Automation/` |
|
| **Automation UI** | Manage rules | `frontend/src/pages/Automation/` |
|
||||||
@@ -410,8 +409,8 @@ class AutomationRule(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Implementation |
|
| Task | File | Implementation |
|
||||||
|------|------|----------------|
|
|------|------|----------------|
|
||||||
| **AutomationRule Model** | `domain/automation/models.py` | Create model with trigger, conditions, actions, schedule |
|
| **AutomationRule Model** | `business/automation/models.py` | Create model with trigger, conditions, actions, schedule |
|
||||||
| **AutomationService** | `domain/automation/services/automation_service.py` | `execute_rule()`, `check_conditions()`, `execute_actions()` |
|
| **AutomationService** | `business/automation/services/automation_service.py` | `execute_rule()`, `check_conditions()`, `execute_actions()` |
|
||||||
| **Celery Beat Tasks** | `infrastructure/messaging/automation_tasks.py` | `@periodic_task` decorators for scheduled rules |
|
| **Celery Beat Tasks** | `infrastructure/messaging/automation_tasks.py` | `@periodic_task` decorators for scheduled rules |
|
||||||
| **Automation API** | `modules/automation/views.py` | CRUD ViewSet for AutomationRule |
|
| **Automation API** | `modules/automation/views.py` | CRUD ViewSet for AutomationRule |
|
||||||
| **Automation UI** | `frontend/src/pages/Automation/` | Dashboard, Rules management, History |
|
| **Automation UI** | `frontend/src/pages/Automation/` | Dashboard, Rules management, History |
|
||||||
@@ -435,9 +434,9 @@ class AutomationRule(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Resource | Limit Type | Enforcement | Location |
|
| Resource | Limit Type | Enforcement | Location |
|
||||||
|-----------|------------|-------------|----------|
|
|-----------|------------|-------------|----------|
|
||||||
| **Daily Content Tasks** | Per site | `Plan.daily_content_tasks` | `domain/content/services/content_generation_service.py` |
|
| **Daily Content Tasks** | Per site | `Plan.daily_content_tasks` | `business/content/services/content_generation_service.py` |
|
||||||
| **Daily AI Requests** | Per site | `Plan.daily_ai_requests` | `infrastructure/ai/engine.py` |
|
| **Daily AI Requests** | Per site | `Plan.daily_ai_requests` | `infrastructure/ai/engine.py` |
|
||||||
| **Monthly Word Count** | Per site | `Plan.monthly_word_count_limit` | `domain/content/services/content_generation_service.py` |
|
| **Monthly Word Count** | Per site | `Plan.monthly_word_count_limit` | `business/content/services/content_generation_service.py` |
|
||||||
| **Daily Image Generation** | Per site | `Plan.daily_image_generation_limit` | `infrastructure/ai/functions/generate_images.py` |
|
| **Daily Image Generation** | Per site | `Plan.daily_image_generation_limit` | `infrastructure/ai/functions/generate_images.py` |
|
||||||
| **Storage Quota** | Per site | Configurable (default: 10GB) | `infrastructure/storage/file_storage.py` |
|
| **Storage Quota** | Per site | Configurable (default: 10GB) | `infrastructure/storage/file_storage.py` |
|
||||||
| **Concurrent Tasks** | Per site | Configurable (default: 5) | Celery queue configuration |
|
| **Concurrent Tasks** | Per site | Configurable (default: 5) | Celery queue configuration |
|
||||||
@@ -531,7 +530,7 @@ class FileStorageService:
|
|||||||
|
|
||||||
**Site Builder File Management**:
|
**Site Builder File Management**:
|
||||||
```python
|
```python
|
||||||
# domain/site_building/services/file_management_service.py
|
# business/site_building/services/file_management_service.py
|
||||||
class SiteBuilderFileService:
|
class SiteBuilderFileService:
|
||||||
def get_user_accessible_sites(self, user) -> List[Site]:
|
def get_user_accessible_sites(self, user) -> List[Site]:
|
||||||
"""Get sites user can access for file management"""
|
"""Get sites user can access for file management"""
|
||||||
@@ -646,22 +645,22 @@ docker-data/
|
|||||||
|
|
||||||
| Task | Files | Status | Priority |
|
| Task | Files | Status | Priority |
|
||||||
|------|-------|--------|-----------|
|
|------|-------|--------|-----------|
|
||||||
| **Extend Content Model** | `domain/content/models.py` | TODO | HIGH |
|
| **Extend Content Model** | `business/content/models.py` | TODO | HIGH |
|
||||||
| **Create Service Layer** | `domain/*/services/` | TODO | HIGH |
|
| **Create Service Layer** | `business/*/services/` | TODO | HIGH |
|
||||||
| **Refactor ViewSets** | `modules/*/views.py` | TODO | HIGH |
|
| **Refactor ViewSets** | `modules/*/views.py` | TODO | HIGH |
|
||||||
| **Implement Automation Models** | `domain/automation/models.py` | TODO | HIGH |
|
| **Implement Automation Models** | `business/automation/models.py` | TODO | HIGH |
|
||||||
| **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH |
|
| **Implement Automation Service** | `business/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 |
|
||||||
| **Implement Schedules UI** | `frontend/src/pages/Schedules.tsx` | TODO | HIGH |
|
| **Note**: Schedules functionality will be integrated into Automation UI, not as a separate page | - | - | - |
|
||||||
|
|
||||||
### 9.2 Phase 1: Site Builder
|
### 9.2 Phase 1: Site Builder
|
||||||
|
|
||||||
| Task | Files | Dependencies | Priority |
|
| Task | Files | Dependencies | Priority |
|
||||||
|------|-------|--------------|----------|
|
|------|-------|--------------|----------|
|
||||||
| **Create Site Builder Container** | `docker-compose.app.yml` | Phase 0 | HIGH |
|
| **Create Site Builder Container** | `docker-compose.app.yml` | Phase 0 | HIGH |
|
||||||
| **Site Builder Models** | `domain/site_building/models.py` | Phase 0 | HIGH |
|
| **Site Builder Models** | `business/site_building/models.py` | Phase 0 | HIGH |
|
||||||
| **Structure Generation Service** | `domain/site_building/services/` | Phase 0 | HIGH |
|
| **Structure Generation Service** | `business/site_building/services/` | Phase 0 | HIGH |
|
||||||
| **Structure Generation AI Function** | `infrastructure/ai/functions/generate_site_structure.py` | Phase 0 | HIGH |
|
| **Structure Generation AI Function** | `infrastructure/ai/functions/generate_site_structure.py` | Phase 0 | HIGH |
|
||||||
| **Site Builder API** | `modules/site_builder/` | Phase 0 | HIGH |
|
| **Site Builder API** | `modules/site_builder/` | Phase 0 | HIGH |
|
||||||
| **Site Builder Frontend** | `site-builder/src/` | Phase 0 | HIGH |
|
| **Site Builder Frontend** | `site-builder/src/` | Phase 0 | HIGH |
|
||||||
@@ -670,12 +669,12 @@ docker-data/
|
|||||||
|
|
||||||
| Task | Files | Dependencies | Priority |
|
| Task | Files | Dependencies | Priority |
|
||||||
|------|-------|--------------|----------|
|
|------|-------|--------------|----------|
|
||||||
| **Linker Models** | `domain/linking/models.py` | Phase 0 | MEDIUM |
|
| **Linker Models** | `business/linking/models.py` | Phase 0 | MEDIUM |
|
||||||
| **Linker Service** | `domain/linking/services/` | Phase 0 | MEDIUM |
|
| **Linker Service** | `business/linking/services/` | Phase 0 | MEDIUM |
|
||||||
| **Linker API** | `modules/linker/` | Phase 0 | MEDIUM |
|
| **Linker API** | `modules/linker/` | Phase 0 | MEDIUM |
|
||||||
| **Linker UI** | `frontend/src/pages/Linker/` | Phase 0 | MEDIUM |
|
| **Linker UI** | `frontend/src/pages/Linker/` | Phase 0 | MEDIUM |
|
||||||
| **Optimizer Models** | `domain/optimization/models.py` | Phase 0 | MEDIUM |
|
| **Optimizer Models** | `business/optimization/models.py` | Phase 0 | MEDIUM |
|
||||||
| **Optimizer Service** | `domain/optimization/services/` | Phase 0 | MEDIUM |
|
| **Optimizer Service** | `business/optimization/services/` | Phase 0 | MEDIUM |
|
||||||
| **Optimizer AI Function** | `infrastructure/ai/functions/optimize_content.py` | Phase 0 | MEDIUM |
|
| **Optimizer AI Function** | `infrastructure/ai/functions/optimize_content.py` | Phase 0 | MEDIUM |
|
||||||
| **Optimizer API** | `modules/optimizer/` | Phase 0 | MEDIUM |
|
| **Optimizer API** | `modules/optimizer/` | Phase 0 | MEDIUM |
|
||||||
| **Optimizer UI** | `frontend/src/pages/Optimizer/` | Phase 0 | MEDIUM |
|
| **Optimizer UI** | `frontend/src/pages/Optimizer/` | Phase 0 | MEDIUM |
|
||||||
@@ -686,22 +685,22 @@ docker-data/
|
|||||||
|------|-------|--------------|----------|
|
|------|-------|--------------|----------|
|
||||||
| **Create Sites Container** | `docker-compose.app.yml` | Phase 1 | MEDIUM |
|
| **Create Sites Container** | `docker-compose.app.yml` | Phase 1 | MEDIUM |
|
||||||
| **Sites Renderer Frontend** | `sites/src/` | Phase 1 | MEDIUM |
|
| **Sites Renderer Frontend** | `sites/src/` | Phase 1 | MEDIUM |
|
||||||
| **Publisher Service** | `domain/publishing/services/` | Phase 0 | MEDIUM |
|
| **Publisher Service** | `business/publishing/services/` | Phase 0 | MEDIUM |
|
||||||
| **Sites Renderer Adapter** | `domain/publishing/services/adapters/` | Phase 1 | MEDIUM |
|
| **Sites Renderer Adapter** | `business/publishing/services/adapters/` | Phase 1 | MEDIUM |
|
||||||
| **Publisher API** | `modules/publisher/` | Phase 0 | MEDIUM |
|
| **Publisher API** | `modules/publisher/` | Phase 0 | MEDIUM |
|
||||||
| **Deployment Service** | `domain/publishing/services/deployment_service.py` | Phase 1 | MEDIUM |
|
| **Deployment Service** | `business/publishing/services/deployment_service.py` | Phase 1 | MEDIUM |
|
||||||
|
|
||||||
### 9.5 Phase 4: Universal Content Types
|
### 9.5 Phase 4: Universal Content Types
|
||||||
|
|
||||||
| Task | Files | Dependencies | Priority |
|
| Task | Files | Dependencies | Priority |
|
||||||
|------|-------|--------------|----------|
|
|------|-------|--------------|----------|
|
||||||
| **Extend Content Model** | `domain/content/models.py` | Phase 0 | LOW |
|
| **Extend Content Model** | `business/content/models.py` | Phase 0 | LOW |
|
||||||
| **Product Content Prompts** | `infrastructure/ai/prompts.py` | Phase 0 | LOW |
|
| **Product Content Prompts** | `infrastructure/ai/prompts.py` | Phase 0 | LOW |
|
||||||
| **Service Page Prompts** | `infrastructure/ai/prompts.py` | Phase 0 | LOW |
|
| **Service Page Prompts** | `infrastructure/ai/prompts.py` | Phase 0 | LOW |
|
||||||
| **Taxonomy Prompts** | `infrastructure/ai/prompts.py` | Phase 0 | LOW |
|
| **Taxonomy Prompts** | `infrastructure/ai/prompts.py` | Phase 0 | LOW |
|
||||||
| **Content Type Support in Writer** | `domain/content/services/` | Phase 0 | LOW |
|
| **Content Type Support in Writer** | `business/content/services/` | Phase 0 | LOW |
|
||||||
| **Content Type Support in Linker** | `domain/linking/services/` | Phase 2 | LOW |
|
| **Content Type Support in Linker** | `business/linking/services/` | Phase 2 | LOW |
|
||||||
| **Content Type Support in Optimizer** | `domain/optimization/services/` | Phase 2 | LOW |
|
| **Content Type Support in Optimizer** | `business/optimization/services/` | Phase 2 | LOW |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -751,11 +750,11 @@ docker-data/
|
|||||||
| Location | Check | Implementation |
|
| Location | Check | Implementation |
|
||||||
|----------|-------|----------------|
|
|----------|-------|----------------|
|
||||||
| **AI Engine** | Before AI call | `infrastructure/ai/engine.py` - Check credits, deduct before request |
|
| **AI Engine** | Before AI call | `infrastructure/ai/engine.py` - Check credits, deduct before request |
|
||||||
| **Content Generation** | Before generation | `domain/content/services/content_generation_service.py` |
|
| **Content Generation** | Before generation | `business/content/services/content_generation_service.py` |
|
||||||
| **Image Generation** | Before generation | `infrastructure/ai/functions/generate_images.py` |
|
| **Image Generation** | Before generation | `infrastructure/ai/functions/generate_images.py` |
|
||||||
| **Linking** | Before linking | `domain/linking/services/linker_service.py` (NEW) |
|
| **Linking** | Before linking | `business/linking/services/linker_service.py` (NEW) |
|
||||||
| **Optimization** | Before optimization | `domain/optimization/services/optimizer_service.py` (NEW) |
|
| **Optimization** | Before optimization | `business/optimization/services/optimizer_service.py` (NEW) |
|
||||||
| **Site Building** | Before structure gen | `domain/site_building/services/structure_generation_service.py` (NEW) |
|
| **Site Building** | Before structure gen | `business/site_building/services/structure_generation_service.py` (NEW) |
|
||||||
|
|
||||||
### 10.5 Credit Logging
|
### 10.5 Credit Logging
|
||||||
|
|
||||||
@@ -785,8 +784,8 @@ These are **NOT** business limits - they're technical constraints for request pr
|
|||||||
|
|
||||||
| Feature | Implementation | Location |
|
| Feature | Implementation | Location |
|
||||||
|---------|----------------|----------|
|
|---------|----------------|----------|
|
||||||
| **Credit Check** | Before every AI operation | `domain/billing/services/credit_service.py` |
|
| **Credit Check** | Before every AI operation | `business/billing/services/credit_service.py` |
|
||||||
| **Credit Deduction** | After successful operation | `domain/billing/services/credit_service.py` |
|
| **Credit Deduction** | After successful operation | `business/billing/services/credit_service.py` |
|
||||||
| **Credit Top-up** | On-demand purchase | `modules/billing/views.py` |
|
| **Credit Top-up** | On-demand purchase | `modules/billing/views.py` |
|
||||||
| **Monthly Replenishment** | Celery Beat task | `infrastructure/messaging/automation_tasks.py` |
|
| **Monthly Replenishment** | Celery Beat task | `infrastructure/messaging/automation_tasks.py` |
|
||||||
| **Low Credit Warning** | When < 10% remaining | Frontend + Email notification |
|
| **Low Credit Warning** | When < 10% remaining | Frontend + Email notification |
|
||||||
@@ -894,7 +893,7 @@ publisher_service.publish(
|
|||||||
### 11.6 Site Integration Service
|
### 11.6 Site Integration Service
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# domain/integration/services/integration_service.py
|
# business/integration/services/integration_service.py
|
||||||
class IntegrationService:
|
class IntegrationService:
|
||||||
def create_integration(self, site, platform, config, credentials):
|
def create_integration(self, site, platform, config, credentials):
|
||||||
"""Create new site integration"""
|
"""Create new site integration"""
|
||||||
@@ -946,12 +945,12 @@ Content/Site Publishing Flow:
|
|||||||
|
|
||||||
| Component | File | Purpose |
|
| Component | File | Purpose |
|
||||||
|-----------|------|---------|
|
|-----------|------|---------|
|
||||||
| **SiteIntegration Model** | `domain/integration/models.py` | Store integration configs |
|
| **SiteIntegration Model** | `business/integration/models.py` | Store integration configs |
|
||||||
| **IntegrationService** | `domain/integration/services/integration_service.py` | Manage integrations |
|
| **IntegrationService** | `business/integration/services/integration_service.py` | Manage integrations |
|
||||||
| **SyncService** | `domain/integration/services/sync_service.py` | Handle two-way sync |
|
| **SyncService** | `business/integration/services/sync_service.py` | Handle two-way sync |
|
||||||
| **WordPressAdapter** | `domain/publishing/services/adapters/wordpress_adapter.py` | WordPress publishing |
|
| **WordPressAdapter** | `business/publishing/services/adapters/wordpress_adapter.py` | WordPress publishing |
|
||||||
| **SitesRendererAdapter** | `domain/publishing/services/adapters/sites_renderer_adapter.py` | IGNY8 Sites deployment |
|
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | IGNY8 Sites deployment |
|
||||||
| **ShopifyAdapter** | `domain/publishing/services/adapters/shopify_adapter.py` | Shopify publishing (future) |
|
| **ShopifyAdapter** | `business/publishing/services/adapters/shopify_adapter.py` | Shopify publishing (future) |
|
||||||
| **Integration API** | `modules/integration/views.py` | CRUD for integrations |
|
| **Integration API** | `modules/integration/views.py` | CRUD for integrations |
|
||||||
| **Integration UI** | `frontend/src/pages/Settings/Integrations.tsx` | Manage integrations |
|
| **Integration UI** | `frontend/src/pages/Settings/Integrations.tsx` | Manage integrations |
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
| Task | Files | Dependencies | Risk |
|
| Task | Files | Dependencies | Risk |
|
||||||
|------|-------|--------------|------|
|
|------|-------|--------------|------|
|
||||||
| **Add Module Enable/Disable** | `domain/system/models.py` | EXISTING (ModuleSettings) | LOW - Extend existing |
|
| **Add Module Enable/Disable** | `business/system/models.py` | EXISTING (ModuleSettings) | LOW - Extend existing |
|
||||||
| **Module Settings API** | `modules/system/views.py` | EXISTING | LOW - Extend existing |
|
| **Module Settings API** | `modules/system/views.py` | EXISTING | LOW - Extend existing |
|
||||||
| **Module Settings UI** | `frontend/src/pages/Settings/Modules.tsx` | EXISTING (placeholder) | LOW - Implement UI |
|
| **Module Settings UI** | `frontend/src/pages/Settings/Modules.tsx` | EXISTING (placeholder) | LOW - Implement UI |
|
||||||
| **Frontend Module Loader** | `frontend/src/config/modules.config.ts` | None | MEDIUM - Conditional loading |
|
| **Frontend Module Loader** | `frontend/src/config/modules.config.ts` | None | MEDIUM - Conditional loading |
|
||||||
@@ -100,18 +100,18 @@
|
|||||||
|------|-------|--------------|------|
|
|------|-------|--------------|------|
|
||||||
| **Remove Plan Limit Fields** | `core/auth/models.py` | None | LOW - Add migration to set defaults |
|
| **Remove Plan Limit Fields** | `core/auth/models.py` | None | LOW - Add migration to set defaults |
|
||||||
| **Update Plan Model** | `core/auth/models.py` | None | LOW - Keep only monthly_credits, support_level |
|
| **Update Plan Model** | `core/auth/models.py` | None | LOW - Keep only monthly_credits, support_level |
|
||||||
| **Update CreditService** | `domain/billing/services/credit_service.py` | None | MEDIUM - Add credit cost constants |
|
| **Update CreditService** | `business/billing/services/credit_service.py` | None | MEDIUM - Add credit cost constants |
|
||||||
| **Add Credit Costs** | `domain/billing/constants.py` | None | LOW - Define credit costs per operation |
|
| **Add Credit Costs** | `business/billing/constants.py` | None | LOW - Define credit costs per operation |
|
||||||
| **Update AI Engine** | `infrastructure/ai/engine.py` | CreditService | MEDIUM - Check credits before AI calls |
|
| **Update AI Engine** | `infrastructure/ai/engine.py` | CreditService | MEDIUM - Check credits before AI calls |
|
||||||
| **Update Content Generation** | `domain/content/services/` | CreditService | MEDIUM - Check credits before generation |
|
| **Update Content Generation** | `business/content/services/` | CreditService | MEDIUM - Check credits before generation |
|
||||||
| **Update Image Generation** | `infrastructure/ai/functions/generate_images.py` | CreditService | MEDIUM - Check credits before generation |
|
| **Update Image Generation** | `infrastructure/ai/functions/generate_images.py` | CreditService | MEDIUM - Check credits before generation |
|
||||||
| **Remove Limit Checks** | All services | None | MEDIUM - Remove all plan limit validations |
|
| **Remove Limit Checks** | All services | None | MEDIUM - Remove all plan limit validations |
|
||||||
| **Update Usage Logging** | `domain/billing/models.py` | None | LOW - Ensure all operations log credits |
|
| **Update Usage Logging** | `business/billing/models.py` | None | LOW - Ensure all operations log credits |
|
||||||
| **Update Frontend Limits UI** | `frontend/src/pages/Billing/` | Backend API | LOW - Replace limits with credit display |
|
| **Update Frontend Limits UI** | `frontend/src/pages/Billing/` | Backend API | LOW - Replace limits with credit display |
|
||||||
|
|
||||||
**Credit Cost Constants**:
|
**Credit Cost Constants**:
|
||||||
```python
|
```python
|
||||||
# domain/billing/constants.py
|
# business/billing/constants.py
|
||||||
CREDIT_COSTS = {
|
CREDIT_COSTS = {
|
||||||
'clustering': 10,
|
'clustering': 10,
|
||||||
'idea_generation': 15,
|
'idea_generation': 15,
|
||||||
@@ -156,16 +156,16 @@ CREDIT_COSTS = {
|
|||||||
|
|
||||||
**Goal**: Extract business logic from ViewSets into services, preserving all existing functionality.
|
**Goal**: Extract business logic from ViewSets into services, preserving all existing functionality.
|
||||||
|
|
||||||
### 1.1 Create Domain Structure
|
### 1.1 Create Business Structure
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Create domain/ folder** | `backend/igny8_core/domain/` | None |
|
| **Create business/ folder** | `backend/igny8_core/business/` | None |
|
||||||
| **Move Content models** | `domain/content/models.py` | Phase 0 |
|
| **Move Content models** | `business/content/models.py` | Phase 0 |
|
||||||
| **Move Planning models** | `domain/planning/models.py` | Phase 0 |
|
| **Move Planning models** | `business/planning/models.py` | Phase 0 |
|
||||||
| **Create ContentService** | `domain/content/services/content_generation_service.py` | Existing Writer logic |
|
| **Create ContentService** | `business/content/services/content_generation_service.py` | Existing Writer logic |
|
||||||
| **Create PlanningService** | `domain/planning/services/clustering_service.py` | Existing Planner logic |
|
| **Create PlanningService** | `business/planning/services/clustering_service.py` | Existing Planner logic |
|
||||||
| **Create IdeasService** | `domain/planning/services/ideas_service.py` | Existing Planner logic |
|
| **Create IdeasService** | `business/planning/services/ideas_service.py` | Existing Planner logic |
|
||||||
|
|
||||||
### 1.2 Refactor ViewSets (Keep APIs Working)
|
### 1.2 Refactor ViewSets (Keep APIs Working)
|
||||||
|
|
||||||
@@ -199,18 +199,18 @@ CREDIT_COSTS = {
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **AutomationRule Model** | `domain/automation/models.py` | Phase 1 |
|
| **AutomationRule Model** | `business/automation/models.py` | Phase 1 |
|
||||||
| **ScheduledTask Model** | `domain/automation/models.py` | Phase 1 |
|
| **ScheduledTask Model** | `business/automation/models.py` | Phase 1 |
|
||||||
| **Automation Migrations** | `domain/automation/migrations/` | Phase 1 |
|
| **Automation Migrations** | `business/automation/migrations/` | Phase 1 |
|
||||||
|
|
||||||
### 2.2 Automation Service
|
### 2.2 Automation Service
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **AutomationService** | `domain/automation/services/automation_service.py` | Phase 1 services |
|
| **AutomationService** | `business/automation/services/automation_service.py` | Phase 1 services |
|
||||||
| **Rule Execution Engine** | `domain/automation/services/rule_engine.py` | Phase 1 services |
|
| **Rule Execution Engine** | `business/automation/services/rule_engine.py` | Phase 1 services |
|
||||||
| **Condition Evaluator** | `domain/automation/services/condition_evaluator.py` | None |
|
| **Condition Evaluator** | `business/automation/services/condition_evaluator.py` | None |
|
||||||
| **Action Executor** | `domain/automation/services/action_executor.py` | Phase 1 services |
|
| **Action Executor** | `business/automation/services/action_executor.py` | Phase 1 services |
|
||||||
|
|
||||||
### 2.3 Celery Beat Tasks
|
### 2.3 Celery Beat Tasks
|
||||||
|
|
||||||
@@ -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 Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) |
|
| **Schedules (within Automation)** | Integrated into Automation Dashboard | Part of automation menu |
|
||||||
| **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
|
||||||
@@ -256,8 +256,8 @@ CREDIT_COSTS = {
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Site File Management Service** | `domain/site_building/services/file_management_service.py` | Phase 1 |
|
| **Site File Management Service** | `business/site_building/services/file_management_service.py` | Phase 1 |
|
||||||
| **User Site Access Check** | `domain/site_building/services/file_management_service.py` | EXISTING (SiteUserAccess) |
|
| **User Site Access Check** | `business/site_building/services/file_management_service.py` | EXISTING (SiteUserAccess) |
|
||||||
| **File Upload API** | `modules/site_builder/views.py` | File Management Service |
|
| **File Upload API** | `modules/site_builder/views.py` | File Management Service |
|
||||||
| **File Browser UI** | `site-builder/src/components/files/FileBrowser.tsx` | NEW |
|
| **File Browser UI** | `site-builder/src/components/files/FileBrowser.tsx` | NEW |
|
||||||
| **Storage Quota Check** | `infrastructure/storage/file_storage.py` | Phase 1 |
|
| **Storage Quota Check** | `infrastructure/storage/file_storage.py` | Phase 1 |
|
||||||
@@ -286,16 +286,16 @@ CREDIT_COSTS = {
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **SiteBlueprint Model** | `domain/site_building/models.py` | Phase 1 |
|
| **SiteBlueprint Model** | `business/site_building/models.py` | Phase 1 |
|
||||||
| **PageBlueprint Model** | `domain/site_building/models.py` | Phase 1 |
|
| **PageBlueprint Model** | `business/site_building/models.py` | Phase 1 |
|
||||||
| **Site Builder Migrations** | `domain/site_building/migrations/` | Phase 1 |
|
| **Site Builder Migrations** | `business/site_building/migrations/` | Phase 1 |
|
||||||
|
|
||||||
### 3.2 Site Structure Generation
|
### 3.2 Site Structure Generation
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Structure Generation AI Function** | `infrastructure/ai/functions/generate_site_structure.py` | Existing AI framework |
|
| **Structure Generation AI Function** | `infrastructure/ai/functions/generate_site_structure.py` | Existing AI framework |
|
||||||
| **Structure Generation Service** | `domain/site_building/services/structure_generation_service.py` | Phase 1, AI framework |
|
| **Structure Generation Service** | `business/site_building/services/structure_generation_service.py` | Phase 1, AI framework |
|
||||||
| **Site Structure Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
|
| **Site Structure Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
|
||||||
|
|
||||||
### 3.3 Site Builder API
|
### 3.3 Site Builder API
|
||||||
@@ -361,8 +361,8 @@ frontend/src/components/shared/
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Extend ContentService** | `domain/content/services/content_generation_service.py` | Phase 1 |
|
| **Extend ContentService** | `business/content/services/content_generation_service.py` | Phase 1 |
|
||||||
| **Add Site Page Type** | `domain/content/models.py` | Phase 1 |
|
| **Add Site Page Type** | `business/content/models.py` | Phase 1 |
|
||||||
| **Page Generation Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
|
| **Page Generation Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
|
||||||
|
|
||||||
### 3.6 Testing
|
### 3.6 Testing
|
||||||
@@ -437,11 +437,11 @@ Entry Point 4: Manual Selection → Linker/Optimizer
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Add source field** | `domain/content/models.py` | Phase 1 |
|
| **Add source field** | `business/content/models.py` | Phase 1 |
|
||||||
| **Add sync_status field** | `domain/content/models.py` | Phase 1 |
|
| **Add sync_status field** | `business/content/models.py` | Phase 1 |
|
||||||
| **Add external_id field** | `domain/content/models.py` | Phase 1 |
|
| **Add external_id field** | `business/content/models.py` | Phase 1 |
|
||||||
| **Add sync_metadata field** | `domain/content/models.py` | Phase 1 |
|
| **Add sync_metadata field** | `business/content/models.py` | Phase 1 |
|
||||||
| **Content Migrations** | `domain/content/migrations/` | Phase 1 |
|
| **Content Migrations** | `business/content/migrations/` | Phase 1 |
|
||||||
|
|
||||||
**Content Model Extensions**:
|
**Content Model Extensions**:
|
||||||
```python
|
```python
|
||||||
@@ -481,26 +481,26 @@ class Content(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **InternalLink Model** | `domain/linking/models.py` | Phase 1 |
|
| **InternalLink Model** | `business/linking/models.py` | Phase 1 |
|
||||||
| **LinkGraph Model** | `domain/linking/models.py` | Phase 1 |
|
| **LinkGraph Model** | `business/linking/models.py` | Phase 1 |
|
||||||
| **Linker Migrations** | `domain/linking/migrations/` | Phase 1 |
|
| **Linker Migrations** | `business/linking/migrations/` | Phase 1 |
|
||||||
|
|
||||||
### 4.3 Linker Service
|
### 4.3 Linker Service
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **LinkerService** | `domain/linking/services/linker_service.py` | Phase 1, ContentService |
|
| **LinkerService** | `business/linking/services/linker_service.py` | Phase 1, ContentService |
|
||||||
| **Link Candidate Engine** | `domain/linking/services/candidate_engine.py` | Phase 1 |
|
| **Link Candidate Engine** | `business/linking/services/candidate_engine.py` | Phase 1 |
|
||||||
| **Link Injection Engine** | `domain/linking/services/injection_engine.py` | Phase 1 |
|
| **Link Injection Engine** | `business/linking/services/injection_engine.py` | Phase 1 |
|
||||||
|
|
||||||
### 4.4 Content Sync Service (For WordPress/3rd Party)
|
### 4.4 Content Sync Service (For WordPress/3rd Party)
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **ContentSyncService** | `domain/integration/services/content_sync_service.py` | Phase 1, Phase 6 |
|
| **ContentSyncService** | `business/integration/services/content_sync_service.py` | Phase 1, Phase 6 |
|
||||||
| **WordPress Content Sync** | `domain/integration/services/wordpress_sync.py` | Phase 6 |
|
| **WordPress Content Sync** | `business/integration/services/wordpress_sync.py` | Phase 6 |
|
||||||
| **3rd Party Content Sync** | `domain/integration/services/external_sync.py` | Phase 6 |
|
| **3rd Party Content Sync** | `business/integration/services/external_sync.py` | Phase 6 |
|
||||||
| **Content Import Logic** | `domain/integration/services/import_service.py` | Phase 1 |
|
| **Content Import Logic** | `business/integration/services/import_service.py` | Phase 1 |
|
||||||
|
|
||||||
**Sync Service Flow**:
|
**Sync Service Flow**:
|
||||||
```python
|
```python
|
||||||
@@ -530,17 +530,17 @@ class ContentSyncService:
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **OptimizationTask Model** | `domain/optimization/models.py` | Phase 1 |
|
| **OptimizationTask Model** | `business/optimization/models.py` | Phase 1 |
|
||||||
| **OptimizationScores Model** | `domain/optimization/models.py` | Phase 1 |
|
| **OptimizationScores Model** | `business/optimization/models.py` | Phase 1 |
|
||||||
| **Optimizer Migrations** | `domain/optimization/migrations/` | Phase 1 |
|
| **Optimizer Migrations** | `business/optimization/migrations/` | Phase 1 |
|
||||||
|
|
||||||
### 4.6 Optimizer Service (Multiple Entry Points)
|
### 4.6 Optimizer Service (Multiple Entry Points)
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **OptimizerService** | `domain/optimization/services/optimizer_service.py` | Phase 1, ContentService |
|
| **OptimizerService** | `business/optimization/services/optimizer_service.py` | Phase 1, ContentService |
|
||||||
| **Content Analyzer** | `domain/optimization/services/analyzer.py` | Phase 1 |
|
| **Content Analyzer** | `business/optimization/services/analyzer.py` | Phase 1 |
|
||||||
| **Entry Point Handler** | `domain/optimization/services/entry_handler.py` | Phase 1 |
|
| **Entry Point Handler** | `business/optimization/services/entry_handler.py` | Phase 1 |
|
||||||
| **Optimization AI Function** | `infrastructure/ai/functions/optimize_content.py` | Existing AI framework |
|
| **Optimization AI Function** | `infrastructure/ai/functions/optimize_content.py` | Existing AI framework |
|
||||||
| **Optimization Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
|
| **Optimization Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
|
||||||
|
|
||||||
@@ -578,8 +578,8 @@ class OptimizerService:
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **OptimizerService** | `domain/optimization/services/optimizer_service.py` | Phase 1, ContentService |
|
| **OptimizerService** | `business/optimization/services/optimizer_service.py` | Phase 1, ContentService |
|
||||||
| **Content Analyzer** | `domain/optimization/services/analyzer.py` | Phase 1 |
|
| **Content Analyzer** | `business/optimization/services/analyzer.py` | Phase 1 |
|
||||||
| **Optimization AI Function** | `infrastructure/ai/functions/optimize_content.py` | Existing AI framework |
|
| **Optimization AI Function** | `infrastructure/ai/functions/optimize_content.py` | Existing AI framework |
|
||||||
| **Optimization Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
|
| **Optimization Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
|
||||||
|
|
||||||
@@ -587,9 +587,9 @@ class OptimizerService:
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **ContentPipelineService** | `domain/content/services/content_pipeline_service.py` | LinkerService, OptimizerService |
|
| **ContentPipelineService** | `business/content/services/content_pipeline_service.py` | LinkerService, OptimizerService |
|
||||||
| **Pipeline Orchestration** | `domain/content/services/pipeline_service.py` | Phase 1 services |
|
| **Pipeline Orchestration** | `business/content/services/pipeline_service.py` | Phase 1 services |
|
||||||
| **Workflow State Machine** | `domain/content/services/workflow_state.py` | Phase 1 services |
|
| **Workflow State Machine** | `business/content/services/workflow_state.py` | Phase 1 services |
|
||||||
|
|
||||||
**Pipeline Workflow States**:
|
**Pipeline Workflow States**:
|
||||||
```
|
```
|
||||||
@@ -711,7 +711,7 @@ class ContentPipelineService:
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Layout Configuration** | `domain/site_building/models.py` | Phase 3 |
|
| **Layout Configuration** | `business/site_building/models.py` | Phase 3 |
|
||||||
| **Layout Selector UI** | `site-builder/src/components/layouts/LayoutSelector.tsx` | Phase 3 |
|
| **Layout Selector UI** | `site-builder/src/components/layouts/LayoutSelector.tsx` | Phase 3 |
|
||||||
| **Layout Renderer** | `sites/src/utils/layoutRenderer.ts` | Phase 5 |
|
| **Layout Renderer** | `sites/src/utils/layoutRenderer.ts` | Phase 5 |
|
||||||
| **Layout Preview** | `site-builder/src/components/preview/LayoutPreview.tsx` | Phase 3 |
|
| **Layout Preview** | `site-builder/src/components/preview/LayoutPreview.tsx` | Phase 3 |
|
||||||
@@ -729,17 +729,17 @@ class ContentPipelineService:
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **PublisherService** | `domain/publishing/services/publisher_service.py` | Phase 1 |
|
| **PublisherService** | `business/publishing/services/publisher_service.py` | Phase 1 |
|
||||||
| **SitesRendererAdapter** | `domain/publishing/services/adapters/sites_renderer_adapter.py` | Phase 3 |
|
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | Phase 3 |
|
||||||
| **DeploymentService** | `domain/publishing/services/deployment_service.py` | Phase 3 |
|
| **DeploymentService** | `business/publishing/services/deployment_service.py` | Phase 3 |
|
||||||
|
|
||||||
### 5.3 Publishing Models
|
### 5.3 Publishing Models
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **PublishingRecord Model** | `domain/publishing/models.py` | Phase 1 |
|
| **PublishingRecord Model** | `business/publishing/models.py` | Phase 1 |
|
||||||
| **DeploymentRecord Model** | `domain/publishing/models.py` | Phase 3 |
|
| **DeploymentRecord Model** | `business/publishing/models.py` | Phase 3 |
|
||||||
| **Publishing Migrations** | `domain/publishing/migrations/` | Phase 1 |
|
| **Publishing Migrations** | `business/publishing/migrations/` | Phase 1 |
|
||||||
|
|
||||||
### 5.4 Publisher API
|
### 5.4 Publisher API
|
||||||
|
|
||||||
@@ -767,32 +767,32 @@ class ContentPipelineService:
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **SiteIntegration Model** | `domain/integration/models.py` | Phase 1 |
|
| **SiteIntegration Model** | `business/integration/models.py` | Phase 1 |
|
||||||
| **Integration Migrations** | `domain/integration/migrations/` | Phase 1 |
|
| **Integration Migrations** | `business/integration/migrations/` | Phase 1 |
|
||||||
|
|
||||||
### 6.2 Integration Service
|
### 6.2 Integration Service
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **IntegrationService** | `domain/integration/services/integration_service.py` | Phase 1 |
|
| **IntegrationService** | `business/integration/services/integration_service.py` | Phase 1 |
|
||||||
| **SyncService** | `domain/integration/services/sync_service.py` | Phase 1 |
|
| **SyncService** | `business/integration/services/sync_service.py` | Phase 1 |
|
||||||
|
|
||||||
### 6.3 Publishing Adapters
|
### 6.3 Publishing Adapters
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **BaseAdapter** | `domain/publishing/services/adapters/base_adapter.py` | Phase 5 |
|
| **BaseAdapter** | `business/publishing/services/adapters/base_adapter.py` | Phase 5 |
|
||||||
| **WordPressAdapter** | `domain/publishing/services/adapters/wordpress_adapter.py` | EXISTING (refactor) |
|
| **WordPressAdapter** | `business/publishing/services/adapters/wordpress_adapter.py` | EXISTING (refactor) |
|
||||||
| **SitesRendererAdapter** | `domain/publishing/services/adapters/sites_renderer_adapter.py` | Phase 5 |
|
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | Phase 5 |
|
||||||
| **ShopifyAdapter** | `domain/publishing/services/adapters/shopify_adapter.py` | Phase 5 (future) |
|
| **ShopifyAdapter** | `business/publishing/services/adapters/shopify_adapter.py` | Phase 5 (future) |
|
||||||
|
|
||||||
### 6.4 Multi-Destination Publishing
|
### 6.4 Multi-Destination Publishing
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Extend PublisherService** | `domain/publishing/services/publisher_service.py` | Phase 5 |
|
| **Extend PublisherService** | `business/publishing/services/publisher_service.py` | Phase 5 |
|
||||||
| **Multi-destination Support** | `domain/publishing/services/publisher_service.py` | Phase 5 |
|
| **Multi-destination Support** | `business/publishing/services/publisher_service.py` | Phase 5 |
|
||||||
| **Update PublishingRecord** | `domain/publishing/models.py` | Phase 5 |
|
| **Update PublishingRecord** | `business/publishing/models.py` | Phase 5 |
|
||||||
|
|
||||||
### 6.5 Site Model Extensions
|
### 6.5 Site Model Extensions
|
||||||
|
|
||||||
@@ -867,7 +867,7 @@ class ContentPipelineService:
|
|||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **WordPress Sync Endpoints** | `modules/integration/views.py` | IntegrationService |
|
| **WordPress Sync Endpoints** | `modules/integration/views.py` | IntegrationService |
|
||||||
| **Two-way Sync Logic** | `domain/integration/services/sync_service.py` | Phase 6.2 |
|
| **Two-way Sync Logic** | `business/integration/services/sync_service.py` | Phase 6.2 |
|
||||||
| **WordPress Webhook Handler** | `modules/integration/views.py` | Phase 6.2 |
|
| **WordPress Webhook Handler** | `modules/integration/views.py` | Phase 6.2 |
|
||||||
|
|
||||||
**Note**: WordPress plugin itself is built separately. Only API endpoints for plugin connection are built here.
|
**Note**: WordPress plugin itself is built separately. Only API endpoints for plugin connection are built here.
|
||||||
@@ -1036,10 +1036,10 @@ const isModuleEnabled = (moduleName: string) => {
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Add entity_type field** | `domain/content/models.py` | Phase 1 |
|
| **Add entity_type field** | `business/content/models.py` | Phase 1 |
|
||||||
| **Add json_blocks field** | `domain/content/models.py` | Phase 1 |
|
| **Add json_blocks field** | `business/content/models.py` | Phase 1 |
|
||||||
| **Add structure_data field** | `domain/content/models.py` | Phase 1 |
|
| **Add structure_data field** | `business/content/models.py` | Phase 1 |
|
||||||
| **Content Migrations** | `domain/content/migrations/` | Phase 1 |
|
| **Content Migrations** | `business/content/migrations/` | Phase 1 |
|
||||||
|
|
||||||
### 8.2 Content Type Prompts
|
### 8.2 Content Type Prompts
|
||||||
|
|
||||||
@@ -1053,18 +1053,18 @@ const isModuleEnabled = (moduleName: string) => {
|
|||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Product Content Generation** | `domain/content/services/content_generation_service.py` | Phase 1 |
|
| **Product Content Generation** | `business/content/services/content_generation_service.py` | Phase 1 |
|
||||||
| **Service Page Generation** | `domain/content/services/content_generation_service.py` | Phase 1 |
|
| **Service Page Generation** | `business/content/services/content_generation_service.py` | Phase 1 |
|
||||||
| **Taxonomy Generation** | `domain/content/services/content_generation_service.py` | Phase 1 |
|
| **Taxonomy Generation** | `business/content/services/content_generation_service.py` | Phase 1 |
|
||||||
|
|
||||||
### 8.4 Linker & Optimizer Extensions
|
### 8.4 Linker & Optimizer Extensions
|
||||||
|
|
||||||
| Task | Files | Dependencies |
|
| Task | Files | Dependencies |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Product Linking** | `domain/linking/services/linker_service.py` | Phase 4 |
|
| **Product Linking** | `business/linking/services/linker_service.py` | Phase 4 |
|
||||||
| **Taxonomy Linking** | `domain/linking/services/linker_service.py` | Phase 4 |
|
| **Taxonomy Linking** | `business/linking/services/linker_service.py` | Phase 4 |
|
||||||
| **Product Optimization** | `domain/optimization/services/optimizer_service.py` | Phase 4 |
|
| **Product Optimization** | `business/optimization/services/optimizer_service.py` | Phase 4 |
|
||||||
| **Taxonomy Optimization** | `domain/optimization/services/optimizer_service.py` | Phase 4 |
|
| **Taxonomy Optimization** | `business/optimization/services/optimizer_service.py` | Phase 4 |
|
||||||
|
|
||||||
### 8.5 Testing
|
### 8.5 Testing
|
||||||
|
|
||||||
|
|||||||
866
docs/planning/PHASE-3-4-IMPLEMENTATION-PLAN.md
Normal file
866
docs/planning/PHASE-3-4-IMPLEMENTATION-PLAN.md
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
# PHASE 3 & 4 IMPLEMENTATION PLAN
|
||||||
|
**Detailed Configuration Plan for Site Builder & Linker/Optimizer**
|
||||||
|
|
||||||
|
**Created**: 2025-01-XX
|
||||||
|
**Status**: Planning Phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TABLE OF CONTENTS
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Phase 3: Site Builder Implementation Plan](#phase-3-site-builder-implementation-plan)
|
||||||
|
3. [Phase 4: Linker & Optimizer Implementation Plan](#phase-4-linker--optimizer-implementation-plan)
|
||||||
|
4. [Integration Points](#integration-points)
|
||||||
|
5. [File Structure](#file-structure)
|
||||||
|
6. [Dependencies & Order](#dependencies--order)
|
||||||
|
7. [Testing Strategy](#testing-strategy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
### Implementation Approach
|
||||||
|
- **Phase 3**: Build Site Builder with wizard, AI structure generation, and file management
|
||||||
|
- **Phase 4**: Implement Linker and Optimizer as post-processing stages with multiple entry points
|
||||||
|
- **Shared Components**: Create global component library for reuse across apps
|
||||||
|
- **Integration**: Ensure seamless integration with existing Phase 1 & 2 services
|
||||||
|
|
||||||
|
### Key Principles
|
||||||
|
- **Service Layer Pattern**: All business logic in services (Phase 1 pattern)
|
||||||
|
- **Credit-Aware**: All operations check credits before execution
|
||||||
|
- **Multiple Entry Points**: Optimizer works from Writer, WordPress sync, 3rd party, manual
|
||||||
|
- **Component Reuse**: Shared components across Site Builder, Sites Renderer, Main App
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3: SITE BUILDER IMPLEMENTATION PLAN
|
||||||
|
|
||||||
|
### 3.1 Backend Structure
|
||||||
|
|
||||||
|
#### Business Layer (`business/site_building/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
business/site_building/
|
||||||
|
├── __init__.py
|
||||||
|
├── models.py # SiteBlueprint, PageBlueprint
|
||||||
|
├── migrations/
|
||||||
|
│ └── 0001_initial.py
|
||||||
|
└── services/
|
||||||
|
├── __init__.py
|
||||||
|
├── file_management_service.py
|
||||||
|
├── structure_generation_service.py
|
||||||
|
└── page_generation_service.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Models to Create**:
|
||||||
|
|
||||||
|
1. **SiteBlueprint** (`business/site_building/models.py`)
|
||||||
|
- Fields:
|
||||||
|
- `name`, `description`
|
||||||
|
- `config_json` (wizard choices: business_type, style, objectives)
|
||||||
|
- `structure_json` (AI-generated structure: pages, layout, theme)
|
||||||
|
- `status` (draft, generating, ready, deployed)
|
||||||
|
- `hosting_type` (igny8_sites, wordpress, shopify, multi)
|
||||||
|
- `version`, `deployed_version`
|
||||||
|
- Inherits from `SiteSectorBaseModel`
|
||||||
|
|
||||||
|
2. **PageBlueprint** (`business/site_building/models.py`)
|
||||||
|
- Fields:
|
||||||
|
- `site_blueprint` (ForeignKey)
|
||||||
|
- `slug`, `title`
|
||||||
|
- `type` (home, about, services, products, blog, contact, custom)
|
||||||
|
- `blocks_json` (page content blocks)
|
||||||
|
- `status` (draft, generating, ready)
|
||||||
|
- `order`
|
||||||
|
- Inherits from `SiteSectorBaseModel`
|
||||||
|
|
||||||
|
#### Services to Create
|
||||||
|
|
||||||
|
1. **FileManagementService** (`business/site_building/services/file_management_service.py`)
|
||||||
|
```python
|
||||||
|
class SiteBuilderFileService:
|
||||||
|
def get_user_accessible_sites(self, user)
|
||||||
|
def check_file_access(self, user, site_id)
|
||||||
|
def upload_file(self, user, site_id, file, folder='images')
|
||||||
|
def delete_file(self, user, site_id, file_path)
|
||||||
|
def list_files(self, user, site_id, folder='images')
|
||||||
|
def check_storage_quota(self, site_id, file_size)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **StructureGenerationService** (`business/site_building/services/structure_generation_service.py`)
|
||||||
|
```python
|
||||||
|
class StructureGenerationService:
|
||||||
|
def __init__(self):
|
||||||
|
self.ai_function = GenerateSiteStructureFunction()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def generate_structure(self, site_blueprint, business_brief, objectives, style)
|
||||||
|
def _create_page_blueprints(self, site_blueprint, structure)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **PageGenerationService** (`business/site_building/services/page_generation_service.py`)
|
||||||
|
```python
|
||||||
|
class PageGenerationService:
|
||||||
|
def __init__(self):
|
||||||
|
self.content_service = ContentGenerationService()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def generate_page_content(self, page_blueprint, account)
|
||||||
|
def regenerate_page(self, page_blueprint, account)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AI Functions (`infrastructure/ai/functions/`)
|
||||||
|
|
||||||
|
1. **GenerateSiteStructureFunction** (`infrastructure/ai/functions/generate_site_structure.py`)
|
||||||
|
- Operation type: `site_structure_generation`
|
||||||
|
- Credit cost: 50 credits (from constants)
|
||||||
|
- Generates site structure JSON from business brief
|
||||||
|
|
||||||
|
#### API Layer (`modules/site_builder/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/site_builder/
|
||||||
|
├── __init__.py
|
||||||
|
├── views.py # SiteBuilderViewSet, PageBlueprintViewSet, FileUploadView
|
||||||
|
├── serializers.py # SiteBlueprintSerializer, PageBlueprintSerializer
|
||||||
|
├── urls.py
|
||||||
|
└── apps.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**ViewSets to Create**:
|
||||||
|
|
||||||
|
1. **SiteBuilderViewSet** (`modules/site_builder/views.py`)
|
||||||
|
- CRUD for SiteBlueprint
|
||||||
|
- Actions:
|
||||||
|
- `generate_structure/` (POST) - Trigger AI structure generation
|
||||||
|
- `deploy/` (POST) - Deploy site to hosting
|
||||||
|
- `preview/` (GET) - Get preview JSON
|
||||||
|
|
||||||
|
2. **PageBlueprintViewSet** (`modules/site_builder/views.py`)
|
||||||
|
- CRUD for PageBlueprint
|
||||||
|
- Actions:
|
||||||
|
- `generate_content/` (POST) - Generate page content
|
||||||
|
- `regenerate/` (POST) - Regenerate page content
|
||||||
|
|
||||||
|
3. **FileUploadView** (`modules/site_builder/views.py`)
|
||||||
|
- `upload/` (POST) - Upload file to site assets
|
||||||
|
- `delete/` (DELETE) - Delete file
|
||||||
|
- `list/` (GET) - List files
|
||||||
|
|
||||||
|
#### File Storage Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/app/sites-data/
|
||||||
|
└── clients/
|
||||||
|
└── {site_id}/
|
||||||
|
└── v{version}/
|
||||||
|
├── site.json # Site definition
|
||||||
|
├── pages/ # Page definitions
|
||||||
|
│ ├── home.json
|
||||||
|
│ ├── about.json
|
||||||
|
│ └── ...
|
||||||
|
└── assets/ # User-managed files
|
||||||
|
├── images/
|
||||||
|
├── documents/
|
||||||
|
└── media/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Frontend Structure
|
||||||
|
|
||||||
|
#### Site Builder Container (`site-builder/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
site-builder/
|
||||||
|
├── src/
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── wizard/
|
||||||
|
│ │ │ ├── Step1TypeSelection.tsx
|
||||||
|
│ │ │ ├── Step2BusinessBrief.tsx
|
||||||
|
│ │ │ ├── Step3Objectives.tsx
|
||||||
|
│ │ │ └── Step4Style.tsx
|
||||||
|
│ │ ├── preview/
|
||||||
|
│ │ │ └── PreviewCanvas.tsx
|
||||||
|
│ │ └── dashboard/
|
||||||
|
│ │ └── SiteList.tsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── blocks/ # Block components (import from shared)
|
||||||
|
│ │ ├── forms/
|
||||||
|
│ │ ├── files/
|
||||||
|
│ │ │ └── FileBrowser.tsx
|
||||||
|
│ │ └── preview-canvas/
|
||||||
|
│ ├── state/
|
||||||
|
│ │ ├── builderStore.ts
|
||||||
|
│ │ └── siteDefinitionStore.ts
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── builder.api.ts
|
||||||
|
│ │ └── sites.api.ts
|
||||||
|
│ └── main.tsx
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.ts
|
||||||
|
└── Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared Component Library (`frontend/src/components/shared/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/components/shared/
|
||||||
|
├── blocks/
|
||||||
|
│ ├── Hero.tsx
|
||||||
|
│ ├── Features.tsx
|
||||||
|
│ ├── Services.tsx
|
||||||
|
│ ├── Products.tsx
|
||||||
|
│ ├── Testimonials.tsx
|
||||||
|
│ ├── ContactForm.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── layouts/
|
||||||
|
│ ├── DefaultLayout.tsx
|
||||||
|
│ ├── MinimalLayout.tsx
|
||||||
|
│ ├── MagazineLayout.tsx
|
||||||
|
│ ├── EcommerceLayout.tsx
|
||||||
|
│ ├── PortfolioLayout.tsx
|
||||||
|
│ ├── BlogLayout.tsx
|
||||||
|
│ └── CorporateLayout.tsx
|
||||||
|
└── templates/
|
||||||
|
├── BlogTemplate.tsx
|
||||||
|
├── BusinessTemplate.tsx
|
||||||
|
└── PortfolioTemplate.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Implementation Tasks
|
||||||
|
|
||||||
|
#### Backend Tasks (Priority Order)
|
||||||
|
|
||||||
|
1. **Create Business Models**
|
||||||
|
- [ ] Create `business/site_building/` folder
|
||||||
|
- [ ] Create `SiteBlueprint` model
|
||||||
|
- [ ] Create `PageBlueprint` model
|
||||||
|
- [ ] Create migrations
|
||||||
|
|
||||||
|
2. **Create Services**
|
||||||
|
- [ ] Create `FileManagementService`
|
||||||
|
- [ ] Create `StructureGenerationService`
|
||||||
|
- [ ] Create `PageGenerationService`
|
||||||
|
- [ ] Integrate with `CreditService`
|
||||||
|
|
||||||
|
3. **Create AI Function**
|
||||||
|
- [ ] Create `GenerateSiteStructureFunction`
|
||||||
|
- [ ] Add prompts for site structure generation
|
||||||
|
- [ ] Test AI function
|
||||||
|
|
||||||
|
4. **Create API Layer**
|
||||||
|
- [ ] Create `modules/site_builder/` folder
|
||||||
|
- [ ] Create `SiteBuilderViewSet`
|
||||||
|
- [ ] Create `PageBlueprintViewSet`
|
||||||
|
- [ ] Create `FileUploadView`
|
||||||
|
- [ ] Create serializers
|
||||||
|
- [ ] Register URLs
|
||||||
|
|
||||||
|
#### Frontend Tasks (Priority Order)
|
||||||
|
|
||||||
|
1. **Create Site Builder Container**
|
||||||
|
- [ ] Create `site-builder/` folder structure
|
||||||
|
- [ ] Set up Vite + React + TypeScript
|
||||||
|
- [ ] Configure Docker container
|
||||||
|
- [ ] Set up routing
|
||||||
|
|
||||||
|
2. **Create Wizard**
|
||||||
|
- [ ] Step 1: Type Selection
|
||||||
|
- [ ] Step 2: Business Brief
|
||||||
|
- [ ] Step 3: Objectives
|
||||||
|
- [ ] Step 4: Style Preferences
|
||||||
|
- [ ] Wizard state management
|
||||||
|
|
||||||
|
3. **Create Preview Canvas**
|
||||||
|
- [ ] Preview renderer
|
||||||
|
- [ ] Block rendering
|
||||||
|
- [ ] Layout rendering
|
||||||
|
|
||||||
|
4. **Create Shared Components**
|
||||||
|
- [ ] Block components
|
||||||
|
- [ ] Layout components
|
||||||
|
- [ ] Template components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 4: LINKER & OPTIMIZER IMPLEMENTATION PLAN
|
||||||
|
|
||||||
|
### 4.1 Backend Structure
|
||||||
|
|
||||||
|
#### Business Layer
|
||||||
|
|
||||||
|
```
|
||||||
|
business/
|
||||||
|
├── linking/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── models.py # InternalLink (optional)
|
||||||
|
│ └── services/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── linker_service.py
|
||||||
|
│ ├── candidate_engine.py
|
||||||
|
│ └── injection_engine.py
|
||||||
|
│
|
||||||
|
├── optimization/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── models.py # OptimizationTask, OptimizationScores
|
||||||
|
│ └── services/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── optimizer_service.py
|
||||||
|
│ └── analyzer.py
|
||||||
|
│
|
||||||
|
└── content/
|
||||||
|
└── services/
|
||||||
|
└── content_pipeline_service.py # NEW: Orchestrates pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Content Model Extensions
|
||||||
|
|
||||||
|
**Extend `business/content/models.py`**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Content(SiteSectorBaseModel):
|
||||||
|
# Existing fields...
|
||||||
|
|
||||||
|
# NEW: Source tracking (Phase 4)
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('igny8', 'IGNY8 Generated'),
|
||||||
|
('wordpress', 'WordPress Synced'),
|
||||||
|
('shopify', 'Shopify Synced'),
|
||||||
|
('custom', 'Custom API Synced'),
|
||||||
|
],
|
||||||
|
default='igny8'
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('native', 'Native IGNY8 Content'),
|
||||||
|
('imported', 'Imported from External'),
|
||||||
|
('synced', 'Synced from External'),
|
||||||
|
],
|
||||||
|
default='native'
|
||||||
|
)
|
||||||
|
|
||||||
|
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
external_url = models.URLField(blank=True, null=True)
|
||||||
|
sync_metadata = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
# NEW: Linking fields
|
||||||
|
internal_links = models.JSONField(default=list)
|
||||||
|
linker_version = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
# NEW: Optimization fields
|
||||||
|
optimizer_version = models.IntegerField(default=0)
|
||||||
|
optimization_scores = models.JSONField(default=dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Models to Create
|
||||||
|
|
||||||
|
1. **OptimizationTask** (`business/optimization/models.py`)
|
||||||
|
- Fields:
|
||||||
|
- `content` (ForeignKey to Content)
|
||||||
|
- `scores_before`, `scores_after` (JSON)
|
||||||
|
- `html_before`, `html_after` (Text)
|
||||||
|
- `status` (pending, completed, failed)
|
||||||
|
- `credits_used`
|
||||||
|
- Inherits from `AccountBaseModel`
|
||||||
|
|
||||||
|
2. **OptimizationScores** (`business/optimization/models.py`) - Optional
|
||||||
|
- Store detailed scoring metrics
|
||||||
|
|
||||||
|
#### Services to Create
|
||||||
|
|
||||||
|
1. **LinkerService** (`business/linking/services/linker_service.py`)
|
||||||
|
```python
|
||||||
|
class LinkerService:
|
||||||
|
def __init__(self):
|
||||||
|
self.candidate_engine = CandidateEngine()
|
||||||
|
self.injection_engine = InjectionEngine()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def process(self, content_id)
|
||||||
|
def batch_process(self, content_ids)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **CandidateEngine** (`business/linking/services/candidate_engine.py`)
|
||||||
|
```python
|
||||||
|
class CandidateEngine:
|
||||||
|
def find_candidates(self, content)
|
||||||
|
def _find_relevant_content(self, content)
|
||||||
|
def _score_candidates(self, content, candidates)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **InjectionEngine** (`business/linking/services/injection_engine.py`)
|
||||||
|
```python
|
||||||
|
class InjectionEngine:
|
||||||
|
def inject_links(self, content, candidates)
|
||||||
|
def _inject_link_into_html(self, html, link_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **OptimizerService** (`business/optimization/services/optimizer_service.py`)
|
||||||
|
```python
|
||||||
|
class OptimizerService:
|
||||||
|
def __init__(self):
|
||||||
|
self.analyzer = ContentAnalyzer()
|
||||||
|
self.ai_function = OptimizeContentFunction()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
# Multiple entry points
|
||||||
|
def optimize_from_writer(self, content_id)
|
||||||
|
def optimize_from_wordpress_sync(self, content_id)
|
||||||
|
def optimize_from_external_sync(self, content_id)
|
||||||
|
def optimize_manual(self, content_id)
|
||||||
|
|
||||||
|
# Unified optimization logic
|
||||||
|
def optimize(self, content)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **ContentAnalyzer** (`business/optimization/services/analyzer.py`)
|
||||||
|
```python
|
||||||
|
class ContentAnalyzer:
|
||||||
|
def analyze(self, content)
|
||||||
|
def _calculate_seo_score(self, content)
|
||||||
|
def _calculate_readability_score(self, content)
|
||||||
|
def _calculate_engagement_score(self, content)
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **ContentPipelineService** (`business/content/services/content_pipeline_service.py`)
|
||||||
|
```python
|
||||||
|
class ContentPipelineService:
|
||||||
|
def __init__(self):
|
||||||
|
self.linker_service = LinkerService()
|
||||||
|
self.optimizer_service = OptimizerService()
|
||||||
|
|
||||||
|
def process_writer_content(self, content_id, stages=['linking', 'optimization'])
|
||||||
|
def process_synced_content(self, content_id, stages=['optimization'])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AI Functions
|
||||||
|
|
||||||
|
1. **OptimizeContentFunction** (`infrastructure/ai/functions/optimize_content.py`)
|
||||||
|
- Operation type: `optimization`
|
||||||
|
- Credit cost: 1 credit per 200 words
|
||||||
|
- Optimizes content for SEO, readability, engagement
|
||||||
|
|
||||||
|
#### API Layer (`modules/linker/` and `modules/optimizer/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/
|
||||||
|
├── linker/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── views.py # LinkerViewSet
|
||||||
|
│ ├── serializers.py
|
||||||
|
│ ├── urls.py
|
||||||
|
│ └── apps.py
|
||||||
|
│
|
||||||
|
└── optimizer/
|
||||||
|
├── __init__.py
|
||||||
|
├── views.py # OptimizerViewSet
|
||||||
|
├── serializers.py
|
||||||
|
├── urls.py
|
||||||
|
└── apps.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**ViewSets to Create**:
|
||||||
|
|
||||||
|
1. **LinkerViewSet** (`modules/linker/views.py`)
|
||||||
|
- Actions:
|
||||||
|
- `process/` (POST) - Process content for linking
|
||||||
|
- `batch_process/` (POST) - Process multiple content items
|
||||||
|
|
||||||
|
2. **OptimizerViewSet** (`modules/optimizer/views.py`)
|
||||||
|
- Actions:
|
||||||
|
- `optimize/` (POST) - Optimize content (auto-detects source)
|
||||||
|
- `optimize_from_writer/` (POST) - Entry point 1
|
||||||
|
- `optimize_from_sync/` (POST) - Entry point 2 & 3
|
||||||
|
- `optimize_manual/` (POST) - Entry point 4
|
||||||
|
- `analyze/` (GET) - Analyze content without optimizing
|
||||||
|
|
||||||
|
### 4.2 Frontend Structure
|
||||||
|
|
||||||
|
#### Linker UI (`frontend/src/pages/Linker/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/pages/Linker/
|
||||||
|
├── Dashboard.tsx
|
||||||
|
├── ContentList.tsx
|
||||||
|
└── LinkResults.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Optimizer UI (`frontend/src/pages/Optimizer/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/pages/Optimizer/
|
||||||
|
├── Dashboard.tsx
|
||||||
|
├── ContentSelector.tsx
|
||||||
|
├── OptimizationResults.tsx
|
||||||
|
└── ScoreComparison.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared Components
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/components/
|
||||||
|
├── content/
|
||||||
|
│ ├── SourceBadge.tsx # Show content source (IGNY8, WordPress, etc.)
|
||||||
|
│ ├── SyncStatusBadge.tsx # Show sync status
|
||||||
|
│ ├── ContentFilter.tsx # Filter by source, sync_status
|
||||||
|
│ └── SourceFilter.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Implementation Tasks
|
||||||
|
|
||||||
|
#### Backend Tasks (Priority Order)
|
||||||
|
|
||||||
|
1. **Extend Content Model**
|
||||||
|
- [ ] Add `source` field
|
||||||
|
- [ ] Add `sync_status` field
|
||||||
|
- [ ] Add `external_id`, `external_url`, `sync_metadata`
|
||||||
|
- [ ] Add `internal_links`, `linker_version`
|
||||||
|
- [ ] Add `optimizer_version`, `optimization_scores`
|
||||||
|
- [ ] Create migration
|
||||||
|
|
||||||
|
2. **Create Linking Services**
|
||||||
|
- [ ] Create `business/linking/` folder
|
||||||
|
- [ ] Create `LinkerService`
|
||||||
|
- [ ] Create `CandidateEngine`
|
||||||
|
- [ ] Create `InjectionEngine`
|
||||||
|
- [ ] Integrate with `CreditService`
|
||||||
|
|
||||||
|
3. **Create Optimization Services**
|
||||||
|
- [ ] Create `business/optimization/` folder
|
||||||
|
- [ ] Create `OptimizationTask` model
|
||||||
|
- [ ] Create `OptimizerService` (with multiple entry points)
|
||||||
|
- [ ] Create `ContentAnalyzer`
|
||||||
|
- [ ] Integrate with `CreditService`
|
||||||
|
|
||||||
|
4. **Create AI Function**
|
||||||
|
- [ ] Create `OptimizeContentFunction`
|
||||||
|
- [ ] Add optimization prompts
|
||||||
|
- [ ] Test AI function
|
||||||
|
|
||||||
|
5. **Create Pipeline Service**
|
||||||
|
- [ ] Create `ContentPipelineService`
|
||||||
|
- [ ] Integrate Linker and Optimizer
|
||||||
|
|
||||||
|
6. **Create API Layer**
|
||||||
|
- [ ] Create `modules/linker/` folder
|
||||||
|
- [ ] Create `LinkerViewSet`
|
||||||
|
- [ ] Create `modules/optimizer/` folder
|
||||||
|
- [ ] Create `OptimizerViewSet`
|
||||||
|
- [ ] Create serializers
|
||||||
|
- [ ] Register URLs
|
||||||
|
|
||||||
|
#### Frontend Tasks (Priority Order)
|
||||||
|
|
||||||
|
1. **Create Linker UI**
|
||||||
|
- [ ] Linker Dashboard
|
||||||
|
- [ ] Content List
|
||||||
|
- [ ] Link Results display
|
||||||
|
|
||||||
|
2. **Create Optimizer UI**
|
||||||
|
- [ ] Optimizer Dashboard
|
||||||
|
- [ ] Content Selector (with source filters)
|
||||||
|
- [ ] Optimization Results
|
||||||
|
- [ ] Score Comparison
|
||||||
|
|
||||||
|
3. **Create Shared Components**
|
||||||
|
- [ ] SourceBadge component
|
||||||
|
- [ ] SyncStatusBadge component
|
||||||
|
- [ ] ContentFilter component
|
||||||
|
- [ ] SourceFilter component
|
||||||
|
|
||||||
|
4. **Update Content List**
|
||||||
|
- [ ] Add source badges
|
||||||
|
- [ ] Add sync status badges
|
||||||
|
- [ ] Add filters (by source, sync_status)
|
||||||
|
- [ ] Add "Send to Optimizer" button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## INTEGRATION POINTS
|
||||||
|
|
||||||
|
### Phase 3 Integration
|
||||||
|
|
||||||
|
1. **With Phase 1 Services**
|
||||||
|
- `StructureGenerationService` uses `CreditService`
|
||||||
|
- `PageGenerationService` uses `ContentGenerationService`
|
||||||
|
- All operations check credits before execution
|
||||||
|
|
||||||
|
2. **With Phase 2 Automation**
|
||||||
|
- Automation rules can trigger site structure generation
|
||||||
|
- Automation can deploy sites automatically
|
||||||
|
|
||||||
|
3. **With Content Service**
|
||||||
|
- Page generation reuses `ContentGenerationService`
|
||||||
|
- Site pages stored as `Content` records
|
||||||
|
|
||||||
|
### Phase 4 Integration
|
||||||
|
|
||||||
|
1. **With Phase 1 Services**
|
||||||
|
- `LinkerService` uses `CreditService`
|
||||||
|
- `OptimizerService` uses `CreditService`
|
||||||
|
- `ContentPipelineService` orchestrates services
|
||||||
|
|
||||||
|
2. **With Writer Module**
|
||||||
|
- Writer → Linker → Optimizer pipeline
|
||||||
|
- Content generated in Writer flows to Linker/Optimizer
|
||||||
|
|
||||||
|
3. **With WordPress Sync** (Phase 6)
|
||||||
|
- WordPress content synced with `source='wordpress'`
|
||||||
|
- Optimizer works on synced content
|
||||||
|
|
||||||
|
4. **With 3rd Party Sync** (Phase 6)
|
||||||
|
- External content synced with `source='shopify'` or `source='custom'`
|
||||||
|
- Optimizer works on all sources
|
||||||
|
|
||||||
|
### Cross-Phase Integration
|
||||||
|
|
||||||
|
1. **Site Builder → Linker/Optimizer**
|
||||||
|
- Site pages can be optimized
|
||||||
|
- Site content can be linked internally
|
||||||
|
|
||||||
|
2. **Content Pipeline**
|
||||||
|
- Unified pipeline: Writer → Linker → Optimizer → Publish
|
||||||
|
- Works for all content sources
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FILE STRUCTURE
|
||||||
|
|
||||||
|
### Complete Backend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/igny8_core/
|
||||||
|
├── business/
|
||||||
|
│ ├── automation/ # Phase 2 ✅
|
||||||
|
│ ├── billing/ # Phase 0, 1 ✅
|
||||||
|
│ ├── content/ # Phase 1 ✅
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ └── content_pipeline_service.py # Phase 4 NEW
|
||||||
|
│ ├── linking/ # Phase 4 NEW
|
||||||
|
│ │ ├── models.py
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ ├── linker_service.py
|
||||||
|
│ │ ├── candidate_engine.py
|
||||||
|
│ │ └── injection_engine.py
|
||||||
|
│ ├── optimization/ # Phase 4 NEW
|
||||||
|
│ │ ├── models.py
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ ├── optimizer_service.py
|
||||||
|
│ │ └── analyzer.py
|
||||||
|
│ ├── planning/ # Phase 1 ✅
|
||||||
|
│ └── site_building/ # Phase 3 NEW
|
||||||
|
│ ├── models.py
|
||||||
|
│ └── services/
|
||||||
|
│ ├── file_management_service.py
|
||||||
|
│ ├── structure_generation_service.py
|
||||||
|
│ └── page_generation_service.py
|
||||||
|
│
|
||||||
|
├── modules/
|
||||||
|
│ ├── automation/ # Phase 2 ✅
|
||||||
|
│ ├── billing/ # Phase 0, 1 ✅
|
||||||
|
│ ├── linker/ # Phase 4 NEW
|
||||||
|
│ ├── optimizer/ # Phase 4 NEW
|
||||||
|
│ ├── planner/ # Phase 1 ✅
|
||||||
|
│ ├── site_builder/ # Phase 3 NEW
|
||||||
|
│ └── writer/ # Phase 1 ✅
|
||||||
|
│
|
||||||
|
└── infrastructure/
|
||||||
|
└── ai/
|
||||||
|
└── functions/
|
||||||
|
├── generate_site_structure.py # Phase 3 NEW
|
||||||
|
└── optimize_content.py # Phase 4 NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Frontend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── components/
|
||||||
|
│ ├── shared/ # Phase 3 NEW
|
||||||
|
│ │ ├── blocks/
|
||||||
|
│ │ ├── layouts/
|
||||||
|
│ │ └── templates/
|
||||||
|
│ └── content/ # Phase 4 NEW
|
||||||
|
│ ├── SourceBadge.tsx
|
||||||
|
│ ├── SyncStatusBadge.tsx
|
||||||
|
│ └── ContentFilter.tsx
|
||||||
|
│
|
||||||
|
└── pages/
|
||||||
|
├── Linker/ # Phase 4 NEW
|
||||||
|
│ ├── Dashboard.tsx
|
||||||
|
│ └── ContentList.tsx
|
||||||
|
├── Optimizer/ # Phase 4 NEW
|
||||||
|
│ ├── Dashboard.tsx
|
||||||
|
│ └── ContentSelector.tsx
|
||||||
|
└── Writer/ # Phase 1 ✅
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
site-builder/src/ # Phase 3 NEW
|
||||||
|
├── pages/
|
||||||
|
│ ├── wizard/
|
||||||
|
│ ├── preview/
|
||||||
|
│ └── dashboard/
|
||||||
|
└── components/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DEPENDENCIES & ORDER
|
||||||
|
|
||||||
|
### Phase 3 Dependencies
|
||||||
|
|
||||||
|
1. **Required (Already Complete)**
|
||||||
|
- ✅ Phase 0: Credit system
|
||||||
|
- ✅ Phase 1: Service layer (ContentGenerationService, CreditService)
|
||||||
|
- ✅ Phase 2: Automation system (optional integration)
|
||||||
|
|
||||||
|
2. **Phase 3 Implementation Order**
|
||||||
|
- Step 1: Create models (SiteBlueprint, PageBlueprint)
|
||||||
|
- Step 2: Create FileManagementService
|
||||||
|
- Step 3: Create StructureGenerationService + AI function
|
||||||
|
- Step 4: Create PageGenerationService
|
||||||
|
- Step 5: Create API layer (ViewSets)
|
||||||
|
- Step 6: Create frontend container structure
|
||||||
|
- Step 7: Create wizard UI
|
||||||
|
- Step 8: Create preview canvas
|
||||||
|
- Step 9: Create shared component library
|
||||||
|
|
||||||
|
### Phase 4 Dependencies
|
||||||
|
|
||||||
|
1. **Required (Already Complete)**
|
||||||
|
- ✅ Phase 0: Credit system
|
||||||
|
- ✅ Phase 1: Service layer (ContentGenerationService, CreditService)
|
||||||
|
- ✅ Content model (needs extension)
|
||||||
|
|
||||||
|
2. **Phase 4 Implementation Order**
|
||||||
|
- Step 1: Extend Content model (add source, sync_status, linking, optimization fields)
|
||||||
|
- Step 2: Create linking services (LinkerService, CandidateEngine, InjectionEngine)
|
||||||
|
- Step 3: Create optimization services (OptimizerService, ContentAnalyzer)
|
||||||
|
- Step 4: Create optimization AI function
|
||||||
|
- Step 5: Create ContentPipelineService
|
||||||
|
- Step 6: Create API layer (LinkerViewSet, OptimizerViewSet)
|
||||||
|
- Step 7: Create frontend UI (Linker Dashboard, Optimizer Dashboard)
|
||||||
|
- Step 8: Create shared components (SourceBadge, ContentFilter)
|
||||||
|
|
||||||
|
### Parallel Implementation
|
||||||
|
|
||||||
|
- **Phase 3 and Phase 4 can be implemented in parallel** after:
|
||||||
|
- Content model extensions (Phase 4 Step 1) are complete
|
||||||
|
- Both phases use Phase 1 services independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TESTING STRATEGY
|
||||||
|
|
||||||
|
### Phase 3 Testing
|
||||||
|
|
||||||
|
1. **Backend Tests**
|
||||||
|
- Test SiteBlueprint CRUD
|
||||||
|
- Test PageBlueprint CRUD
|
||||||
|
- Test structure generation (AI function)
|
||||||
|
- Test file upload/delete/access
|
||||||
|
- Test credit deduction
|
||||||
|
|
||||||
|
2. **Frontend Tests**
|
||||||
|
- Test wizard flow
|
||||||
|
- Test preview rendering
|
||||||
|
- Test file browser
|
||||||
|
- Test component library
|
||||||
|
|
||||||
|
### Phase 4 Testing
|
||||||
|
|
||||||
|
1. **Backend Tests**
|
||||||
|
- Test Content model extensions
|
||||||
|
- Test LinkerService (find candidates, inject links)
|
||||||
|
- Test OptimizerService (all entry points)
|
||||||
|
- Test ContentPipelineService
|
||||||
|
- Test credit deduction
|
||||||
|
|
||||||
|
2. **Frontend Tests**
|
||||||
|
- Test Linker UI
|
||||||
|
- Test Optimizer UI
|
||||||
|
- Test source filtering
|
||||||
|
- Test content selection
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
1. **Writer → Linker → Optimizer Pipeline**
|
||||||
|
- Test full pipeline flow
|
||||||
|
- Test credit deduction at each stage
|
||||||
|
|
||||||
|
2. **WordPress Sync → Optimizer** (Phase 6)
|
||||||
|
- Test synced content optimization
|
||||||
|
- Test source tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CREDIT COSTS
|
||||||
|
|
||||||
|
### Phase 3 Credit Costs
|
||||||
|
|
||||||
|
- `site_structure_generation`: 50 credits (per site blueprint)
|
||||||
|
- `site_page_generation`: 20 credits (per page)
|
||||||
|
- File storage: No credits (storage quota based)
|
||||||
|
|
||||||
|
### Phase 4 Credit Costs
|
||||||
|
|
||||||
|
- `linking`: 8 credits (per content piece)
|
||||||
|
- `optimization`: 1 credit per 200 words
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SUCCESS CRITERIA
|
||||||
|
|
||||||
|
### Phase 3 Success Criteria
|
||||||
|
|
||||||
|
- ✅ Site Builder wizard works end-to-end
|
||||||
|
- ✅ AI structure generation creates valid blueprints
|
||||||
|
- ✅ Preview renders correctly
|
||||||
|
- ✅ File management works
|
||||||
|
- ✅ Shared components work across apps
|
||||||
|
- ✅ Page generation reuses ContentGenerationService
|
||||||
|
|
||||||
|
### Phase 4 Success Criteria
|
||||||
|
|
||||||
|
- ✅ Linker finds appropriate link candidates
|
||||||
|
- ✅ Links inject correctly into content
|
||||||
|
- ✅ Optimizer works from all entry points (Writer, WordPress, 3rd party, Manual)
|
||||||
|
- ✅ Content source tracking works
|
||||||
|
- ✅ Pipeline orchestrates correctly
|
||||||
|
- ✅ UI shows content sources and filters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RISK MITIGATION
|
||||||
|
|
||||||
|
### Phase 3 Risks
|
||||||
|
|
||||||
|
1. **AI Structure Generation Quality**
|
||||||
|
- Mitigation: Prompt engineering, validation, user feedback loop
|
||||||
|
|
||||||
|
2. **Component Compatibility**
|
||||||
|
- Mitigation: Shared component library, comprehensive testing
|
||||||
|
|
||||||
|
3. **File Management Security**
|
||||||
|
- Mitigation: Access control, validation, quota checks
|
||||||
|
|
||||||
|
### Phase 4 Risks
|
||||||
|
|
||||||
|
1. **Link Quality**
|
||||||
|
- Mitigation: Candidate scoring algorithm, relevance checks
|
||||||
|
|
||||||
|
2. **Optimization Quality**
|
||||||
|
- Mitigation: Content analysis, before/after comparison, user review
|
||||||
|
|
||||||
|
3. **Multiple Entry Points Complexity**
|
||||||
|
- Mitigation: Unified optimization logic, clear entry point methods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**END OF IMPLEMENTATION PLAN**
|
||||||
|
|
||||||
|
|
||||||
@@ -48,13 +48,13 @@
|
|||||||
|
|
||||||
| Task | File | Current State | Implementation |
|
| Task | File | Current State | Implementation |
|
||||||
|------|------|---------------|----------------|
|
|------|------|---------------|----------------|
|
||||||
| **Extend ModuleSettings Model** | `domain/system/models.py` | EXISTING (ModuleSettings) | Add `enabled` boolean field per module |
|
| **Extend ModuleSettings Model** | `business/system/models.py` | EXISTING (ModuleSettings) | Add `enabled` boolean field per module |
|
||||||
| **Module Settings API** | `modules/system/views.py` | EXISTING | Extend ViewSet to handle enable/disable |
|
| **Module Settings API** | `modules/system/views.py` | EXISTING | Extend ViewSet to handle enable/disable |
|
||||||
| **Module Settings Serializer** | `modules/system/serializers.py` | EXISTING | Add enabled field to serializer |
|
| **Module Settings Serializer** | `modules/system/serializers.py` | EXISTING | Add enabled field to serializer |
|
||||||
|
|
||||||
**ModuleSettings Model Extension**:
|
**ModuleSettings Model Extension**:
|
||||||
```python
|
```python
|
||||||
# domain/system/models.py (or core/system/models.py if exists)
|
# business/system/models.py (or core/system/models.py if exists)
|
||||||
class ModuleSettings(AccountBaseModel):
|
class ModuleSettings(AccountBaseModel):
|
||||||
# Existing fields...
|
# Existing fields...
|
||||||
|
|
||||||
@@ -173,11 +173,11 @@ class Plan(models.Model):
|
|||||||
|
|
||||||
| Task | File | Current State | Implementation |
|
| Task | File | Current State | Implementation |
|
||||||
|------|------|---------------|----------------|
|
|------|------|---------------|----------------|
|
||||||
| **Add Credit Costs** | `domain/billing/constants.py` | NEW | Define credit costs per operation |
|
| **Add Credit Costs** | `business/billing/constants.py` | NEW | Define credit costs per operation |
|
||||||
|
|
||||||
**Credit Cost Constants**:
|
**Credit Cost Constants**:
|
||||||
```python
|
```python
|
||||||
# domain/billing/constants.py
|
# business/billing/constants.py
|
||||||
CREDIT_COSTS = {
|
CREDIT_COSTS = {
|
||||||
'clustering': 10, # Per clustering request
|
'clustering': 10, # Per clustering request
|
||||||
'idea_generation': 15, # Per cluster → ideas request
|
'idea_generation': 15, # Per cluster → ideas request
|
||||||
@@ -195,11 +195,11 @@ CREDIT_COSTS = {
|
|||||||
|
|
||||||
| Task | File | Current State | Implementation |
|
| Task | File | Current State | Implementation |
|
||||||
|------|------|---------------|----------------|
|
|------|------|---------------|----------------|
|
||||||
| **Update CreditService** | `domain/billing/services/credit_service.py` | EXISTING | Add credit cost constants, update methods |
|
| **Update CreditService** | `business/billing/services/credit_service.py` | EXISTING | Add credit cost constants, update methods |
|
||||||
|
|
||||||
**CreditService Methods**:
|
**CreditService Methods**:
|
||||||
```python
|
```python
|
||||||
# domain/billing/services/credit_service.py
|
# business/billing/services/credit_service.py
|
||||||
class CreditService:
|
class CreditService:
|
||||||
def check_credits(self, account, operation_type, amount=None):
|
def check_credits(self, account, operation_type, amount=None):
|
||||||
"""Check if account has sufficient credits"""
|
"""Check if account has sufficient credits"""
|
||||||
@@ -256,11 +256,11 @@ class AIEngine:
|
|||||||
|
|
||||||
| Task | File | Current State | Implementation |
|
| Task | File | Current State | Implementation |
|
||||||
|------|------|---------------|----------------|
|
|------|------|---------------|----------------|
|
||||||
| **Update Content Generation** | `domain/content/services/content_generation_service.py` | NEW (Phase 1) | Check credits before generation |
|
| **Update Content Generation** | `business/content/services/content_generation_service.py` | NEW (Phase 1) | Check credits before generation |
|
||||||
|
|
||||||
**Content Generation Credit Check**:
|
**Content Generation Credit Check**:
|
||||||
```python
|
```python
|
||||||
# domain/content/services/content_generation_service.py
|
# business/content/services/content_generation_service.py
|
||||||
class ContentGenerationService:
|
class ContentGenerationService:
|
||||||
def generate_content(self, task, account):
|
def generate_content(self, task, account):
|
||||||
# Check credits before generation
|
# Check credits before generation
|
||||||
@@ -331,11 +331,11 @@ credit_service.check_credits(account, 'clustering', keyword_count)
|
|||||||
|
|
||||||
| Task | File | Current State | Implementation |
|
| Task | File | Current State | Implementation |
|
||||||
|------|------|---------------|----------------|
|
|------|------|---------------|----------------|
|
||||||
| **Update Usage Logging** | `domain/billing/models.py` | EXISTING | Ensure all operations log credits |
|
| **Update Usage Logging** | `business/billing/models.py` | EXISTING | Ensure all operations log credits |
|
||||||
|
|
||||||
**CreditUsageLog Model**:
|
**CreditUsageLog Model**:
|
||||||
```python
|
```python
|
||||||
# domain/billing/models.py
|
# business/billing/models.py
|
||||||
class CreditUsageLog(AccountBaseModel):
|
class CreditUsageLog(AccountBaseModel):
|
||||||
account = models.ForeignKey(Account, on_delete=models.CASCADE)
|
account = models.ForeignKey(Account, on_delete=models.CASCADE)
|
||||||
operation_type = models.CharField(max_length=50)
|
operation_type = models.CharField(max_length=50)
|
||||||
@@ -399,7 +399,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
**Migration 2: Add Credit Cost Tracking**:
|
**Migration 2: Add Credit Cost Tracking**:
|
||||||
```python
|
```python
|
||||||
# domain/billing/migrations/XXXX_add_credit_tracking.py
|
# business/billing/migrations/XXXX_add_credit_tracking.py
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
@@ -461,7 +461,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
### Backend Tasks
|
### Backend Tasks
|
||||||
|
|
||||||
- [ ] Create `domain/billing/constants.py` with credit costs
|
- [ ] Create `business/billing/constants.py` with credit costs
|
||||||
- [ ] Update `CreditService` with credit cost methods
|
- [ ] Update `CreditService` with credit cost methods
|
||||||
- [ ] Update `Plan` model - remove limit fields
|
- [ ] Update `Plan` model - remove limit fields
|
||||||
- [ ] Create migration to remove plan limit fields
|
- [ ] Create migration to remove plan limit fields
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
## TABLE OF CONTENTS
|
## TABLE OF CONTENTS
|
||||||
|
|
||||||
1. [Overview](#overview)
|
1. [Overview](#overview)
|
||||||
2. [Create Domain Structure](#create-domain-structure)
|
2. [Create Business Structure](#create-business-structure)
|
||||||
3. [Move Models to Domain](#move-models-to-domain)
|
3. [Move Models to Business](#move-models-to-business)
|
||||||
4. [Create Services](#create-services)
|
4. [Create Services](#create-services)
|
||||||
5. [Refactor ViewSets](#refactor-viewsets)
|
5. [Refactor ViewSets](#refactor-viewsets)
|
||||||
6. [Testing & Validation](#testing--validation)
|
6. [Testing & Validation](#testing--validation)
|
||||||
@@ -24,32 +24,31 @@
|
|||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
### Objectives
|
### Objectives
|
||||||
- ✅ Create `domain/` folder structure
|
- ✅ Create `business/` folder structure
|
||||||
- ✅ Move models from `modules/` to `domain/`
|
- ✅ Move models from `modules/` to `business/`
|
||||||
- ✅ Extract business logic from ViewSets to services
|
- ✅ Extract business logic from ViewSets to services
|
||||||
- ✅ Keep ViewSets as thin wrappers
|
- ✅ Keep ViewSets as thin wrappers
|
||||||
- ✅ Preserve all existing API functionality
|
- ✅ Preserve all existing API functionality
|
||||||
|
|
||||||
### Key Principles
|
### Key Principles
|
||||||
- **Backward Compatibility**: All APIs remain unchanged
|
|
||||||
- **Service Layer Pattern**: Business logic in services, not ViewSets
|
- **Service Layer Pattern**: Business logic in services, not ViewSets
|
||||||
- **No Breaking Changes**: Response formats unchanged
|
|
||||||
- **Testable Services**: Services can be tested independently
|
- **Testable Services**: Services can be tested independently
|
||||||
|
- **Clean Architecture**: Clear separation between API layer (modules/) and business logic (business/)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CREATE DOMAIN STRUCTURE
|
## CREATE BUSINESS STRUCTURE
|
||||||
|
|
||||||
### 1.1 Create Domain Structure
|
### 1.1 Create Business Structure
|
||||||
|
|
||||||
**Purpose**: Organize code by business domains, not technical layers.
|
**Purpose**: Organize code by business logic, not technical layers.
|
||||||
|
|
||||||
#### Folder Structure
|
#### Folder Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/igny8_core/
|
backend/igny8_core/
|
||||||
├── domain/ # NEW: Domain layer
|
├── business/ # NEW: Business logic layer
|
||||||
│ ├── content/ # Content domain
|
│ ├── content/ # Content business logic
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── models.py # Content, Tasks, Images
|
│ │ ├── models.py # Content, Tasks, Images
|
||||||
│ │ ├── services/
|
│ │ ├── services/
|
||||||
@@ -59,7 +58,7 @@ backend/igny8_core/
|
|||||||
│ │ │ └── content_versioning_service.py
|
│ │ │ └── content_versioning_service.py
|
||||||
│ │ └── migrations/
|
│ │ └── migrations/
|
||||||
│ │
|
│ │
|
||||||
│ ├── planning/ # Planning domain
|
│ ├── planning/ # Planning business logic
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── models.py # Keywords, Clusters, Ideas
|
│ │ ├── models.py # Keywords, Clusters, Ideas
|
||||||
│ │ ├── services/
|
│ │ ├── services/
|
||||||
@@ -68,12 +67,12 @@ backend/igny8_core/
|
|||||||
│ │ │ └── ideas_service.py
|
│ │ │ └── ideas_service.py
|
||||||
│ │ └── migrations/
|
│ │ └── migrations/
|
||||||
│ │
|
│ │
|
||||||
│ ├── billing/ # Billing domain (already exists)
|
│ ├── billing/ # Billing business logic (already exists)
|
||||||
│ │ ├── models.py # Credits, Transactions
|
│ │ ├── models.py # Credits, Transactions
|
||||||
│ │ └── services/
|
│ │ └── services/
|
||||||
│ │ └── credit_service.py # Already exists
|
│ │ └── credit_service.py # Already exists
|
||||||
│ │
|
│ │
|
||||||
│ └── automation/ # Automation domain (Phase 2)
|
│ └── automation/ # Automation business logic (Phase 2)
|
||||||
│ ├── models.py
|
│ ├── models.py
|
||||||
│ └── services/
|
│ └── services/
|
||||||
```
|
```
|
||||||
@@ -82,30 +81,30 @@ backend/igny8_core/
|
|||||||
|
|
||||||
| Task | File | Current Location | New Location | Risk |
|
| Task | File | Current Location | New Location | Risk |
|
||||||
|------|------|------------------|--------------|------|
|
|------|------|------------------|--------------|------|
|
||||||
| **Create domain/ folder** | `backend/igny8_core/domain/` | N/A | NEW | LOW |
|
| **Create business/ folder** | `backend/igny8_core/business/` | N/A | NEW | LOW |
|
||||||
| **Create content domain** | `domain/content/` | N/A | NEW | LOW |
|
| **Create content business** | `business/content/` | N/A | NEW | LOW |
|
||||||
| **Create planning domain** | `domain/planning/` | N/A | NEW | LOW |
|
| **Create planning business** | `business/planning/` | N/A | NEW | LOW |
|
||||||
| **Create billing domain** | `domain/billing/` | `modules/billing/` | MOVE | LOW |
|
| **Create billing business** | `business/billing/` | `modules/billing/` | MOVE | LOW |
|
||||||
| **Create automation domain** | `domain/automation/` | N/A | NEW (Phase 2) | LOW |
|
| **Create automation business** | `business/automation/` | N/A | NEW (Phase 2) | LOW |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MOVE MODELS TO DOMAIN
|
## MOVE MODELS TO BUSINESS
|
||||||
|
|
||||||
### 1.2 Move Models to Domain
|
### 1.2 Move Models to Business
|
||||||
|
|
||||||
**Purpose**: Move models from `modules/` to `domain/` to separate business logic from API layer.
|
**Purpose**: Move models from `modules/` to `business/` to separate business logic from API layer.
|
||||||
|
|
||||||
#### Content Models Migration
|
#### Content Models Migration
|
||||||
|
|
||||||
| Model | Current Location | New Location | Changes Needed |
|
| Model | Current Location | New Location | Changes Needed |
|
||||||
|------|------------------|--------------|----------------|
|
|------|------------------|--------------|----------------|
|
||||||
| `Content` | `modules/writer/models.py` | `domain/content/models.py` | Move, update imports |
|
| `Content` | `modules/writer/models.py` | `business/content/models.py` | Move, update imports |
|
||||||
| `Tasks` | `modules/writer/models.py` | `domain/content/models.py` | Move, update imports |
|
| `Tasks` | `modules/writer/models.py` | `business/content/models.py` | Move, update imports |
|
||||||
| `Images` | `modules/writer/models.py` | `domain/content/models.py` | Move, update imports |
|
| `Images` | `modules/writer/models.py` | `business/content/models.py` | Move, update imports |
|
||||||
|
|
||||||
**Migration Steps**:
|
**Migration Steps**:
|
||||||
1. Create `domain/content/models.py`
|
1. Create `business/content/models.py`
|
||||||
2. Copy models from `modules/writer/models.py`
|
2. Copy models from `modules/writer/models.py`
|
||||||
3. Update imports in `modules/writer/views.py`
|
3. Update imports in `modules/writer/views.py`
|
||||||
4. Create migration to ensure no data loss
|
4. Create migration to ensure no data loss
|
||||||
@@ -115,12 +114,12 @@ backend/igny8_core/
|
|||||||
|
|
||||||
| Model | Current Location | New Location | Changes Needed |
|
| Model | Current Location | New Location | Changes Needed |
|
||||||
|------|------------------|--------------|----------------|
|
|------|------------------|--------------|----------------|
|
||||||
| `Keywords` | `modules/planner/models.py` | `domain/planning/models.py` | Move, update imports |
|
| `Keywords` | `modules/planner/models.py` | `business/planning/models.py` | Move, update imports |
|
||||||
| `Clusters` | `modules/planner/models.py` | `domain/planning/models.py` | Move, update imports |
|
| `Clusters` | `modules/planner/models.py` | `business/planning/models.py` | Move, update imports |
|
||||||
| `ContentIdeas` | `modules/planner/models.py` | `domain/planning/models.py` | Move, update imports |
|
| `ContentIdeas` | `modules/planner/models.py` | `business/planning/models.py` | Move, update imports |
|
||||||
|
|
||||||
**Migration Steps**:
|
**Migration Steps**:
|
||||||
1. Create `domain/planning/models.py`
|
1. Create `business/planning/models.py`
|
||||||
2. Copy models from `modules/planner/models.py`
|
2. Copy models from `modules/planner/models.py`
|
||||||
3. Update imports in `modules/planner/views.py`
|
3. Update imports in `modules/planner/views.py`
|
||||||
4. Create migration to ensure no data loss
|
4. Create migration to ensure no data loss
|
||||||
@@ -130,13 +129,13 @@ backend/igny8_core/
|
|||||||
|
|
||||||
| Model | Current Location | New Location | Changes Needed |
|
| Model | Current Location | New Location | Changes Needed |
|
||||||
|------|------------------|--------------|----------------|
|
|------|------------------|--------------|----------------|
|
||||||
| `CreditTransaction` | `modules/billing/models.py` | `domain/billing/models.py` | Move, update imports |
|
| `CreditTransaction` | `modules/billing/models.py` | `business/billing/models.py` | Move, update imports |
|
||||||
| `CreditUsageLog` | `modules/billing/models.py` | `domain/billing/models.py` | Move, update imports |
|
| `CreditUsageLog` | `modules/billing/models.py` | `business/billing/models.py` | Move, update imports |
|
||||||
|
|
||||||
**Migration Steps**:
|
**Migration Steps**:
|
||||||
1. Create `domain/billing/models.py`
|
1. Create `business/billing/models.py`
|
||||||
2. Copy models from `modules/billing/models.py`
|
2. Copy models from `modules/billing/models.py`
|
||||||
3. Move `CreditService` to `domain/billing/services/credit_service.py`
|
3. Move `CreditService` to `business/billing/services/credit_service.py`
|
||||||
4. Update imports in `modules/billing/views.py`
|
4. Update imports in `modules/billing/views.py`
|
||||||
5. Create migration to ensure no data loss
|
5. Create migration to ensure no data loss
|
||||||
|
|
||||||
@@ -152,11 +151,11 @@ backend/igny8_core/
|
|||||||
|
|
||||||
| Task | File | Purpose | Dependencies |
|
| Task | File | Purpose | Dependencies |
|
||||||
|------|------|---------|--------------|
|
|------|------|---------|--------------|
|
||||||
| **Create ContentService** | `domain/content/services/content_generation_service.py` | Unified content generation | Existing Writer logic, CreditService |
|
| **Create ContentService** | `business/content/services/content_generation_service.py` | Unified content generation | Existing Writer logic, CreditService |
|
||||||
|
|
||||||
**ContentService Methods**:
|
**ContentService Methods**:
|
||||||
```python
|
```python
|
||||||
# domain/content/services/content_generation_service.py
|
# business/content/services/content_generation_service.py
|
||||||
class ContentGenerationService:
|
class ContentGenerationService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.credit_service = CreditService()
|
self.credit_service = CreditService()
|
||||||
@@ -184,11 +183,11 @@ class ContentGenerationService:
|
|||||||
|
|
||||||
| Task | File | Purpose | Dependencies |
|
| Task | File | Purpose | Dependencies |
|
||||||
|------|------|---------|--------------|
|
|------|------|---------|--------------|
|
||||||
| **Create PlanningService** | `domain/planning/services/clustering_service.py` | Keyword clustering | Existing Planner logic, CreditService |
|
| **Create PlanningService** | `business/planning/services/clustering_service.py` | Keyword clustering | Existing Planner logic, CreditService |
|
||||||
|
|
||||||
**PlanningService Methods**:
|
**PlanningService Methods**:
|
||||||
```python
|
```python
|
||||||
# domain/planning/services/clustering_service.py
|
# business/planning/services/clustering_service.py
|
||||||
class ClusteringService:
|
class ClusteringService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.credit_service = CreditService()
|
self.credit_service = CreditService()
|
||||||
@@ -211,11 +210,11 @@ class ClusteringService:
|
|||||||
|
|
||||||
| Task | File | Purpose | Dependencies |
|
| Task | File | Purpose | Dependencies |
|
||||||
|------|------|---------|--------------|
|
|------|------|---------|--------------|
|
||||||
| **Create IdeasService** | `domain/planning/services/ideas_service.py` | Generate content ideas | Existing Planner logic, CreditService |
|
| **Create IdeasService** | `business/planning/services/ideas_service.py` | Generate content ideas | Existing Planner logic, CreditService |
|
||||||
|
|
||||||
**IdeasService Methods**:
|
**IdeasService Methods**:
|
||||||
```python
|
```python
|
||||||
# domain/planning/services/ideas_service.py
|
# business/planning/services/ideas_service.py
|
||||||
class IdeasService:
|
class IdeasService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.credit_service = CreditService()
|
self.credit_service = CreditService()
|
||||||
@@ -380,13 +379,13 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
### Backend Tasks
|
### Backend Tasks
|
||||||
|
|
||||||
- [ ] Create `domain/` folder structure
|
- [ ] Create `business/` folder structure
|
||||||
- [ ] Create `domain/content/` folder
|
- [ ] Create `business/content/` folder
|
||||||
- [ ] Create `domain/planning/` folder
|
- [ ] Create `business/planning/` folder
|
||||||
- [ ] Create `domain/billing/` folder (move existing)
|
- [ ] Create `business/billing/` folder (move existing)
|
||||||
- [ ] Move Content models to `domain/content/models.py`
|
- [ ] Move Content models to `business/content/models.py`
|
||||||
- [ ] Move Planning models to `domain/planning/models.py`
|
- [ ] Move Planning models to `business/planning/models.py`
|
||||||
- [ ] Move Billing models to `domain/billing/models.py`
|
- [ ] Move Billing models to `business/billing/models.py`
|
||||||
- [ ] Create migrations for model moves
|
- [ ] Create migrations for model moves
|
||||||
- [ ] Create `ContentGenerationService`
|
- [ ] Create `ContentGenerationService`
|
||||||
- [ ] Create `ClusteringService`
|
- [ ] Create `ClusteringService`
|
||||||
@@ -413,22 +412,21 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
| Risk | Level | Mitigation |
|
| Risk | Level | Mitigation |
|
||||||
|------|-------|------------|
|
|------|-------|------------|
|
||||||
| **Breaking API changes** | MEDIUM | Extensive testing, keep response formats identical |
|
|
||||||
| **Import errors** | MEDIUM | Update all imports systematically |
|
| **Import errors** | MEDIUM | Update all imports systematically |
|
||||||
| **Data loss during migration** | LOW | Backup before migration, test on staging |
|
| **Data loss during migration** | LOW | Backup before migration, test on staging |
|
||||||
| **Service logic errors** | MEDIUM | Unit tests for all services |
|
| **Service logic errors** | MEDIUM | Unit tests for all services |
|
||||||
|
| **Model migration complexity** | MEDIUM | Use Django migrations, test thoroughly |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SUCCESS CRITERIA
|
## SUCCESS CRITERIA
|
||||||
|
|
||||||
- ✅ All existing API endpoints work identically
|
|
||||||
- ✅ Response formats unchanged
|
|
||||||
- ✅ No breaking changes for frontend
|
|
||||||
- ✅ Services are testable independently
|
- ✅ Services are testable independently
|
||||||
- ✅ Business logic extracted from ViewSets
|
- ✅ Business logic extracted from ViewSets
|
||||||
- ✅ ViewSets are thin wrappers
|
- ✅ ViewSets are thin wrappers that delegate to services
|
||||||
- ✅ All models moved to domain layer
|
- ✅ All models moved to business layer
|
||||||
|
- ✅ All imports updated correctly
|
||||||
|
- ✅ Services handle credit checks and business rules
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,11 @@
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **AutomationRule Model** | `domain/automation/models.py` | Phase 1 | Create model with trigger, conditions, actions, schedule |
|
| **AutomationRule Model** | `business/automation/models.py` | Phase 1 | Create model with trigger, conditions, actions, schedule |
|
||||||
|
|
||||||
**AutomationRule Model**:
|
**AutomationRule Model**:
|
||||||
```python
|
```python
|
||||||
# domain/automation/models.py
|
# business/automation/models.py
|
||||||
class AutomationRule(SiteSectorBaseModel):
|
class AutomationRule(SiteSectorBaseModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
@@ -101,11 +101,11 @@ class AutomationRule(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **ScheduledTask Model** | `domain/automation/models.py` | Phase 1 | Create model to track scheduled executions |
|
| **ScheduledTask Model** | `business/automation/models.py` | Phase 1 | Create model to track scheduled executions |
|
||||||
|
|
||||||
**ScheduledTask Model**:
|
**ScheduledTask Model**:
|
||||||
```python
|
```python
|
||||||
# domain/automation/models.py
|
# business/automation/models.py
|
||||||
class ScheduledTask(SiteSectorBaseModel):
|
class ScheduledTask(SiteSectorBaseModel):
|
||||||
automation_rule = models.ForeignKey(AutomationRule, on_delete=models.CASCADE)
|
automation_rule = models.ForeignKey(AutomationRule, on_delete=models.CASCADE)
|
||||||
scheduled_at = models.DateTimeField()
|
scheduled_at = models.DateTimeField()
|
||||||
@@ -133,7 +133,7 @@ class ScheduledTask(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Automation Migrations** | `domain/automation/migrations/` | Phase 1 | Create initial migrations |
|
| **Automation Migrations** | `business/automation/migrations/` | Phase 1 | Create initial migrations |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -147,11 +147,11 @@ class ScheduledTask(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **AutomationService** | `domain/automation/services/automation_service.py` | Phase 1 services | Main service for rule execution |
|
| **AutomationService** | `business/automation/services/automation_service.py` | Phase 1 services | Main service for rule execution |
|
||||||
|
|
||||||
**AutomationService Methods**:
|
**AutomationService Methods**:
|
||||||
```python
|
```python
|
||||||
# domain/automation/services/automation_service.py
|
# business/automation/services/automation_service.py
|
||||||
class AutomationService:
|
class AutomationService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.rule_engine = RuleEngine()
|
self.rule_engine = RuleEngine()
|
||||||
@@ -202,11 +202,11 @@ class AutomationService:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Rule Execution Engine** | `domain/automation/services/rule_engine.py` | Phase 1 services | Orchestrates rule execution |
|
| **Rule Execution Engine** | `business/automation/services/rule_engine.py` | Phase 1 services | Orchestrates rule execution |
|
||||||
|
|
||||||
**RuleEngine Methods**:
|
**RuleEngine Methods**:
|
||||||
```python
|
```python
|
||||||
# domain/automation/services/rule_engine.py
|
# business/automation/services/rule_engine.py
|
||||||
class RuleEngine:
|
class RuleEngine:
|
||||||
def execute_rule(self, rule, context):
|
def execute_rule(self, rule, context):
|
||||||
"""Orchestrate rule execution"""
|
"""Orchestrate rule execution"""
|
||||||
@@ -221,11 +221,11 @@ class RuleEngine:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Condition Evaluator** | `domain/automation/services/condition_evaluator.py` | None | Evaluates rule conditions |
|
| **Condition Evaluator** | `business/automation/services/condition_evaluator.py` | None | Evaluates rule conditions |
|
||||||
|
|
||||||
**ConditionEvaluator Methods**:
|
**ConditionEvaluator Methods**:
|
||||||
```python
|
```python
|
||||||
# domain/automation/services/condition_evaluator.py
|
# business/automation/services/condition_evaluator.py
|
||||||
class ConditionEvaluator:
|
class ConditionEvaluator:
|
||||||
def evaluate(self, conditions, context):
|
def evaluate(self, conditions, context):
|
||||||
"""Evaluate rule conditions"""
|
"""Evaluate rule conditions"""
|
||||||
@@ -238,11 +238,11 @@ class ConditionEvaluator:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Action Executor** | `domain/automation/services/action_executor.py` | Phase 1 services | Executes rule actions |
|
| **Action Executor** | `business/automation/services/action_executor.py` | Phase 1 services | Executes rule actions |
|
||||||
|
|
||||||
**ActionExecutor Methods**:
|
**ActionExecutor Methods**:
|
||||||
```python
|
```python
|
||||||
# domain/automation/services/action_executor.py
|
# business/automation/services/action_executor.py
|
||||||
class ActionExecutor:
|
class ActionExecutor:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.clustering_service = ClusteringService()
|
self.clustering_service = ClusteringService()
|
||||||
@@ -290,7 +290,7 @@ from celery.schedules import crontab
|
|||||||
@shared_task
|
@shared_task
|
||||||
def execute_scheduled_automation_rules():
|
def execute_scheduled_automation_rules():
|
||||||
"""Execute all scheduled automation rules"""
|
"""Execute all scheduled automation rules"""
|
||||||
from domain.automation.services.automation_service import AutomationService
|
from business.automation.services.automation_service import AutomationService
|
||||||
|
|
||||||
service = AutomationService()
|
service = AutomationService()
|
||||||
rules = AutomationRule.objects.filter(
|
rules = AutomationRule.objects.filter(
|
||||||
@@ -316,7 +316,7 @@ def execute_scheduled_automation_rules():
|
|||||||
@shared_task
|
@shared_task
|
||||||
def replenish_monthly_credits():
|
def replenish_monthly_credits():
|
||||||
"""Replenish monthly credits for all active accounts"""
|
"""Replenish monthly credits for all active accounts"""
|
||||||
from domain.billing.services.credit_service import CreditService
|
from business.billing.services.credit_service import CreditService
|
||||||
|
|
||||||
service = CreditService()
|
service = CreditService()
|
||||||
accounts = Account.objects.filter(status='active')
|
accounts = Account.objects.filter(status='active')
|
||||||
@@ -462,13 +462,11 @@ urlpatterns = router.urls
|
|||||||
- Test rule
|
- Test rule
|
||||||
- Manual execution
|
- Manual execution
|
||||||
|
|
||||||
#### Schedules Page
|
#### Schedules (Part of Automation Menu)
|
||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
**Note**: Schedules functionality will be integrated into the Automation menu group, not as a separate page.
|
||||||
|------|------|--------------|----------------|
|
|
||||||
| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) | View scheduled task history |
|
|
||||||
|
|
||||||
**Schedules Page Features**:
|
**Schedules Features** (within Automation Dashboard):
|
||||||
- List scheduled tasks
|
- List scheduled tasks
|
||||||
- Filter by status, rule, date
|
- Filter by status, rule, date
|
||||||
- View execution results
|
- View execution results
|
||||||
@@ -530,14 +528,14 @@ export const automationApi = {
|
|||||||
|
|
||||||
### Backend Tasks
|
### Backend Tasks
|
||||||
|
|
||||||
- [ ] Create `domain/automation/models.py`
|
- [ ] Create `business/automation/models.py`
|
||||||
- [ ] Create AutomationRule model
|
- [ ] Create AutomationRule model
|
||||||
- [ ] Create ScheduledTask model
|
- [ ] Create ScheduledTask model
|
||||||
- [ ] Create automation migrations
|
- [ ] Create automation migrations
|
||||||
- [ ] Create `domain/automation/services/automation_service.py`
|
- [ ] Create `business/automation/services/automation_service.py`
|
||||||
- [ ] Create `domain/automation/services/rule_engine.py`
|
- [ ] Create `business/automation/services/rule_engine.py`
|
||||||
- [ ] Create `domain/automation/services/condition_evaluator.py`
|
- [ ] Create `business/automation/services/condition_evaluator.py`
|
||||||
- [ ] Create `domain/automation/services/action_executor.py`
|
- [ ] Create `business/automation/services/action_executor.py`
|
||||||
- [ ] Create `infrastructure/messaging/automation_tasks.py`
|
- [ ] Create `infrastructure/messaging/automation_tasks.py`
|
||||||
- [ ] Add scheduled automation task
|
- [ ] Add scheduled automation task
|
||||||
- [ ] Add monthly credit replenishment task
|
- [ ] Add monthly credit replenishment task
|
||||||
@@ -553,11 +551,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`
|
||||||
- [ ] Implement `frontend/src/pages/Schedules.tsx`
|
- [ ] Integrate schedules functionality into Automation Dashboard (not as separate page)
|
||||||
- [ ] 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
|
- [ ] Create schedule history table (within Automation Dashboard)
|
||||||
|
|
||||||
### Testing Tasks
|
### Testing Tasks
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,11 @@
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Site File Management Service** | `domain/site_building/services/file_management_service.py` | Phase 1 | File upload, delete, organize |
|
| **Site File Management Service** | `business/site_building/services/file_management_service.py` | Phase 1 | File upload, delete, organize |
|
||||||
|
|
||||||
**FileManagementService**:
|
**FileManagementService**:
|
||||||
```python
|
```python
|
||||||
# domain/site_building/services/file_management_service.py
|
# business/site_building/services/file_management_service.py
|
||||||
class SiteBuilderFileService:
|
class SiteBuilderFileService:
|
||||||
def get_user_accessible_sites(self, user):
|
def get_user_accessible_sites(self, user):
|
||||||
"""Get sites user can access for file management"""
|
"""Get sites user can access for file management"""
|
||||||
@@ -142,11 +142,11 @@ class SiteBuilderFileService:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **SiteBlueprint Model** | `domain/site_building/models.py` | Phase 1 | Store site structure |
|
| **SiteBlueprint Model** | `business/site_building/models.py` | Phase 1 | Store site structure |
|
||||||
|
|
||||||
**SiteBlueprint Model**:
|
**SiteBlueprint Model**:
|
||||||
```python
|
```python
|
||||||
# domain/site_building/models.py
|
# business/site_building/models.py
|
||||||
class SiteBlueprint(SiteSectorBaseModel):
|
class SiteBlueprint(SiteSectorBaseModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
@@ -195,11 +195,11 @@ class SiteBlueprint(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **PageBlueprint Model** | `domain/site_building/models.py` | Phase 1 | Store page definitions |
|
| **PageBlueprint Model** | `business/site_building/models.py` | Phase 1 | Store page definitions |
|
||||||
|
|
||||||
**PageBlueprint Model**:
|
**PageBlueprint Model**:
|
||||||
```python
|
```python
|
||||||
# domain/site_building/models.py
|
# business/site_building/models.py
|
||||||
class PageBlueprint(SiteSectorBaseModel):
|
class PageBlueprint(SiteSectorBaseModel):
|
||||||
site_blueprint = models.ForeignKey(SiteBlueprint, on_delete=models.CASCADE, related_name='pages')
|
site_blueprint = models.ForeignKey(SiteBlueprint, on_delete=models.CASCADE, related_name='pages')
|
||||||
slug = models.SlugField(max_length=255)
|
slug = models.SlugField(max_length=255)
|
||||||
@@ -246,7 +246,7 @@ class PageBlueprint(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Site Builder Migrations** | `domain/site_building/migrations/` | Phase 1 | Create initial migrations |
|
| **Site Builder Migrations** | `business/site_building/migrations/` | Phase 1 | Create initial migrations |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -294,11 +294,11 @@ class GenerateSiteStructureFunction(BaseAIFunction):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Structure Generation Service** | `domain/site_building/services/structure_generation_service.py` | Phase 1, AI framework | Service to generate site structure |
|
| **Structure Generation Service** | `business/site_building/services/structure_generation_service.py` | Phase 1, AI framework | Service to generate site structure |
|
||||||
|
|
||||||
**StructureGenerationService**:
|
**StructureGenerationService**:
|
||||||
```python
|
```python
|
||||||
# domain/site_building/services/structure_generation_service.py
|
# business/site_building/services/structure_generation_service.py
|
||||||
class StructureGenerationService:
|
class StructureGenerationService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.ai_function = GenerateSiteStructureFunction()
|
self.ai_function = GenerateSiteStructureFunction()
|
||||||
@@ -531,13 +531,13 @@ frontend/src/components/shared/
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Extend ContentService** | `domain/content/services/content_generation_service.py` | Phase 1 | Add site page generation method |
|
| **Extend ContentService** | `business/content/services/content_generation_service.py` | Phase 1 | Add site page generation method |
|
||||||
|
|
||||||
#### Add Site Page Type
|
#### Add Site Page Type
|
||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Add Site Page Type** | `domain/content/models.py` | Phase 1 | Add site page content type |
|
| **Add Site Page Type** | `business/content/models.py` | Phase 1 | Add site page content type |
|
||||||
|
|
||||||
#### Page Generation Prompts
|
#### Page Generation Prompts
|
||||||
|
|
||||||
@@ -575,12 +575,12 @@ frontend/src/components/shared/
|
|||||||
|
|
||||||
### Backend Tasks
|
### Backend Tasks
|
||||||
|
|
||||||
- [ ] Create `domain/site_building/models.py`
|
- [ ] Create `business/site_building/models.py`
|
||||||
- [ ] Create SiteBlueprint model
|
- [ ] Create SiteBlueprint model
|
||||||
- [ ] Create PageBlueprint model
|
- [ ] Create PageBlueprint model
|
||||||
- [ ] Create site builder migrations
|
- [ ] Create site builder migrations
|
||||||
- [ ] Create `domain/site_building/services/file_management_service.py`
|
- [ ] Create `business/site_building/services/file_management_service.py`
|
||||||
- [ ] Create `domain/site_building/services/structure_generation_service.py`
|
- [ ] Create `business/site_building/services/structure_generation_service.py`
|
||||||
- [ ] Create `infrastructure/ai/functions/generate_site_structure.py`
|
- [ ] Create `infrastructure/ai/functions/generate_site_structure.py`
|
||||||
- [ ] Add site structure prompts
|
- [ ] Add site structure prompts
|
||||||
- [ ] Create `modules/site_builder/views.py`
|
- [ ] Create `modules/site_builder/views.py`
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ Entry Point 4: Manual Selection → Linker/Optimizer
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Add source field** | `domain/content/models.py` | Phase 1 | Track content source |
|
| **Add source field** | `business/content/models.py` | Phase 1 | Track content source |
|
||||||
| **Add sync_status field** | `domain/content/models.py` | Phase 1 | Track sync status |
|
| **Add sync_status field** | `business/content/models.py` | Phase 1 | Track sync status |
|
||||||
| **Add external_id field** | `domain/content/models.py` | Phase 1 | Store external platform ID |
|
| **Add external_id field** | `business/content/models.py` | Phase 1 | Store external platform ID |
|
||||||
| **Add sync_metadata field** | `domain/content/models.py` | Phase 1 | Store platform-specific metadata |
|
| **Add sync_metadata field** | `business/content/models.py` | Phase 1 | Store platform-specific metadata |
|
||||||
|
|
||||||
**Content Model Extensions**:
|
**Content Model Extensions**:
|
||||||
```python
|
```python
|
||||||
# domain/content/models.py
|
# business/content/models.py
|
||||||
class Content(SiteSectorBaseModel):
|
class Content(SiteSectorBaseModel):
|
||||||
# Existing fields...
|
# Existing fields...
|
||||||
|
|
||||||
@@ -129,20 +129,20 @@ class Content(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **InternalLink Model** | `domain/linking/models.py` | Phase 1 | Store link relationships |
|
| **InternalLink Model** | `business/linking/models.py` | Phase 1 | Store link relationships |
|
||||||
| **LinkGraph Model** | `domain/linking/models.py` | Phase 1 | Store link graph |
|
| **LinkGraph Model** | `business/linking/models.py` | Phase 1 | Store link graph |
|
||||||
|
|
||||||
### 4.3 Linker Service
|
### 4.3 Linker Service
|
||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **LinkerService** | `domain/linking/services/linker_service.py` | Phase 1, ContentService | Main linking service |
|
| **LinkerService** | `business/linking/services/linker_service.py` | Phase 1, ContentService | Main linking service |
|
||||||
| **Link Candidate Engine** | `domain/linking/services/candidate_engine.py` | Phase 1 | Find link candidates |
|
| **Link Candidate Engine** | `business/linking/services/candidate_engine.py` | Phase 1 | Find link candidates |
|
||||||
| **Link Injection Engine** | `domain/linking/services/injection_engine.py` | Phase 1 | Inject links into content |
|
| **Link Injection Engine** | `business/linking/services/injection_engine.py` | Phase 1 | Inject links into content |
|
||||||
|
|
||||||
**LinkerService**:
|
**LinkerService**:
|
||||||
```python
|
```python
|
||||||
# domain/linking/services/linker_service.py
|
# business/linking/services/linker_service.py
|
||||||
class LinkerService:
|
class LinkerService:
|
||||||
def process(self, content_id):
|
def process(self, content_id):
|
||||||
"""Process content for linking"""
|
"""Process content for linking"""
|
||||||
@@ -176,20 +176,20 @@ class LinkerService:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **OptimizationTask Model** | `domain/optimization/models.py` | Phase 1 | Store optimization results |
|
| **OptimizationTask Model** | `business/optimization/models.py` | Phase 1 | Store optimization results |
|
||||||
| **OptimizationScores Model** | `domain/optimization/models.py` | Phase 1 | Store optimization scores |
|
| **OptimizationScores Model** | `business/optimization/models.py` | Phase 1 | Store optimization scores |
|
||||||
|
|
||||||
### 4.6 Optimizer Service (Multiple Entry Points)
|
### 4.6 Optimizer Service (Multiple Entry Points)
|
||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **OptimizerService** | `domain/optimization/services/optimizer_service.py` | Phase 1, ContentService | Main optimization service |
|
| **OptimizerService** | `business/optimization/services/optimizer_service.py` | Phase 1, ContentService | Main optimization service |
|
||||||
| **Content Analyzer** | `domain/optimization/services/analyzer.py` | Phase 1 | Analyze content quality |
|
| **Content Analyzer** | `business/optimization/services/analyzer.py` | Phase 1 | Analyze content quality |
|
||||||
| **Optimization AI Function** | `infrastructure/ai/functions/optimize_content.py` | Existing AI framework | AI optimization function |
|
| **Optimization AI Function** | `infrastructure/ai/functions/optimize_content.py` | Existing AI framework | AI optimization function |
|
||||||
|
|
||||||
**OptimizerService**:
|
**OptimizerService**:
|
||||||
```python
|
```python
|
||||||
# domain/optimization/services/optimizer_service.py
|
# business/optimization/services/optimizer_service.py
|
||||||
class OptimizerService:
|
class OptimizerService:
|
||||||
def optimize_from_writer(self, content_id):
|
def optimize_from_writer(self, content_id):
|
||||||
"""Entry Point 1: Writer → Optimizer"""
|
"""Entry Point 1: Writer → Optimizer"""
|
||||||
@@ -253,7 +253,7 @@ class OptimizerService:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **ContentPipelineService** | `domain/content/services/content_pipeline_service.py` | LinkerService, OptimizerService | Orchestrate content pipeline |
|
| **ContentPipelineService** | `business/content/services/content_pipeline_service.py` | LinkerService, OptimizerService | Orchestrate content pipeline |
|
||||||
|
|
||||||
**Pipeline Workflow States**:
|
**Pipeline Workflow States**:
|
||||||
```
|
```
|
||||||
@@ -267,7 +267,7 @@ Content States:
|
|||||||
|
|
||||||
**ContentPipelineService**:
|
**ContentPipelineService**:
|
||||||
```python
|
```python
|
||||||
# domain/content/services/content_pipeline_service.py
|
# business/content/services/content_pipeline_service.py
|
||||||
class ContentPipelineService:
|
class ContentPipelineService:
|
||||||
def process_writer_content(self, content_id, stages=['linking', 'optimization']):
|
def process_writer_content(self, content_id, stages=['linking', 'optimization']):
|
||||||
"""Writer → Linker → Optimizer pipeline"""
|
"""Writer → Linker → Optimizer pipeline"""
|
||||||
@@ -356,9 +356,9 @@ class ContentPipelineService:
|
|||||||
### Backend Tasks
|
### Backend Tasks
|
||||||
|
|
||||||
- [ ] Extend Content model with source/sync fields
|
- [ ] Extend Content model with source/sync fields
|
||||||
- [ ] Create `domain/linking/models.py`
|
- [ ] Create `business/linking/models.py`
|
||||||
- [ ] Create LinkerService
|
- [ ] Create LinkerService
|
||||||
- [ ] Create `domain/optimization/models.py`
|
- [ ] Create `business/optimization/models.py`
|
||||||
- [ ] Create OptimizerService
|
- [ ] Create OptimizerService
|
||||||
- [ ] Create optimization AI function
|
- [ ] Create optimization AI function
|
||||||
- [ ] Create ContentPipelineService
|
- [ ] Create ContentPipelineService
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ igny8_sites:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **PublisherService** | `domain/publishing/services/publisher_service.py` | Phase 1 | Main publishing service |
|
| **PublisherService** | `business/publishing/services/publisher_service.py` | Phase 1 | Main publishing service |
|
||||||
| **SitesRendererAdapter** | `domain/publishing/services/adapters/sites_renderer_adapter.py` | Phase 3 | Adapter for Sites renderer |
|
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | Phase 3 | Adapter for Sites renderer |
|
||||||
| **DeploymentService** | `domain/publishing/services/deployment_service.py` | Phase 3 | Deploy sites to renderer |
|
| **DeploymentService** | `business/publishing/services/deployment_service.py` | Phase 3 | Deploy sites to renderer |
|
||||||
|
|
||||||
**PublisherService**:
|
**PublisherService**:
|
||||||
```python
|
```python
|
||||||
# domain/publishing/services/publisher_service.py
|
# business/publishing/services/publisher_service.py
|
||||||
class PublisherService:
|
class PublisherService:
|
||||||
def publish_to_sites(self, site_blueprint):
|
def publish_to_sites(self, site_blueprint):
|
||||||
"""Publish site to Sites renderer"""
|
"""Publish site to Sites renderer"""
|
||||||
@@ -97,8 +97,8 @@ class PublisherService:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **PublishingRecord Model** | `domain/publishing/models.py` | Phase 1 | Track content publishing |
|
| **PublishingRecord Model** | `business/publishing/models.py` | Phase 1 | Track content publishing |
|
||||||
| **DeploymentRecord Model** | `domain/publishing/models.py` | Phase 3 | Track site deployments |
|
| **DeploymentRecord Model** | `business/publishing/models.py` | Phase 3 | Track site deployments |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ class PublisherService:
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Layout Configuration** | `domain/site_building/models.py` | Phase 3 | Store layout selection |
|
| **Layout Configuration** | `business/site_building/models.py` | Phase 3 | Store layout selection |
|
||||||
| **Layout Renderer** | `sites/src/utils/layoutRenderer.ts` | Phase 5 | Render different layouts |
|
| **Layout Renderer** | `sites/src/utils/layoutRenderer.ts` | Phase 5 | Render different layouts |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -49,11 +49,11 @@
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **SiteIntegration Model** | `domain/integration/models.py` | Phase 1 | Store integration configs |
|
| **SiteIntegration Model** | `business/integration/models.py` | Phase 1 | Store integration configs |
|
||||||
|
|
||||||
**SiteIntegration Model**:
|
**SiteIntegration Model**:
|
||||||
```python
|
```python
|
||||||
# domain/integration/models.py
|
# business/integration/models.py
|
||||||
class SiteIntegration(SiteSectorBaseModel):
|
class SiteIntegration(SiteSectorBaseModel):
|
||||||
site = models.ForeignKey(Site, on_delete=models.CASCADE)
|
site = models.ForeignKey(Site, on_delete=models.CASCADE)
|
||||||
platform = models.CharField(max_length=50) # 'wordpress', 'shopify', 'custom'
|
platform = models.CharField(max_length=50) # 'wordpress', 'shopify', 'custom'
|
||||||
@@ -74,8 +74,8 @@ class SiteIntegration(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **IntegrationService** | `domain/integration/services/integration_service.py` | Phase 1 | Manage integrations |
|
| **IntegrationService** | `business/integration/services/integration_service.py` | Phase 1 | Manage integrations |
|
||||||
| **SyncService** | `domain/integration/services/sync_service.py` | Phase 1 | Handle two-way sync |
|
| **SyncService** | `business/integration/services/sync_service.py` | Phase 1 | Handle two-way sync |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,10 +85,10 @@ class SiteIntegration(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **BaseAdapter** | `domain/publishing/services/adapters/base_adapter.py` | Phase 5 | Base adapter interface |
|
| **BaseAdapter** | `business/publishing/services/adapters/base_adapter.py` | Phase 5 | Base adapter interface |
|
||||||
| **WordPressAdapter** | `domain/publishing/services/adapters/wordpress_adapter.py` | EXISTING (refactor) | WordPress publishing |
|
| **WordPressAdapter** | `business/publishing/services/adapters/wordpress_adapter.py` | EXISTING (refactor) | WordPress publishing |
|
||||||
| **SitesRendererAdapter** | `domain/publishing/services/adapters/sites_renderer_adapter.py` | Phase 5 | IGNY8 Sites deployment |
|
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | Phase 5 | IGNY8 Sites deployment |
|
||||||
| **ShopifyAdapter** | `domain/publishing/services/adapters/shopify_adapter.py` | Phase 5 (future) | Shopify publishing |
|
| **ShopifyAdapter** | `business/publishing/services/adapters/shopify_adapter.py` | Phase 5 (future) | Shopify publishing |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,12 +98,12 @@ class SiteIntegration(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Extend PublisherService** | `domain/publishing/services/publisher_service.py` | Phase 5 | Support multiple destinations |
|
| **Extend PublisherService** | `business/publishing/services/publisher_service.py` | Phase 5 | Support multiple destinations |
|
||||||
| **Update PublishingRecord** | `domain/publishing/models.py` | Phase 5 | Track multiple destinations |
|
| **Update PublishingRecord** | `business/publishing/models.py` | Phase 5 | Track multiple destinations |
|
||||||
|
|
||||||
**Multi-Destination Publishing**:
|
**Multi-Destination Publishing**:
|
||||||
```python
|
```python
|
||||||
# domain/publishing/services/publisher_service.py
|
# business/publishing/services/publisher_service.py
|
||||||
class PublisherService:
|
class PublisherService:
|
||||||
def publish(self, content, destinations):
|
def publish(self, content, destinations):
|
||||||
"""Publish content to multiple destinations"""
|
"""Publish content to multiple destinations"""
|
||||||
|
|||||||
@@ -43,13 +43,13 @@
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Add entity_type field** | `domain/content/models.py` | Phase 1 | Content type field |
|
| **Add entity_type field** | `business/content/models.py` | Phase 1 | Content type field |
|
||||||
| **Add json_blocks field** | `domain/content/models.py` | Phase 1 | Structured content blocks |
|
| **Add json_blocks field** | `business/content/models.py` | Phase 1 | Structured content blocks |
|
||||||
| **Add structure_data field** | `domain/content/models.py` | Phase 1 | Content structure data |
|
| **Add structure_data field** | `business/content/models.py` | Phase 1 | Content structure data |
|
||||||
|
|
||||||
**Content Model Extensions**:
|
**Content Model Extensions**:
|
||||||
```python
|
```python
|
||||||
# domain/content/models.py
|
# business/content/models.py
|
||||||
class Content(SiteSectorBaseModel):
|
class Content(SiteSectorBaseModel):
|
||||||
# Existing fields...
|
# Existing fields...
|
||||||
|
|
||||||
@@ -92,9 +92,9 @@ class Content(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Product Content Generation** | `domain/content/services/content_generation_service.py` | Phase 1 | Generate product content |
|
| **Product Content Generation** | `business/content/services/content_generation_service.py` | Phase 1 | Generate product content |
|
||||||
| **Service Page Generation** | `domain/content/services/content_generation_service.py` | Phase 1 | Generate service pages |
|
| **Service Page Generation** | `business/content/services/content_generation_service.py` | Phase 1 | Generate service pages |
|
||||||
| **Taxonomy Generation** | `domain/content/services/content_generation_service.py` | Phase 1 | Generate taxonomy pages |
|
| **Taxonomy Generation** | `business/content/services/content_generation_service.py` | Phase 1 | Generate taxonomy pages |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,10 +104,10 @@ class Content(SiteSectorBaseModel):
|
|||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
| Task | File | Dependencies | Implementation |
|
||||||
|------|------|--------------|----------------|
|
|------|------|--------------|----------------|
|
||||||
| **Product Linking** | `domain/linking/services/linker_service.py` | Phase 4 | Link products |
|
| **Product Linking** | `business/linking/services/linker_service.py` | Phase 4 | Link products |
|
||||||
| **Taxonomy Linking** | `domain/linking/services/linker_service.py` | Phase 4 | Link taxonomies |
|
| **Taxonomy Linking** | `business/linking/services/linker_service.py` | Phase 4 | Link taxonomies |
|
||||||
| **Product Optimization** | `domain/optimization/services/optimizer_service.py` | Phase 4 | Optimize products |
|
| **Product Optimization** | `business/optimization/services/optimizer_service.py` | Phase 4 | Optimize products |
|
||||||
| **Taxonomy Optimization** | `domain/optimization/services/optimizer_service.py` | Phase 4 | Optimize taxonomies |
|
| **Taxonomy Optimization** | `business/optimization/services/optimizer_service.py` | Phase 4 | Optimize taxonomies |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ backend/igny8_core/
|
|||||||
```
|
```
|
||||||
backend/igny8_core/
|
backend/igny8_core/
|
||||||
├── core/ # Core models (Account, User, Site, Sector)
|
├── core/ # Core models (Account, User, Site, Sector)
|
||||||
├── domain/ # Domain-specific code
|
├── business/ # Domain-specific code
|
||||||
│ ├── content/ # Content domain
|
│ ├── content/ # Content domain
|
||||||
│ ├── planning/ # Planning domain
|
│ ├── planning/ # Planning domain
|
||||||
│ ├── linking/ # Linking domain
|
│ ├── linking/ # Linking domain
|
||||||
@@ -52,8 +52,8 @@ backend/igny8_core/
|
|||||||
|
|
||||||
## File Organization Rules
|
## File Organization Rules
|
||||||
|
|
||||||
- **Models**: `domain/{domain}/models.py`
|
- **Models**: `business/{business}/models.py`
|
||||||
- **Services**: `domain/{domain}/services/`
|
- **Services**: `business/{business}/services/`
|
||||||
- **Serializers**: `modules/{module}/serializers.py`
|
- **Serializers**: `modules/{module}/serializers.py`
|
||||||
- **ViewSets**: `modules/{module}/views.py`
|
- **ViewSets**: `modules/{module}/views.py`
|
||||||
- **URLs**: `modules/{module}/urls.py`
|
- **URLs**: `modules/{module}/urls.py`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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";
|
||||||
|
|
||||||
@@ -49,7 +50,6 @@ 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,90 +133,122 @@ export default function App() {
|
|||||||
{/* Planner Module */}
|
{/* Planner Module */}
|
||||||
<Route path="/planner" element={
|
<Route path="/planner" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PlannerDashboard />
|
<ModuleGuard module="planner">
|
||||||
|
<PlannerDashboard />
|
||||||
|
</ModuleGuard>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/planner/keywords" element={
|
<Route path="/planner/keywords" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Keywords />
|
<ModuleGuard module="planner">
|
||||||
|
<Keywords />
|
||||||
|
</ModuleGuard>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/planner/clusters" element={
|
<Route path="/planner/clusters" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Clusters />
|
<ModuleGuard module="planner">
|
||||||
|
<Clusters />
|
||||||
|
</ModuleGuard>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/planner/ideas" element={
|
<Route path="/planner/ideas" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Ideas />
|
<ModuleGuard module="planner">
|
||||||
|
<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 />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/writer/tasks" element={
|
<Route path="/writer/tasks" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="writer">
|
||||||
<Tasks />
|
<Tasks />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</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 />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</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 />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</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 />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/writer/published" element={
|
<Route path="/writer/published" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="writer">
|
||||||
<Published />
|
<Published />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Thinker Module */}
|
{/* Thinker Module */}
|
||||||
<Route path="/thinker" element={
|
<Route path="/thinker" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="thinker">
|
||||||
<ThinkerDashboard />
|
<ThinkerDashboard />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/thinker/prompts" element={
|
<Route path="/thinker/prompts" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="thinker">
|
||||||
<Prompts />
|
<Prompts />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/thinker/author-profiles" element={
|
<Route path="/thinker/author-profiles" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="thinker">
|
||||||
<AuthorProfiles />
|
<AuthorProfiles />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/thinker/profile" element={
|
<Route path="/thinker/profile" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="thinker">
|
||||||
<ThinkerProfile />
|
<ThinkerProfile />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/thinker/strategies" element={
|
<Route path="/thinker/strategies" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="thinker">
|
||||||
<Strategies />
|
<Strategies />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/thinker/image-testing" element={
|
<Route path="/thinker/image-testing" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="thinker">
|
||||||
<ImageTesting />
|
<ImageTesting />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Billing Module */}
|
{/* Billing Module */}
|
||||||
@@ -256,13 +288,10 @@ 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 />
|
||||||
</Suspense>
|
</ModuleGuard>
|
||||||
} />
|
</Suspense>
|
||||||
<Route path="/schedules" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Schedules />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
|
|||||||
40
frontend/src/components/common/ModuleGuard.tsx
Normal file
40
frontend/src/components/common/ModuleGuard.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { isModuleEnabled } from '../../config/modules.config';
|
||||||
|
|
||||||
|
interface ModuleGuardProps {
|
||||||
|
module: string;
|
||||||
|
children: ReactNode;
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModuleGuard - Protects routes based on module enable status
|
||||||
|
* Redirects to settings page if module is disabled
|
||||||
|
*/
|
||||||
|
export default function ModuleGuard({ module, children, redirectTo = '/settings/modules' }: ModuleGuardProps) {
|
||||||
|
const { moduleEnableSettings, loadModuleEnableSettings, loading } = useSettingsStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load module enable settings if not already loaded
|
||||||
|
if (!moduleEnableSettings && !loading) {
|
||||||
|
loadModuleEnableSettings();
|
||||||
|
}
|
||||||
|
}, [moduleEnableSettings, loading, loadModuleEnableSettings]);
|
||||||
|
|
||||||
|
// While loading, show children (optimistic rendering)
|
||||||
|
if (loading || !moduleEnableSettings) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if module is enabled
|
||||||
|
const enabled = isModuleEnabled(module, moduleEnableSettings as any);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return <Navigate to={redirectTo} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user