8 Phases refactor

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-03 16:08:02 +00:00
parent 30bbcb08a1
commit 39df00e5ae
55 changed files with 2120 additions and 5527 deletions

View File

@@ -36,8 +36,6 @@ class AIEngine:
return f"{count} task{'s' if count != 1 else ''}"
elif function_name == 'generate_site_structure':
return "1 site blueprint"
elif function_name == 'generate_page_content':
return f"{count} page{'s' if count != 1 else ''}"
return f"{count} item{'s' if count != 1 else ''}"
def _build_validation_message(self, function_name: str, payload: dict, count: int, input_description: str) -> str:
@@ -91,8 +89,6 @@ class AIEngine:
if blueprint and getattr(blueprint, 'name', None):
blueprint_name = f'"{blueprint.name}"'
return f"Preparing site blueprint {blueprint_name}".strip()
elif function_name == 'generate_page_content':
return f"Preparing {count} page{'s' if count != 1 else ''} for content generation"
return f"Preparing {count} item{'s' if count != 1 else ''}"
def _get_ai_call_message(self, function_name: str, count: int) -> str:
@@ -107,8 +103,6 @@ class AIEngine:
return f"Creating image{'s' if count != 1 else ''} with AI"
elif function_name == 'generate_site_structure':
return "Designing complete site architecture"
elif function_name == 'generate_page_content':
return f"Generating structured page content"
return f"Processing with AI"
def _get_parse_message(self, function_name: str) -> str:
@@ -123,8 +117,6 @@ class AIEngine:
return "Processing images"
elif function_name == 'generate_site_structure':
return "Compiling site map"
elif function_name == 'generate_page_content':
return "Structuring content blocks"
return "Processing results"
def _get_parse_message_with_count(self, function_name: str, count: int) -> str:
@@ -145,8 +137,6 @@ class AIEngine:
return "Writing Inarticle Image Prompts"
elif function_name == 'generate_site_structure':
return f"{count} page blueprint{'s' if count != 1 else ''} mapped"
elif function_name == 'generate_page_content':
return f"{count} page{'s' if count != 1 else ''} with structured blocks"
return f"{count} item{'s' if count != 1 else ''} processed"
def _get_save_message(self, function_name: str, count: int) -> str:
@@ -164,8 +154,6 @@ class AIEngine:
return f"Assigning {count} Prompts to Dedicated Slots"
elif function_name == 'generate_site_structure':
return f"Publishing {count} page blueprint{'s' if count != 1 else ''}"
elif function_name == 'generate_page_content':
return f"Saving {count} page{'s' if count != 1 else ''} with content blocks"
return f"Saving {count} item{'s' if count != 1 else ''}"
def execute(self, fn: BaseAIFunction, payload: dict) -> dict:
@@ -524,16 +512,14 @@ class AIEngine:
'generate_image_prompts': 'image_prompt_extraction',
'generate_images': 'image_generation',
'generate_site_structure': 'site_structure_generation',
'generate_page_content': 'content_generation', # Site Builder page content
}
return mapping.get(function_name, function_name)
def _get_estimated_amount(self, function_name, data, payload):
"""Get estimated amount for credit calculation (before operation)"""
if function_name == 'generate_content' or function_name == 'generate_page_content':
if function_name == 'generate_content':
# Estimate word count - tasks don't have word_count field, use default
# For generate_content, data is a list of Task objects
# For generate_page_content, data is a PageBlueprint object
# data is a list of Task objects
if isinstance(data, list) and len(data) > 0:
# Multiple tasks - estimate 1000 words per task
return len(data) * 1000
@@ -554,7 +540,7 @@ class AIEngine:
def _get_actual_amount(self, function_name, save_result, parsed, data):
"""Get actual amount for credit calculation (after operation)"""
if function_name == 'generate_content' or function_name == 'generate_page_content':
if function_name == 'generate_content':
# Get actual word count from saved content
if isinstance(save_result, dict):
word_count = save_result.get('word_count')

View File

@@ -6,7 +6,6 @@ from igny8_core.ai.functions.generate_ideas import GenerateIdeasFunction
from igny8_core.ai.functions.generate_content import GenerateContentFunction
from igny8_core.ai.functions.generate_images import GenerateImagesFunction, generate_images_core
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
from igny8_core.ai.functions.generate_page_content import GeneratePageContentFunction
__all__ = [
'AutoClusterFunction',
@@ -15,5 +14,4 @@ __all__ = [
'GenerateImagesFunction',
'generate_images_core',
'GenerateImagePromptsFunction',
'GeneratePageContentFunction',
]

View File

@@ -249,7 +249,7 @@ class AutoClusterFunction(BaseAIFunction):
sector=sector,
defaults={
'description': cluster_data.get('description', ''),
'status': 'active',
'status': 'new', # FIXED: Changed from 'active' to 'new'
}
)
else:
@@ -260,7 +260,7 @@ class AutoClusterFunction(BaseAIFunction):
sector__isnull=True,
defaults={
'description': cluster_data.get('description', ''),
'status': 'active',
'status': 'new', # FIXED: Changed from 'active' to 'new'
'sector': None,
}
)
@@ -292,9 +292,10 @@ class AutoClusterFunction(BaseAIFunction):
else:
keyword_filter = keyword_filter.filter(sector__isnull=True)
# FIXED: Ensure keywords status updates from 'new' to 'mapped'
updated_count = keyword_filter.update(
cluster=cluster,
status='mapped'
status='mapped' # Status changes from 'new' to 'mapped'
)
keywords_updated += updated_count

View File

@@ -1,273 +0,0 @@
"""
Generate Page Content AI Function
Site Builder specific content generation that outputs structured JSON blocks.
This is separate from the default writer module's GenerateContentFunction.
It uses different prompts optimized for site builder pages and outputs
structured blocks_json format instead of HTML.
"""
import logging
import json
from typing import Dict, List, Any
from django.db import transaction
from igny8_core.ai.base import BaseAIFunction
from igny8_core.business.site_building.models import PageBlueprint
from igny8_core.business.content.models import Tasks, Content
from igny8_core.ai.ai_core import AICore
from igny8_core.ai.prompts import PromptRegistry
from igny8_core.ai.settings import get_model_config
logger = logging.getLogger(__name__)
class GeneratePageContentFunction(BaseAIFunction):
"""
Generate structured page content for Site Builder pages.
Outputs JSON blocks format optimized for site rendering.
"""
def get_name(self) -> str:
return 'generate_page_content'
def get_metadata(self) -> Dict:
return {
'display_name': 'Generate Page Content',
'description': 'Generate structured page content with JSON blocks for Site Builder',
'phases': {
'INIT': 'Initializing page content generation...',
'PREP': 'Loading page blueprint and building prompt...',
'AI_CALL': 'Generating structured content with AI...',
'PARSE': 'Parsing JSON blocks...',
'SAVE': 'Saving blocks to page...',
'DONE': 'Page content generated!'
}
}
def get_max_items(self) -> int:
return 20 # Max pages per batch
def validate(self, payload: dict, account=None) -> Dict:
"""Validate page blueprint IDs"""
result = super().validate(payload, account)
if not result['valid']:
return result
page_ids = payload.get('ids', [])
if page_ids:
from igny8_core.business.site_building.models import PageBlueprint
queryset = PageBlueprint.objects.filter(id__in=page_ids)
if account:
queryset = queryset.filter(account=account)
if queryset.count() == 0:
return {'valid': False, 'error': 'No page blueprints found'}
return {'valid': True}
def prepare(self, payload: dict, account=None) -> List:
"""Load page blueprints with relationships"""
page_ids = payload.get('ids', [])
queryset = PageBlueprint.objects.filter(id__in=page_ids)
if account:
queryset = queryset.filter(account=account)
# Preload relationships
pages = list(queryset.select_related(
'site_blueprint', 'account', 'site', 'sector'
))
if not pages:
raise ValueError("No page blueprints found")
return pages
def build_prompt(self, data: Any, account=None) -> str:
"""Build page content generation prompt optimized for Site Builder"""
if isinstance(data, list):
page = data[0] if data else None
else:
page = data
if not page:
raise ValueError("No page blueprint provided")
account = account or page.account
# Build page context
page_context = {
'PAGE_TITLE': page.title or page.slug.replace('-', ' ').title(),
'PAGE_SLUG': page.slug,
'PAGE_TYPE': page.type or 'custom',
'SITE_NAME': page.site_blueprint.name if page.site_blueprint else '',
'SITE_DESCRIPTION': page.site_blueprint.description or '',
}
# Extract existing block structure hints
block_hints = []
if page.blocks_json:
for block in page.blocks_json[:5]: # First 5 blocks as hints
if isinstance(block, dict):
block_type = block.get('type', '')
heading = block.get('heading') or block.get('title') or ''
if block_type and heading:
block_hints.append(f"- {block_type}: {heading}")
if block_hints:
page_context['EXISTING_BLOCKS'] = '\n'.join(block_hints)
else:
page_context['EXISTING_BLOCKS'] = 'None (new page)'
# Get site blueprint structure hints
structure_hints = ''
if page.site_blueprint and page.site_blueprint.structure_json:
structure = page.site_blueprint.structure_json
if isinstance(structure, dict):
layout = structure.get('layout', 'default')
theme = structure.get('theme', {})
structure_hints = f"Layout: {layout}\nTheme: {json.dumps(theme, indent=2)}"
page_context['STRUCTURE_HINTS'] = structure_hints or 'Default layout'
# Get prompt from registry (site-builder specific)
prompt = PromptRegistry.get_prompt(
function_name='generate_page_content',
account=account,
context=page_context
)
return prompt
def parse_response(self, response: str, step_tracker=None) -> Dict:
"""Parse AI response - must be JSON with blocks structure"""
import json
# Try to extract JSON from response
try:
# Try direct JSON parse
parsed = json.loads(response.strip())
except json.JSONDecodeError:
# Try to extract JSON object from text
try:
# Look for JSON object in response
start = response.find('{')
end = response.rfind('}')
if start != -1 and end != -1 and end > start:
json_str = response[start:end + 1]
parsed = json.loads(json_str)
else:
raise ValueError("No JSON object found in response")
except (json.JSONDecodeError, ValueError) as e:
logger.error(f"Failed to parse page content response as JSON: {e}")
logger.error(f"Response preview: {response[:500]}")
raise ValueError(f"Invalid JSON response from AI: {str(e)}")
if not isinstance(parsed, dict):
raise ValueError("Response must be a JSON object")
# Validate required fields
if 'blocks' not in parsed and 'blocks_json' not in parsed:
raise ValueError("Response must include 'blocks' or 'blocks_json' field")
# Normalize to 'blocks' key
if 'blocks_json' in parsed:
parsed['blocks'] = parsed.pop('blocks_json')
return parsed
def save_output(
self,
parsed: Any,
original_data: Any,
account=None,
progress_tracker=None,
step_tracker=None
) -> Dict:
"""Save blocks to PageBlueprint and create/update Content record"""
if isinstance(original_data, list):
page = original_data[0] if original_data else None
else:
page = original_data
if not page:
raise ValueError("No page blueprint provided for saving")
if not isinstance(parsed, dict):
raise ValueError("Parsed response must be a dict")
blocks = parsed.get('blocks', [])
if not blocks:
raise ValueError("No blocks found in parsed response")
# Ensure blocks is a list
if not isinstance(blocks, list):
blocks = [blocks]
with transaction.atomic():
# Update PageBlueprint with generated blocks
page.blocks_json = blocks
page.status = 'ready' # Mark as ready after content generation
page.save(update_fields=['blocks_json', 'status', 'updated_at'])
# Find or create associated Task
task_title = f"[Site Builder] {page.title or page.slug.replace('-', ' ').title()}"
task = Tasks.objects.filter(
account=page.account,
site=page.site,
sector=page.sector,
title=task_title
).first()
# Create or update Content record with blocks
if task:
content_record, created = Content.objects.get_or_create(
task=task,
defaults={
'account': page.account,
'site': page.site,
'sector': page.sector,
'title': parsed.get('title') or page.title,
'html_content': parsed.get('html_content', ''),
'word_count': parsed.get('word_count', 0),
'status': 'draft',
'json_blocks': blocks, # Store blocks in json_blocks
'metadata': {
'page_id': page.id,
'page_slug': page.slug,
'page_type': page.type,
'generated_by': 'generate_page_content'
}
}
)
if not created:
# Update existing content
content_record.json_blocks = blocks
content_record.html_content = parsed.get('html_content', content_record.html_content)
content_record.word_count = parsed.get('word_count', content_record.word_count)
content_record.title = parsed.get('title') or content_record.title or page.title
if not content_record.metadata:
content_record.metadata = {}
content_record.metadata.update({
'page_id': page.id,
'page_slug': page.slug,
'page_type': page.type,
'generated_by': 'generate_page_content'
})
content_record.save()
else:
logger.warning(f"No task found for page {page.id}, skipping Content record creation")
content_record = None
logger.info(
f"[GeneratePageContentFunction] Saved {len(blocks)} blocks to page {page.id} "
f"(Content ID: {content_record.id if content_record else 'N/A'})"
)
return {
'count': 1,
'pages_updated': 1,
'blocks_count': len(blocks),
'content_id': content_record.id if content_record else None
}

View File

@@ -526,169 +526,6 @@ CONTENT REQUIREMENTS:
- Include call-to-action sections
""",
'generate_page_content': """You are a Site Builder content specialist. Generate structured page content optimized for website pages with JSON blocks format.
Your task is to generate content that will be rendered as a modern website page with structured blocks (hero, features, testimonials, text, CTA, etc.).
INPUT DATA:
----------
Page Title: [IGNY8_PAGE_TITLE]
Page Slug: [IGNY8_PAGE_SLUG]
Page Type: [IGNY8_PAGE_TYPE] (home, products, blog, contact, about, services, custom)
Site Name: [IGNY8_SITE_NAME]
Site Description: [IGNY8_SITE_DESCRIPTION]
Existing Block Hints: [IGNY8_EXISTING_BLOCKS]
Structure Hints: [IGNY8_STRUCTURE_HINTS]
OUTPUT FORMAT:
--------------
Return ONLY a JSON object in this exact structure:
{{
"title": "[Page title - SEO optimized, natural]",
"html_content": "[Full HTML content for fallback/SEO - complete article]",
"word_count": [Integer - word count of HTML content],
"blocks": [
{{
"type": "hero",
"data": {{
"heading": "[Compelling hero headline]",
"subheading": "[Supporting subheadline]",
"content": "[Brief hero description - 1-2 sentences]",
"buttonText": "[CTA button text]",
"buttonLink": "[CTA link URL]"
}}
}},
{{
"type": "text",
"data": {{
"heading": "[Section heading]",
"content": "[Rich text content with paragraphs, lists, etc.]"
}}
}},
{{
"type": "features",
"data": {{
"heading": "[Features section heading]",
"content": [
"[Feature 1: Description]",
"[Feature 2: Description]",
"[Feature 3: Description]"
]
}}
}},
{{
"type": "testimonials",
"data": {{
"heading": "[Testimonials heading]",
"subheading": "[Optional subheading]",
"content": [
"[Testimonial quote 1]",
"[Testimonial quote 2]",
"[Testimonial quote 3]"
]
}}
}},
{{
"type": "cta",
"data": {{
"heading": "[CTA heading]",
"subheading": "[CTA subheading]",
"content": "[CTA description]",
"buttonText": "[Button text]",
"buttonLink": "[Button link]"
}}
}}
]
}}
BLOCK TYPE GUIDELINES:
----------------------
Based on page type, use appropriate blocks:
**Home Page:**
- Start with "hero" block (compelling headline + CTA)
- Follow with "features" or "text" blocks
- Include "testimonials" block
- End with "cta" block
**Products Page:**
- Start with "text" block (product overview)
- Use "features" or "grid" blocks for product listings
- Include "text" blocks for product details
**Blog Page:**
- Use "text" blocks for article content
- Can include "quote" blocks for highlights
- Structure as readable article format
**Contact Page:**
- Start with "text" block (contact info)
- Use "form" block structure hints
- Include "text" blocks for location/hours
**About Page:**
- Start with "hero" or "text" block
- Use "features" for team/values
- Include "stats" block if applicable
- End with "text" block
**Services Page:**
- Start with "text" block (service overview)
- Use "features" for service offerings
- Include "text" blocks for details
CONTENT REQUIREMENTS:
---------------------
1. **Hero Block** (for home/about pages):
- Compelling headline (8-12 words)
- Clear value proposition
- Strong CTA button
2. **Text Blocks**:
- Natural, engaging copy
- SEO-optimized headings
- Varied content (paragraphs, lists, emphasis)
3. **Features Blocks**:
- 3-6 features
- Clear benefit statements
- Action-oriented language
4. **Testimonials Blocks**:
- 3-5 authentic-sounding testimonials
- Specific, believable quotes
- Varied lengths
5. **CTA Blocks**:
- Clear value proposition
- Strong action words
- Compelling button text
6. **HTML Content** (for SEO):
- Complete article version of all blocks
- Proper HTML structure
- SEO-optimized with headings, paragraphs, lists
- 800-1500 words total
TONE & STYLE:
-------------
- Professional but approachable
- Clear and concise
- Benefit-focused
- Action-oriented
- Natural keyword usage (not forced)
- No generic phrases or placeholder text
IMPORTANT:
----------
- Return ONLY the JSON object
- Do NOT include markdown formatting
- Do NOT include explanations or comments
- Ensure all blocks have proper "type" and "data" structure
- HTML content should be complete and standalone
- Blocks should be optimized for the specific page type""",
'taxonomy_generation': """You are a taxonomy and categorization specialist. Generate comprehensive taxonomy page content that organizes and explains categories, tags, and hierarchical structures.
INPUT:
@@ -764,7 +601,6 @@ CONTENT REQUIREMENTS:
'extract_image_prompts': 'image_prompt_extraction',
'generate_image_prompts': 'image_prompt_extraction',
'generate_site_structure': 'site_structure_generation',
'generate_page_content': 'generate_page_content', # Site Builder specific
'optimize_content': 'optimize_content',
# Phase 8: Universal Content Types
'generate_product_content': 'product_generation',

