19 Commits

Author SHA1 Message Date
alorig
fe7af3c81c Revert "Enhance dashboard data fetching by adding active site checks"
This reverts commit 75ba407df5.
2025-11-17 17:28:30 +05:00
alorig
ea9ffedc01 Revert "Update Usage.tsx"
This reverts commit bf6589449f.
2025-11-17 17:28:24 +05:00
alorig
bf6589449f Update Usage.tsx 2025-11-17 17:24:38 +05:00
alorig
75ba407df5 Enhance dashboard data fetching by adding active site checks
- Implemented checks for active site in Home, Planner, and Writer dashboards to prevent data fetching when no site is selected.
- Updated API calls to include site_id in requests for better data accuracy.
- Modified user messages to guide users in selecting an active site for insights.
2025-11-17 17:22:15 +05:00
alorig
4b21009cf8 Update README.md 2025-11-17 16:59:48 +05:00
IGNY8 VPS (Salman)
8a9dd8ed2f aaaa 2025-11-17 11:58:45 +00:00
IGNY8 VPS (Salman)
9930728e8a Add source tracking and sync status fields to Content model; update services module
- Introduced new fields in the Content model for source tracking and sync status, including external references and optimization fields.
- Updated the services module to include new content generation and pipeline services for better organization and clarity.
2025-11-17 11:15:15 +00:00
IGNY8 VPS (Salman)
fe95d09bbe phase 0 to 2 completed 2025-11-16 23:02:22 +00:00
IGNY8 VPS (Salman)
4ecc1706bc celery 2025-11-16 22:57:36 +00:00
IGNY8 VPS (Salman)
0f02bd6409 celery 2025-11-16 22:52:43 +00:00
IGNY8 VPS (Salman)
1134285a12 Update app labels for billing, writer, and planner models; fix foreign key references in automation migrations
- Set app labels for CreditTransaction and CreditUsageLog models to 'billing'.
- Updated app labels for Tasks, Content, and Images models to 'writer'.
- Changed foreign key references in automation migrations from 'account' to 'tenant' for consistency.
2025-11-16 22:37:16 +00:00
IGNY8 VPS (Salman)
1c2c9354ba Add automation module to settings and update app labels
- Registered the new AutomationConfig in the INSTALLED_APPS of settings.py.
- Set the app_label for AutomationRule and ScheduledTask models to 'automation' for better organization and clarity in the database schema.
2025-11-16 22:23:39 +00:00
IGNY8 VPS (Salman)
92f51859fe reaminign phase 1-2 tasks 2025-11-16 22:17:33 +00:00
IGNY8 VPS (Salman)
7f8982a0ab Add scheduled automation task and update URL routing
- Introduced a new scheduled task for executing automation rules every 5 minutes in the Celery beat schedule.
- Updated URL routing to include a new endpoint for automation-related functionalities.
- Refactored imports in various modules to align with the new business layer structure, ensuring backward compatibility for billing models, exceptions, and services.
2025-11-16 22:11:05 +00:00
IGNY8 VPS (Salman)
455358ecfc Refactor domain structure to business layer
- Renamed `domain/` to `business/` to better reflect the organization of code by business logic.
- Updated all relevant file paths and references throughout the project to align with the new structure.
- Ensured that all models and services are now located under the `business/` directory, maintaining existing functionality while improving clarity.
2025-11-16 21:47:51 +00:00
IGNY8 VPS (Salman)
cb0e42bb8d dd 2025-11-16 21:33:55 +00:00
IGNY8 VPS (Salman)
9ab87416d8 Merge branch 'feature/phase-0-credit-system' 2025-11-16 21:29:55 +00:00
IGNY8 VPS (Salman)
b2e60b749a 1 2025-11-16 20:02:45 +00:00
IGNY8 VPS (Salman)
9f3c4a6cdd Fix middleware: Don't set request.user, only request.account
- Middleware should only set request.account, not request.user
- Let DRF authentication handle request.user setting
- This prevents conflicts between middleware and DRF authentication
- Fixes /me endpoint returning wrong user issue
2025-11-16 19:49:55 +00:00
83 changed files with 5019 additions and 1430 deletions

View File

@@ -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)
- **Frontend**: React 19 with TypeScript and Vite (Port 5173/8021)

Binary file not shown.

View File

@@ -195,8 +195,8 @@ class AIEngine:
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
if self.account:
try:
from igny8_core.modules.billing.services import CreditService
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
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)
@@ -353,8 +353,8 @@ class AIEngine:
# Phase 5.5: DEDUCT CREDITS - Deduct credits after successful save
if self.account and raw_response:
try:
from igny8_core.modules.billing.services import CreditService
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
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)

View File

@@ -67,16 +67,10 @@ class JWTAuthentication(BaseAuthentication):
try:
account = Account.objects.get(id=account_id)
except Account.DoesNotExist:
pass
if not account:
try:
account = getattr(user, 'account', None)
except (AttributeError, Exception):
# If account access fails, set to None
# Account from token doesn't exist - don't fallback, set to None
account = None
# Set account on request
# Set account on request (only if account_id was in token and account exists)
request.account = account
return (user, token)

View File

@@ -8,7 +8,7 @@ from django.db.models import Q
from igny8_core.auth.models import Account, User, Site, Sector
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
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.settings_models import AccountSettings, UserSettings, ModuleSettings, AISettings

View File

@@ -76,7 +76,6 @@ class AccountContextMiddleware(MiddlewareMixin):
if not JWT_AVAILABLE:
# JWT library not installed yet - skip for now
request.account = None
request.user = None
return None
# Decode JWT token with signature verification
@@ -94,42 +93,30 @@ class AccountContextMiddleware(MiddlewareMixin):
if user_id:
from .models import User, Account
try:
# Refresh user from DB with account and plan relationships to get latest data
# This ensures changes to account/plan are reflected immediately without re-login
# Get user from DB (but don't set request.user - let DRF authentication handle that)
# Only set request.account for account context
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
request.user = user
if account_id:
# Verify account still exists and matches user
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:
# Verify account still exists
try:
user_account = getattr(user, 'account', None)
if user_account:
request.account = user_account
else:
request.account = None
except (AttributeError, Exception):
# If account access fails (e.g., column mismatch), set to None
account = Account.objects.get(id=account_id)
request.account = account
except Account.DoesNotExist:
# Account from token doesn't exist - don't fallback, set to 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):
request.account = None
request.user = None
else:
request.account = None
request.user = None
except jwt.InvalidTokenError:
request.account = None
request.user = None
except Exception:
# Fail silently for now - allow unauthenticated access
request.account = None
request.user = None
return None

View File

@@ -0,0 +1,5 @@
"""
Business logic layer - Models and Services
Separated from API layer (modules/) for clean architecture
"""

View File

@@ -0,0 +1,4 @@
"""
Automation business logic - AutomationRule, ScheduledTask models and services
"""

View 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.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'),
),
]

View 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}"

View File

@@ -0,0 +1,4 @@
"""
Automation services
"""

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View 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)
}

View File

@@ -0,0 +1,4 @@
"""
Billing business logic - CreditTransaction, CreditUsageLog models and services
"""

View 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
}

View 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

View 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'}"

View File

@@ -0,0 +1,4 @@
"""
Billing services
"""

View 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)

View File

@@ -0,0 +1,4 @@
"""
Content business logic - Content, Tasks, Images models and services
"""

View 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}"

View 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']

View File

@@ -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)
}

View File

@@ -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

View File

@@ -0,0 +1,6 @@
"""
Linking Business Logic
Phase 4: Linker & Optimizer
"""

View File

@@ -0,0 +1,5 @@
"""
Linking Services
"""

View 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"

View File

@@ -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)
}

View 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

View File

@@ -0,0 +1,6 @@
"""
Optimization Business Logic
Phase 4: Linker & Optimizer
"""

View 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()})"

View File

@@ -0,0 +1,5 @@
"""
Optimization Services
"""

View 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)

View File

@@ -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)

View File

@@ -0,0 +1,4 @@
"""
Planning business logic - Keywords, Clusters, ContentIdeas models and services
"""

View 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

View File

@@ -0,0 +1,4 @@
"""
Planning services
"""

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -0,0 +1,6 @@
"""
Site Building Business Logic
Phase 3: Site Builder
"""

View 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})"

View File

@@ -0,0 +1,5 @@
"""
Site Building Services
"""

View File

@@ -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

View File

@@ -25,6 +25,10 @@ app.conf.beat_schedule = {
'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)

View File

@@ -0,0 +1,5 @@
"""
Automation Module - API Layer
Business logic is in business/automation/
"""

View 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'

View 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'),
),
]

View 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']

View 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']

View 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)),
]

View 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']

View File

@@ -1,21 +1,4 @@
"""
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
}
# Backward compatibility alias - constants moved to business/billing/
from igny8_core.business.billing.constants import CREDIT_COSTS
__all__ = ['CREDIT_COSTS']

View File

@@ -1,14 +1,4 @@
"""
Billing Exceptions
"""
class InsufficientCreditsError(Exception):
"""Raised when account doesn't have enough credits"""
pass
class CreditCalculationError(Exception):
"""Raised when credit calculation fails"""
pass
# Backward compatibility aliases - exceptions moved to business/billing/
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
__all__ = ['InsufficientCreditsError', 'CreditCalculationError']

