8 Phases refactor
This commit is contained in:
@@ -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 In‑article 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')
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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']),
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
|
||||
@@ -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)}']
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
Site Building Business Logic
|
||||
Phase 3: Site Builder
|
||||
"""
|
||||
|
||||
default_app_config = 'igny8_core.business.site_building.apps.SiteBuildingConfig'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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;"],
|
||||
),
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Site Builder module (Phase 3)
|
||||
"""
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
204
backend/verify_migrations.py
Normal file
204
backend/verify_migrations.py
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database Migration Verification Script
|
||||
Checks for orphaned SiteBlueprint tables and verifies new migrations
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from django.db import connection
|
||||
from django.core.management import call_command
|
||||
|
||||
|
||||
def check_orphaned_tables():
|
||||
"""Check for orphaned blueprint tables"""
|
||||
print("\n" + "="*60)
|
||||
print("CHECKING FOR ORPHANED SITEBLUEPRINT TABLES")
|
||||
print("="*60 + "\n")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE '%blueprint%'
|
||||
ORDER BY table_name;
|
||||
""")
|
||||
tables = cursor.fetchall()
|
||||
|
||||
if tables:
|
||||
print("⚠️ Found blueprint-related tables:")
|
||||
for table in tables:
|
||||
print(f" - {table[0]}")
|
||||
print("\n💡 These tables can be safely dropped if no longer needed.")
|
||||
else:
|
||||
print("✅ No orphaned blueprint tables found.")
|
||||
|
||||
return len(tables) if tables else 0
|
||||
|
||||
|
||||
def verify_cluster_constraint():
|
||||
"""Verify cluster unique constraint is per-site/sector"""
|
||||
print("\n" + "="*60)
|
||||
print("VERIFYING CLUSTER UNIQUE CONSTRAINT")
|
||||
print("="*60 + "\n")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
tc.constraint_name,
|
||||
tc.constraint_type,
|
||||
string_agg(kcu.column_name, ', ' ORDER BY kcu.ordinal_position) as columns
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE tc.table_name = 'igny8_clusters'
|
||||
AND tc.constraint_type = 'UNIQUE'
|
||||
GROUP BY tc.constraint_name, tc.constraint_type;
|
||||
""")
|
||||
constraints = cursor.fetchall()
|
||||
|
||||
if constraints:
|
||||
print("Found unique constraints on igny8_clusters:")
|
||||
for constraint in constraints:
|
||||
name, ctype, columns = constraint
|
||||
print(f" {name}: {columns}")
|
||||
|
||||
# Check if it includes site and sector
|
||||
if 'site' in columns.lower() and 'sector' in columns.lower():
|
||||
print(f" ✅ Constraint is scoped per-site/sector")
|
||||
else:
|
||||
print(f" ⚠️ Constraint may need updating")
|
||||
else:
|
||||
print("⚠️ No unique constraints found on igny8_clusters")
|
||||
|
||||
|
||||
def verify_automation_delays():
|
||||
"""Verify automation delay fields exist"""
|
||||
print("\n" + "="*60)
|
||||
print("VERIFYING AUTOMATION DELAY CONFIGURATION")
|
||||
print("="*60 + "\n")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'igny8_automationconfig'
|
||||
AND column_name IN ('within_stage_delay', 'between_stage_delay')
|
||||
ORDER BY column_name;
|
||||
""")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
if len(columns) == 2:
|
||||
print("✅ Delay configuration fields found:")
|
||||
for col in columns:
|
||||
name, dtype, default = col
|
||||
print(f" {name}: {dtype} (default: {default})")
|
||||
else:
|
||||
print(f"⚠️ Expected 2 delay fields, found {len(columns)}")
|
||||
|
||||
|
||||
def check_migration_status():
|
||||
"""Check migration status"""
|
||||
print("\n" + "="*60)
|
||||
print("CHECKING MIGRATION STATUS")
|
||||
print("="*60 + "\n")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT app, name, applied
|
||||
FROM django_migrations
|
||||
WHERE name LIKE '%cluster%' OR name LIKE '%delay%'
|
||||
ORDER BY applied DESC
|
||||
LIMIT 10;
|
||||
""")
|
||||
migrations = cursor.fetchall()
|
||||
|
||||
if migrations:
|
||||
print("Recent relevant migrations:")
|
||||
for mig in migrations:
|
||||
app, name, applied = mig
|
||||
status = "✅" if applied else "⏳"
|
||||
print(f" {status} {app}.{name}")
|
||||
print(f" Applied: {applied}")
|
||||
else:
|
||||
print("No relevant migrations found in history")
|
||||
|
||||
|
||||
def check_data_integrity():
|
||||
"""Check for data integrity issues"""
|
||||
print("\n" + "="*60)
|
||||
print("DATA INTEGRITY CHECKS")
|
||||
print("="*60 + "\n")
|
||||
|
||||
from igny8_core.business.planning.models import Clusters, Keywords
|
||||
|
||||
# Check for clusters with 'active' status (should all be 'new' or 'mapped')
|
||||
active_clusters = Clusters.objects.filter(status='active').count()
|
||||
if active_clusters > 0:
|
||||
print(f"⚠️ Found {active_clusters} clusters with status='active'")
|
||||
print(" These should be updated to 'new' or 'mapped'")
|
||||
else:
|
||||
print("✅ No clusters with invalid 'active' status")
|
||||
|
||||
# Check for duplicate cluster names in same site/sector
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT name, site_id, sector_id, COUNT(*) as count
|
||||
FROM igny8_clusters
|
||||
GROUP BY name, site_id, sector_id
|
||||
HAVING COUNT(*) > 1;
|
||||
""")
|
||||
duplicates = cursor.fetchall()
|
||||
|
||||
if duplicates:
|
||||
print(f"\n⚠️ Found {len(duplicates)} duplicate cluster names in same site/sector:")
|
||||
for dup in duplicates[:5]: # Show first 5
|
||||
print(f" - '{dup[0]}' (site={dup[1]}, sector={dup[2]}): {dup[3]} duplicates")
|
||||
else:
|
||||
print("✅ No duplicate cluster names within same site/sector")
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#"*60)
|
||||
print("# IGNY8 DATABASE MIGRATION VERIFICATION")
|
||||
print("# Date:", __import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
|
||||
print("#"*60)
|
||||
|
||||
try:
|
||||
orphaned = check_orphaned_tables()
|
||||
verify_cluster_constraint()
|
||||
verify_automation_delays()
|
||||
check_migration_status()
|
||||
check_data_integrity()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("VERIFICATION COMPLETE")
|
||||
print("="*60)
|
||||
|
||||
if orphaned > 0:
|
||||
print(f"\n⚠️ {orphaned} orphaned table(s) found - review recommended")
|
||||
else:
|
||||
print("\n✅ All verifications passed!")
|
||||
|
||||
print("\n")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user