View File

@@ -0,0 +1,23 @@
# Generated migration for delay configuration fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('automation', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='automationconfig',
name='within_stage_delay',
field=models.IntegerField(default=3, help_text='Delay between batches within a stage (seconds)'),
),
migrations.AddField(
model_name='automationconfig',
name='between_stage_delay',
field=models.IntegerField(default=5, help_text='Delay between stage transitions (seconds)'),
),
]

View File

@@ -0,0 +1,166 @@
# Generated by Django 5.2.8 on 2025-12-03 16:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('automation', '0002_add_delay_configuration'),
('igny8_core_auth', '0003_add_sync_event_model'),
]
operations = [
migrations.AlterModelOptions(
name='automationconfig',
options={'verbose_name': 'Automation Config', 'verbose_name_plural': 'Automation Configs'},
),
migrations.AlterModelOptions(
name='automationrun',
options={'ordering': ['-started_at'], 'verbose_name': 'Automation Run', 'verbose_name_plural': 'Automation Runs'},
),
migrations.RemoveIndex(
model_name='automationrun',
name='automation_site_status_idx',
),
migrations.RemoveIndex(
model_name='automationrun',
name='automation_site_started_idx',
),
migrations.AlterField(
model_name='automationconfig',
name='account',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='automation_configs', to='igny8_core_auth.account'),
),
migrations.AlterField(
model_name='automationconfig',
name='is_enabled',
field=models.BooleanField(default=False, help_text='Whether scheduled automation is active'),
),
migrations.AlterField(
model_name='automationconfig',
name='next_run_at',
field=models.DateTimeField(blank=True, help_text='Calculated based on frequency', null=True),
),
migrations.AlterField(
model_name='automationconfig',
name='scheduled_time',
field=models.TimeField(default='02:00', help_text='Time to run (e.g., 02:00)'),
),
migrations.AlterField(
model_name='automationconfig',
name='stage_1_batch_size',
field=models.IntegerField(default=20, help_text='Keywords per batch'),
),
migrations.AlterField(
model_name='automationconfig',
name='stage_2_batch_size',
field=models.IntegerField(default=1, help_text='Clusters at a time'),
),
migrations.AlterField(
model_name='automationconfig',
name='stage_3_batch_size',
field=models.IntegerField(default=20, help_text='Ideas per batch'),
),
migrations.AlterField(
model_name='automationconfig',
name='stage_4_batch_size',
field=models.IntegerField(default=1, help_text='Tasks - sequential'),
),
migrations.AlterField(
model_name='automationconfig',
name='stage_5_batch_size',
field=models.IntegerField(default=1, help_text='Content at a time'),
),
migrations.AlterField(
model_name='automationconfig',
name='stage_6_batch_size',
field=models.IntegerField(default=1, help_text='Images - sequential'),
),
migrations.AlterField(
model_name='automationrun',
name='account',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='automation_runs', to='igny8_core_auth.account'),
),
migrations.AlterField(
model_name='automationrun',
name='current_stage',
field=models.IntegerField(default=1, help_text='Current stage number (1-7)'),
),
migrations.AlterField(
model_name='automationrun',
name='run_id',
field=models.CharField(db_index=True, help_text='Format: run_20251203_140523_manual', max_length=100, unique=True),
),
migrations.AlterField(
model_name='automationrun',
name='stage_1_result',
field=models.JSONField(blank=True, help_text='{keywords_processed, clusters_created, batches}', null=True),
),
migrations.AlterField(
model_name='automationrun',
name='stage_2_result',
field=models.JSONField(blank=True, help_text='{clusters_processed, ideas_created}', null=True),
),
migrations.AlterField(
model_name='automationrun',
name='stage_3_result',
field=models.JSONField(blank=True, help_text='{ideas_processed, tasks_created}', null=True),
),
migrations.AlterField(
model_name='automationrun',
name='stage_4_result',
field=models.JSONField(blank=True, help_text='{tasks_processed, content_created, total_words}', null=True),
),
migrations.AlterField(
model_name='automationrun',
name='stage_5_result',
field=models.JSONField(blank=True, help_text='{content_processed, prompts_created}', null=True),
),
migrations.AlterField(
model_name='automationrun',
name='stage_6_result',
field=models.JSONField(blank=True, help_text='{images_processed, images_generated}', null=True),
),
migrations.AlterField(
model_name='automationrun',
name='stage_7_result',
field=models.JSONField(blank=True, help_text='{ready_for_review}', null=True),
),
migrations.AlterField(
model_name='automationrun',
name='started_at',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
migrations.AlterField(
model_name='automationrun',
name='status',
field=models.CharField(choices=[('running', 'Running'), ('paused', 'Paused'), ('completed', 'Completed'), ('failed', 'Failed')], db_index=True, default='running', max_length=20),
),
migrations.AlterField(
model_name='automationrun',
name='trigger_type',
field=models.CharField(choices=[('manual', 'Manual'), ('scheduled', 'Scheduled')], max_length=20),
),
migrations.AddIndex(
model_name='automationconfig',
index=models.Index(fields=['is_enabled', 'next_run_at'], name='igny8_autom_is_enab_038ce6_idx'),
),
migrations.AddIndex(
model_name='automationconfig',
index=models.Index(fields=['account', 'site'], name='igny8_autom_account_c6092f_idx'),
),
migrations.AddIndex(
model_name='automationrun',
index=models.Index(fields=['site', '-started_at'], name='igny8_autom_site_id_b5bf36_idx'),
),
migrations.AddIndex(
model_name='automationrun',
index=models.Index(fields=['status', '-started_at'], name='igny8_autom_status_1457b0_idx'),
),
migrations.AddIndex(
model_name='automationrun',
index=models.Index(fields=['account', '-started_at'], name='igny8_autom_account_27cb3c_idx'),
),
]

View File

@@ -31,6 +31,10 @@ class AutomationConfig(models.Model):
stage_5_batch_size = models.IntegerField(default=1, help_text="Content at a time")
stage_6_batch_size = models.IntegerField(default=1, help_text="Images - sequential")
# Delay configuration (in seconds)
within_stage_delay = models.IntegerField(default=3, help_text="Delay between batches within a stage (seconds)")
between_stage_delay = models.IntegerField(default=5, help_text="Delay between stage transitions (seconds)")
last_run_at = models.DateTimeField(null=True, blank=True)
next_run_at = models.DateTimeField(null=True, blank=True, help_text="Calculated based on frequency")

View File

@@ -146,8 +146,11 @@ class AutomationService:
self.run.save()
return
# Process in batches
# Process in batches with dynamic sizing
batch_size = self.config.stage_1_batch_size
# FIXED: Use min() for dynamic batch sizing
actual_batch_size = min(total_count, batch_size)
keywords_processed = 0
clusters_created = 0
batches_run = 0
@@ -155,10 +158,10 @@ class AutomationService:
keyword_ids = list(pending_keywords.values_list('id', flat=True))
for i in range(0, len(keyword_ids), batch_size):
batch = keyword_ids[i:i + batch_size]
batch_num = (i // batch_size) + 1
total_batches = (len(keyword_ids) + batch_size - 1) // batch_size
for i in range(0, len(keyword_ids), actual_batch_size):
batch = keyword_ids[i:i + actual_batch_size]
batch_num = (i // actual_batch_size) + 1
total_batches = (len(keyword_ids) + actual_batch_size - 1) // actual_batch_size
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
@@ -185,6 +188,19 @@ class AutomationService:
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Batch {batch_num} complete"
)
# ADDED: Within-stage delay (between batches)
if i + actual_batch_size < len(keyword_ids): # Not the last batch
delay = self.config.within_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Waiting {delay} seconds before next batch..."
)
time.sleep(delay)
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "Delay complete, resuming processing"
)
# Get clusters created count
clusters_created = Clusters.objects.filter(
@@ -204,6 +220,12 @@ class AutomationService:
stage_number, keywords_processed, time_elapsed, credits_used
)
# ADDED: Post-stage validation
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Validation: {keywords_processed} keywords processed, {clusters_created} clusters created"
)
# Save results
self.run.stage_1_result = {
'keywords_processed': keywords_processed,
@@ -216,6 +238,14 @@ class AutomationService:
self.run.save()
logger.info(f"[AutomationService] Stage 1 complete: {keywords_processed} keywords → {clusters_created} clusters")
# ADDED: Between-stage delay
delay = self.config.between_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Stage complete. Waiting {delay} seconds before next stage..."
)
time.sleep(delay)
def run_stage_2(self):
"""Stage 2: Clusters → Ideas"""
@@ -223,6 +253,32 @@ class AutomationService:
stage_name = "Clusters → Ideas (AI)"
start_time = time.time()
# ADDED: Pre-stage validation - verify Stage 1 completion
pending_keywords = Keywords.objects.filter(
site=self.site,
status='new',
cluster__isnull=True,
disabled=False
).count()
if pending_keywords > 0:
error_msg = f"Stage 1 incomplete: {pending_keywords} keywords still pending"
self.logger.log_stage_error(
self.run.run_id, self.account.id, self.site.id,
stage_number, error_msg
)
logger.error(f"[AutomationService] {error_msg}")
# Continue anyway but log warning
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Warning: Proceeding despite {pending_keywords} pending keywords from Stage 1"
)
else:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "Pre-stage validation passed: 0 keywords pending from Stage 1"
)
# Query clusters without ideas
pending_clusters = Clusters.objects.filter(
site=self.site,
@@ -308,6 +364,14 @@ class AutomationService:
self.run.save()
logger.info(f"[AutomationService] Stage 2 complete: {clusters_processed} clusters → {ideas_created} ideas")
# ADDED: Between-stage delay
delay = self.config.between_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Stage complete. Waiting {delay} seconds before next stage..."
)
time.sleep(delay)
def run_stage_3(self):
"""Stage 3: Ideas → Tasks (Local Queue)"""
@@ -315,6 +379,26 @@ class AutomationService:
stage_name = "Ideas → Tasks (Local Queue)"
start_time = time.time()
# ADDED: Pre-stage validation - verify Stage 2 completion
pending_clusters = Clusters.objects.filter(
site=self.site,
status='new',
disabled=False
).exclude(
ideas__isnull=False
).count()
if pending_clusters > 0:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Warning: {pending_clusters} clusters from Stage 2 still pending"
)
else:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "Pre-stage validation passed: 0 clusters pending from Stage 2"
)
# Query pending ideas
pending_ideas = ContentIdeas.objects.filter(
site=self.site,
@@ -414,6 +498,14 @@ class AutomationService:
self.run.save()
logger.info(f"[AutomationService] Stage 3 complete: {ideas_processed} ideas → {tasks_created} tasks")
# ADDED: Between-stage delay
delay = self.config.between_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Stage complete. Waiting {delay} seconds before next stage..."
)
time.sleep(delay)
def run_stage_4(self):
"""Stage 4: Tasks → Content"""
@@ -421,6 +513,23 @@ class AutomationService:
stage_name = "Tasks → Content (AI)"
start_time = time.time()
# ADDED: Pre-stage validation - verify Stage 3 completion
pending_ideas = ContentIdeas.objects.filter(
site=self.site,
status='new'
).count()
if pending_ideas > 0:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Warning: {pending_ideas} ideas from Stage 3 still pending"
)
else:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "Pre-stage validation passed: 0 ideas pending from Stage 3"
)
# Query queued tasks (all queued tasks need content generated)
pending_tasks = Tasks.objects.filter(
site=self.site,
@@ -449,10 +558,14 @@ class AutomationService:
tasks_processed = 0
credits_before = self._get_credits_used()
for task in pending_tasks:
# FIXED: Ensure ALL tasks are processed by iterating over queryset list
task_list = list(pending_tasks)
total_tasks = len(task_list)
for idx, task in enumerate(task_list, 1):
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Generating content for task: {task.title}"
stage_number, f"Generating content for task {idx}/{total_tasks}: {task.title}"
)
# Call AI function via AIEngine
@@ -469,10 +582,20 @@ class AutomationService:
tasks_processed += 1
# Log progress
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Task '{task.title}' complete"
stage_number, f"Task '{task.title}' complete ({tasks_processed}/{total_tasks})"
)
# ADDED: Within-stage delay between tasks (if not last task)
if idx < total_tasks:
delay = self.config.within_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Waiting {delay} seconds before next task..."
)
time.sleep(delay)
# Get content created count and total words
content_created = Content.objects.filter(
@@ -497,6 +620,23 @@ class AutomationService:
stage_number, tasks_processed, time_elapsed, credits_used
)
# ADDED: Post-stage validation - verify all tasks processed
remaining_tasks = Tasks.objects.filter(
site=self.site,
status='queued'
).count()
if remaining_tasks > 0:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Warning: {remaining_tasks} tasks still queued after Stage 4"
)
else:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "Post-stage validation passed: 0 tasks remaining"
)
# Save results
self.run.stage_4_result = {
'tasks_processed': tasks_processed,
@@ -509,6 +649,14 @@ class AutomationService:
self.run.save()
logger.info(f"[AutomationService] Stage 4 complete: {tasks_processed} tasks → {content_created} content")
# ADDED: Between-stage delay
delay = self.config.between_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Stage complete. Waiting {delay} seconds before next stage..."
)
time.sleep(delay)
def run_stage_5(self):
"""Stage 5: Content → Image Prompts"""
@@ -516,10 +664,27 @@ class AutomationService:
stage_name = "Content → Image Prompts (AI)"
start_time = time.time()
# Query content without Images records
# ADDED: Pre-stage validation - verify Stage 4 completion
remaining_tasks = Tasks.objects.filter(
site=self.site,
status='queued'
).count()
if remaining_tasks > 0:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Warning: {remaining_tasks} tasks from Stage 4 still queued"
)
else:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "Pre-stage validation passed: 0 tasks pending from Stage 4"
)
# FIXED: Query content without Images records (ensure status='draft')
content_without_images = Content.objects.filter(
site=self.site,
status='draft'
status='draft' # Explicitly check for draft status
).annotate(
images_count=Count('images')
).filter(
@@ -528,6 +693,12 @@ class AutomationService:
total_count = content_without_images.count()
# ADDED: Enhanced logging
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Stage 5: Found {total_count} content pieces without images (status='draft', images_count=0)"
)
# Log stage start
self.logger.log_stage_start(
self.run.run_id, self.account.id, self.site.id,
@@ -548,10 +719,13 @@ class AutomationService:
content_processed = 0
credits_before = self._get_credits_used()
for content in content_without_images:
content_list = list(content_without_images)
total_content = len(content_list)
for idx, content in enumerate(content_list, 1):
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Extracting prompts from: {content.title}"
stage_number, f"Extracting prompts {idx}/{total_content}: {content.title}"
)
# Call AI function via AIEngine
@@ -570,8 +744,17 @@ class AutomationService:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Content '{content.title}' complete"
stage_number, f"Content '{content.title}' complete ({content_processed}/{total_content})"
)
# ADDED: Within-stage delay between content pieces
if idx < total_content:
delay = self.config.within_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Waiting {delay} seconds before next content..."
)
time.sleep(delay)
# Get prompts created count
prompts_created = Images.objects.filter(
@@ -603,6 +786,14 @@ class AutomationService:
self.run.save()
logger.info(f"[AutomationService] Stage 5 complete: {content_processed} content → {prompts_created} prompts")
# ADDED: Between-stage delay
delay = self.config.between_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Stage complete. Waiting {delay} seconds before next stage..."
)
time.sleep(delay)
def run_stage_6(self):
"""Stage 6: Image Prompts → Generated Images"""
@@ -610,6 +801,27 @@ class AutomationService:
stage_name = "Images (Prompts) → Generated Images (AI)"
start_time = time.time()
# ADDED: Pre-stage validation - verify Stage 5 completion
content_without_images = Content.objects.filter(
site=self.site,
status='draft'
).annotate(
images_count=Count('images')
).filter(
images_count=0
).count()
if content_without_images > 0:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Warning: {content_without_images} content pieces from Stage 5 still without images"
)
else:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "Pre-stage validation passed: All content has image prompts"
)
# Query pending images
pending_images = Images.objects.filter(
site=self.site,
@@ -638,11 +850,14 @@ class AutomationService:
images_processed = 0
credits_before = self._get_credits_used()
for image in pending_images:
image_list = list(pending_images)
total_images = len(image_list)
for idx, image in enumerate(image_list, 1):
content_title = image.content.title if image.content else 'Unknown'
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Generating image: {image.image_type} for '{content_title}'"
stage_number, f"Generating image {idx}/{total_images}: {image.image_type} for '{content_title}'"
)
# Call AI function via AIEngine
@@ -661,8 +876,17 @@ class AutomationService:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Image generated for '{content_title}'"
stage_number, f"Image generated for '{content_title}' ({images_processed}/{total_images})"
)
# ADDED: Within-stage delay between images
if idx < total_images:
delay = self.config.within_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Waiting {delay} seconds before next image..."
)
time.sleep(delay)
# Get images generated count
images_generated = Images.objects.filter(
@@ -702,6 +926,14 @@ class AutomationService:
self.run.save()
logger.info(f"[AutomationService] Stage 6 complete: {images_processed} images generated, {content_moved_to_review} content moved to review")
# ADDED: Between-stage delay
delay = self.config.between_stage_delay
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Stage complete. Waiting {delay} seconds before final stage..."
)
time.sleep(delay)
def run_stage_7(self):
"""Stage 7: Manual Review Gate (Count Only)"""