View File

@@ -1,72 +1,4 @@
"""
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:
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'}"
# Backward compatibility aliases - models moved to business/billing/
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
__all__ = ['CreditTransaction', 'CreditUsageLog']

View File

@@ -1,264 +1,4 @@
"""
Credit Service for managing credit transactions and deductions
"""
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 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)
# Backward compatibility alias - service moved to business/billing/services/
from igny8_core.business.billing.services.credit_service import CreditService
__all__ = ['CreditService']

View File

@@ -1,194 +1,4 @@
from django.db import models
from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
# Backward compatibility aliases - models moved to business/planning/
from igny8_core.business.planning.models import 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
__all__ = ['Keywords', 'Clusters', 'ContentIdeas']

View File

@@ -17,6 +17,9 @@ from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove
from .models import Keywords, Clusters, ContentIdeas
from .serializers import KeywordSerializer, ContentIdeasSerializer
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(
@@ -568,93 +571,55 @@ class KeywordViewSet(SiteSectorModelViewSet):
@action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster')
def auto_cluster(self, request):
"""Auto-cluster keywords using AI - New unified framework"""
"""Auto-cluster keywords using ClusteringService"""
import logging
from igny8_core.ai.tasks import run_ai_task
from kombu.exceptions import OperationalError as KombuOperationalError
logger = logging.getLogger(__name__)
try:
keyword_ids = request.data.get('ids', [])
sector_id = request.data.get('sector_id')
# Get account
account = getattr(request, 'account', None)
account_id = account.id if account else None
# 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']:
if not account:
return error_response(
error='No IDs provided',
error='Account is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if len(payload['ids']) > 20:
return error_response(
error='Maximum 20 keywords allowed for clustering',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Try to queue Celery task
# Use service to cluster keywords
service = ClusteringService()
try:
if hasattr(run_ai_task, 'delay'):
task = run_ai_task.delay(
function_name='auto_cluster',
payload=payload,
account_id=account_id
)
logger.info(f"Task queued: {task.id}")
return success_response(
data={'task_id': str(task.id)},
message='Clustering started',
request=request
)
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'):
result = service.cluster_keywords(keyword_ids, account, sector_id)
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', 'Clustering started'),
request=request
)
else:
# Synchronous execution
return success_response(
data=result,
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:
return error_response(
error=result.get('error', 'Clustering 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_cluster: {str(e)}", exc_info=True)
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')
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
from igny8_core.ai.tasks import run_ai_task
from kombu.exceptions import OperationalError as KombuOperationalError
logger = logging.getLogger(__name__)
try:
cluster_ids = request.data.get('ids', [])
# Get account
account = getattr(request, 'account', None)
account_id = account.id if account else None
# 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']:
if not account:
return error_response(
error='No IDs provided',
error='Account is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if len(payload['ids']) > 10:
return error_response(
error='Maximum 10 clusters allowed for idea generation',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Try to queue Celery task
# Use service to generate ideas
service = IdeasService()
try:
if hasattr(run_ai_task, 'delay'):
task = run_ai_task.delay(
function_name='auto_generate_ideas',
payload=payload,
account_id=account_id
)
logger.info(f"Task queued: {task.id}")
return success_response(
data={'task_id': str(task.id)},
message='Idea generation started',
request=request
)
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'):
result = service.generate_ideas(cluster_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', 'Idea generation started'),
request=request
)
else:
# Synchronous execution
return success_response(
data=result,
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:
return error_response(
error=result.get('error', 'Idea 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_ideas: {str(e)}", exc_info=True)
return error_response(

View File

@@ -1,206 +1,4 @@
from django.db import models
from django.core.validators import MinValueValidator
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}"
# Backward compatibility aliases - models moved to business/content/
from igny8_core.business.content.models import Tasks, Content, Images
__all__ = ['Tasks', 'Content', 'Images']

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from django.db import models
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):

View File

@@ -12,6 +12,8 @@ from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove
from .models import Tasks, Images, Content
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(
@@ -137,17 +139,14 @@ class TasksViewSet(SiteSectorModelViewSet):
@action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content')
def auto_generate_content(self, request):
"""Auto-generate content for tasks using AI"""
"""Auto-generate content for tasks using ContentGenerationService"""
import logging
from django.db import OperationalError, DatabaseError, IntegrityError
from django.core.exceptions import ValidationError
logger = logging.getLogger(__name__)
try:
ids = request.data.get('ids', [])
if not ids:
logger.warning("auto_generate_content: No IDs provided")
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
@@ -155,229 +154,77 @@ class TasksViewSet(SiteSectorModelViewSet):
)
if len(ids) > 10:
logger.warning(f"auto_generate_content: Too many IDs provided: {len(ids)}")
return error_response(
error='Maximum 10 tasks allowed for content generation',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}")
# Get account
account = getattr(request, 'account', None)
account_id = account.id if account else None
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)
if not account:
return error_response(
error=f'Database error while querying tasks: {str(db_error)}',
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)}',
error='Account is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
except Exception as e:
logger.error("=" * 80)
logger.error("UNEXPECTED ERROR in auto_generate_content")
logger.error(f" - Error type: {type(e).__name__}")
logger.error(f" - Error message: {str(e)}")
logger.error(f" - Task IDs: {ids}")
logger.error(f" - Account ID: {account_id}")
logger.error("=" * 80, exc_info=True)
# Validate task IDs exist
queryset = self.get_queryset()
existing_tasks = queryset.filter(id__in=ids, account=account)
existing_count = existing_tasks.count()
if existing_count == 0:
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,
request=request
)
except Exception as outer_error:
logger.error("=" * 80)
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)
except Exception as e:
logger.error(f"Unexpected error in auto_generate_content: {str(e)}", exc_info=True)
return error_response(
error=f'Critical error: {str(outer_error)}',
error=f'Unexpected error: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)

View File

@@ -51,6 +51,7 @@ INSTALLED_APPS = [
'igny8_core.modules.writer.apps.WriterConfig',
'igny8_core.modules.system.apps.SystemConfig',
'igny8_core.modules.billing.apps.BillingConfig',
'igny8_core.modules.automation.apps.AutomationConfig',
]
# System module needs explicit registration for admin

View File

@@ -29,6 +29,7 @@ urlpatterns = [
path('api/v1/writer/', include('igny8_core.modules.writer.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/automation/', include('igny8_core.modules.automation.urls')), # Automation endpoints
# OpenAPI Schema and Documentation
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),

View 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

View File

@@ -27,7 +27,7 @@
| 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 |
| **Single Responsibility** | Each layer has one clear purpose | Core → Domain → Module → Infrastructure |
| **No Duplication** | Reuse services across modules | ContentGenerationService used by Writer + Site Builder |
@@ -108,7 +108,7 @@ backend/igny8_core/
│ ├── permissions.py # IsAuthenticatedAndActive, HasTenantAccess
│ └── throttles.py # DebugScopedRateThrottle
├── domain/ # DOMAIN LAYER (Business Logic)
├── business/ # BUSINESS LAYER (Business Logic)
│ ├── content/ # Content domain
│ │ ├── models.py # Content, Tasks, Images (unified, extended)
│ │ ├── services/
@@ -248,20 +248,20 @@ backend/igny8_core/
| 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` |
| `Tasks` | `modules/writer/models.py` | `domain/content/models.py` | Add: `entity_type` choices (product, service, taxonomy, etc.) |
| `Keywords` | `modules/planner/models.py` | `domain/planning/models.py` | No changes |
| `Clusters` | `modules/planner/models.py` | `domain/planning/models.py` | No changes |
| `ContentIdeas` | `modules/planner/models.py` | `domain/planning/models.py` | Add: `entity_type` support |
| `InternalLinks` | - | `domain/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` |
| `SiteBlueprint` | - | `domain/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` |
| `SiteIntegration` | - | `domain/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` |
| `DeploymentRecord` | - | `domain/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` |
| `ScheduledTask` | - | `domain/automation/models.py` | NEW: `automation_rule`, `scheduled_at`, `status`, `executed_at` |
| `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` | `business/content/models.py` | Add: `entity_type` choices (product, service, taxonomy, etc.) |
| `Keywords` | `modules/planner/models.py` | `business/planning/models.py` | No changes |
| `Clusters` | `modules/planner/models.py` | `business/planning/models.py` | No changes |
| `ContentIdeas` | `modules/planner/models.py` | `business/planning/models.py` | Add: `entity_type` support |
| `InternalLinks` | - | `business/linking/models.py` | NEW: `source_id`, `target_id`, `anchor`, `position`, `link_type` |
| `OptimizationTask` | - | `business/optimization/models.py` | NEW: `content_id`, `type`, `target_keyword`, `scores_before`, `scores_after`, `html_before`, `html_after` |
| `SiteBlueprint` | - | `business/site_building/models.py` | NEW: `tenant`, `site`, `config_json`, `structure_json`, `status`, `hosting_type` |
| `PageBlueprint` | - | `business/site_building/models.py` | NEW: `site_blueprint`, `slug`, `type`, `blocks_json`, `status` |
| `SiteIntegration` | - | `business/integration/models.py` | NEW: `site`, `platform`, `platform_type`, `config_json`, `credentials`, `is_active`, `sync_enabled` |
| `PublishingRecord` | - | `business/publishing/models.py` | NEW: `content_id`, `destination`, `destination_type`, `status`, `external_id`, `published_at`, `sync_status` |
| `DeploymentRecord` | - | `business/publishing/models.py` | NEW: `site_blueprint`, `version`, `status`, `build_url`, `deployed_at`, `deployment_type` |
| `AutomationRule` | - | `business/automation/models.py` | NEW: `name`, `trigger`, `conditions`, `actions`, `schedule`, `is_active` |
| `ScheduledTask` | - | `business/automation/models.py` | NEW: `automation_rule`, `scheduled_at`, `status`, `executed_at` |
---
@@ -366,8 +366,8 @@ sites/src/
| Component | Purpose | Implementation |
|-----------|---------|----------------|
| **AutomationRule Model** | Store automation rules | `domain/automation/models.py` |
| **AutomationService** | Execute automation rules | `domain/automation/services/automation_service.py` |
| **AutomationRule Model** | Store automation rules | `business/automation/models.py` |
| **AutomationService** | Execute automation rules | `business/automation/services/automation_service.py` |
| **Celery Beat Tasks** | Scheduled automation | `infrastructure/messaging/automation_tasks.py` |
| **Automation API** | CRUD for rules | `modules/automation/views.py` |
| **Automation UI** | Manage rules | `frontend/src/pages/Automation/` |
@@ -409,8 +409,8 @@ class AutomationRule(SiteSectorBaseModel):
| Task | File | Implementation |
|------|------|----------------|
| **AutomationRule Model** | `domain/automation/models.py` | Create model with trigger, conditions, actions, schedule |
| **AutomationService** | `domain/automation/services/automation_service.py` | `execute_rule()`, `check_conditions()`, `execute_actions()` |
| **AutomationRule Model** | `business/automation/models.py` | Create model with trigger, conditions, actions, schedule |
| **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 |
| **Automation API** | `modules/automation/views.py` | CRUD ViewSet for AutomationRule |
| **Automation UI** | `frontend/src/pages/Automation/` | Dashboard, Rules management, History |
@@ -434,9 +434,9 @@ class AutomationRule(SiteSectorBaseModel):
| 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` |
| **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` |
| **Storage Quota** | Per site | Configurable (default: 10GB) | `infrastructure/storage/file_storage.py` |
| **Concurrent Tasks** | Per site | Configurable (default: 5) | Celery queue configuration |
@@ -530,7 +530,7 @@ class FileStorageService:
**Site Builder File Management**:
```python
# domain/site_building/services/file_management_service.py
# business/site_building/services/file_management_service.py
class SiteBuilderFileService:
def get_user_accessible_sites(self, user) -> List[Site]:
"""Get sites user can access for file management"""
@@ -645,11 +645,11 @@ docker-data/
| Task | Files | Status | Priority |
|------|-------|--------|-----------|
| **Extend Content Model** | `domain/content/models.py` | TODO | HIGH |
| **Create Service Layer** | `domain/*/services/` | TODO | HIGH |
| **Extend Content Model** | `business/content/models.py` | TODO | HIGH |
| **Create Service Layer** | `business/*/services/` | TODO | HIGH |
| **Refactor ViewSets** | `modules/*/views.py` | TODO | HIGH |
| **Implement Automation Models** | `domain/automation/models.py` | TODO | HIGH |
| **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH |
| **Implement Automation Models** | `business/automation/models.py` | TODO | HIGH |
| **Implement Automation Service** | `business/automation/services/` | TODO | HIGH |
| **Implement Automation API** | `modules/automation/` | TODO | HIGH |
| **Implement Automation UI** | `frontend/src/pages/Automation/` | TODO | HIGH |
| **Note**: Schedules functionality will be integrated into Automation UI, not as a separate page | - | - | - |
@@ -659,8 +659,8 @@ docker-data/
| Task | Files | Dependencies | Priority |
|------|-------|--------------|----------|
| **Create Site Builder Container** | `docker-compose.app.yml` | Phase 0 | HIGH |
| **Site Builder Models** | `domain/site_building/models.py` | Phase 0 | HIGH |
| **Structure Generation Service** | `domain/site_building/services/` | Phase 0 | HIGH |
| **Site Builder Models** | `business/site_building/models.py` | 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 |
| **Site Builder API** | `modules/site_builder/` | Phase 0 | HIGH |
| **Site Builder Frontend** | `site-builder/src/` | Phase 0 | HIGH |
@@ -669,12 +669,12 @@ docker-data/
| Task | Files | Dependencies | Priority |
|------|-------|--------------|----------|
| **Linker Models** | `domain/linking/models.py` | Phase 0 | MEDIUM |
| **Linker Service** | `domain/linking/services/` | Phase 0 | MEDIUM |
| **Linker Models** | `business/linking/models.py` | Phase 0 | MEDIUM |
| **Linker Service** | `business/linking/services/` | Phase 0 | MEDIUM |
| **Linker API** | `modules/linker/` | Phase 0 | MEDIUM |
| **Linker UI** | `frontend/src/pages/Linker/` | Phase 0 | MEDIUM |
| **Optimizer Models** | `domain/optimization/models.py` | Phase 0 | MEDIUM |
| **Optimizer Service** | `domain/optimization/services/` | Phase 0 | MEDIUM |
| **Optimizer Models** | `business/optimization/models.py` | Phase 0 | MEDIUM |
| **Optimizer Service** | `business/optimization/services/` | Phase 0 | MEDIUM |
| **Optimizer AI Function** | `infrastructure/ai/functions/optimize_content.py` | Phase 0 | MEDIUM |
| **Optimizer API** | `modules/optimizer/` | Phase 0 | MEDIUM |
| **Optimizer UI** | `frontend/src/pages/Optimizer/` | Phase 0 | MEDIUM |
@@ -685,22 +685,22 @@ docker-data/
|------|-------|--------------|----------|
| **Create Sites Container** | `docker-compose.app.yml` | Phase 1 | MEDIUM |
| **Sites Renderer Frontend** | `sites/src/` | Phase 1 | MEDIUM |
| **Publisher Service** | `domain/publishing/services/` | Phase 0 | MEDIUM |
| **Sites Renderer Adapter** | `domain/publishing/services/adapters/` | Phase 1 | MEDIUM |
| **Publisher Service** | `business/publishing/services/` | Phase 0 | MEDIUM |
| **Sites Renderer Adapter** | `business/publishing/services/adapters/` | Phase 1 | 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
| 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 |
| **Service Page 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 Linker** | `domain/linking/services/` | Phase 2 | LOW |
| **Content Type Support in Optimizer** | `domain/optimization/services/` | Phase 2 | LOW |
| **Content Type Support in Writer** | `business/content/services/` | Phase 0 | LOW |
| **Content Type Support in Linker** | `business/linking/services/` | Phase 2 | LOW |
| **Content Type Support in Optimizer** | `business/optimization/services/` | Phase 2 | LOW |
---
@@ -750,11 +750,11 @@ docker-data/
| Location | Check | Implementation |
|----------|-------|----------------|
| **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` |
| **Linking** | Before linking | `domain/linking/services/linker_service.py` (NEW) |
| **Optimization** | Before optimization | `domain/optimization/services/optimizer_service.py` (NEW) |
| **Site Building** | Before structure gen | `domain/site_building/services/structure_generation_service.py` (NEW) |
| **Linking** | Before linking | `business/linking/services/linker_service.py` (NEW) |
| **Optimization** | Before optimization | `business/optimization/services/optimizer_service.py` (NEW) |
| **Site Building** | Before structure gen | `business/site_building/services/structure_generation_service.py` (NEW) |
### 10.5 Credit Logging
@@ -784,8 +784,8 @@ These are **NOT** business limits - they're technical constraints for request pr
| Feature | Implementation | Location |
|---------|----------------|----------|
| **Credit Check** | Before every AI operation | `domain/billing/services/credit_service.py` |
| **Credit Deduction** | After successful operation | `domain/billing/services/credit_service.py` |
| **Credit Check** | Before every AI operation | `business/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` |
| **Monthly Replenishment** | Celery Beat task | `infrastructure/messaging/automation_tasks.py` |
| **Low Credit Warning** | When < 10% remaining | Frontend + Email notification |
@@ -893,7 +893,7 @@ publisher_service.publish(
### 11.6 Site Integration Service
```python
# domain/integration/services/integration_service.py
# business/integration/services/integration_service.py
class IntegrationService:
def create_integration(self, site, platform, config, credentials):
"""Create new site integration"""
@@ -945,12 +945,12 @@ Content/Site Publishing Flow:
| Component | File | Purpose |
|-----------|------|---------|
| **SiteIntegration Model** | `domain/integration/models.py` | Store integration configs |
| **IntegrationService** | `domain/integration/services/integration_service.py` | Manage integrations |
| **SyncService** | `domain/integration/services/sync_service.py` | Handle two-way sync |
| **WordPressAdapter** | `domain/publishing/services/adapters/wordpress_adapter.py` | WordPress publishing |
| **SitesRendererAdapter** | `domain/publishing/services/adapters/sites_renderer_adapter.py` | IGNY8 Sites deployment |
| **ShopifyAdapter** | `domain/publishing/services/adapters/shopify_adapter.py` | Shopify publishing (future) |
| **SiteIntegration Model** | `business/integration/models.py` | Store integration configs |
| **IntegrationService** | `business/integration/services/integration_service.py` | Manage integrations |
| **SyncService** | `business/integration/services/sync_service.py` | Handle two-way sync |
| **WordPressAdapter** | `business/publishing/services/adapters/wordpress_adapter.py` | WordPress publishing |
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | IGNY8 Sites deployment |
| **ShopifyAdapter** | `business/publishing/services/adapters/shopify_adapter.py` | Shopify publishing (future) |
| **Integration API** | `modules/integration/views.py` | CRUD for integrations |
| **Integration UI** | `frontend/src/pages/Settings/Integrations.tsx` | Manage integrations |

View File

@@ -72,7 +72,7 @@
| 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 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 |
@@ -100,18 +100,18 @@
|------|-------|--------------|------|
| **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 CreditService** | `domain/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 |
| **Update CreditService** | `business/billing/services/credit_service.py` | None | MEDIUM - Add credit cost constants |
| **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 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 |
| **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 |
**Credit Cost Constants**:
```python
# domain/billing/constants.py
# business/billing/constants.py
CREDIT_COSTS = {
'clustering': 10,
'idea_generation': 15,
@@ -156,16 +156,16 @@ CREDIT_COSTS = {
**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 |
|------|-------|--------------|
| **Create domain/ folder** | `backend/igny8_core/domain/` | None |
| **Move Content models** | `domain/content/models.py` | Phase 0 |
| **Move Planning models** | `domain/planning/models.py` | Phase 0 |
| **Create ContentService** | `domain/content/services/content_generation_service.py` | Existing Writer logic |
| **Create PlanningService** | `domain/planning/services/clustering_service.py` | Existing Planner logic |
| **Create IdeasService** | `domain/planning/services/ideas_service.py` | Existing Planner logic |
| **Create business/ folder** | `backend/igny8_core/business/` | None |
| **Move Content models** | `business/content/models.py` | Phase 0 |
| **Move Planning models** | `business/planning/models.py` | Phase 0 |
| **Create ContentService** | `business/content/services/content_generation_service.py` | Existing Writer logic |
| **Create PlanningService** | `business/planning/services/clustering_service.py` | Existing Planner logic |
| **Create IdeasService** | `business/planning/services/ideas_service.py` | Existing Planner logic |
### 1.2 Refactor ViewSets (Keep APIs Working)
@@ -199,18 +199,18 @@ CREDIT_COSTS = {
| Task | Files | Dependencies |
|------|-------|--------------|
| **AutomationRule Model** | `domain/automation/models.py` | Phase 1 |
| **ScheduledTask Model** | `domain/automation/models.py` | Phase 1 |
| **Automation Migrations** | `domain/automation/migrations/` | Phase 1 |
| **AutomationRule Model** | `business/automation/models.py` | Phase 1 |
| **ScheduledTask Model** | `business/automation/models.py` | Phase 1 |
| **Automation Migrations** | `business/automation/migrations/` | Phase 1 |
### 2.2 Automation Service
| Task | Files | Dependencies |
|------|-------|--------------|
| **AutomationService** | `domain/automation/services/automation_service.py` | Phase 1 services |
| **Rule Execution Engine** | `domain/automation/services/rule_engine.py` | Phase 1 services |
| **Condition Evaluator** | `domain/automation/services/condition_evaluator.py` | None |
| **Action Executor** | `domain/automation/services/action_executor.py` | Phase 1 services |
| **AutomationService** | `business/automation/services/automation_service.py` | Phase 1 services |
| **Rule Execution Engine** | `business/automation/services/rule_engine.py` | Phase 1 services |
| **Condition Evaluator** | `business/automation/services/condition_evaluator.py` | None |
| **Action Executor** | `business/automation/services/action_executor.py` | Phase 1 services |
### 2.3 Celery Beat Tasks
@@ -256,8 +256,8 @@ CREDIT_COSTS = {
| Task | Files | Dependencies |
|------|-------|--------------|
| **Site File Management Service** | `domain/site_building/services/file_management_service.py` | Phase 1 |
| **User Site Access Check** | `domain/site_building/services/file_management_service.py` | EXISTING (SiteUserAccess) |
| **Site File Management Service** | `business/site_building/services/file_management_service.py` | Phase 1 |
| **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 Browser UI** | `site-builder/src/components/files/FileBrowser.tsx` | NEW |
| **Storage Quota Check** | `infrastructure/storage/file_storage.py` | Phase 1 |
@@ -286,16 +286,16 @@ CREDIT_COSTS = {
| Task | Files | Dependencies |
|------|-------|--------------|
| **SiteBlueprint Model** | `domain/site_building/models.py` | Phase 1 |
| **PageBlueprint Model** | `domain/site_building/models.py` | Phase 1 |
| **Site Builder Migrations** | `domain/site_building/migrations/` | Phase 1 |
| **SiteBlueprint Model** | `business/site_building/models.py` | Phase 1 |
| **PageBlueprint Model** | `business/site_building/models.py` | Phase 1 |
| **Site Builder Migrations** | `business/site_building/migrations/` | Phase 1 |
### 3.2 Site Structure Generation
| Task | Files | Dependencies |
|------|-------|--------------|
| **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 |
### 3.3 Site Builder API
@@ -361,8 +361,8 @@ frontend/src/components/shared/
| Task | Files | Dependencies |
|------|-------|--------------|
| **Extend ContentService** | `domain/content/services/content_generation_service.py` | Phase 1 |
| **Add Site Page Type** | `domain/content/models.py` | Phase 1 |
| **Extend ContentService** | `business/content/services/content_generation_service.py` | Phase 1 |
| **Add Site Page Type** | `business/content/models.py` | Phase 1 |
| **Page Generation Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
### 3.6 Testing
@@ -437,11 +437,11 @@ Entry Point 4: Manual Selection → Linker/Optimizer
| Task | Files | Dependencies |
|------|-------|--------------|
| **Add source field** | `domain/content/models.py` | Phase 1 |
| **Add sync_status field** | `domain/content/models.py` | Phase 1 |
| **Add external_id field** | `domain/content/models.py` | Phase 1 |
| **Add sync_metadata field** | `domain/content/models.py` | Phase 1 |
| **Content Migrations** | `domain/content/migrations/` | Phase 1 |
| **Add source field** | `business/content/models.py` | Phase 1 |
| **Add sync_status field** | `business/content/models.py` | Phase 1 |
| **Add external_id field** | `business/content/models.py` | Phase 1 |
| **Add sync_metadata field** | `business/content/models.py` | Phase 1 |
| **Content Migrations** | `business/content/migrations/` | Phase 1 |
**Content Model Extensions**:
```python
@@ -481,26 +481,26 @@ class Content(SiteSectorBaseModel):
| Task | Files | Dependencies |
|------|-------|--------------|
| **InternalLink Model** | `domain/linking/models.py` | Phase 1 |
| **LinkGraph Model** | `domain/linking/models.py` | Phase 1 |
| **Linker Migrations** | `domain/linking/migrations/` | Phase 1 |
| **InternalLink Model** | `business/linking/models.py` | Phase 1 |
| **LinkGraph Model** | `business/linking/models.py` | Phase 1 |
| **Linker Migrations** | `business/linking/migrations/` | Phase 1 |
### 4.3 Linker Service
| Task | Files | Dependencies |
|------|-------|--------------|
| **LinkerService** | `domain/linking/services/linker_service.py` | Phase 1, ContentService |
| **Link Candidate Engine** | `domain/linking/services/candidate_engine.py` | Phase 1 |
| **Link Injection Engine** | `domain/linking/services/injection_engine.py` | Phase 1 |
| **LinkerService** | `business/linking/services/linker_service.py` | Phase 1, ContentService |
| **Link Candidate Engine** | `business/linking/services/candidate_engine.py` | Phase 1 |
| **Link Injection Engine** | `business/linking/services/injection_engine.py` | Phase 1 |
### 4.4 Content Sync Service (For WordPress/3rd Party)
| Task | Files | Dependencies |
|------|-------|--------------|
| **ContentSyncService** | `domain/integration/services/content_sync_service.py` | Phase 1, Phase 6 |
| **WordPress Content Sync** | `domain/integration/services/wordpress_sync.py` | Phase 6 |
| **3rd Party Content Sync** | `domain/integration/services/external_sync.py` | Phase 6 |
| **Content Import Logic** | `domain/integration/services/import_service.py` | Phase 1 |
| **ContentSyncService** | `business/integration/services/content_sync_service.py` | Phase 1, Phase 6 |
| **WordPress Content Sync** | `business/integration/services/wordpress_sync.py` | Phase 6 |
| **3rd Party Content Sync** | `business/integration/services/external_sync.py` | Phase 6 |
| **Content Import Logic** | `business/integration/services/import_service.py` | Phase 1 |
**Sync Service Flow**:
```python
@@ -530,17 +530,17 @@ class ContentSyncService:
| Task | Files | Dependencies |
|------|-------|--------------|
| **OptimizationTask Model** | `domain/optimization/models.py` | Phase 1 |
| **OptimizationScores Model** | `domain/optimization/models.py` | Phase 1 |
| **Optimizer Migrations** | `domain/optimization/migrations/` | Phase 1 |
| **OptimizationTask Model** | `business/optimization/models.py` | Phase 1 |
| **OptimizationScores Model** | `business/optimization/models.py` | Phase 1 |
| **Optimizer Migrations** | `business/optimization/migrations/` | Phase 1 |
### 4.6 Optimizer Service (Multiple Entry Points)
| Task | Files | Dependencies |
|------|-------|--------------|
| **OptimizerService** | `domain/optimization/services/optimizer_service.py` | Phase 1, ContentService |
| **Content Analyzer** | `domain/optimization/services/analyzer.py` | Phase 1 |
| **Entry Point Handler** | `domain/optimization/services/entry_handler.py` | Phase 1 |
| **OptimizerService** | `business/optimization/services/optimizer_service.py` | Phase 1, ContentService |
| **Content Analyzer** | `business/optimization/services/analyzer.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 Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
@@ -578,8 +578,8 @@ class OptimizerService:
| Task | Files | Dependencies |
|------|-------|--------------|
| **OptimizerService** | `domain/optimization/services/optimizer_service.py` | Phase 1, ContentService |
| **Content Analyzer** | `domain/optimization/services/analyzer.py` | Phase 1 |
| **OptimizerService** | `business/optimization/services/optimizer_service.py` | Phase 1, ContentService |
| **Content Analyzer** | `business/optimization/services/analyzer.py` | Phase 1 |
| **Optimization AI Function** | `infrastructure/ai/functions/optimize_content.py` | Existing AI framework |
| **Optimization Prompts** | `infrastructure/ai/prompts.py` | Existing prompt system |
@@ -587,9 +587,9 @@ class OptimizerService:
| Task | Files | Dependencies |
|------|-------|--------------|
| **ContentPipelineService** | `domain/content/services/content_pipeline_service.py` | LinkerService, OptimizerService |
| **Pipeline Orchestration** | `domain/content/services/pipeline_service.py` | Phase 1 services |
| **Workflow State Machine** | `domain/content/services/workflow_state.py` | Phase 1 services |
| **ContentPipelineService** | `business/content/services/content_pipeline_service.py` | LinkerService, OptimizerService |
| **Pipeline Orchestration** | `business/content/services/pipeline_service.py` | Phase 1 services |
| **Workflow State Machine** | `business/content/services/workflow_state.py` | Phase 1 services |
**Pipeline Workflow States**:
```
@@ -711,7 +711,7 @@ class ContentPipelineService:
| 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 Renderer** | `sites/src/utils/layoutRenderer.ts` | Phase 5 |
| **Layout Preview** | `site-builder/src/components/preview/LayoutPreview.tsx` | Phase 3 |
@@ -729,17 +729,17 @@ class ContentPipelineService:
| Task | Files | Dependencies |
|------|-------|--------------|
| **PublisherService** | `domain/publishing/services/publisher_service.py` | Phase 1 |
| **SitesRendererAdapter** | `domain/publishing/services/adapters/sites_renderer_adapter.py` | Phase 3 |
| **DeploymentService** | `domain/publishing/services/deployment_service.py` | Phase 3 |
| **PublisherService** | `business/publishing/services/publisher_service.py` | Phase 1 |
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | Phase 3 |
| **DeploymentService** | `business/publishing/services/deployment_service.py` | Phase 3 |
### 5.3 Publishing Models
| Task | Files | Dependencies |
|------|-------|--------------|
| **PublishingRecord Model** | `domain/publishing/models.py` | Phase 1 |
| **DeploymentRecord Model** | `domain/publishing/models.py` | Phase 3 |
| **Publishing Migrations** | `domain/publishing/migrations/` | Phase 1 |
| **PublishingRecord Model** | `business/publishing/models.py` | Phase 1 |
| **DeploymentRecord Model** | `business/publishing/models.py` | Phase 3 |
| **Publishing Migrations** | `business/publishing/migrations/` | Phase 1 |
### 5.4 Publisher API
@@ -767,32 +767,32 @@ class ContentPipelineService:
| Task | Files | Dependencies |
|------|-------|--------------|
| **SiteIntegration Model** | `domain/integration/models.py` | Phase 1 |
| **Integration Migrations** | `domain/integration/migrations/` | Phase 1 |
| **SiteIntegration Model** | `business/integration/models.py` | Phase 1 |
| **Integration Migrations** | `business/integration/migrations/` | Phase 1 |
### 6.2 Integration Service
| Task | Files | Dependencies |
|------|-------|--------------|
| **IntegrationService** | `domain/integration/services/integration_service.py` | Phase 1 |
| **SyncService** | `domain/integration/services/sync_service.py` | Phase 1 |
| **IntegrationService** | `business/integration/services/integration_service.py` | Phase 1 |
| **SyncService** | `business/integration/services/sync_service.py` | Phase 1 |
### 6.3 Publishing Adapters
| Task | Files | Dependencies |
|------|-------|--------------|
| **BaseAdapter** | `domain/publishing/services/adapters/base_adapter.py` | Phase 5 |
| **WordPressAdapter** | `domain/publishing/services/adapters/wordpress_adapter.py` | EXISTING (refactor) |
| **SitesRendererAdapter** | `domain/publishing/services/adapters/sites_renderer_adapter.py` | Phase 5 |
| **ShopifyAdapter** | `domain/publishing/services/adapters/shopify_adapter.py` | Phase 5 (future) |
| **BaseAdapter** | `business/publishing/services/adapters/base_adapter.py` | Phase 5 |
| **WordPressAdapter** | `business/publishing/services/adapters/wordpress_adapter.py` | EXISTING (refactor) |
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | Phase 5 |
| **ShopifyAdapter** | `business/publishing/services/adapters/shopify_adapter.py` | Phase 5 (future) |
### 6.4 Multi-Destination Publishing
| Task | Files | Dependencies |
|------|-------|--------------|
| **Extend PublisherService** | `domain/publishing/services/publisher_service.py` | Phase 5 |
| **Multi-destination Support** | `domain/publishing/services/publisher_service.py` | Phase 5 |
| **Update PublishingRecord** | `domain/publishing/models.py` | Phase 5 |
| **Extend PublisherService** | `business/publishing/services/publisher_service.py` | Phase 5 |
| **Multi-destination Support** | `business/publishing/services/publisher_service.py` | Phase 5 |
| **Update PublishingRecord** | `business/publishing/models.py` | Phase 5 |
### 6.5 Site Model Extensions
@@ -867,7 +867,7 @@ class ContentPipelineService:
| Task | Files | Dependencies |
|------|-------|--------------|
| **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 |
**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 |
|------|-------|--------------|
| **Add entity_type field** | `domain/content/models.py` | Phase 1 |
| **Add json_blocks field** | `domain/content/models.py` | Phase 1 |
| **Add structure_data field** | `domain/content/models.py` | Phase 1 |
| **Content Migrations** | `domain/content/migrations/` | Phase 1 |
| **Add entity_type field** | `business/content/models.py` | Phase 1 |
| **Add json_blocks field** | `business/content/models.py` | Phase 1 |
| **Add structure_data field** | `business/content/models.py` | Phase 1 |
| **Content Migrations** | `business/content/migrations/` | Phase 1 |
### 8.2 Content Type Prompts
@@ -1053,18 +1053,18 @@ const isModuleEnabled = (moduleName: string) => {
| Task | Files | Dependencies |
|------|-------|--------------|
| **Product Content Generation** | `domain/content/services/content_generation_service.py` | Phase 1 |
| **Service Page Generation** | `domain/content/services/content_generation_service.py` | Phase 1 |
| **Taxonomy 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** | `business/content/services/content_generation_service.py` | Phase 1 |
| **Taxonomy Generation** | `business/content/services/content_generation_service.py` | Phase 1 |
### 8.4 Linker & Optimizer Extensions
| Task | Files | Dependencies |
|------|-------|--------------|
| **Product Linking** | `domain/linking/services/linker_service.py` | Phase 4 |
| **Taxonomy Linking** | `domain/linking/services/linker_service.py` | Phase 4 |
| **Product Optimization** | `domain/optimization/services/optimizer_service.py` | Phase 4 |
| **Taxonomy Optimization** | `domain/optimization/services/optimizer_service.py` | Phase 4 |
| **Product Linking** | `business/linking/services/linker_service.py` | Phase 4 |
| **Taxonomy Linking** | `business/linking/services/linker_service.py` | Phase 4 |
| **Product Optimization** | `business/optimization/services/optimizer_service.py` | Phase 4 |
| **Taxonomy Optimization** | `business/optimization/services/optimizer_service.py` | Phase 4 |
### 8.5 Testing

View 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**

View File

@@ -48,13 +48,13 @@
| 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 Serializer** | `modules/system/serializers.py` | EXISTING | Add enabled field to serializer |
**ModuleSettings Model Extension**:
```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):
# Existing fields...
@@ -173,11 +173,11 @@ class Plan(models.Model):
| 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**:
```python
# domain/billing/constants.py
# business/billing/constants.py
CREDIT_COSTS = {
'clustering': 10, # Per clustering request
'idea_generation': 15, # Per cluster → ideas request
@@ -195,11 +195,11 @@ CREDIT_COSTS = {
| 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**:
```python
# domain/billing/services/credit_service.py
# business/billing/services/credit_service.py
class CreditService:
def check_credits(self, account, operation_type, amount=None):
"""Check if account has sufficient credits"""
@@ -256,11 +256,11 @@ class AIEngine:
| 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**:
```python
# domain/content/services/content_generation_service.py
# business/content/services/content_generation_service.py
class ContentGenerationService:
def generate_content(self, task, account):
# Check credits before generation
@@ -331,11 +331,11 @@ credit_service.check_credits(account, 'clustering', keyword_count)
| 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**:
```python
# domain/billing/models.py
# business/billing/models.py
class CreditUsageLog(AccountBaseModel):
account = models.ForeignKey(Account, on_delete=models.CASCADE)
operation_type = models.CharField(max_length=50)
@@ -399,7 +399,7 @@ class Migration(migrations.Migration):
**Migration 2: Add Credit Cost Tracking**:
```python
# domain/billing/migrations/XXXX_add_credit_tracking.py
# business/billing/migrations/XXXX_add_credit_tracking.py
class Migration(migrations.Migration):
operations = [
migrations.AddField(
@@ -461,7 +461,7 @@ class Migration(migrations.Migration):
### 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 `Plan` model - remove limit fields
- [ ] Create migration to remove plan limit fields

View File

@@ -12,8 +12,8 @@
## TABLE OF CONTENTS
1. [Overview](#overview)
2. [Create Domain Structure](#create-domain-structure)
3. [Move Models to Domain](#move-models-to-domain)
2. [Create Business Structure](#create-business-structure)
3. [Move Models to Business](#move-models-to-business)
4. [Create Services](#create-services)
5. [Refactor ViewSets](#refactor-viewsets)
6. [Testing & Validation](#testing--validation)
@@ -24,32 +24,31 @@
## OVERVIEW
### Objectives
- ✅ Create `domain/` folder structure
- ✅ Move models from `modules/` to `domain/`
- ✅ Create `business/` folder structure
- ✅ Move models from `modules/` to `business/`
- ✅ Extract business logic from ViewSets to services
- ✅ Keep ViewSets as thin wrappers
- ✅ Preserve all existing API functionality
### Key Principles
- **Backward Compatibility**: All APIs remain unchanged
- **Service Layer Pattern**: Business logic in services, not ViewSets
- **No Breaking Changes**: Response formats unchanged
- **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
```
backend/igny8_core/
├── domain/ # NEW: Domain layer
│ ├── content/ # Content domain
├── business/ # NEW: Business logic layer
│ ├── content/ # Content business logic
│ │ ├── __init__.py
│ │ ├── models.py # Content, Tasks, Images
│ │ ├── services/
@@ -59,7 +58,7 @@ backend/igny8_core/
│ │ │ └── content_versioning_service.py
│ │ └── migrations/
│ │
│ ├── planning/ # Planning domain
│ ├── planning/ # Planning business logic
│ │ ├── __init__.py
│ │ ├── models.py # Keywords, Clusters, Ideas
│ │ ├── services/
@@ -68,12 +67,12 @@ backend/igny8_core/
│ │ │ └── ideas_service.py
│ │ └── migrations/
│ │
│ ├── billing/ # Billing domain (already exists)
│ ├── billing/ # Billing business logic (already exists)
│ │ ├── models.py # Credits, Transactions
│ │ └── services/
│ │ └── credit_service.py # Already exists
│ │
│ └── automation/ # Automation domain (Phase 2)
│ └── automation/ # Automation business logic (Phase 2)
│ ├── models.py
│ └── services/
```
@@ -82,30 +81,30 @@ backend/igny8_core/
| Task | File | Current Location | New Location | Risk |
|------|------|------------------|--------------|------|
| **Create domain/ folder** | `backend/igny8_core/domain/` | N/A | NEW | LOW |
| **Create content domain** | `domain/content/` | N/A | NEW | LOW |
| **Create planning domain** | `domain/planning/` | N/A | NEW | LOW |
| **Create billing domain** | `domain/billing/` | `modules/billing/` | MOVE | LOW |
| **Create automation domain** | `domain/automation/` | N/A | NEW (Phase 2) | LOW |
| **Create business/ folder** | `backend/igny8_core/business/` | N/A | NEW | LOW |
| **Create content business** | `business/content/` | N/A | NEW | LOW |
| **Create planning business** | `business/planning/` | N/A | NEW | LOW |
| **Create billing business** | `business/billing/` | `modules/billing/` | MOVE | 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
| Model | Current Location | New Location | Changes Needed |
|------|------------------|--------------|----------------|
| `Content` | `modules/writer/models.py` | `domain/content/models.py` | Move, update imports |
| `Tasks` | `modules/writer/models.py` | `domain/content/models.py` | Move, update imports |
| `Images` | `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` | `business/content/models.py` | Move, update imports |
| `Images` | `modules/writer/models.py` | `business/content/models.py` | Move, update imports |
**Migration Steps**:
1. Create `domain/content/models.py`
1. Create `business/content/models.py`
2. Copy models from `modules/writer/models.py`
3. Update imports in `modules/writer/views.py`
4. Create migration to ensure no data loss
@@ -115,12 +114,12 @@ backend/igny8_core/
| Model | Current Location | New Location | Changes Needed |
|------|------------------|--------------|----------------|
| `Keywords` | `modules/planner/models.py` | `domain/planning/models.py` | Move, update imports |
| `Clusters` | `modules/planner/models.py` | `domain/planning/models.py` | Move, update imports |
| `ContentIdeas` | `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` | `business/planning/models.py` | Move, update imports |
| `ContentIdeas` | `modules/planner/models.py` | `business/planning/models.py` | Move, update imports |
**Migration Steps**:
1. Create `domain/planning/models.py`
1. Create `business/planning/models.py`
2. Copy models from `modules/planner/models.py`
3. Update imports in `modules/planner/views.py`
4. Create migration to ensure no data loss
@@ -130,13 +129,13 @@ backend/igny8_core/
| Model | Current Location | New Location | Changes Needed |
|------|------------------|--------------|----------------|
| `CreditTransaction` | `modules/billing/models.py` | `domain/billing/models.py` | Move, update imports |
| `CreditUsageLog` | `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` | `business/billing/models.py` | Move, update imports |
**Migration Steps**:
1. Create `domain/billing/models.py`
1. Create `business/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`
5. Create migration to ensure no data loss
@@ -152,11 +151,11 @@ backend/igny8_core/
| 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**:
```python
# domain/content/services/content_generation_service.py
# business/content/services/content_generation_service.py
class ContentGenerationService:
def __init__(self):
self.credit_service = CreditService()
@@ -184,11 +183,11 @@ class ContentGenerationService:
| 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**:
```python
# domain/planning/services/clustering_service.py
# business/planning/services/clustering_service.py
class ClusteringService:
def __init__(self):
self.credit_service = CreditService()
@@ -211,11 +210,11 @@ class ClusteringService:
| 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**:
```python
# domain/planning/services/ideas_service.py
# business/planning/services/ideas_service.py
class IdeasService:
def __init__(self):
self.credit_service = CreditService()
@@ -380,13 +379,13 @@ class TasksViewSet(SiteSectorModelViewSet):
### Backend Tasks
- [ ] Create `domain/` folder structure
- [ ] Create `domain/content/` folder
- [ ] Create `domain/planning/` folder
- [ ] Create `domain/billing/` folder (move existing)
- [ ] Move Content models to `domain/content/models.py`
- [ ] Move Planning models to `domain/planning/models.py`
- [ ] Move Billing models to `domain/billing/models.py`
- [ ] Create `business/` folder structure
- [ ] Create `business/content/` folder
- [ ] Create `business/planning/` folder
- [ ] Create `business/billing/` folder (move existing)
- [ ] Move Content models to `business/content/models.py`
- [ ] Move Planning models to `business/planning/models.py`
- [ ] Move Billing models to `business/billing/models.py`
- [ ] Create migrations for model moves
- [ ] Create `ContentGenerationService`
- [ ] Create `ClusteringService`
@@ -413,22 +412,21 @@ class TasksViewSet(SiteSectorModelViewSet):
| Risk | Level | Mitigation |
|------|-------|------------|
| **Breaking API changes** | MEDIUM | Extensive testing, keep response formats identical |
| **Import errors** | MEDIUM | Update all imports systematically |
| **Data loss during migration** | LOW | Backup before migration, test on staging |
| **Service logic errors** | MEDIUM | Unit tests for all services |
| **Model migration complexity** | MEDIUM | Use Django migrations, test thoroughly |
---
## SUCCESS CRITERIA
- ✅ All existing API endpoints work identically
- ✅ Response formats unchanged
- ✅ No breaking changes for frontend
- ✅ Services are testable independently
- ✅ Business logic extracted from ViewSets
- ✅ ViewSets are thin wrappers
- ✅ All models moved to domain layer
- ✅ ViewSets are thin wrappers that delegate to services
- ✅ All models moved to business layer
- ✅ All imports updated correctly
- ✅ Services handle credit checks and business rules
---

View File

@@ -49,11 +49,11 @@
| 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**:
```python
# domain/automation/models.py
# business/automation/models.py
class AutomationRule(SiteSectorBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
@@ -101,11 +101,11 @@ class AutomationRule(SiteSectorBaseModel):
| 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**:
```python
# domain/automation/models.py
# business/automation/models.py
class ScheduledTask(SiteSectorBaseModel):
automation_rule = models.ForeignKey(AutomationRule, on_delete=models.CASCADE)
scheduled_at = models.DateTimeField()
@@ -133,7 +133,7 @@ class ScheduledTask(SiteSectorBaseModel):
| 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 |
|------|------|--------------|----------------|
| **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**:
```python
# domain/automation/services/automation_service.py
# business/automation/services/automation_service.py
class AutomationService:
def __init__(self):
self.rule_engine = RuleEngine()
@@ -202,11 +202,11 @@ class AutomationService:
| 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**:
```python
# domain/automation/services/rule_engine.py
# business/automation/services/rule_engine.py
class RuleEngine:
def execute_rule(self, rule, context):
"""Orchestrate rule execution"""
@@ -221,11 +221,11 @@ class RuleEngine:
| 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**:
```python
# domain/automation/services/condition_evaluator.py
# business/automation/services/condition_evaluator.py
class ConditionEvaluator:
def evaluate(self, conditions, context):
"""Evaluate rule conditions"""
@@ -238,11 +238,11 @@ class ConditionEvaluator:
| 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**:
```python
# domain/automation/services/action_executor.py
# business/automation/services/action_executor.py
class ActionExecutor:
def __init__(self):
self.clustering_service = ClusteringService()
@@ -290,7 +290,7 @@ from celery.schedules import crontab
@shared_task
def execute_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()
rules = AutomationRule.objects.filter(
@@ -316,7 +316,7 @@ def execute_scheduled_automation_rules():
@shared_task
def replenish_monthly_credits():
"""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()
accounts = Account.objects.filter(status='active')
@@ -528,14 +528,14 @@ export const automationApi = {
### Backend Tasks
- [ ] Create `domain/automation/models.py`
- [ ] Create `business/automation/models.py`
- [ ] Create AutomationRule model
- [ ] Create ScheduledTask model
- [ ] Create automation migrations
- [ ] Create `domain/automation/services/automation_service.py`
- [ ] Create `domain/automation/services/rule_engine.py`
- [ ] Create `domain/automation/services/condition_evaluator.py`
- [ ] Create `domain/automation/services/action_executor.py`
- [ ] Create `business/automation/services/automation_service.py`
- [ ] Create `business/automation/services/rule_engine.py`
- [ ] Create `business/automation/services/condition_evaluator.py`
- [ ] Create `business/automation/services/action_executor.py`
- [ ] Create `infrastructure/messaging/automation_tasks.py`
- [ ] Add scheduled automation task
- [ ] Add monthly credit replenishment task

View File

@@ -77,11 +77,11 @@
| 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**:
```python
# domain/site_building/services/file_management_service.py
# business/site_building/services/file_management_service.py
class SiteBuilderFileService:
def get_user_accessible_sites(self, user):
"""Get sites user can access for file management"""
@@ -142,11 +142,11 @@ class SiteBuilderFileService:
| 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**:
```python
# domain/site_building/models.py
# business/site_building/models.py
class SiteBlueprint(SiteSectorBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
@@ -195,11 +195,11 @@ class SiteBlueprint(SiteSectorBaseModel):
| 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**:
```python
# domain/site_building/models.py
# business/site_building/models.py
class PageBlueprint(SiteSectorBaseModel):
site_blueprint = models.ForeignKey(SiteBlueprint, on_delete=models.CASCADE, related_name='pages')
slug = models.SlugField(max_length=255)
@@ -246,7 +246,7 @@ class PageBlueprint(SiteSectorBaseModel):
| 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 |
|------|------|--------------|----------------|
| **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**:
```python
# domain/site_building/services/structure_generation_service.py
# business/site_building/services/structure_generation_service.py
class StructureGenerationService:
def __init__(self):
self.ai_function = GenerateSiteStructureFunction()
@@ -531,13 +531,13 @@ frontend/src/components/shared/
| 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
| 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
@@ -575,12 +575,12 @@ frontend/src/components/shared/
### Backend Tasks
- [ ] Create `domain/site_building/models.py`
- [ ] Create `business/site_building/models.py`
- [ ] Create SiteBlueprint model
- [ ] Create PageBlueprint model
- [ ] Create site builder migrations
- [ ] Create `domain/site_building/services/file_management_service.py`
- [ ] Create `domain/site_building/services/structure_generation_service.py`
- [ ] Create `business/site_building/services/file_management_service.py`
- [ ] Create `business/site_building/services/structure_generation_service.py`
- [ ] Create `infrastructure/ai/functions/generate_site_structure.py`
- [ ] Add site structure prompts
- [ ] Create `modules/site_builder/views.py`

View File

@@ -73,14 +73,14 @@ Entry Point 4: Manual Selection → Linker/Optimizer
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **Add source field** | `domain/content/models.py` | Phase 1 | Track content source |
| **Add sync_status field** | `domain/content/models.py` | Phase 1 | Track sync status |
| **Add external_id field** | `domain/content/models.py` | Phase 1 | Store external platform ID |
| **Add sync_metadata field** | `domain/content/models.py` | Phase 1 | Store platform-specific metadata |
| **Add source field** | `business/content/models.py` | Phase 1 | Track content source |
| **Add sync_status field** | `business/content/models.py` | Phase 1 | Track sync status |
| **Add external_id field** | `business/content/models.py` | Phase 1 | Store external platform ID |
| **Add sync_metadata field** | `business/content/models.py` | Phase 1 | Store platform-specific metadata |
**Content Model Extensions**:
```python
# domain/content/models.py
# business/content/models.py
class Content(SiteSectorBaseModel):
# Existing fields...
@@ -129,20 +129,20 @@ class Content(SiteSectorBaseModel):
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **InternalLink Model** | `domain/linking/models.py` | Phase 1 | Store link relationships |
| **LinkGraph Model** | `domain/linking/models.py` | Phase 1 | Store link graph |
| **InternalLink Model** | `business/linking/models.py` | Phase 1 | Store link relationships |
| **LinkGraph Model** | `business/linking/models.py` | Phase 1 | Store link graph |
### 4.3 Linker Service
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **LinkerService** | `domain/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 Injection Engine** | `domain/linking/services/injection_engine.py` | Phase 1 | Inject links into content |
| **LinkerService** | `business/linking/services/linker_service.py` | Phase 1, ContentService | Main linking service |
| **Link Candidate Engine** | `business/linking/services/candidate_engine.py` | Phase 1 | Find link candidates |
| **Link Injection Engine** | `business/linking/services/injection_engine.py` | Phase 1 | Inject links into content |
**LinkerService**:
```python
# domain/linking/services/linker_service.py
# business/linking/services/linker_service.py
class LinkerService:
def process(self, content_id):
"""Process content for linking"""
@@ -176,20 +176,20 @@ class LinkerService:
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **OptimizationTask Model** | `domain/optimization/models.py` | Phase 1 | Store optimization results |
| **OptimizationScores Model** | `domain/optimization/models.py` | Phase 1 | Store optimization scores |
| **OptimizationTask Model** | `business/optimization/models.py` | Phase 1 | Store optimization results |
| **OptimizationScores Model** | `business/optimization/models.py` | Phase 1 | Store optimization scores |
### 4.6 Optimizer Service (Multiple Entry Points)
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **OptimizerService** | `domain/optimization/services/optimizer_service.py` | Phase 1, ContentService | Main optimization service |
| **Content Analyzer** | `domain/optimization/services/analyzer.py` | Phase 1 | Analyze content quality |
| **OptimizerService** | `business/optimization/services/optimizer_service.py` | Phase 1, ContentService | Main optimization service |
| **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 |
**OptimizerService**:
```python
# domain/optimization/services/optimizer_service.py
# business/optimization/services/optimizer_service.py
class OptimizerService:
def optimize_from_writer(self, content_id):
"""Entry Point 1: Writer → Optimizer"""
@@ -253,7 +253,7 @@ class OptimizerService:
| 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**:
```
@@ -267,7 +267,7 @@ Content States:
**ContentPipelineService**:
```python
# domain/content/services/content_pipeline_service.py
# business/content/services/content_pipeline_service.py
class ContentPipelineService:
def process_writer_content(self, content_id, stages=['linking', 'optimization']):
"""Writer → Linker → Optimizer pipeline"""
@@ -356,9 +356,9 @@ class ContentPipelineService:
### Backend Tasks
- [ ] Extend Content model with source/sync fields
- [ ] Create `domain/linking/models.py`
- [ ] Create `business/linking/models.py`
- [ ] Create LinkerService
- [ ] Create `domain/optimization/models.py`
- [ ] Create `business/optimization/models.py`
- [ ] Create OptimizerService
- [ ] Create optimization AI function
- [ ] Create ContentPipelineService

View File

@@ -75,13 +75,13 @@ igny8_sites:
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **PublisherService** | `domain/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 |
| **DeploymentService** | `domain/publishing/services/deployment_service.py` | Phase 3 | Deploy sites to renderer |
| **PublisherService** | `business/publishing/services/publisher_service.py` | Phase 1 | Main publishing service |
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | Phase 3 | Adapter for Sites renderer |
| **DeploymentService** | `business/publishing/services/deployment_service.py` | Phase 3 | Deploy sites to renderer |
**PublisherService**:
```python
# domain/publishing/services/publisher_service.py
# business/publishing/services/publisher_service.py
class PublisherService:
def publish_to_sites(self, site_blueprint):
"""Publish site to Sites renderer"""
@@ -97,8 +97,8 @@ class PublisherService:
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **PublishingRecord Model** | `domain/publishing/models.py` | Phase 1 | Track content publishing |
| **DeploymentRecord Model** | `domain/publishing/models.py` | Phase 3 | Track site deployments |
| **PublishingRecord Model** | `business/publishing/models.py` | Phase 1 | Track content publishing |
| **DeploymentRecord Model** | `business/publishing/models.py` | Phase 3 | Track site deployments |
---
@@ -127,7 +127,7 @@ class PublisherService:
| 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 |
---

View File

@@ -49,11 +49,11 @@
| 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**:
```python
# domain/integration/models.py
# business/integration/models.py
class SiteIntegration(SiteSectorBaseModel):
site = models.ForeignKey(Site, on_delete=models.CASCADE)
platform = models.CharField(max_length=50) # 'wordpress', 'shopify', 'custom'
@@ -74,8 +74,8 @@ class SiteIntegration(SiteSectorBaseModel):
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **IntegrationService** | `domain/integration/services/integration_service.py` | Phase 1 | Manage integrations |
| **SyncService** | `domain/integration/services/sync_service.py` | Phase 1 | Handle two-way sync |
| **IntegrationService** | `business/integration/services/integration_service.py` | Phase 1 | Manage integrations |
| **SyncService** | `business/integration/services/sync_service.py` | Phase 1 | Handle two-way sync |
---
@@ -85,10 +85,10 @@ class SiteIntegration(SiteSectorBaseModel):
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **BaseAdapter** | `domain/publishing/services/adapters/base_adapter.py` | Phase 5 | Base adapter interface |
| **WordPressAdapter** | `domain/publishing/services/adapters/wordpress_adapter.py` | EXISTING (refactor) | WordPress publishing |
| **SitesRendererAdapter** | `domain/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 |
| **BaseAdapter** | `business/publishing/services/adapters/base_adapter.py` | Phase 5 | Base adapter interface |
| **WordPressAdapter** | `business/publishing/services/adapters/wordpress_adapter.py` | EXISTING (refactor) | WordPress publishing |
| **SitesRendererAdapter** | `business/publishing/services/adapters/sites_renderer_adapter.py` | Phase 5 | IGNY8 Sites deployment |
| **ShopifyAdapter** | `business/publishing/services/adapters/shopify_adapter.py` | Phase 5 (future) | Shopify publishing |
---
@@ -98,12 +98,12 @@ class SiteIntegration(SiteSectorBaseModel):
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **Extend PublisherService** | `domain/publishing/services/publisher_service.py` | Phase 5 | Support multiple destinations |
| **Update PublishingRecord** | `domain/publishing/models.py` | Phase 5 | Track multiple destinations |
| **Extend PublisherService** | `business/publishing/services/publisher_service.py` | Phase 5 | Support multiple destinations |
| **Update PublishingRecord** | `business/publishing/models.py` | Phase 5 | Track multiple destinations |
**Multi-Destination Publishing**:
```python
# domain/publishing/services/publisher_service.py
# business/publishing/services/publisher_service.py
class PublisherService:
def publish(self, content, destinations):
"""Publish content to multiple destinations"""

View File

@@ -43,13 +43,13 @@
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **Add entity_type field** | `domain/content/models.py` | Phase 1 | Content type field |
| **Add json_blocks field** | `domain/content/models.py` | Phase 1 | Structured content blocks |
| **Add structure_data field** | `domain/content/models.py` | Phase 1 | Content structure data |
| **Add entity_type field** | `business/content/models.py` | Phase 1 | Content type field |
| **Add json_blocks field** | `business/content/models.py` | Phase 1 | Structured content blocks |
| **Add structure_data field** | `business/content/models.py` | Phase 1 | Content structure data |
**Content Model Extensions**:
```python
# domain/content/models.py
# business/content/models.py
class Content(SiteSectorBaseModel):
# Existing fields...
@@ -92,9 +92,9 @@ class Content(SiteSectorBaseModel):
| Task | File | Dependencies | Implementation |
|------|------|--------------|----------------|
| **Product Content Generation** | `domain/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 |
| **Taxonomy Generation** | `domain/content/services/content_generation_service.py` | Phase 1 | Generate taxonomy pages |
| **Product Content Generation** | `business/content/services/content_generation_service.py` | Phase 1 | Generate product content |
| **Service Page Generation** | `business/content/services/content_generation_service.py` | Phase 1 | Generate service 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 |
|------|------|--------------|----------------|
| **Product Linking** | `domain/linking/services/linker_service.py` | Phase 4 | Link products |
| **Taxonomy Linking** | `domain/linking/services/linker_service.py` | Phase 4 | Link taxonomies |
| **Product Optimization** | `domain/optimization/services/optimizer_service.py` | Phase 4 | Optimize products |
| **Taxonomy Optimization** | `domain/optimization/services/optimizer_service.py` | Phase 4 | Optimize taxonomies |
| **Product Linking** | `business/linking/services/linker_service.py` | Phase 4 | Link products |
| **Taxonomy Linking** | `business/linking/services/linker_service.py` | Phase 4 | Link taxonomies |
| **Product Optimization** | `business/optimization/services/optimizer_service.py` | Phase 4 | Optimize products |
| **Taxonomy Optimization** | `business/optimization/services/optimizer_service.py` | Phase 4 | Optimize taxonomies |
---

View File

@@ -21,7 +21,7 @@ backend/igny8_core/
```
backend/igny8_core/
├── core/ # Core models (Account, User, Site, Sector)
├── domain/ # Domain-specific code
├── business/ # Domain-specific code
│ ├── content/ # Content domain
│ ├── planning/ # Planning domain
│ ├── linking/ # Linking domain
@@ -52,8 +52,8 @@ backend/igny8_core/
## File Organization Rules
- **Models**: `domain/{domain}/models.py`
- **Services**: `domain/{domain}/services/`
- **Models**: `business/{business}/models.py`
- **Services**: `business/{business}/services/`
- **Serializers**: `modules/{module}/serializers.py`
- **ViewSets**: `modules/{module}/views.py`
- **URLs**: `modules/{module}/urls.py`

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useRef } from "react";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { Link } from "react-router";
@@ -6,6 +6,7 @@ import { Link } from "react-router";
export default function NotificationDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [notifying, setNotifying] = useState(true);
const buttonRef = useRef<HTMLButtonElement>(null);
function toggleDropdown() {
setIsOpen(!isOpen);
@@ -22,6 +23,7 @@ export default function NotificationDropdown() {
return (
<div className="relative">
<button
ref={buttonRef}
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full dropdown-toggle hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
onClick={handleClick}
>
@@ -50,7 +52,9 @@ export default function NotificationDropdown() {
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="absolute -right-[240px] mt-[17px] flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px] lg:right-0"
anchorRef={buttonRef}
placement="bottom-right"
className="flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px]"
>
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useRef } from "react";
import { useNavigate } from "react-router";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { Dropdown } from "../ui/dropdown/Dropdown";
@@ -9,6 +9,7 @@ export default function UserDropdown() {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const buttonRef = useRef<HTMLButtonElement>(null);
function toggleDropdown() {
setIsOpen(!isOpen);
@@ -26,6 +27,7 @@ export default function UserDropdown() {
return (
<div className="relative">
<button
ref={buttonRef}
onClick={toggleDropdown}
className="flex items-center text-gray-700 dropdown-toggle dark:text-gray-400"
>
@@ -65,7 +67,9 @@ export default function UserDropdown() {
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark"
anchorRef={buttonRef}
placement="bottom-right"
className="flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark"
>
<div>
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">