View File

@@ -475,72 +475,10 @@ class ContentSyncService:
client: WordPressClient
) -> Dict[str, Any]:
"""
Ensure taxonomies exist in WordPress before publishing content.
Args:
integration: SiteIntegration instance
client: WordPressClient instance
Returns:
dict: Sync result with synced_count
DEPRECATED: Legacy SiteBlueprint taxonomy sync removed.
Taxonomy management now uses ContentTaxonomy model.
"""
try:
from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy
# Get site blueprint
blueprint = SiteBlueprint.objects.filter(
account=integration.account,
site=integration.site
).first()
if not blueprint:
return {'success': True, 'synced_count': 0}
synced_count = 0
# Get taxonomies that don't have external_reference (not yet synced)
taxonomies = SiteBlueprintTaxonomy.objects.filter(
site_blueprint=blueprint,
external_reference__isnull=True
)
for taxonomy in taxonomies:
try:
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
result = client.create_category(
name=taxonomy.name,
slug=taxonomy.slug,
description=taxonomy.description
)
if result.get('success'):
taxonomy.external_reference = str(result.get('category_id'))
taxonomy.save(update_fields=['external_reference'])
synced_count += 1
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
result = client.create_tag(
name=taxonomy.name,
slug=taxonomy.slug,
description=taxonomy.description
)
if result.get('success'):
taxonomy.external_reference = str(result.get('tag_id'))
taxonomy.save(update_fields=['external_reference'])
synced_count += 1
except Exception as e:
logger.warning(f"Error syncing taxonomy {taxonomy.id} to WordPress: {e}")
continue
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(f"Error syncing taxonomies to WordPress: {e}", exc_info=True)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
return {'success': True, 'synced_count': 0}
def _sync_products_from_wordpress(
self,

View File

@@ -0,0 +1,24 @@
# Generated migration to fix cluster name uniqueness
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('planner', '0001_initial'), # Update this to match your latest migration
]
operations = [
# Remove the old unique constraint on name field
migrations.AlterField(
model_name='clusters',
name='name',
field=models.CharField(db_index=True, max_length=255),
),
# Add unique_together constraint for name, site, sector
migrations.AlterUniqueTogether(
name='clusters',
unique_together={('name', 'site', 'sector')},
),
]

View File

@@ -10,7 +10,7 @@ class Clusters(SiteSectorBaseModel):
('mapped', 'Mapped'),
]
name = models.CharField(max_length=255, unique=True, db_index=True)
name = models.CharField(max_length=255, db_index=True)
description = models.TextField(blank=True, null=True)
keywords_count = models.IntegerField(default=0)
volume = models.IntegerField(default=0)
@@ -26,6 +26,7 @@ class Clusters(SiteSectorBaseModel):
ordering = ['name']
verbose_name = 'Cluster'
verbose_name_plural = 'Clusters'
unique_together = [['name', 'site', 'sector']] # Unique per site/sector
indexes = [
models.Index(fields=['name']),
models.Index(fields=['status']),

View File

@@ -1,530 +0,0 @@
"""
Sites Renderer Adapter
Phase 5: Sites Renderer & Publishing
Stage 4: Enhanced with Stage 3 metadata (clusters, taxonomies, internal links)
Adapter for deploying sites to IGNY8 Sites renderer.
"""
import logging
import json
import os
from typing import Dict, Any, Optional, List
from pathlib import Path
from datetime import datetime
from igny8_core.business.site_building.models import SiteBlueprint
from igny8_core.business.publishing.models import DeploymentRecord
from igny8_core.business.publishing.services.adapters.base_adapter import BaseAdapter
logger = logging.getLogger(__name__)
class SitesRendererAdapter(BaseAdapter):
"""
Adapter for deploying sites to IGNY8 Sites renderer.
Writes site definitions to filesystem for Sites container to serve.
"""
def __init__(self):
self.sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data')
def deploy(self, site_blueprint: SiteBlueprint) -> Dict[str, Any]:
"""
Deploy site blueprint to Sites renderer.
Args:
site_blueprint: SiteBlueprint instance to deploy
Returns:
dict: Deployment result with status and deployment record
"""
try:
# Create deployment record
deployment = DeploymentRecord.objects.create(
account=site_blueprint.account,
site=site_blueprint.site,
sector=site_blueprint.sector,
site_blueprint=site_blueprint,
version=site_blueprint.version,
status='deploying'
)
# Build site definition
site_definition = self._build_site_definition(site_blueprint)
# Write to filesystem
deployment_path = self._write_site_definition(
site_blueprint,
site_definition,
deployment.version
)
# Update deployment record
deployment.status = 'deployed'
deployment.deployed_version = site_blueprint.version
deployment.deployment_url = self._get_deployment_url(site_blueprint)
deployment.metadata = {
'deployment_path': str(deployment_path),
'site_definition': site_definition
}
deployment.save()
# Update blueprint
site_blueprint.deployed_version = site_blueprint.version
site_blueprint.status = 'deployed'
site_blueprint.save(update_fields=['deployed_version', 'status', 'updated_at'])
logger.info(
f"[SitesRendererAdapter] Successfully deployed site {site_blueprint.id} v{deployment.version}"
)
return {
'success': True,
'deployment_id': deployment.id,
'version': deployment.version,
'deployment_url': deployment.deployment_url,
'deployment_path': str(deployment_path)
}
except Exception as e:
logger.error(
f"[SitesRendererAdapter] Error deploying site {site_blueprint.id}: {str(e)}",
exc_info=True
)
# Update deployment record with error
if 'deployment' in locals():
deployment.status = 'failed'
deployment.error_message = str(e)
deployment.save()
return {
'success': False,
'error': str(e)
}
def _build_site_definition(self, site_blueprint: SiteBlueprint) -> Dict[str, Any]:
"""
Build site definition JSON from blueprint.
Merges actual Content from Writer into PageBlueprint blocks.
Stage 4: Enhanced with Stage 3 metadata (clusters, taxonomies, internal links).
Args:
site_blueprint: SiteBlueprint instance
Returns:
dict: Site definition structure
"""
from igny8_core.business.content.models import Tasks, Content, ContentClusterMap
# Get all pages
pages = []
content_id_to_page = {} # Map content IDs to pages for metadata lookup
for page in site_blueprint.pages.all().order_by('order'):
# Get blocks from blueprint (placeholders)
blocks = page.blocks_json or []
page_metadata = {
'content_type': page.content_type if hasattr(page, 'content_type') else None,
'cluster_id': None,
'cluster_name': None,
'content_structure': None,
'taxonomy_terms': [], # Changed from taxonomy_id/taxonomy_name to list of terms
'internal_links': []
}
# Try to find actual Content from Writer
# PageBlueprint -> Task (by title pattern) -> Content
task_title = f"[Site Builder] {page.title or page.slug.replace('-', ' ').title()}"
task = Tasks.objects.filter(
account=page.account,
site=page.site,
sector=page.sector,
title=task_title
).first()
# If task exists, get its Content
if task and hasattr(task, 'content_record'):
content = task.content_record
# If content is published, merge its blocks
if content and content.status == 'publish' and content.json_blocks:
# Merge Content.json_blocks into PageBlueprint.blocks_json
# Content blocks take precedence over blueprint placeholders
blocks = content.json_blocks
logger.info(
f"[SitesRendererAdapter] Using published Content blocks for page {page.slug} "
f"(Content ID: {content.id})"
)
elif content and content.status == 'publish' and content.html_content:
# If no json_blocks but has html_content, convert to text block
blocks = [{
'type': 'text',
'data': {
'content': content.html_content,
'title': content.title or page.title
}
}]
logger.info(
f"[SitesRendererAdapter] Converted HTML content to text block for page {page.slug}"
)
# Stage 4: Add Stage 3 metadata if content exists
if content:
content_id_to_page[content.id] = page.slug
# Get cluster mapping
cluster_map = ContentClusterMap.objects.filter(content=content).first()
if cluster_map and cluster_map.cluster:
page_metadata['cluster_id'] = cluster_map.cluster.id
page_metadata['cluster_name'] = cluster_map.cluster.name
page_metadata['content_structure'] = cluster_map.role or task.content_structure if task else None
# Get taxonomy terms using M2M relationship
taxonomy_terms = content.taxonomy_terms.all()
if taxonomy_terms.exists():
page_metadata['taxonomy_terms'] = [
{'id': term.id, 'name': term.name, 'type': term.taxonomy_type}
for term in taxonomy_terms
]
# Get internal links from content
if content.internal_links:
page_metadata['internal_links'] = content.internal_links
# Use content_type if available
if content.content_type:
page_metadata['content_type'] = content.content_type
# Fallback to task metadata if content not found
if task and not page_metadata.get('cluster_id'):
if task.cluster:
page_metadata['cluster_id'] = task.cluster.id
page_metadata['cluster_name'] = task.cluster.name
page_metadata['content_structure'] = task.content_structure
if task.taxonomy:
page_metadata['taxonomy_id'] = task.taxonomy.id
page_metadata['taxonomy_name'] = task.taxonomy.name
if task.content_type:
page_metadata['content_type'] = task.content_type
pages.append({
'id': page.id,
'slug': page.slug,
'title': page.title,
'type': page.type,
'blocks': blocks,
'status': page.status,
'metadata': page_metadata, # Stage 4: Add metadata
})
# Stage 4: Build navigation with cluster grouping
navigation = self._build_navigation_with_metadata(site_blueprint, pages)
# Stage 4: Build taxonomy tree for breadcrumbs
taxonomy_tree = self._build_taxonomy_tree(site_blueprint)
# Build site definition
definition = {
'id': site_blueprint.id,
'name': site_blueprint.name,
'description': site_blueprint.description,
'version': site_blueprint.version,
'layout': site_blueprint.structure_json.get('layout', 'default'),
'theme': site_blueprint.structure_json.get('theme', {}),
'navigation': navigation, # Stage 4: Enhanced navigation
'taxonomy_tree': taxonomy_tree, # Stage 4: Taxonomy tree for breadcrumbs
'pages': pages,
'config': site_blueprint.config_json,
'created_at': site_blueprint.created_at.isoformat(),
'updated_at': site_blueprint.updated_at.isoformat(),
}
return definition
def _build_navigation_with_metadata(
self,
site_blueprint: SiteBlueprint,
pages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""
Build navigation structure with cluster grouping.
Stage 4: Groups pages by cluster for better navigation.
Args:
site_blueprint: SiteBlueprint instance
pages: List of page dictionaries
Returns:
List of navigation items
"""
# If explicit navigation exists in structure_json, use it
explicit_nav = site_blueprint.structure_json.get('navigation', [])
if explicit_nav:
return explicit_nav
# Otherwise, build navigation from pages grouped by cluster
navigation = []
# Group pages by cluster
cluster_groups = {}
ungrouped_pages = []
for page in pages:
if page.get('status') in ['published', 'ready']:
cluster_id = page.get('metadata', {}).get('cluster_id')
if cluster_id:
if cluster_id not in cluster_groups:
cluster_groups[cluster_id] = {
'cluster_id': cluster_id,
'cluster_name': page.get('metadata', {}).get('cluster_name', 'Unknown'),
'pages': []
}
cluster_groups[cluster_id]['pages'].append({
'slug': page['slug'],
'title': page['title'],
'type': page['type']
})
else:
ungrouped_pages.append({
'slug': page['slug'],
'title': page['title'],
'type': page['type']
})
# Add cluster groups to navigation
for cluster_group in cluster_groups.values():
navigation.append({
'type': 'cluster',
'name': cluster_group['cluster_name'],
'items': cluster_group['pages']
})
# Add ungrouped pages
if ungrouped_pages:
navigation.extend(ungrouped_pages)
return navigation if navigation else [
{'slug': page['slug'], 'title': page['title']}
for page in pages
if page.get('status') in ['published', 'ready']
]
def _build_taxonomy_tree(self, site_blueprint: SiteBlueprint) -> Dict[str, Any]:
"""
Build taxonomy tree structure for breadcrumbs.
Stage 4: Creates hierarchical taxonomy structure.
Args:
site_blueprint: SiteBlueprint instance
Returns:
dict: Taxonomy tree structure
"""
taxonomies = site_blueprint.taxonomies.all()
tree = {
'categories': [],
'tags': [],
'product_categories': [],
'product_attributes': []
}
for taxonomy in taxonomies:
taxonomy_item = {
'id': taxonomy.id,
'name': taxonomy.name,
'slug': taxonomy.slug,
'type': taxonomy.taxonomy_type,
'description': taxonomy.description
}
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
category_key = 'product_categories' if 'product' in taxonomy.taxonomy_type else 'categories'
tree[category_key].append(taxonomy_item)
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
tag_key = 'product_tags' if 'product' in taxonomy.taxonomy_type else 'tags'
if tag_key not in tree:
tree[tag_key] = []
tree[tag_key].append(taxonomy_item)
elif taxonomy.taxonomy_type == 'product_attribute':
tree['product_attributes'].append(taxonomy_item)
return tree
def _write_site_definition(
self,
site_blueprint: SiteBlueprint,
site_definition: Dict[str, Any],
version: int
) -> Path:
"""
Write site definition to filesystem.
Args:
site_blueprint: SiteBlueprint instance
site_definition: Site definition dict
version: Version number
Returns:
Path: Deployment path
"""
# Build path: /data/app/sites-data/clients/{site_id}/v{version}/
site_id = site_blueprint.site.id
deployment_dir = Path(self.sites_data_path) / 'clients' / str(site_id) / f'v{version}'
deployment_dir.mkdir(parents=True, exist_ok=True)
# Write site.json
site_json_path = deployment_dir / 'site.json'
with open(site_json_path, 'w', encoding='utf-8') as f:
json.dump(site_definition, f, indent=2, ensure_ascii=False)
# Write pages
pages_dir = deployment_dir / 'pages'
pages_dir.mkdir(exist_ok=True)
for page in site_definition.get('pages', []):
page_json_path = pages_dir / f"{page['slug']}.json"
with open(page_json_path, 'w', encoding='utf-8') as f:
json.dump(page, f, indent=2, ensure_ascii=False)
# Ensure assets directory exists
assets_dir = deployment_dir / 'assets'
assets_dir.mkdir(exist_ok=True)
(assets_dir / 'images').mkdir(exist_ok=True)
(assets_dir / 'documents').mkdir(exist_ok=True)
(assets_dir / 'media').mkdir(exist_ok=True)
logger.info(f"[SitesRendererAdapter] Wrote site definition to {deployment_dir}")
return deployment_dir
def _get_deployment_url(self, site_blueprint: SiteBlueprint) -> str:
"""
Get deployment URL for site.
Args:
site_blueprint: SiteBlueprint instance
Returns:
str: Deployment URL
"""
site_id = site_blueprint.site.id
# Get Sites Renderer URL from environment or use default
sites_renderer_host = os.getenv('SITES_RENDERER_HOST', '31.97.144.105')
sites_renderer_port = os.getenv('SITES_RENDERER_PORT', '8024')
sites_renderer_protocol = os.getenv('SITES_RENDERER_PROTOCOL', 'http')
# Construct URL: http://31.97.144.105:8024/{site_id}
# Sites Renderer routes: /:siteId/* -> SiteRenderer component
return f"{sites_renderer_protocol}://{sites_renderer_host}:{sites_renderer_port}/{site_id}"
# BaseAdapter interface implementation
def publish(
self,
content: Any,
destination_config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Publish content to destination (implements BaseAdapter interface).
Args:
content: SiteBlueprint to publish
destination_config: Destination-specific configuration
Returns:
dict: Publishing result
"""
if not isinstance(content, SiteBlueprint):
return {
'success': False,
'error': 'SitesRendererAdapter only accepts SiteBlueprint instances'
}
result = self.deploy(content)
if result.get('success'):
return {
'success': True,
'external_id': str(result.get('deployment_id')),
'url': result.get('deployment_url'),
'published_at': datetime.now(),
'metadata': {
'deployment_path': result.get('deployment_path'),
'version': result.get('version')
}
}
else:
return {
'success': False,
'error': result.get('error'),
'metadata': {}
}
def test_connection(
self,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Test connection to Sites renderer (implements BaseAdapter interface).
Args:
config: Destination configuration
Returns:
dict: Connection test result
"""
sites_data_path = config.get('sites_data_path', os.getenv('SITES_DATA_PATH', '/data/app/sites-data'))
try:
path = Path(sites_data_path)
if path.exists() and path.is_dir():
return {
'success': True,
'message': 'Sites data directory is accessible',
'details': {'path': str(path)}
}
else:
return {
'success': False,
'message': f'Sites data directory does not exist: {sites_data_path}',
'details': {}
}
except Exception as e:
return {
'success': False,
'message': f'Error accessing sites data directory: {str(e)}',
'details': {}
}
def get_status(
self,
published_id: str,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Get publishing status for published content (implements BaseAdapter interface).
Args:
published_id: Deployment record ID
config: Destination configuration
Returns:
dict: Status information
"""
try:
deployment = DeploymentRecord.objects.get(id=published_id)
return {
'status': deployment.status,
'url': deployment.deployment_url,
'updated_at': deployment.updated_at,
'metadata': deployment.metadata or {}
}
except DeploymentRecord.DoesNotExist:
return {
'status': 'not_found',
'url': None,
'updated_at': None,
'metadata': {}
}

View File

@@ -1,422 +0,0 @@
"""
Deployment Readiness Service
Stage 4: Checks if site blueprint is ready for deployment
Validates cluster coverage, content validation, sync status, and taxonomy completeness.
"""
import logging
from typing import Dict, Any, List
from igny8_core.business.site_building.models import SiteBlueprint
from igny8_core.business.content.services.validation_service import ContentValidationService
from igny8_core.business.integration.services.sync_health_service import SyncHealthService
logger = logging.getLogger(__name__)
class DeploymentReadinessService:
"""
Service for checking deployment readiness.
"""
def __init__(self):
self.validation_service = ContentValidationService()
self.sync_health_service = SyncHealthService()
def check_readiness(self, site_blueprint_id: int) -> Dict[str, Any]:
"""
Check if site blueprint is ready for deployment.
Args:
site_blueprint_id: SiteBlueprint ID
Returns:
dict: {
'ready': bool,
'checks': {
'cluster_coverage': bool,
'content_validation': bool,
'sync_status': bool,
'taxonomy_completeness': bool
},
'errors': List[str],
'warnings': List[str],
'details': {
'cluster_coverage': dict,
'content_validation': dict,
'sync_status': dict,
'taxonomy_completeness': dict
}
}
"""
try:
blueprint = SiteBlueprint.objects.get(id=site_blueprint_id)
except SiteBlueprint.DoesNotExist:
return {
'ready': False,
'checks': {},
'errors': [f'SiteBlueprint {site_blueprint_id} not found'],
'warnings': [],
'details': {}
}
checks = {}
errors = []
warnings = []
details = {}
# Check 1: Cluster Coverage
cluster_check = self._check_cluster_coverage(blueprint)
checks['cluster_coverage'] = cluster_check['ready']
details['cluster_coverage'] = cluster_check
if not cluster_check['ready']:
errors.extend(cluster_check.get('errors', []))
if cluster_check.get('warnings'):
warnings.extend(cluster_check['warnings'])
# Check 2: Content Validation
content_check = self._check_content_validation(blueprint)
checks['content_validation'] = content_check['ready']
details['content_validation'] = content_check
if not content_check['ready']:
errors.extend(content_check.get('errors', []))
if content_check.get('warnings'):
warnings.extend(content_check['warnings'])
# Check 3: Sync Status (if WordPress integration exists)
sync_check = self._check_sync_status(blueprint)
checks['sync_status'] = sync_check['ready']
details['sync_status'] = sync_check
if not sync_check['ready']:
warnings.extend(sync_check.get('warnings', []))
if sync_check.get('errors'):
errors.extend(sync_check['errors'])
# Check 4: Taxonomy Completeness
taxonomy_check = self._check_taxonomy_completeness(blueprint)
checks['taxonomy_completeness'] = taxonomy_check['ready']
details['taxonomy_completeness'] = taxonomy_check
if not taxonomy_check['ready']:
warnings.extend(taxonomy_check.get('warnings', []))
if taxonomy_check.get('errors'):
errors.extend(taxonomy_check['errors'])
# Overall readiness: all critical checks must pass
ready = (
checks.get('cluster_coverage', False) and
checks.get('content_validation', False)
)
return {
'ready': ready,
'checks': checks,
'errors': errors,
'warnings': warnings,
'details': details
}
def _check_cluster_coverage(self, blueprint: SiteBlueprint) -> Dict[str, Any]:
"""
Check if all clusters have required coverage.
Returns:
dict: {
'ready': bool,
'total_clusters': int,
'covered_clusters': int,
'incomplete_clusters': List[Dict],
'errors': List[str],
'warnings': List[str]
}
"""
try:
cluster_links = blueprint.cluster_links.all()
total_clusters = cluster_links.count()
if total_clusters == 0:
return {
'ready': False,
'total_clusters': 0,
'covered_clusters': 0,
'incomplete_clusters': [],
'errors': ['No clusters attached to blueprint'],
'warnings': []
}
incomplete_clusters = []
covered_count = 0
for cluster_link in cluster_links:
if cluster_link.coverage_status == 'complete':
covered_count += 1
else:
incomplete_clusters.append({
'cluster_id': cluster_link.cluster_id,
'cluster_name': getattr(cluster_link.cluster, 'name', 'Unknown'),
'status': cluster_link.coverage_status,
'role': cluster_link.role
})
ready = covered_count == total_clusters
errors = []
warnings = []
if not ready:
if covered_count == 0:
errors.append('No clusters have complete coverage')
else:
warnings.append(
f'{total_clusters - covered_count} of {total_clusters} clusters need coverage'
)
return {
'ready': ready,
'total_clusters': total_clusters,
'covered_clusters': covered_count,
'incomplete_clusters': incomplete_clusters,
'errors': errors,
'warnings': warnings
}
except Exception as e:
logger.error(f"Error checking cluster coverage: {e}", exc_info=True)
return {
'ready': False,
'total_clusters': 0,
'covered_clusters': 0,
'incomplete_clusters': [],
'errors': [f'Error checking cluster coverage: {str(e)}'],
'warnings': []
}
def _check_content_validation(self, blueprint: SiteBlueprint) -> Dict[str, Any]:
"""
Check if all published content passes validation.
Returns:
dict: {
'ready': bool,
'total_content': int,
'valid_content': int,
'invalid_content': List[Dict],
'errors': List[str],
'warnings': List[str]
}
"""
try:
from igny8_core.business.content.models import Content, Tasks
# Get all content associated with this blueprint
# Content is linked via Tasks -> PageBlueprint -> SiteBlueprint
page_ids = blueprint.pages.values_list('id', flat=True)
# Find tasks that match page blueprints
tasks = Tasks.objects.filter(
account=blueprint.account,
site=blueprint.site,
sector=blueprint.sector
)
# Filter tasks that might be related to this blueprint
# (This is a simplified check - in practice, tasks should have blueprint reference)
content_items = Content.objects.filter(
task__in=tasks,
status='publish',
source='igny8'
)
total_content = content_items.count()
if total_content == 0:
return {
'ready': True, # No content to validate is OK
'total_content': 0,
'valid_content': 0,
'invalid_content': [],
'errors': [],
'warnings': ['No published content found for validation']
}
invalid_content = []
valid_count = 0
for content in content_items:
errors = self.validation_service.validate_for_publish(content)
if errors:
invalid_content.append({
'content_id': content.id,
'title': content.title or 'Untitled',
'errors': errors
})
else:
valid_count += 1
ready = len(invalid_content) == 0
errors = []
warnings = []
if not ready:
errors.append(
f'{len(invalid_content)} of {total_content} content items have validation errors'
)
return {
'ready': ready,
'total_content': total_content,
'valid_content': valid_count,
'invalid_content': invalid_content,
'errors': errors,
'warnings': warnings
}
except Exception as e:
logger.error(f"Error checking content validation: {e}", exc_info=True)
return {
'ready': False,
'total_content': 0,
'valid_content': 0,
'invalid_content': [],
'errors': [f'Error checking content validation: {str(e)}'],
'warnings': []
}
def _check_sync_status(self, blueprint: SiteBlueprint) -> Dict[str, Any]:
"""
Check sync status for WordPress integrations.
Returns:
dict: {
'ready': bool,
'has_integration': bool,
'sync_status': str,
'mismatch_count': int,
'errors': List[str],
'warnings': List[str]
}
"""
try:
from igny8_core.business.integration.models import SiteIntegration
integrations = SiteIntegration.objects.filter(
site=blueprint.site,
is_active=True,
platform='wordpress'
)
if not integrations.exists():
return {
'ready': True, # No WordPress integration is OK
'has_integration': False,
'sync_status': None,
'mismatch_count': 0,
'errors': [],
'warnings': []
}
# Get sync status from SyncHealthService
sync_status = self.sync_health_service.get_sync_status(blueprint.site.id)
overall_status = sync_status.get('overall_status', 'error')
is_healthy = overall_status == 'healthy'
# Count total mismatches
mismatch_count = sum(
i.get('mismatch_count', 0) for i in sync_status.get('integrations', [])
)
errors = []
warnings = []
if not is_healthy:
if overall_status == 'error':
errors.append('WordPress sync has errors')
else:
warnings.append('WordPress sync has warnings')
if mismatch_count > 0:
warnings.append(f'{mismatch_count} sync mismatches detected')
# Sync status doesn't block deployment, but should be warned
return {
'ready': True, # Sync issues are warnings, not blockers
'has_integration': True,
'sync_status': overall_status,
'mismatch_count': mismatch_count,
'errors': errors,
'warnings': warnings
}
except Exception as e:
logger.error(f"Error checking sync status: {e}", exc_info=True)
return {
'ready': True, # Don't block on sync check errors
'has_integration': False,
'sync_status': None,
'mismatch_count': 0,
'errors': [],
'warnings': [f'Could not check sync status: {str(e)}']
}
def _check_taxonomy_completeness(self, blueprint: SiteBlueprint) -> Dict[str, Any]:
"""
Check if taxonomies are complete for the site type.
Returns:
dict: {
'ready': bool,
'total_taxonomies': int,
'required_taxonomies': List[str],
'missing_taxonomies': List[str],
'errors': List[str],
'warnings': List[str]
}
"""
try:
taxonomies = blueprint.taxonomies.all()
total_taxonomies = taxonomies.count()
# Determine required taxonomies based on site type
site_type = blueprint.site.site_type if hasattr(blueprint.site, 'site_type') else None
required_types = []
if site_type == 'blog':
required_types = ['blog_category', 'blog_tag']
elif site_type == 'ecommerce':
required_types = ['product_category', 'product_tag', 'product_attribute']
elif site_type == 'company':
required_types = ['service_category']
existing_types = set(taxonomies.values_list('taxonomy_type', flat=True))
missing_types = set(required_types) - existing_types
ready = len(missing_types) == 0
errors = []
warnings = []
if not ready:
warnings.append(
f'Missing required taxonomies for {site_type} site: {", ".join(missing_types)}'
)
if total_taxonomies == 0:
warnings.append('No taxonomies defined')
return {
'ready': ready,
'total_taxonomies': total_taxonomies,
'required_taxonomies': required_types,
'missing_taxonomies': list(missing_types),
'errors': errors,
'warnings': warnings
}
except Exception as e:
logger.error(f"Error checking taxonomy completeness: {e}", exc_info=True)
return {
'ready': True, # Don't block on taxonomy check errors
'total_taxonomies': 0,
'required_taxonomies': [],
'missing_taxonomies': [],
'errors': [],
'warnings': [f'Could not check taxonomy completeness: {str(e)}']
}

View File

@@ -1,140 +1,17 @@
"""
Deployment Service
Phase 5: Sites Renderer & Publishing
Deployment Service - DEPRECATED
Manages deployment lifecycle for sites.
Legacy SiteBlueprint deployment functionality removed.
Use WordPress integration sync for publishing.
"""
import logging
from typing import Optional
from igny8_core.business.site_building.models import SiteBlueprint
from igny8_core.business.publishing.models import DeploymentRecord
logger = logging.getLogger(__name__)
class DeploymentService:
"""
Service for managing site deployment lifecycle.
DEPRECATED: Legacy SiteBlueprint deployment service.
Use integration sync services instead.
"""
def get_status(self, site_blueprint: SiteBlueprint) -> Optional[DeploymentRecord]:
"""
Get current deployment status for a site.
Args:
site_blueprint: SiteBlueprint instance
Returns:
DeploymentRecord or None
"""
return DeploymentRecord.objects.filter(
site_blueprint=site_blueprint,
status='deployed'
).order_by('-deployed_at').first()
def get_latest_deployment(
self,
site_blueprint: SiteBlueprint
) -> Optional[DeploymentRecord]:
"""
Get latest deployment record (any status).
Args:
site_blueprint: SiteBlueprint instance
Returns:
DeploymentRecord or None
"""
return DeploymentRecord.objects.filter(
site_blueprint=site_blueprint
).order_by('-created_at').first()
def rollback(
self,
site_blueprint: SiteBlueprint,
target_version: int
) -> dict:
"""
Rollback site to a previous version.
Args:
site_blueprint: SiteBlueprint instance
target_version: Version to rollback to
Returns:
dict: Rollback result
"""
try:
# Find deployment record for target version
target_deployment = DeploymentRecord.objects.filter(
site_blueprint=site_blueprint,
version=target_version,
status='deployed'
).first()
if not target_deployment:
return {
'success': False,
'error': f'Deployment for version {target_version} not found'
}
# Create new deployment record for rollback
rollback_deployment = DeploymentRecord.objects.create(
account=site_blueprint.account,
site=site_blueprint.site,
sector=site_blueprint.sector,
site_blueprint=site_blueprint,
version=target_version,
status='deployed',
deployed_version=target_version,
deployment_url=target_deployment.deployment_url,
metadata={
'rollback_from': site_blueprint.version,
'rollback_to': target_version
}
)
# Update blueprint
site_blueprint.deployed_version = target_version
site_blueprint.save(update_fields=['deployed_version', 'updated_at'])
logger.info(
f"[DeploymentService] Rolled back site {site_blueprint.id} to version {target_version}"
)
return {
'success': True,
'deployment_id': rollback_deployment.id,
'version': target_version
}
except Exception as e:
logger.error(
f"[DeploymentService] Error rolling back site {site_blueprint.id}: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e)
}
def list_deployments(
self,
site_blueprint: SiteBlueprint
) -> list:
"""
List all deployments for a site.
Args:
site_blueprint: SiteBlueprint instance
Returns:
list: List of DeploymentRecord instances
"""
return list(
DeploymentRecord.objects.filter(
site_blueprint=site_blueprint
).order_by('-created_at')
)
pass

View File

@@ -368,10 +368,8 @@ class PublisherService:
Adapter instance or None
"""
# Lazy import to avoid circular dependencies
if destination == 'sites':
from igny8_core.business.publishing.services.adapters.sites_renderer_adapter import SitesRendererAdapter
return SitesRendererAdapter()
elif destination == 'wordpress':
# REMOVED: 'sites' destination (SitesRendererAdapter) - legacy SiteBlueprint functionality
if destination == 'wordpress':
from igny8_core.business.publishing.services.adapters.wordpress_adapter import WordPressAdapter
return WordPressAdapter()
elif destination == 'shopify':

View File

@@ -1,6 +0,0 @@
"""
Site Building Business Logic
Phase 3: Site Builder
"""
default_app_config = 'igny8_core.business.site_building.apps.SiteBuildingConfig'

View File

@@ -1,12 +0,0 @@
"""
Admin interface for Site Building
Legacy SiteBlueprint admin removed - models deprecated.
"""
from django.contrib import admin
# All SiteBuilder admin classes removed:
# - SiteBlueprintAdmin
# - PageBlueprintAdmin
# - BusinessTypeAdmin, AudienceProfileAdmin, BrandPersonalityAdmin, HeroImageryDirectionAdmin
#
# Site Builder functionality has been deprecated and removed from the system.

View File

@@ -1,16 +0,0 @@
from django.apps import AppConfig
class SiteBuildingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.business.site_building'
verbose_name = 'Site Building'
def ready(self):
"""Import admin to register models"""
try:
import igny8_core.business.site_building.admin # noqa
except ImportError:
pass

View File

@@ -1,248 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-20 23:27
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', '0001_initial'),
('planner', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AudienceProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('description', models.CharField(blank=True, max_length=255)),
('is_active', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Audience Profile',
'verbose_name_plural': 'Audience Profiles',
'db_table': 'igny8_site_builder_audience_profiles',
'ordering': ['order', 'name'],
'abstract': False,
},
),
migrations.CreateModel(
name='BrandPersonality',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('description', models.CharField(blank=True, max_length=255)),
('is_active', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Brand Personality',
'verbose_name_plural': 'Brand Personalities',
'db_table': 'igny8_site_builder_brand_personalities',
'ordering': ['order', 'name'],
'abstract': False,
},
),
migrations.CreateModel(
name='BusinessType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('description', models.CharField(blank=True, max_length=255)),
('is_active', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Business Type',
'verbose_name_plural': 'Business Types',
'db_table': 'igny8_site_builder_business_types',
'ordering': ['order', 'name'],
'abstract': False,
},
),
migrations.CreateModel(
name='HeroImageryDirection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('description', models.CharField(blank=True, max_length=255)),
('is_active', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Hero Imagery Direction',
'verbose_name_plural': 'Hero Imagery Directions',
'db_table': 'igny8_site_builder_hero_imagery',
'ordering': ['order', 'name'],
'abstract': False,
},
),
migrations.CreateModel(
name='SiteBlueprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Site name', max_length=255)),
('description', models.TextField(blank=True, help_text='Site description', null=True)),
('config_json', models.JSONField(default=dict, help_text='Wizard configuration: business_type, style, objectives, etc.')),
('structure_json', models.JSONField(default=dict, help_text='AI-generated structure: pages, layout, theme, etc.')),
('status', models.CharField(choices=[('draft', 'Draft'), ('generating', 'Generating'), ('ready', 'Ready'), ('deployed', 'Deployed')], db_index=True, default='draft', help_text='Blueprint status', max_length=20)),
('hosting_type', models.CharField(choices=[('igny8_sites', 'IGNY8 Sites'), ('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('multi', 'Multiple Destinations')], default='igny8_sites', help_text='Target hosting platform', max_length=50)),
('version', models.IntegerField(default=1, help_text='Blueprint version', validators=[django.core.validators.MinValueValidator(1)])),
('deployed_version', models.IntegerField(blank=True, help_text='Currently deployed version', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')),
],
options={
'verbose_name': 'Site Blueprint',
'verbose_name_plural': 'Site Blueprints',
'db_table': 'igny8_site_blueprints',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='PageBlueprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(help_text='Page URL slug', max_length=255)),
('title', models.CharField(help_text='Page title', max_length=255)),
('type', models.CharField(choices=[('home', 'Home'), ('about', 'About'), ('services', 'Services'), ('products', 'Products'), ('blog', 'Blog'), ('contact', 'Contact'), ('custom', 'Custom')], default='custom', help_text='Page type', max_length=50)),
('blocks_json', models.JSONField(default=list, help_text="Page content blocks: [{'type': 'hero', 'data': {...}}, ...]")),
('status', models.CharField(choices=[('draft', 'Draft'), ('generating', 'Generating'), ('ready', 'Ready'), ('published', 'Published')], db_index=True, default='draft', help_text='Page status', max_length=20)),
('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)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')),
('site_blueprint', models.ForeignKey(help_text='The site blueprint this page belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='pages', to='site_building.siteblueprint')),
],
options={
'verbose_name': 'Page Blueprint',
'verbose_name_plural': 'Page Blueprints',
'db_table': 'igny8_page_blueprints',
'ordering': ['order', 'created_at'],
},
),
migrations.CreateModel(
name='SiteBlueprintCluster',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('hub', 'Hub Page'), ('supporting', 'Supporting Page'), ('attribute', 'Attribute Page')], default='hub', max_length=50)),
('coverage_status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('complete', 'Complete')], default='pending', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional coverage metadata (target pages, keyword counts, ai hints)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('cluster', models.ForeignKey(help_text='Planner cluster being mapped into the site blueprint', on_delete=django.db.models.deletion.CASCADE, related_name='blueprint_links', to='planner.clusters')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')),
('site_blueprint', models.ForeignKey(help_text='Site blueprint that is planning coverage for the cluster', on_delete=django.db.models.deletion.CASCADE, related_name='cluster_links', to='site_building.siteblueprint')),
],
options={
'verbose_name': 'Site Blueprint Cluster',
'verbose_name_plural': 'Site Blueprint Clusters',
'db_table': 'igny8_site_blueprint_clusters',
},
),
migrations.CreateModel(
name='SiteBlueprintTaxonomy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name', max_length=255)),
('slug', models.SlugField(help_text='Slug/identifier within the site blueprint', max_length=255)),
('taxonomy_type', models.CharField(choices=[('blog_category', 'Blog Category'), ('blog_tag', 'Blog Tag'), ('product_category', 'Product Category'), ('product_tag', 'Product Tag'), ('product_attribute', 'Product Attribute'), ('service_category', 'Service Category')], default='blog_category', max_length=50)),
('description', models.TextField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional taxonomy metadata or AI hints')),
('external_reference', models.CharField(blank=True, help_text='External system ID (WordPress/WooCommerce/etc.)', max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('clusters', models.ManyToManyField(blank=True, help_text='Planner clusters that this taxonomy maps to', related_name='blueprint_taxonomies', to='planner.clusters')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')),
('site_blueprint', models.ForeignKey(help_text='Site blueprint this taxonomy belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='taxonomies', to='site_building.siteblueprint')),
],
options={
'verbose_name': 'Site Blueprint Taxonomy',
'verbose_name_plural': 'Site Blueprint Taxonomies',
'db_table': 'igny8_site_blueprint_taxonomies',
},
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['status'], name='igny8_site__status_e7ca10_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['hosting_type'], name='igny8_site__hosting_7a9a3e_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['site', 'sector'], name='igny8_site__site_id_cb1aca_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['account', 'status'], name='igny8_site__tenant__1bb483_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['site_blueprint', 'status'], name='igny8_page__site_bl_2dede2_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['type'], name='igny8_page__type_4af2bd_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['site_blueprint', 'order'], name='igny8_page__site_bl_c56196_idx'),
),
migrations.AlterUniqueTogether(
name='pageblueprint',
unique_together={('site_blueprint', 'slug')},
),
migrations.AddIndex(
model_name='siteblueprintcluster',
index=models.Index(fields=['site_blueprint', 'cluster'], name='igny8_site__site_bl_904406_idx'),
),
migrations.AddIndex(
model_name='siteblueprintcluster',
index=models.Index(fields=['site_blueprint', 'coverage_status'], name='igny8_site__site_bl_cff5ab_idx'),
),
migrations.AddIndex(
model_name='siteblueprintcluster',
index=models.Index(fields=['cluster', 'role'], name='igny8_site__cluster_d75a2f_idx'),
),
migrations.AlterUniqueTogether(
name='siteblueprintcluster',
unique_together={('site_blueprint', 'cluster', 'role')},
),
migrations.AddIndex(
model_name='siteblueprinttaxonomy',
index=models.Index(fields=['site_blueprint', 'taxonomy_type'], name='igny8_site__site_bl_f952f7_idx'),
),
migrations.AddIndex(
model_name='siteblueprinttaxonomy',
index=models.Index(fields=['taxonomy_type'], name='igny8_site__taxonom_178987_idx'),
),
migrations.AlterUniqueTogether(
name='siteblueprinttaxonomy',
unique_together={('site_blueprint', 'slug')},
),
]

View File

@@ -1,53 +0,0 @@
# Generated manually on 2025-12-01
# Remove SiteBlueprint, PageBlueprint, SiteBlueprintCluster, and SiteBlueprintTaxonomy models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('site_building', '0001_initial'), # Changed from 0002_initial
]
operations = [
# Drop tables in reverse dependency order
migrations.RunSQL(
sql=[
# Drop foreign key constraints first
"ALTER TABLE igny8_publishing_records DROP CONSTRAINT IF EXISTS igny8_publishing_recor_site_blueprint_id_9f4e8c7a_fk_igny8_sit CASCADE;",
"ALTER TABLE igny8_deployment_records DROP CONSTRAINT IF EXISTS igny8_deployment_recor_site_blueprint_id_3a2b7c1d_fk_igny8_sit CASCADE;",
# Drop the tables
"DROP TABLE IF EXISTS igny8_site_blueprint_taxonomies CASCADE;",
"DROP TABLE IF EXISTS igny8_site_blueprint_clusters CASCADE;",
"DROP TABLE IF EXISTS igny8_page_blueprints CASCADE;",
"DROP TABLE IF EXISTS igny8_site_blueprints CASCADE;",
"DROP TABLE IF EXISTS igny8_site_builder_business_types CASCADE;",
"DROP TABLE IF EXISTS igny8_site_builder_audience_profiles CASCADE;",
"DROP TABLE IF EXISTS igny8_site_builder_brand_personalities CASCADE;",
"DROP TABLE IF EXISTS igny8_site_builder_hero_imagery CASCADE;",
],
reverse_sql=[
# Reverse migration not supported - this is a destructive operation
"SELECT 1;"
],
),
# Also drop the site_blueprint_id column from PublishingRecord
migrations.RunSQL(
sql=[
"ALTER TABLE igny8_publishing_records DROP COLUMN IF EXISTS site_blueprint_id CASCADE;",
"DROP INDEX IF EXISTS igny8_publishing_recor_site_blueprint_id_des_b7c4e5f8_idx;",
],
reverse_sql=["SELECT 1;"],
),
# Drop the site_blueprint_id column from DeploymentRecord
migrations.RunSQL(
sql=[
"ALTER TABLE igny8_deployment_records DROP COLUMN IF EXISTS site_blueprint_id CASCADE;",
],
reverse_sql=["SELECT 1;"],
),
]

View File

@@ -1,45 +0,0 @@
"""
Site Building Models
Legacy SiteBuilder module has been removed.
This file is kept for backwards compatibility with migrations and legacy code.
"""
from django.db import models
from igny8_core.auth.models import AccountBaseModel
# All SiteBuilder models have been removed:
# - SiteBlueprint
# - PageBlueprint
# - SiteBlueprintCluster
# - SiteBlueprintTaxonomy
# - BusinessType, AudienceProfile, BrandPersonality, HeroImageryDirection
#
# Taxonomy functionality moved to ContentTaxonomy model in business/content/models.py
# Stub classes for backwards compatibility with legacy imports
class SiteBlueprint(AccountBaseModel):
"""Legacy stub - SiteBuilder has been removed"""
class Meta:
app_label = 'site_building'
db_table = 'legacy_site_blueprint_stub'
managed = False # Don't create table
class PageBlueprint(AccountBaseModel):
"""Legacy stub - SiteBuilder has been removed"""
class Meta:
app_label = 'site_building'
db_table = 'legacy_page_blueprint_stub'
managed = False # Don't create table
class SiteBlueprintCluster(AccountBaseModel):
"""Legacy stub - SiteBuilder has been removed"""
class Meta:
app_label = 'site_building'
db_table = 'legacy_site_blueprint_cluster_stub'
managed = False # Don't create table
class SiteBlueprintTaxonomy(AccountBaseModel):
"""Legacy stub - SiteBuilder has been removed"""
class Meta:
app_label = 'site_building'
db_table = 'legacy_site_blueprint_taxonomy_stub'
managed = False # Don't create table

View File

@@ -1,15 +0,0 @@
"""
Site Building Services
"""
from igny8_core.business.site_building.services.file_management_service import SiteBuilderFileService
from igny8_core.business.site_building.services.structure_generation_service import StructureGenerationService
from igny8_core.business.site_building.services.page_generation_service import PageGenerationService
from igny8_core.business.site_building.services.taxonomy_service import TaxonomyService
__all__ = [
'SiteBuilderFileService',
'StructureGenerationService',
'PageGenerationService',
'TaxonomyService',
]

View File

@@ -1,264 +0,0 @@
"""
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

@@ -1,316 +0,0 @@
"""
Page Generation Service
Leverages the Writer ContentGenerationService to draft page copy for Site Builder blueprints.
"""
import logging
from typing import Optional, List
from django.db import transaction
from igny8_core.business.content.models import Tasks
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
from igny8_core.business.site_building.models import PageBlueprint, SiteBlueprint
logger = logging.getLogger(__name__)
class PageGenerationService:
"""
Thin wrapper that converts Site Builder pages into writer tasks and reuses the
existing content generation pipeline. This keeps content authoring logic
inside the Writer module while Site Builder focuses on structure.
"""
def __init__(self):
self.content_service = ContentGenerationService()
# Site Builder uses its own AI function for structured block generation
from igny8_core.ai.functions.generate_page_content import GeneratePageContentFunction
self.page_content_function = GeneratePageContentFunction()
def generate_page_content(self, page_blueprint: PageBlueprint, force_regenerate: bool = False) -> dict:
"""
Generate (or regenerate) content for a single Site Builder page.
Uses Site Builder specific AI function that outputs structured JSON blocks.
Args:
page_blueprint: Target PageBlueprint instance.
force_regenerate: If True, resets any temporary task data.
"""
if not page_blueprint:
raise ValueError("Page blueprint is required")
# Mark page as generating
page_blueprint.status = 'generating'
page_blueprint.save(update_fields=['status', 'updated_at'])
account = page_blueprint.account
# Use Site Builder specific AI function for structured block generation
from igny8_core.ai.engine import AIEngine
ai_engine = AIEngine(account=account)
logger.info(
"[PageGenerationService] Generating structured content for page %s using generate_page_content function",
page_blueprint.id,
)
# Execute Site Builder page content generation
result = ai_engine.execute(
self.page_content_function,
{'ids': [page_blueprint.id]}
)
if result.get('error'):
page_blueprint.status = 'draft'
page_blueprint.save(update_fields=['status', 'updated_at'])
raise ValueError(f"Content generation failed: {result.get('error')}")
return {
'success': True,
'page_id': page_blueprint.id,
'blocks_count': result.get('blocks_count', 0),
'content_id': result.get('content_id')
}
def regenerate_page(self, page_blueprint: PageBlueprint) -> dict:
"""Force regeneration by dropping the cached task metadata."""
return self.generate_page_content(page_blueprint, force_regenerate=True)
def bulk_generate_pages(
self,
site_blueprint: SiteBlueprint,
page_ids: Optional[List[int]] = None,
force_regenerate: bool = False
) -> dict:
"""
Generate content for multiple pages in a blueprint.
Similar to how ideas are queued to writer:
1. Get pages (filtered by page_ids if provided)
2. Create/update Writer Tasks for each page
3. Queue content generation for all tasks
4. Return task IDs for progress tracking
Args:
site_blueprint: SiteBlueprint instance
page_ids: Optional list of specific page IDs to generate, or all if None
force_regenerate: If True, resets any temporary task data
Returns:
dict: {
'success': bool,
'pages_queued': int,
'task_ids': List[int],
'celery_task_id': Optional[str]
}
"""
if not site_blueprint:
raise ValueError("Site blueprint is required")
pages = site_blueprint.pages.all()
if page_ids:
pages = pages.filter(id__in=page_ids)
if not pages.exists():
return {
'success': False,
'error': 'No pages found to generate',
'pages_queued': 0,
'task_ids': [],
}
task_ids = []
with transaction.atomic():
for page in pages:
task = self._ensure_task(page, force_regenerate=force_regenerate)
task_ids.append(task.id)
page.status = 'generating'
page.save(update_fields=['status', 'updated_at'])
account = site_blueprint.account
logger.info(
"[PageGenerationService] Bulk generating content for %d pages (blueprint %s)",
len(task_ids),
site_blueprint.id,
)
result = self.content_service.generate_content(task_ids, account)
return {
'success': True,
'pages_queued': len(task_ids),
'task_ids': task_ids,
'celery_task_id': result.get('task_id'),
}
def create_tasks_for_pages(
self,
site_blueprint: SiteBlueprint,
page_ids: Optional[List[int]] = None,
force_regenerate: bool = False
) -> List[Tasks]:
"""
Create Writer Tasks for blueprint pages without generating content.
Useful for:
- Previewing what tasks will be created
- Manual task management
- Integration with existing Writer UI
Args:
site_blueprint: SiteBlueprint instance
page_ids: Optional list of specific page IDs, or all if None
force_regenerate: If True, resets any temporary task data
Returns:
List[Tasks]: List of created or existing tasks
"""
if not site_blueprint:
raise ValueError("Site blueprint is required")
pages = site_blueprint.pages.all()
if page_ids:
pages = pages.filter(id__in=page_ids)
tasks = []
with transaction.atomic():
for page in pages:
task = self._ensure_task(page, force_regenerate=force_regenerate)
tasks.append(task)
logger.info(
"[PageGenerationService] Created %d tasks for pages (blueprint %s)",
len(tasks),
site_blueprint.id,
)
return tasks
# Internal helpers --------------------------------------------------------
def _ensure_task(self, page_blueprint: PageBlueprint, force_regenerate: bool = False) -> Tasks:
"""
Create or reuse a Writer task that mirrors the given page blueprint.
We rely on a deterministic title pattern to keep the mapping lightweight
without introducing new relations/migrations.
"""
title = self._build_task_title(page_blueprint)
task_qs = Tasks.objects.filter(
account=page_blueprint.account,
site=page_blueprint.site,
sector=page_blueprint.sector,
title=title,
)
if force_regenerate:
task_qs.delete()
else:
existing = task_qs.first()
if existing:
return existing
return self._create_task_from_page(page_blueprint, title)
@transaction.atomic
def _create_task_from_page(self, page_blueprint: PageBlueprint, title: str) -> Tasks:
"""Translate blueprint metadata into a Writer task."""
description_parts = [
f"Site Blueprint: {page_blueprint.site_blueprint.name}",
f"Page Type: {page_blueprint.type}",
]
hero_block = self._first_block_heading(page_blueprint)
if hero_block:
description_parts.append(f"Hero/Primary Heading: {hero_block}")
keywords = self._build_keywords_hint(page_blueprint)
# Stage 3: Map page type to entity_type
entity_type_map = {
'home': 'page',
'about': 'page',
'services': 'service',
'products': 'product',
'blog': 'blog_post',
'contact': 'page',
'custom': 'page',
}
content_type = entity_type_map.get(page_blueprint.type, 'page')
# Try to find related cluster and taxonomy from blueprint
content_structure = 'article' # Default
taxonomy = None
# Find cluster link for this blueprint to infer structure
from igny8_core.business.site_building.models import SiteBlueprintCluster
cluster_link = SiteBlueprintCluster.objects.filter(
site_blueprint=page_blueprint.site_blueprint
).first()
if cluster_link:
content_structure = cluster_link.role or 'article'
# Find taxonomy if page type suggests it (products/services)
if page_blueprint.type in ['products', 'services']:
from igny8_core.business.site_building.models import SiteBlueprintTaxonomy
taxonomy = SiteBlueprintTaxonomy.objects.filter(
site_blueprint=page_blueprint.site_blueprint,
taxonomy_type__in=['product_category', 'service_category']
).first()
task = Tasks.objects.create(
account=page_blueprint.account,
site=page_blueprint.site,
sector=page_blueprint.sector,
title=title,
description="\n".join(filter(None, description_parts)),
keywords=keywords,
content_structure=self._map_content_structure(page_blueprint.type) or content_structure,
content_type=content_type,
status='queued',
taxonomy=taxonomy,
)
logger.info(
"[PageGenerationService] Created writer task %s for page blueprint %s",
task.id,
page_blueprint.id,
)
return task
def _build_task_title(self, page_blueprint: PageBlueprint) -> str:
base = page_blueprint.title or page_blueprint.slug.replace('-', ' ').title()
return f"[Site Builder] {base}"
def _build_keywords_hint(self, page_blueprint: PageBlueprint) -> str:
keywords = []
if page_blueprint.blocks_json:
for block in page_blueprint.blocks_json:
heading = block.get('heading') if isinstance(block, dict) else None
if heading:
keywords.append(heading)
keywords.append(page_blueprint.slug.replace('-', ' '))
return ", ".join(dict.fromkeys(filter(None, keywords)))
def _map_content_structure(self, page_type: Optional[str]) -> str:
if not page_type:
return 'landing_page'
mapping = {
'home': 'landing_page',
'about': 'supporting_page',
'services': 'pillar_page',
'products': 'pillar_page',
'blog': 'cluster_hub',
'contact': 'supporting_page',
}
return mapping.get(page_type.lower(), 'landing_page')
def _first_block_heading(self, page_blueprint: PageBlueprint) -> Optional[str]:
if not page_blueprint.blocks_json:
return None
for block in page_blueprint.blocks_json:
if isinstance(block, dict):
heading = block.get('heading') or block.get('title')
if heading:
return heading
return None

View File

@@ -1,122 +0,0 @@
"""
Structure Generation Service
Triggers the AI workflow that maps business briefs to page blueprints.
"""
import logging
from typing import Any, Dict, List, Optional
from django.utils import timezone
from igny8_core.business.billing.exceptions import InsufficientCreditsError
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.site_building.models import SiteBlueprint
logger = logging.getLogger(__name__)
class StructureGenerationService:
"""Orchestrates AI-powered site structure generation."""
def __init__(self):
self.credit_service = CreditService()
def generate_structure(
self,
site_blueprint: SiteBlueprint,
business_brief: str,
objectives: Optional[List[str]] = None,
style_preferences: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Kick off AI structure generation for a single blueprint.
Args:
site_blueprint: Target blueprint instance.
business_brief: Business description / positioning statement.
objectives: Optional list of goals for the new site.
style_preferences: Optional design/style hints.
metadata: Additional free-form context.
"""
if not site_blueprint:
raise ValueError("Site blueprint is required")
account = site_blueprint.account
objectives = objectives or []
style_preferences = style_preferences or {}
metadata = metadata or {}
logger.info(
"[StructureGenerationService] Starting generation for blueprint %s (account %s)",
site_blueprint.id,
getattr(account, 'id', None),
)
# Ensure the account can afford the request
try:
self.credit_service.check_credits(account, 'site_structure_generation')
except InsufficientCreditsError:
site_blueprint.status = 'draft'
site_blueprint.save(update_fields=['status', 'updated_at'])
raise
# Persist the latest inputs for future regenerations
config = site_blueprint.config_json or {}
config.update({
'business_brief': business_brief,
'objectives': objectives,
'style': style_preferences,
'last_requested_at': timezone.now().isoformat(),
'metadata': metadata,
})
site_blueprint.config_json = config
site_blueprint.status = 'generating'
site_blueprint.save(update_fields=['config_json', 'status', 'updated_at'])
payload = {
'ids': [site_blueprint.id],
'business_brief': business_brief,
'objectives': objectives,
'style': style_preferences,
'metadata': metadata,
}
return self._dispatch_ai_task(payload, account_id=account.id)
# Internal helpers --------------------------------------------------------
def _dispatch_ai_task(self, payload: Dict[str, Any], account_id: int) -> Dict[str, Any]:
from igny8_core.ai.tasks import run_ai_task
try:
if hasattr(run_ai_task, 'delay'):
async_result = run_ai_task.delay(
function_name='generate_site_structure',
payload=payload,
account_id=account_id
)
logger.info(
"[StructureGenerationService] Queued AI task %s for account %s",
async_result.id,
account_id,
)
return {
'success': True,
'task_id': str(async_result.id),
'message': 'Site structure generation queued',
}
# Celery not available run synchronously
logger.warning("[StructureGenerationService] Celery unavailable, running synchronously")
return run_ai_task(
function_name='generate_site_structure',
payload=payload,
account_id=account_id
)
except Exception as exc:
logger.error("Failed to dispatch structure generation: %s", exc, exc_info=True)
return {
'success': False,
'error': str(exc),
}

View File

@@ -1,125 +0,0 @@
"""
Taxonomy Service
Handles CRUD + import helpers for blueprint taxonomies.
"""
from __future__ import annotations
import logging
from typing import Iterable, Optional, Sequence
from django.db import transaction
from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy
logger = logging.getLogger(__name__)
class TaxonomyService:
"""High-level helper used by the wizard + sync flows."""
def create_taxonomy(
self,
site_blueprint: SiteBlueprint,
*,
name: str,
slug: str,
taxonomy_type: str,
description: Optional[str] = None,
metadata: Optional[dict] = None,
clusters: Optional[Sequence] = None,
external_reference: Optional[str] = None,
) -> SiteBlueprintTaxonomy:
taxonomy = SiteBlueprintTaxonomy.objects.create(
site_blueprint=site_blueprint,
name=name,
slug=slug,
taxonomy_type=taxonomy_type,
description=description or '',
metadata=metadata or {},
external_reference=external_reference,
)
if clusters:
taxonomy.clusters.set(self._normalize_cluster_ids(clusters))
return taxonomy
def update_taxonomy(
self,
taxonomy: SiteBlueprintTaxonomy,
*,
name: Optional[str] = None,
slug: Optional[str] = None,
description: Optional[str] = None,
metadata: Optional[dict] = None,
clusters: Optional[Sequence] = None,
external_reference: Optional[str] = None,
) -> SiteBlueprintTaxonomy:
if name is not None:
taxonomy.name = name
if slug is not None:
taxonomy.slug = slug
if description is not None:
taxonomy.description = description
if metadata is not None:
taxonomy.metadata = metadata
if external_reference is not None:
taxonomy.external_reference = external_reference
taxonomy.save()
if clusters is not None:
taxonomy.clusters.set(self._normalize_cluster_ids(clusters))
return taxonomy
def map_clusters(
self,
taxonomy: SiteBlueprintTaxonomy,
clusters: Sequence,
) -> SiteBlueprintTaxonomy:
taxonomy.clusters.set(self._normalize_cluster_ids(clusters))
return taxonomy
def import_from_external(
self,
site_blueprint: SiteBlueprint,
records: Iterable[dict],
*,
default_type: str = 'blog_category',
) -> list[SiteBlueprintTaxonomy]:
"""
Import helper consumed by WordPress/WooCommerce sync flows.
Each record should contain name, slug, optional type + description.
"""
created = []
with transaction.atomic():
for record in records:
name = record.get('name')
slug = record.get('slug') or name
if not name or not slug:
logger.warning("Skipping taxonomy import with missing name/slug: %s", record)
continue
taxonomy_type = record.get('taxonomy_type') or default_type
taxonomy, _ = SiteBlueprintTaxonomy.objects.update_or_create(
site_blueprint=site_blueprint,
slug=slug,
defaults={
'name': name,
'taxonomy_type': taxonomy_type,
'description': record.get('description') or '',
'metadata': record.get('metadata') or {},
'external_reference': record.get('external_reference'),
},
)
created.append(taxonomy)
return created
def _normalize_cluster_ids(self, clusters: Sequence) -> list[int]:
"""Accept queryset/model/ids and normalize to integer IDs."""
normalized = []
for cluster in clusters:
if cluster is None:
continue
if hasattr(cluster, 'id'):
normalized.append(cluster.id)
else:
normalized.append(int(cluster))
return normalized

View File

@@ -1,78 +0,0 @@
from __future__ import annotations
from decimal import Decimal
from django.test import TestCase
from igny8_core.auth.models import (
Account,
Industry,
IndustrySector,
Plan,
Sector,
Site,
User,
)
class SiteBuilderTestBase(TestCase):
"""
DEPRECATED: Provides a lightweight set of fixtures (account/site/sector/blueprint)
SiteBlueprint models have been removed.
"""
def setUp(self):
super().setUp()
self.plan = Plan.objects.create(
name='Test Plan',
slug='test-plan',
price=Decimal('0.00'),
included_credits=1000,
)
self.user = User.objects.create_user(
username='blueprint-owner',
email='owner@example.com',
password='testpass123',
role='owner',
)
self.account = Account.objects.create(
name='Site Builder Account',
slug='site-builder-account',
owner=self.user,
plan=self.plan,
)
self.user.account = self.account
self.user.save()
self.industry = Industry.objects.create(name='Automation', slug='automation')
self.industry_sector = IndustrySector.objects.create(
industry=self.industry,
name='Robotics',
slug='robotics',
)
self.site = Site.objects.create(
name='Acme Robotics',
slug='acme-robotics',
account=self.account,
industry=self.industry,
)
self.sector = Sector.objects.create(
site=self.site,
industry_sector=self.industry_sector,
name='Warehouse Automation',
slug='warehouse-automation',
account=self.account,
)
# DEPRECATED: SiteBlueprint and PageBlueprint models removed
self.blueprint = None
self.page_blueprint = None
slug='home',
title='Home',
type='home',
blocks_json=[{'type': 'hero', 'heading': 'Welcome'}],
status='draft',
order=0,
)

View File

@@ -1,124 +0,0 @@
"""
DEPRECATED: Tests for Bulk Page Generation - SiteBlueprint models removed
Phase 5: Sites Renderer & Bulk Generation
"""
from django.test import TestCase
from unittest.mock import patch, Mock
from igny8_core.auth.models import Account, Site, Sector
from igny8_core.business.content.models import Tasks
from .base import SiteBuilderTestBase
class BulkGenerationTestCase(SiteBuilderTestBase):
"""DEPRECATED: Test cases for bulk page generation"""
def setUp(self):
"""Set up test data"""
super().setUp()
# Delete the base page_blueprint so we control exactly which pages exist
self.page_blueprint.delete()
self.page1 = PageBlueprint.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
site_blueprint=self.blueprint,
title="Page 1",
slug="page-1",
type="home",
status="draft"
)
self.page2 = PageBlueprint.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
site_blueprint=self.blueprint,
title="Page 2",
slug="page-2",
type="about",
status="draft"
)
self.service = PageGenerationService()
def test_bulk_generate_pages_creates_tasks(self):
"""Test: Bulk page generation works"""
with patch.object(self.service.content_service, 'generate_content') as mock_generate:
mock_generate.return_value = {'task_id': 'test-task-id'}
result = self.service.bulk_generate_pages(self.blueprint)
self.assertTrue(result.get('success'))
self.assertEqual(result.get('pages_queued'), 2)
self.assertEqual(len(result.get('task_ids', [])), 2)
# Verify tasks were created
tasks = Tasks.objects.filter(account=self.account)
self.assertEqual(tasks.count(), 2)
def test_bulk_generate_selected_pages_only(self):
"""Test: Selected pages can be generated"""
with patch.object(self.service.content_service, 'generate_content') as mock_generate:
mock_generate.return_value = {'task_id': 'test-task-id'}
result = self.service.bulk_generate_pages(
self.blueprint,
page_ids=[self.page1.id]
)
self.assertTrue(result.get('success'))
self.assertEqual(result.get('pages_queued'), 1)
self.assertEqual(len(result.get('task_ids', [])), 1)
def test_bulk_generate_force_regenerate_deletes_existing_tasks(self):
"""Test: Force regenerate works"""
# Create existing task
Tasks.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="[Site Builder] Page 1",
description="Test",
status='completed'
)
with patch.object(self.service.content_service, 'generate_content') as mock_generate:
mock_generate.return_value = {'task_id': 'test-task-id'}
result = self.service.bulk_generate_pages(
self.blueprint,
force_regenerate=True
)
self.assertTrue(result.get('success'))
# Verify new tasks were created (old ones deleted)
tasks = Tasks.objects.filter(account=self.account)
self.assertEqual(tasks.count(), 2)
def test_create_tasks_for_pages_without_generation(self):
"""Test: Task creation works correctly"""
tasks = self.service.create_tasks_for_pages(self.blueprint)
self.assertEqual(len(tasks), 2)
self.assertIsInstance(tasks[0], Tasks)
self.assertEqual(tasks[0].title, "[Site Builder] Page 1")
# Verify tasks exist but content not generated
tasks_db = Tasks.objects.filter(account=self.account)
self.assertEqual(tasks_db.count(), 2)
self.assertEqual(tasks_db.first().status, 'queued')
def test_bulk_generate_updates_page_status(self):
"""Test: Progress tracking works"""
with patch.object(self.service.content_service, 'generate_content') as mock_generate:
mock_generate.return_value = {'task_id': 'test-task-id'}
self.service.bulk_generate_pages(self.blueprint)
# Verify page status updated
self.page1.refresh_from_db()
self.page2.refresh_from_db()
self.assertEqual(self.page1.status, 'generating')
self.assertEqual(self.page2.status, 'generating')

View File

@@ -1,97 +0,0 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from igny8_core.business.billing.exceptions import InsufficientCreditsError
from igny8_core.business.content.models import Tasks
from igny8_core.business.site_building.services.page_generation_service import PageGenerationService
from igny8_core.business.site_building.services.structure_generation_service import (
StructureGenerationService,
)
from .base import SiteBuilderTestBase
class StructureGenerationServiceTests(SiteBuilderTestBase):
"""Covers the orchestration path for generating site structures."""
@patch('igny8_core.ai.tasks.run_ai_task')
@patch('igny8_core.business.site_building.services.structure_generation_service.CreditService.check_credits')
def test_generate_structure_updates_config_and_dispatches_task(self, mock_check, mock_run_ai):
mock_async_result = MagicMock()
mock_async_result.id = 'celery-123'
mock_run_ai.delay.return_value = mock_async_result
service = StructureGenerationService()
payload = {
'business_brief': 'We build autonomous fulfillment robots.',
'objectives': ['Book more demos'],
'style_preferences': {'palette': 'cool', 'personality': 'optimistic'},
'metadata': {'requested_by': 'integration-test'},
}
result = service.generate_structure(self.blueprint, **payload)
self.assertTrue(result['success'])
self.assertEqual(result['task_id'], 'celery-123')
mock_check.assert_called_once_with(self.account, 'site_structure_generation')
mock_run_ai.delay.assert_called_once()
self.blueprint.refresh_from_db()
self.assertEqual(self.blueprint.status, 'generating')
self.assertEqual(self.blueprint.config_json['business_brief'], payload['business_brief'])
self.assertEqual(self.blueprint.config_json['objectives'], payload['objectives'])
self.assertEqual(self.blueprint.config_json['style'], payload['style_preferences'])
self.assertIn('last_requested_at', self.blueprint.config_json)
self.assertEqual(self.blueprint.config_json['metadata'], payload['metadata'])
@patch('igny8_core.business.site_building.services.structure_generation_service.CreditService.check_credits')
def test_generate_structure_rolls_back_when_insufficient_credits(self, mock_check):
mock_check.side_effect = InsufficientCreditsError('No credits remaining')
service = StructureGenerationService()
with self.assertRaises(InsufficientCreditsError):
service.generate_structure(
self.blueprint,
business_brief='Too expensive request',
)
self.blueprint.refresh_from_db()
self.assertEqual(self.blueprint.status, 'draft')
class PageGenerationServiceTests(SiteBuilderTestBase):
"""Ensures Site Builder pages correctly leverage the Writer pipeline."""
@patch('igny8_core.business.site_building.services.page_generation_service.ContentGenerationService.generate_content')
def test_generate_page_content_creates_writer_task(self, mock_generate_content):
mock_generate_content.return_value = {'success': True}
service = PageGenerationService()
result = service.generate_page_content(self.page_blueprint)
created_task = Tasks.objects.get()
expected_title = '[Site Builder] Home'
self.assertEqual(created_task.title, expected_title)
mock_generate_content.assert_called_once_with([created_task.id], self.account)
self.page_blueprint.refresh_from_db()
self.assertEqual(self.page_blueprint.status, 'generating')
self.assertEqual(result, {'success': True})
@patch('igny8_core.business.site_building.services.page_generation_service.ContentGenerationService.generate_content')
def test_regenerate_page_replaces_writer_task(self, mock_generate_content):
mock_generate_content.return_value = {'success': True}
service = PageGenerationService()
first_result = service.generate_page_content(self.page_blueprint)
first_task_id = Tasks.objects.get().id
self.assertEqual(first_result, {'success': True})
second_result = service.regenerate_page(self.page_blueprint)
second_task = Tasks.objects.get()
self.assertEqual(second_result, {'success': True})
self.assertNotEqual(first_task_id, second_task.id)
self.assertEqual(Tasks.objects.count(), 1)
self.assertEqual(mock_generate_content.call_count, 2)

View File

@@ -0,0 +1,24 @@
# Generated migration to fix cluster name uniqueness
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('planner', '0006_unified_status_refactor'),
]
operations = [
# Remove the old unique constraint on name field
migrations.AlterField(
model_name='clusters',
name='name',
field=models.CharField(db_index=True, max_length=255),
),
# Add unique_together constraint for name, site, sector
migrations.AlterUniqueTogether(
name='clusters',
unique_together={('name', 'site', 'sector')},
),
]

View File

@@ -1,5 +0,0 @@
"""
Site Builder module (Phase 3)
"""

View File

@@ -1,9 +0,0 @@
from django.apps import AppConfig
class SiteBuilderConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.modules.site_builder'
verbose_name = 'Site Builder'

View File

@@ -1,102 +0,0 @@
from django.conf import settings
from rest_framework import serializers
from igny8_core.business.site_building.models import (
AudienceProfile,
BrandPersonality,
BusinessType,
HeroImageryDirection,
PageBlueprint,
SiteBlueprint,
)
class PageBlueprintSerializer(serializers.ModelSerializer):
site_blueprint_id = serializers.PrimaryKeyRelatedField(
source='site_blueprint',
queryset=SiteBlueprint.objects.all(),
write_only=True
)
site_blueprint = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = PageBlueprint
fields = [
'id',
'site_blueprint_id',
'site_blueprint',
'slug',
'title',
'type',
'blocks_json',
'status',
'order',
'created_at',
'updated_at',
]
read_only_fields = [
'site_blueprint',
'created_at',
'updated_at',
]
class SiteBlueprintSerializer(serializers.ModelSerializer):
pages = PageBlueprintSerializer(many=True, read_only=True)
site_id = serializers.IntegerField(required=False, read_only=True)
sector_id = serializers.IntegerField(required=False, read_only=True)
account_id = serializers.IntegerField(read_only=True)
class Meta:
model = SiteBlueprint
fields = [
'id',
'name',
'description',
'config_json',
'structure_json',
'status',
'hosting_type',
'version',
'deployed_version',
'account_id',
'site_id',
'sector_id',
'created_at',
'updated_at',
'pages',
]
read_only_fields = [
'structure_json',
'status',
'created_at',
'updated_at',
'pages',
]
def validate(self, attrs):
site_id = attrs.pop('site_id', None)
sector_id = attrs.pop('sector_id', None)
if self.instance is None:
if not site_id:
raise serializers.ValidationError({'site_id': 'This field is required.'})
if not sector_id:
raise serializers.ValidationError({'sector_id': 'This field is required.'})
attrs['site_id'] = site_id
attrs['sector_id'] = sector_id
return attrs
class MetadataOptionSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField(required=False, allow_blank=True)
class SiteBuilderMetadataSerializer(serializers.Serializer):
business_types = MetadataOptionSerializer(many=True)
audience_profiles = MetadataOptionSerializer(many=True)
brand_personalities = MetadataOptionSerializer(many=True)
hero_imagery_directions = MetadataOptionSerializer(many=True)

View File

@@ -1,20 +0,0 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from igny8_core.modules.site_builder.views import (
PageBlueprintViewSet,
SiteAssetView,
SiteBlueprintViewSet,
SiteBuilderMetadataView,
)
router = DefaultRouter()
router.register(r'blueprints', SiteBlueprintViewSet, basename='site_blueprint')
router.register(r'pages', PageBlueprintViewSet, basename='page_blueprint')
urlpatterns = [
path('', include(router.urls)),
path('assets/', SiteAssetView.as_view(), name='site_builder_assets'),
path('metadata/', SiteBuilderMetadataView.as_view(), name='site_builder_metadata'),
]

View File

@@ -1,709 +0,0 @@
import logging
from django.conf import settings
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.exceptions import ValidationError
logger = logging.getLogger(__name__)
from igny8_core.api.base import SiteSectorModelViewSet
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.business.site_building.models import (
AudienceProfile,
BrandPersonality,
BusinessType,
HeroImageryDirection,
PageBlueprint,
SiteBlueprint,
SiteBlueprintCluster,
SiteBlueprintTaxonomy,
)
from igny8_core.business.site_building.services import (
PageGenerationService,
SiteBuilderFileService,
StructureGenerationService,
TaxonomyService,
)
from igny8_core.modules.site_builder.serializers import (
PageBlueprintSerializer,
SiteBlueprintSerializer,
SiteBuilderMetadataSerializer,
)
class SiteBlueprintViewSet(SiteSectorModelViewSet):
"""
CRUD + AI actions for site blueprints.
"""
queryset = SiteBlueprint.objects.all().prefetch_related('pages')
serializer_class = SiteBlueprintSerializer
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
throttle_scope = 'site_builder'
throttle_classes = [DebugScopedRateThrottle]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.taxonomy_service = TaxonomyService()
def get_permissions(self):
"""
Allow public read access for list requests with site filter (used by Sites Renderer fallback).
This allows the Sites Renderer to load blueprint data for deployed sites without authentication.
"""
# Allow public access for list requests with site filter (used by Sites Renderer)
if self.action == 'list' and self.request.query_params.get('site'):
from rest_framework.permissions import AllowAny
return [AllowAny()]
# Otherwise use default permissions
return super().get_permissions()
def get_throttles(self):
"""
Bypass throttling for public list requests with site filter (used by Sites Renderer).
"""
# Bypass throttling for public requests (no auth) with site filter
if self.action == 'list' and self.request.query_params.get('site'):
if not self.request.user or not self.request.user.is_authenticated:
return [] # No throttling for public blueprint access
return super().get_throttles()
def get_queryset(self):
"""
Override to allow public access when filtering by site_id.
"""
# If this is a public request (no auth) with site filter, bypass base class filtering
# and return deployed blueprints for that site
if not self.request.user or not self.request.user.is_authenticated:
site_id = self.request.query_params.get('site')
if site_id:
# Return queryset directly from model (bypassing base class account/site filtering)
from igny8_core.business.site_building.models import SiteBlueprint
return SiteBlueprint.objects.filter(
site_id=site_id,
status='deployed'
).prefetch_related('pages').order_by('-version')
# For authenticated users, use base class filtering
return super().get_queryset()
def perform_create(self, serializer):
from igny8_core.auth.models import Site, Sector
site_id = serializer.validated_data.pop('site_id', None)
sector_id = serializer.validated_data.pop('sector_id', None)
if not site_id or not sector_id:
raise ValidationError({'detail': 'site_id and sector_id are required.'})
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
raise ValidationError({'site_id': 'Site not found.'})
try:
sector = Sector.objects.get(id=sector_id, site=site)
except Sector.DoesNotExist:
raise ValidationError({'sector_id': 'Sector does not belong to the selected site.'})
blueprint = serializer.save(account=site.account, site=site, sector=sector)
@action(detail=True, methods=['post'])
def generate_structure(self, request, pk=None):
blueprint = self.get_object()
business_brief = request.data.get('business_brief') or \
blueprint.config_json.get('business_brief', '')
objectives = request.data.get('objectives') or \
blueprint.config_json.get('objectives', [])
style = request.data.get('style') or \
blueprint.config_json.get('style', {})
service = StructureGenerationService()
result = service.generate_structure(
site_blueprint=blueprint,
business_brief=business_brief,
objectives=objectives,
style_preferences=style,
metadata=request.data.get('metadata', {}),
)
response = Response(result, status=status.HTTP_202_ACCEPTED if 'task_id' in result else status.HTTP_200_OK)
return response
@action(detail=True, methods=['post'])
def generate_all_pages(self, request, pk=None):
"""
Generate content for all pages in blueprint.
Request body:
{
"page_ids": [1, 2, 3], # Optional: specific pages, or all if omitted
"force": false # Optional: force regenerate existing content
}
"""
blueprint = self.get_object()
page_ids = request.data.get('page_ids')
force = request.data.get('force', False)
service = PageGenerationService()
try:
result = service.bulk_generate_pages(
blueprint,
page_ids=page_ids,
force_regenerate=force
)
response_status = status.HTTP_202_ACCEPTED if result.get('success') else status.HTTP_400_BAD_REQUEST
response = success_response(result, request=request, status_code=response_status)
return response
except Exception as e:
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
@action(detail=True, methods=['post'])
def create_tasks(self, request, pk=None):
"""
Create Writer tasks for pages without generating content.
Request body:
{
"page_ids": [1, 2, 3] # Optional: specific pages, or all if omitted
}
Useful for:
- Previewing what tasks will be created
- Manual task management
- Integration with existing Writer UI
"""
blueprint = self.get_object()
page_ids = request.data.get('page_ids')
service = PageGenerationService()
try:
tasks = service.create_tasks_for_pages(blueprint, page_ids=page_ids)
# Serialize tasks
from igny8_core.business.content.serializers import TasksSerializer
serializer = TasksSerializer(tasks, many=True)
response = success_response({'tasks': serializer.data, 'count': len(tasks)}, request=request)
return response
except Exception as e:
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
@action(detail=True, methods=['get'], url_path='progress', url_name='progress')
def progress(self, request, pk=None):
"""
Stage 3: Get cluster-level completion + validation status for site.
GET /api/v1/site-builder/blueprints/{id}/progress/
Returns progress summary with cluster coverage, validation flags.
"""
blueprint = self.get_object()
from igny8_core.business.content.models import (
Tasks,
Content,
ContentClusterMap,
ContentTaxonomyMap,
)
from igny8_core.business.planning.models import Clusters
from django.db.models import Count, Q
# Get clusters attached to blueprint
blueprint_clusters = blueprint.cluster_links.all()
cluster_ids = list(blueprint_clusters.values_list('cluster_id', flat=True))
# Get tasks and content for this blueprint's site
tasks = Tasks.objects.filter(site=blueprint.site)
content = Content.objects.filter(site=blueprint.site)
# Cluster coverage analysis
cluster_progress = []
for cluster_link in blueprint_clusters:
cluster = cluster_link.cluster
cluster_tasks = tasks.filter(cluster=cluster)
cluster_content_ids = ContentClusterMap.objects.filter(
cluster=cluster
).values_list('content_id', flat=True).distinct()
cluster_content = content.filter(id__in=cluster_content_ids)
# Count by structure
hub_count = cluster_tasks.filter(content_structure='cluster_hub').count()
supporting_count = cluster_tasks.filter(content_structure__in=['article', 'guide', 'comparison']).count()
attribute_count = cluster_tasks.filter(content_structure='attribute_archive').count()
cluster_progress.append({
'cluster_id': cluster.id,
'cluster_name': cluster.name,
'role': cluster_link.role,
'coverage_status': cluster_link.coverage_status,
'tasks_count': cluster_tasks.count(),
'content_count': cluster_content.count(),
'hub_pages': hub_count,
'supporting_pages': supporting_count,
'attribute_pages': attribute_count,
'is_complete': cluster_link.coverage_status == 'complete',
})
# Overall stats
total_tasks = tasks.count()
total_content = content.count()
tasks_with_cluster = tasks.filter(cluster__isnull=False).count()
content_with_cluster_map = ContentClusterMap.objects.filter(
content__site=blueprint.site
).values('content').distinct().count()
return success_response(
data={
'blueprint_id': blueprint.id,
'blueprint_name': blueprint.name,
'overall_progress': {
'total_tasks': total_tasks,
'total_content': total_content,
'tasks_with_cluster': tasks_with_cluster,
'content_with_cluster_mapping': content_with_cluster_map,
'completion_percentage': (
(content_with_cluster_map / total_content * 100) if total_content > 0 else 0
),
},
'cluster_progress': cluster_progress,
'validation_flags': {
'has_clusters': blueprint_clusters.exists(),
'has_taxonomies': blueprint.taxonomies.exists(),
'has_pages': blueprint.pages.exists(),
}
},
request=request
)
@action(detail=True, methods=['post'], url_path='clusters/attach')
def attach_clusters(self, request, pk=None):
"""
Attach planner clusters to site blueprint.
Request body:
{
"cluster_ids": [1, 2, 3], # List of cluster IDs to attach
"role": "hub" # Optional: default role (hub, supporting, attribute)
}
Returns:
{
"attached_count": 3,
"clusters": [...] # List of attached cluster data
}
"""
blueprint = self.get_object()
cluster_ids = request.data.get('cluster_ids', [])
role = request.data.get('role', 'hub')
if not cluster_ids:
return error_response(
'cluster_ids is required',
status.HTTP_400_BAD_REQUEST,
request
)
# Validate role
valid_roles = [choice[0] for choice in SiteBlueprintCluster.ROLE_CHOICES]
if role not in valid_roles:
return error_response(
f'Invalid role. Must be one of: {", ".join(valid_roles)}',
status.HTTP_400_BAD_REQUEST,
request
)
# Import Clusters model
from igny8_core.business.planning.models import Clusters
# Validate clusters exist and belong to same account/site/sector
clusters = Clusters.objects.filter(
id__in=cluster_ids,
account=blueprint.account,
site=blueprint.site,
sector=blueprint.sector
)
if clusters.count() != len(cluster_ids):
return error_response(
'Some clusters not found or do not belong to this blueprint\'s site/sector',
status.HTTP_400_BAD_REQUEST,
request
)
# Attach clusters (create SiteBlueprintCluster records)
attached = []
for cluster in clusters:
# Check if already attached with this role
existing = SiteBlueprintCluster.objects.filter(
site_blueprint=blueprint,
cluster=cluster,
role=role
).first()
if not existing:
link = SiteBlueprintCluster.objects.create(
site_blueprint=blueprint,
cluster=cluster,
role=role,
account=blueprint.account,
site=blueprint.site,
sector=blueprint.sector
)
attached.append({
'id': cluster.id,
'name': cluster.name,
'role': role,
'link_id': link.id
})
else:
# Already attached, include in response
attached.append({
'id': cluster.id,
'name': cluster.name,
'role': role,
'link_id': existing.id
})
return success_response(
data={
'attached_count': len(attached),
'clusters': attached
},
request=request
)
@action(detail=True, methods=['post'], url_path='clusters/detach')
def detach_clusters(self, request, pk=None):
"""
Detach planner clusters from site blueprint.
Request body:
{
"cluster_ids": [1, 2, 3], # List of cluster IDs to detach (optional: detach all if omitted)
"role": "hub" # Optional: only detach clusters with this role
}
Returns:
{
"detached_count": 3
}
"""
blueprint = self.get_object()
cluster_ids = request.data.get('cluster_ids', [])
role = request.data.get('role')
# Build query
query = SiteBlueprintCluster.objects.filter(site_blueprint=blueprint)
if cluster_ids:
query = query.filter(cluster_id__in=cluster_ids)
if role:
valid_roles = [choice[0] for choice in SiteBlueprintCluster.ROLE_CHOICES]
if role not in valid_roles:
return error_response(
f'Invalid role. Must be one of: {", ".join(valid_roles)}',
status.HTTP_400_BAD_REQUEST,
request
)
query = query.filter(role=role)
detached_count = query.count()
query.delete()
return success_response(
data={'detached_count': detached_count},
request=request
)
@action(detail=True, methods=['get'], url_path='taxonomies')
def list_taxonomies(self, request, pk=None):
"""
List taxonomies for a blueprint.
Returns:
{
"count": 5,
"taxonomies": [...]
}
"""
blueprint = self.get_object()
taxonomies = blueprint.taxonomies.all().select_related().prefetch_related('clusters')
# Serialize taxonomies
data = []
for taxonomy in taxonomies:
data.append({
'id': taxonomy.id,
'name': taxonomy.name,
'slug': taxonomy.slug,
'taxonomy_type': taxonomy.taxonomy_type,
'description': taxonomy.description,
'cluster_ids': list(taxonomy.clusters.values_list('id', flat=True)),
'external_reference': taxonomy.external_reference,
'created_at': taxonomy.created_at.isoformat(),
'updated_at': taxonomy.updated_at.isoformat(),
})
return success_response(
data={'count': len(data), 'taxonomies': data},
request=request
)
@action(detail=True, methods=['post'], url_path='taxonomies')
def create_taxonomy(self, request, pk=None):
"""
Create a taxonomy for a blueprint.
Request body:
{
"name": "Product Categories",
"slug": "product-categories",
"taxonomy_type": "product_category",
"description": "Product category taxonomy",
"cluster_ids": [1, 2, 3], # Optional
"external_reference": "wp_term_123" # Optional
}
"""
blueprint = self.get_object()
name = request.data.get('name')
slug = request.data.get('slug')
taxonomy_type = request.data.get('taxonomy_type', 'blog_category')
description = request.data.get('description', '')
cluster_ids = request.data.get('cluster_ids', [])
external_reference = request.data.get('external_reference')
if not name or not slug:
return error_response(
'name and slug are required',
status.HTTP_400_BAD_REQUEST,
request
)
# Validate taxonomy type
valid_types = [choice[0] for choice in SiteBlueprintTaxonomy.TAXONOMY_TYPE_CHOICES]
if taxonomy_type not in valid_types:
return error_response(
f'Invalid taxonomy_type. Must be one of: {", ".join(valid_types)}',
status.HTTP_400_BAD_REQUEST,
request
)
# Create taxonomy
taxonomy = self.taxonomy_service.create_taxonomy(
blueprint,
name=name,
slug=slug,
taxonomy_type=taxonomy_type,
description=description,
clusters=cluster_ids if cluster_ids else None,
external_reference=external_reference,
)
return success_response(
data={
'id': taxonomy.id,
'name': taxonomy.name,
'slug': taxonomy.slug,
'taxonomy_type': taxonomy.taxonomy_type,
},
request=request,
status_code=status.HTTP_201_CREATED
)
@action(detail=True, methods=['post'], url_path='taxonomies/import')
def import_taxonomies(self, request, pk=None):
"""
Import taxonomies from external source (WordPress/WooCommerce).
Request body:
{
"records": [
{
"name": "Category Name",
"slug": "category-slug",
"taxonomy_type": "blog_category",
"description": "Category description",
"external_reference": "wp_term_123"
},
...
],
"default_type": "blog_category" # Optional
}
"""
blueprint = self.get_object()
records = request.data.get('records', [])
default_type = request.data.get('default_type', 'blog_category')
if not records:
return error_response(
'records array is required',
status.HTTP_400_BAD_REQUEST,
request
)
# Import taxonomies
imported = self.taxonomy_service.import_from_external(
blueprint,
records,
default_type=default_type
)
return success_response(
data={
'imported_count': len(imported),
'taxonomies': [
{
'id': t.id,
'name': t.name,
'slug': t.slug,
'taxonomy_type': t.taxonomy_type,
}
for t in imported
]
},
request=request
)
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
def bulk_delete(self, request):
"""
Bulk delete blueprints.
Request body:
{
"ids": [1, 2, 3] # List of blueprint IDs to delete
}
Returns:
{
"deleted_count": 3
}
"""
ids = request.data.get('ids', [])
if not ids:
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
queryset = self.get_queryset()
deleted_count, _ = queryset.filter(id__in=ids).delete()
return success_response(data={'deleted_count': deleted_count}, request=request)
class PageBlueprintViewSet(SiteSectorModelViewSet):
"""
CRUD endpoints for page blueprints with content generation hooks.
"""
queryset = PageBlueprint.objects.select_related('site_blueprint')
serializer_class = PageBlueprintSerializer
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
throttle_scope = 'site_builder'
throttle_classes = [DebugScopedRateThrottle]
def perform_create(self, serializer):
page = serializer.save()
# Align account/site/sector with parent blueprint
page.account = page.site_blueprint.account
page.site = page.site_blueprint.site
page.sector = page.site_blueprint.sector
page.save(update_fields=['account', 'site', 'sector'])
@action(detail=True, methods=['post'])
def generate_content(self, request, pk=None):
page = self.get_object()
service = PageGenerationService()
result = service.generate_page_content(page, force_regenerate=request.data.get('force', False))
return success_response(result, request=request)
@action(detail=True, methods=['post'])
def regenerate(self, request, pk=None):
page = self.get_object()
service = PageGenerationService()
result = service.regenerate_page(page)
return success_response(result, request=request)
class SiteAssetView(APIView):
"""
File management for Site Builder assets.
"""
permission_classes = [IsAuthenticated]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.file_service = SiteBuilderFileService()
def get(self, request, *args, **kwargs):
site_id = request.query_params.get('site_id')
folder = request.query_params.get('folder')
if not site_id:
return error_response('site_id is required', status.HTTP_400_BAD_REQUEST, request)
files = self.file_service.list_files(request.user, int(site_id), folder=folder)
return success_response({'files': files}, request)
def post(self, request, *args, **kwargs):
site_id = request.data.get('site_id')
version = int(request.data.get('version', 1))
folder = request.data.get('folder', 'images')
upload = request.FILES.get('file')
if not site_id or not upload:
return error_response('site_id and file are required', status.HTTP_400_BAD_REQUEST, request)
info = self.file_service.upload_file(request.user, int(site_id), upload, folder=folder, version=version)
return success_response(info, request, status.HTTP_201_CREATED)
def delete(self, request, *args, **kwargs):
site_id = request.data.get('site_id')
file_path = request.data.get('path')
version = int(request.data.get('version', 1))
if not site_id or not file_path:
return error_response('site_id and path are required', status.HTTP_400_BAD_REQUEST, request)
deleted = self.file_service.delete_file(request.user, int(site_id), file_path, version=version)
if deleted:
return success_response({'deleted': True}, request, status.HTTP_204_NO_CONTENT)
return error_response('File not found', status.HTTP_404_NOT_FOUND, request)
class SiteBuilderMetadataView(APIView):
"""
Read-only metadata for Site Builder dropdowns.
"""
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
def get(self, request, *args, **kwargs):
def serialize_queryset(qs):
return [
{
'id': item.id,
'name': item.name,
'description': item.description or '',
}
for item in qs
]
data = {
'business_types': serialize_queryset(
BusinessType.objects.filter(is_active=True).order_by('order', 'name')
),
'audience_profiles': serialize_queryset(
AudienceProfile.objects.filter(is_active=True).order_by('order', 'name')
),
'brand_personalities': serialize_queryset(
BrandPersonality.objects.filter(is_active=True).order_by('order', 'name')
),
'hero_imagery_directions': serialize_queryset(
HeroImageryDirection.objects.filter(is_active=True).order_by('order', 'name')
),
}
serializer = SiteBuilderMetadataSerializer(data)
return Response(serializer.data)

View File

@@ -52,13 +52,10 @@ 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', # Removed - automation module disabled
# 'igny8_core.business.site_building.apps.SiteBuildingConfig', # REMOVED: SiteBuilder/Blueprint deprecated
'igny8_core.business.automation', # AI Automation Pipeline
'igny8_core.business.optimization.apps.OptimizationConfig',
'igny8_core.business.publishing.apps.PublishingConfig',
'igny8_core.business.integration.apps.IntegrationConfig',
# 'igny8_core.modules.site_builder.apps.SiteBuilderConfig', # REMOVED: SiteBuilder deprecated
'igny8_core.modules.linker.apps.LinkerConfig',
'igny8_core.modules.optimizer.apps.OptimizerConfig',
'igny8_core.modules.publisher.apps.PublisherConfig',

View File

@@ -39,7 +39,6 @@ urlpatterns = [
path('api/v1/auth/', include('igny8_core.auth.urls')), # Auth endpoints
path('api/v1/planner/', include('igny8_core.modules.planner.urls')),
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
# Site Builder module removed - legacy blueprint functionality deprecated
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.business.automation.urls')), # Automation endpoints