Add site builder service to Docker Compose and remove obsolete scripts

- Introduced a new service `igny8_site_builder` in `docker-compose.app.yml` for site building functionality, including environment variables and volume mappings.
- Deleted several outdated scripts: `create_test_users.py`, `test_image_write_access.py`, `update_free_plan.py`, and the database file `db.sqlite3` to clean up the backend.
- Updated Django settings and URL configurations to integrate the new site builder module.
This commit is contained in:
IGNY8 VPS (Salman)
2025-11-17 16:08:51 +00:00
parent e3d4ba2c02
commit 5a36686844
74 changed files with 7217 additions and 374 deletions

Binary file not shown.

View File

@@ -1,187 +0,0 @@
#!/usr/bin/env python
"""
Script to create 3 real users with 3 paid packages (Starter, Growth, Scale)
All accounts will be active and properly configured.
Email format: plan-name@igny8.com
"""
import os
import django
import sys
from decimal import Decimal
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from django.db import transaction
from igny8_core.auth.models import Plan, Account, User
from django.utils.text import slugify
# User data - 3 users with 3 different paid plans
# Email format: plan-name@igny8.com
USERS_DATA = [
{
"email": "starter@igny8.com",
"username": "starter",
"first_name": "Starter",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "starter", # $89/month
"account_name": "Starter Account",
},
{
"email": "growth@igny8.com",
"username": "growth",
"first_name": "Growth",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "growth", # $139/month
"account_name": "Growth Account",
},
{
"email": "scale@igny8.com",
"username": "scale",
"first_name": "Scale",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "scale", # $229/month
"account_name": "Scale Account",
},
]
def create_user_with_plan(user_data):
"""Create a user with account and assigned plan."""
try:
with transaction.atomic():
# Get the plan
try:
plan = Plan.objects.get(slug=user_data['plan_slug'], is_active=True)
except Plan.DoesNotExist:
print(f"❌ ERROR: Plan '{user_data['plan_slug']}' not found or inactive!")
return None
# Check if user already exists
if User.objects.filter(email=user_data['email']).exists():
print(f"⚠️ User {user_data['email']} already exists. Updating...")
existing_user = User.objects.get(email=user_data['email'])
if existing_user.account:
existing_user.account.plan = plan
existing_user.account.status = 'active'
existing_user.account.save()
print(f" ✅ Updated account plan to {plan.name} and set status to active")
return existing_user
# Generate unique account slug
base_slug = slugify(user_data['account_name'])
account_slug = base_slug
counter = 1
while Account.objects.filter(slug=account_slug).exists():
account_slug = f"{base_slug}-{counter}"
counter += 1
# Create user first (without account)
user = User.objects.create_user(
username=user_data['username'],
email=user_data['email'],
password=user_data['password'],
first_name=user_data['first_name'],
last_name=user_data['last_name'],
account=None, # Will be set after account creation
role='owner'
)
# Create account with user as owner and assigned plan
account = Account.objects.create(
name=user_data['account_name'],
slug=account_slug,
owner=user,
plan=plan,
status='active', # Set to active
credits=plan.included_credits or 0, # Set initial credits from plan
)
# Update user to reference the new account
user.account = account
user.save()
print(f"✅ Created user: {user.email}")
print(f" - Name: {user.get_full_name()}")
print(f" - Username: {user.username}")
print(f" - Account: {account.name} (slug: {account.slug})")
print(f" - Plan: {plan.name} (${plan.price}/month)")
print(f" - Status: {account.status}")
print(f" - Credits: {account.credits}")
print(f" - Max Sites: {plan.max_sites}")
print(f" - Max Users: {plan.max_users}")
print()
return user
except Exception as e:
print(f"❌ ERROR creating user {user_data['email']}: {e}")
import traceback
traceback.print_exc()
return None
def main():
"""Main function to create all users."""
print("=" * 80)
print("Creating 3 Users with Paid Plans")
print("=" * 80)
print()
# Verify plans exist
print("Checking available plans...")
plans = Plan.objects.filter(is_active=True).order_by('price')
if plans.count() < 3:
print(f"⚠️ WARNING: Only {plans.count()} active plan(s) found. Need at least 3.")
print("Available plans:")
for p in plans:
print(f" - {p.slug} (${p.price})")
print()
print("Please run import_plans.py first to create the plans.")
return
print("✅ Found plans:")
for p in plans:
print(f" - {p.name} ({p.slug}): ${p.price}/month")
print()
# Create users
created_users = []
for user_data in USERS_DATA:
user = create_user_with_plan(user_data)
if user:
created_users.append(user)
# Summary
print("=" * 80)
print("SUMMARY")
print("=" * 80)
print(f"Total users created/updated: {len(created_users)}")
print()
print("User Login Credentials:")
print("-" * 80)
for user_data in USERS_DATA:
print(f"Email: {user_data['email']}")
print(f"Password: {user_data['password']}")
print(f"Plan: {user_data['plan_slug'].title()}")
print()
print("✅ All users created successfully!")
print()
print("You can now log in with any of these accounts at:")
print("https://app.igny8.com/login")
if __name__ == '__main__':
try:
main()
except Exception as e:
print(f"❌ Fatal error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

Binary file not shown.

View File

@@ -34,6 +34,8 @@ class AIEngine:
return f"{count} task{'s' if count != 1 else ''}"
elif function_name == 'generate_images':
return f"{count} task{'s' if count != 1 else ''}"
elif function_name == 'generate_site_structure':
return "1 site blueprint"
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:
@@ -80,6 +82,12 @@ class AIEngine:
total_images = 1 + max_images
return f"Mapping Content for {total_images} Image Prompts"
return f"Mapping Content for Image Prompts"
elif function_name == 'generate_site_structure':
blueprint_name = ''
if isinstance(data, dict):
blueprint = data.get('blueprint')
blueprint_name = f"{getattr(blueprint, 'name', '')}" if blueprint and getattr(blueprint, 'name', None) else ''
return f"Preparing site blueprint {blueprint_name}".strip()
return f"Preparing {count} item{'s' if count != 1 else ''}"
def _get_ai_call_message(self, function_name: str, count: int) -> str:
@@ -92,6 +100,8 @@ class AIEngine:
return f"Writing article{'s' if count != 1 else ''} with AI"
elif function_name == 'generate_images':
return f"Creating image{'s' if count != 1 else ''} with AI"
elif function_name == 'generate_site_structure':
return "Designing complete site architecture"
return f"Processing with AI"
def _get_parse_message(self, function_name: str) -> str:
@@ -104,6 +114,8 @@ class AIEngine:
return "Formatting content"
elif function_name == 'generate_images':
return "Processing images"
elif function_name == 'generate_site_structure':
return "Compiling site map"
return "Processing results"
def _get_parse_message_with_count(self, function_name: str, count: int) -> str:
@@ -122,6 +134,8 @@ class AIEngine:
if in_article_count > 0:
return f"Writing {in_article_count} Inarticle Image Prompts"
return "Writing Inarticle Image Prompts"
elif function_name == 'generate_site_structure':
return f"{count} page blueprint{'s' if count != 1 else ''} mapped"
return f"{count} item{'s' if count != 1 else ''} processed"
def _get_save_message(self, function_name: str, count: int) -> str:
@@ -137,6 +151,8 @@ class AIEngine:
elif function_name == 'generate_image_prompts':
# Count is total prompts created
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 ''}"
return f"Saving {count} item{'s' if count != 1 else ''}"
def execute(self, fn: BaseAIFunction, payload: dict) -> dict:
@@ -494,6 +510,7 @@ class AIEngine:
'generate_content': 'content_generation',
'generate_image_prompts': 'image_prompt_extraction',
'generate_images': 'image_generation',
'generate_site_structure': 'site_structure_generation',
}
return mapping.get(function_name, function_name)
@@ -554,6 +571,7 @@ class AIEngine:
'generate_content': 'content',
'generate_image_prompts': 'image',
'generate_images': 'image',
'generate_site_structure': 'site_blueprint',
}
return mapping.get(function_name, 'unknown')

View File

@@ -6,6 +6,7 @@ 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_site_structure import GenerateSiteStructureFunction
__all__ = [
'AutoClusterFunction',
@@ -14,4 +15,5 @@ __all__ = [
'GenerateImagesFunction',
'generate_images_core',
'GenerateImagePromptsFunction',
'GenerateSiteStructureFunction',
]

View File

@@ -0,0 +1,214 @@
"""
Generate Site Structure AI Function
Phase 3 Site Builder
"""
import json
import logging
from typing import Any, Dict, List, Tuple
from django.utils.text import slugify
from igny8_core.ai.base import BaseAIFunction
from igny8_core.ai.prompts import PromptRegistry
from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint
logger = logging.getLogger(__name__)
class GenerateSiteStructureFunction(BaseAIFunction):
"""AI function that turns a business brief into a full site blueprint."""
def get_name(self) -> str:
return 'generate_site_structure'
def get_metadata(self) -> Dict:
metadata = super().get_metadata()
metadata.update({
'display_name': 'Generate Site Structure',
'description': 'Create site/page architecture from business brief, objectives, and style guides.',
'phases': {
'INIT': 'Validating blueprint data…',
'PREP': 'Preparing site context…',
'AI_CALL': 'Generating site structure with AI…',
'PARSE': 'Parsing generated blueprint…',
'SAVE': 'Saving pages and blocks…',
'DONE': 'Site structure ready!'
}
})
return metadata
def validate(self, payload: dict, account=None) -> Dict[str, Any]:
if not payload.get('ids'):
return {'valid': False, 'error': 'Site blueprint ID is required'}
return {'valid': True}
def prepare(self, payload: dict, account=None) -> Dict[str, Any]:
blueprint_ids = payload.get('ids', [])
queryset = SiteBlueprint.objects.filter(id__in=blueprint_ids)
if account:
queryset = queryset.filter(account=account)
blueprint = queryset.select_related('account', 'site').prefetch_related('pages').first()
if not blueprint:
raise ValueError("Site blueprint not found")
config = blueprint.config_json or {}
business_brief = payload.get('business_brief') or config.get('business_brief') or ''
objectives = payload.get('objectives') or config.get('objectives') or []
style = payload.get('style') or config.get('style') or {}
return {
'blueprint': blueprint,
'business_brief': business_brief,
'objectives': objectives,
'style': style,
}
def build_prompt(self, data: Dict[str, Any], account=None) -> str:
blueprint: SiteBlueprint = data['blueprint']
objectives = data.get('objectives') or []
objectives_text = '\n'.join(f"- {obj}" for obj in objectives) if isinstance(objectives, list) else objectives
style = data.get('style') or {}
style_text = json.dumps(style, indent=2) if isinstance(style, dict) and style else str(style)
existing_pages = [
{
'title': page.title,
'slug': page.slug,
'type': page.type,
'status': page.status,
}
for page in blueprint.pages.all()
]
context = {
'BUSINESS_BRIEF': data.get('business_brief', ''),
'OBJECTIVES': objectives_text or 'Create a full marketing site with clear navigation.',
'STYLE': style_text or 'Modern, responsive, accessible web design.',
'SITE_INFO': json.dumps({
'site_name': blueprint.name,
'site_description': blueprint.description,
'hosting_type': blueprint.hosting_type,
'existing_pages': existing_pages,
'existing_structure': blueprint.structure_json or {},
}, indent=2)
}
return PromptRegistry.get_prompt(
'generate_site_structure',
account=account or blueprint.account,
context=context
)
def parse_response(self, response: str, step_tracker=None) -> Dict[str, Any]:
if not response:
raise ValueError("AI response is empty")
response = response.strip()
try:
return self._ensure_dict(json.loads(response))
except json.JSONDecodeError:
logger.warning("Response not valid JSON, attempting to extract JSON object")
cleaned = self._extract_json_object(response)
if cleaned:
return self._ensure_dict(json.loads(cleaned))
raise ValueError("Unable to parse AI response into JSON")
def save_output(
self,
parsed: Dict[str, Any],
original_data: Dict[str, Any],
account=None,
progress_tracker=None,
step_tracker=None
) -> Dict[str, Any]:
blueprint: SiteBlueprint = original_data['blueprint']
structure = self._ensure_dict(parsed)
pages = structure.get('pages', [])
blueprint.structure_json = structure
blueprint.status = 'ready'
blueprint.save(update_fields=['structure_json', 'status', 'updated_at'])
created, updated, deleted = self._sync_page_blueprints(blueprint, pages)
message = f"Pages synced (created: {created}, updated: {updated}, deleted: {deleted})"
logger.info("[GenerateSiteStructure] %s for blueprint %s", message, blueprint.id)
return {
'success': True,
'count': created + updated,
'site_blueprint_id': blueprint.id,
'pages_created': created,
'pages_updated': updated,
'pages_deleted': deleted,
}
# Helpers -----------------------------------------------------------------
def _ensure_dict(self, data: Any) -> Dict[str, Any]:
if isinstance(data, dict):
return data
raise ValueError("AI response must be a JSON object with site metadata")
def _extract_json_object(self, text: str) -> str:
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
return ''
def _sync_page_blueprints(self, blueprint: SiteBlueprint, pages: List[Dict[str, Any]]) -> Tuple[int, int, int]:
existing = {page.slug: page for page in blueprint.pages.all()}
seen_slugs = set()
created = updated = 0
for order, page_data in enumerate(pages or []):
slug = page_data.get('slug') or page_data.get('id') or page_data.get('title') or f"page-{order + 1}"
slug = slugify(slug) or f"page-{order + 1}"
seen_slugs.add(slug)
defaults = {
'title': page_data.get('title') or page_data.get('name') or slug.replace('-', ' ').title(),
'type': self._map_page_type(page_data.get('type')),
'blocks_json': page_data.get('blocks') or page_data.get('sections') or [],
'status': page_data.get('status') or 'draft',
'order': order,
}
page_obj, created_flag = PageBlueprint.objects.update_or_create(
site_blueprint=blueprint,
slug=slug,
defaults=defaults
)
if created_flag:
created += 1
else:
updated += 1
# Delete pages not present in new structure
deleted = 0
for slug, page in existing.items():
if slug not in seen_slugs:
page.delete()
deleted += 1
return created, updated, deleted
def _map_page_type(self, page_type: Any) -> str:
allowed = {choice[0] for choice in PageBlueprint._meta.get_field('type').choices}
if isinstance(page_type, str):
normalized = page_type.lower()
if normalized in allowed:
return normalized
# Map friendly names
mapping = {
'homepage': 'home',
'landing': 'home',
'service': 'services',
'product': 'products',
}
mapped = mapping.get(normalized)
if mapped in allowed:
return mapped
return 'custom'

View File

@@ -238,6 +238,73 @@ OUTPUT FORMAT
Return ONLY the final JSON object.
Do NOT include any comments, formatting, or explanations.""",
'site_structure_generation': """You are a senior UX architect and information designer. Use the business brief, objectives, style references, and existing site info to propose a complete multi-page marketing website structure.
INPUT CONTEXT
==============
BUSINESS BRIEF:
[IGNY8_BUSINESS_BRIEF]
PRIMARY OBJECTIVES:
[IGNY8_OBJECTIVES]
STYLE & BRAND NOTES:
[IGNY8_STYLE]
SITE INFO / CURRENT STRUCTURE:
[IGNY8_SITE_INFO]
OUTPUT REQUIREMENTS
====================
Return ONE JSON object with the following keys:
{
"site": {
"name": "...",
"primary_navigation": ["home", "services", "about", "contact"],
"secondary_navigation": ["blog", "faq"],
"hero_message": "High level value statement",
"tone": "voice + tone summary"
},
"pages": [
{
"slug": "home",
"title": "Home",
"type": "home | about | services | products | blog | contact | custom",
"status": "draft",
"objective": "Explain the core brand promise and primary CTA",
"primary_cta": "Book a strategy call",
"seo": {
"meta_title": "...",
"meta_description": "..."
},
"blocks": [
{
"type": "hero | features | services | stats | testimonials | faq | contact | custom",
"heading": "Section headline",
"subheading": "Support copy",
"layout": "full-width | two-column | cards | carousel",
"content": [
"Bullet or short paragraph describing what to render in this block"
]
}
]
}
]
}
RULES
=====
- Include 58 pages covering the complete buyer journey (awareness → evaluation → conversion → trust).
- Every page must have at least 3 blocks with concrete guidance (no placeholders like "Lorem ipsum").
- Use consistent slug naming, all lowercase with hyphens.
- Type must match the allowed enum and reflect page intent.
- Ensure the navigation arrays align with the page list.
- Focus on practical descriptions that an engineering team can hand off directly to the Site Builder.
Return ONLY valid JSON. No commentary, explanations, or Markdown.
""",
'image_prompt_extraction': """Extract image prompts from the following article content.
@@ -275,6 +342,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi
'generate_images': 'image_prompt_extraction',
'extract_image_prompts': 'image_prompt_extraction',
'generate_image_prompts': 'image_prompt_extraction',
'generate_site_structure': 'site_structure_generation',
}
@classmethod

View File

@@ -94,9 +94,15 @@ def _load_generate_image_prompts():
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
return GenerateImagePromptsFunction
def _load_generate_site_structure():
"""Lazy loader for generate_site_structure function"""
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
return GenerateSiteStructureFunction
register_lazy_function('auto_cluster', _load_auto_cluster)
register_lazy_function('generate_ideas', _load_generate_ideas)
register_lazy_function('generate_content', _load_generate_content)
register_lazy_function('generate_images', _load_generate_images)
register_lazy_function('generate_image_prompts', _load_generate_image_prompts)
register_lazy_function('generate_site_structure', _load_generate_site_structure)

View File

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

View File

@@ -0,0 +1,9 @@
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'

View File

@@ -0,0 +1,94 @@
from django.db import migrations, models
import django.db.models.deletion
import django.core.validators
class Migration(migrations.Migration):
initial = True
dependencies = [
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
]
operations = [
migrations.CreateModel(
name='SiteBlueprint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(help_text='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)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprint_set', to='igny8_core_auth.account')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprint_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprint_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')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('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')], db_index=True, default='draft', help_text='Page status', max_length=20)),
('order', models.IntegerField(default=0, help_text='Page order in navigation')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pageblueprint_set', to='igny8_core_auth.account')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pageblueprint_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pageblueprint_set', to='igny8_core_auth.site')),
('site_blueprint', models.ForeignKey(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'],
'unique_together': {('site_blueprint', 'slug')},
},
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['status'], name='igny8_site__status_247ddc_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['hosting_type'], name='igny8_site__hosting_c4bb41_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['site', 'sector'], name='igny8_site__site_id__5f0a4e_idx'),
),
migrations.AddIndex(
model_name='siteblueprint',
index=models.Index(fields=['account', 'status'], name='igny8_site__account__38f18a_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['site_blueprint', 'status'], name='igny8_page__site_bl_1b5d8b_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['type'], name='igny8_page__type_b11552_idx'),
),
migrations.AddIndex(
model_name='pageblueprint',
index=models.Index(fields=['site_blueprint', 'order'], name='igny8_page__site_bl_7a77d7_idx'),
),
]

View File

@@ -0,0 +1,2 @@

View File

@@ -2,4 +2,12 @@
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
__all__ = [
'SiteBuilderFileService',
'StructureGenerationService',
'PageGenerationService',
]

View File

@@ -0,0 +1,149 @@
"""
Page Generation Service
Leverages the Writer ContentGenerationService to draft page copy for Site Builder blueprints.
"""
import logging
from typing import Optional
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
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()
def generate_page_content(self, page_blueprint: PageBlueprint, force_regenerate: bool = False) -> dict:
"""
Generate (or regenerate) content for a single Site Builder page.
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")
task = self._ensure_task(page_blueprint, force_regenerate=force_regenerate)
# Mark page as generating before handing off to Writer pipeline
page_blueprint.status = 'generating'
page_blueprint.save(update_fields=['status', 'updated_at'])
account = page_blueprint.account
logger.info(
"[PageGenerationService] Triggering content generation for page %s (task %s)",
page_blueprint.id,
task.id,
)
return self.content_service.generate_content([task.id], account)
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)
# 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)
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),
content_type='article',
status='queued',
)
logger.info(
"[PageGenerationService] Created writer task %s for page blueprint %s",
task.id,
page_blueprint.id,
)
return task
def _build_task_title(self, page_blueprint: PageBlueprint) -> str:
base = page_blueprint.title or page_blueprint.slug.replace('-', ' ').title()
return f"[Site Builder] {base}"
def _build_keywords_hint(self, page_blueprint: PageBlueprint) -> str:
keywords = []
if page_blueprint.blocks_json:
for block in page_blueprint.blocks_json:
heading = block.get('heading') if isinstance(block, dict) else None
if heading:
keywords.append(heading)
keywords.append(page_blueprint.slug.replace('-', ' '))
return ", ".join(dict.fromkeys(filter(None, keywords)))
def _map_content_structure(self, page_type: Optional[str]) -> str:
if not page_type:
return 'landing_page'
mapping = {
'home': 'landing_page',
'about': 'supporting_page',
'services': 'pillar_page',
'products': 'pillar_page',
'blog': 'cluster_hub',
'contact': 'supporting_page',
}
return mapping.get(page_type.lower(), 'landing_page')
def _first_block_heading(self, page_blueprint: PageBlueprint) -> Optional[str]:
if not page_blueprint.blocks_json:
return None
for block in page_blueprint.blocks_json:
if isinstance(block, dict):
heading = block.get('heading') or block.get('title')
if heading:
return heading
return None

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
from rest_framework import serializers
from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint
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(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
class Meta:
model = SiteBlueprint
fields = [
'id',
'name',
'description',
'config_json',
'structure_json',
'status',
'hosting_type',
'version',
'deployed_version',
'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

View File

@@ -0,0 +1,18 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from igny8_core.modules.site_builder.views import (
PageBlueprintViewSet,
SiteAssetView,
SiteBlueprintViewSet,
)
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'),
]

View File

@@ -0,0 +1,150 @@
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
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 SiteBlueprint, PageBlueprint
from igny8_core.business.site_building.services import (
PageGenerationService,
SiteBuilderFileService,
StructureGenerationService,
)
from igny8_core.modules.site_builder.serializers import (
PageBlueprintSerializer,
SiteBlueprintSerializer,
)
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 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.'})
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', {}),
)
return Response(result, status=status.HTTP_202_ACCEPTED if 'task_id' in result else status.HTTP_200_OK)
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)

View File

@@ -52,6 +52,8 @@ INSTALLED_APPS = [
'igny8_core.modules.system.apps.SystemConfig',
'igny8_core.modules.billing.apps.BillingConfig',
'igny8_core.modules.automation.apps.AutomationConfig',
'igny8_core.business.site_building.apps.SiteBuildingConfig',
'igny8_core.modules.site_builder.apps.SiteBuilderConfig',
]
# System module needs explicit registration for admin

View File

@@ -27,6 +27,7 @@ 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')),
path('api/v1/site-builder/', include('igny8_core.modules.site_builder.urls')),
path('api/v1/system/', include('igny8_core.modules.system.urls')),
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
path('api/v1/automation/', include('igny8_core.modules.automation.urls')), # Automation endpoints

View File

@@ -1,126 +0,0 @@
"""
Test script to verify write access to image directories
"""
import os
import sys
from pathlib import Path
# Add project to path
sys.path.insert(0, str(Path(__file__).parent))
# Test write access logic
def test_write_access():
print("=" * 60)
print("Testing Image Directory Write Access")
print("=" * 60)
# Test 1: Absolute path /data/app/images
images_dir = '/data/app/images'
write_test_passed = False
print(f"\n[Test 1] Testing absolute path: {images_dir}")
try:
os.makedirs(images_dir, exist_ok=True)
print(f" ✓ Directory created/verified: {images_dir}")
# Test write access
test_file = os.path.join(images_dir, '.write_test')
print(f" → Attempting to write test file: {test_file}")
with open(test_file, 'w') as f:
f.write('test')
print(f" ✓ Write successful")
os.remove(test_file)
print(f" ✓ Test file removed")
write_test_passed = True
print(f" ✅ SUCCESS: {images_dir} is writable")
except PermissionError as e:
print(f" ✗ PERMISSION DENIED: {e}")
print(f" → Trying fallback path...")
# Fallback to project-relative path
try:
from django.conf import settings
base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent
except:
base_dir = Path(__file__).resolve().parent
images_dir = str(base_dir / 'data' / 'app' / 'images')
print(f"\n[Test 2] Testing fallback path: {images_dir}")
try:
os.makedirs(images_dir, exist_ok=True)
print(f" ✓ Directory created/verified: {images_dir}")
# Test fallback directory write access
test_file = os.path.join(images_dir, '.write_test')
print(f" → Attempting to write test file: {test_file}")
with open(test_file, 'w') as f:
f.write('test')
print(f" ✓ Write successful")
os.remove(test_file)
print(f" ✓ Test file removed")
write_test_passed = True
print(f" ✅ SUCCESS: {images_dir} is writable")
except Exception as fallback_error:
print(f" ✗ FAILED: {fallback_error}")
print(f" ❌ ERROR: Neither /data/app/images nor {images_dir} is writable")
return False
except Exception as e:
print(f" ✗ ERROR: {e}")
print(f" → Trying fallback path...")
# Fallback to project-relative path
try:
from django.conf import settings
base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent
except:
base_dir = Path(__file__).resolve().parent
images_dir = str(base_dir / 'data' / 'app' / 'images')
print(f"\n[Test 2] Testing fallback path: {images_dir}")
try:
os.makedirs(images_dir, exist_ok=True)
print(f" ✓ Directory created/verified: {images_dir}")
# Test fallback directory write access
test_file = os.path.join(images_dir, '.write_test')
print(f" → Attempting to write test file: {test_file}")
with open(test_file, 'w') as f:
f.write('test')
print(f" ✓ Write successful")
os.remove(test_file)
print(f" ✓ Test file removed")
write_test_passed = True
print(f" ✅ SUCCESS: {images_dir} is writable")
except Exception as fallback_error:
print(f" ✗ FAILED: {fallback_error}")
print(f" ❌ ERROR: Neither /data/app/images nor {images_dir} is writable")
return False
if not write_test_passed:
print(f"\n❌ FAILED: No writable directory found")
return False
print(f"\n" + "=" * 60)
print(f"✅ FINAL RESULT: Images will be saved to: {images_dir}")
print("=" * 60)
return True
if __name__ == '__main__':
success = test_write_access()
sys.exit(0 if success else 1)

View File

@@ -1,60 +0,0 @@
#!/usr/bin/env python
"""
Script to fix and update the Free plan with proper values.
"""
import os
import django
import sys
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from igny8_core.auth.models import Plan
from decimal import Decimal
def update_free_plan():
"""Update the Free plan with proper values and fix JSON fields."""
try:
free_plan = Plan.objects.get(slug='free')
print(f"Found Free plan: {free_plan.name}")
print("Updating values...")
# Update free plan values - keeping existing values but fixing JSON fields
# Fix JSON fields - use proper Python lists/dicts
free_plan.image_model_choices = [] # Empty list for free plan (no image generation)
# Ensure features is a proper list (not dict)
if isinstance(free_plan.features, dict):
free_plan.features = []
elif not isinstance(free_plan.features, list):
free_plan.features = []
# Save the plan
free_plan.save()
print("✅ Successfully updated Free plan!")
print(f" - Image Model Choices: {free_plan.image_model_choices}")
print(f" - Features: {free_plan.features}")
print(f" - Max Sites: {free_plan.max_sites}")
print(f" - Max Users: {free_plan.max_users}")
print(f" - Max Keywords: {free_plan.max_keywords}")
print(f" - Max Clusters: {free_plan.max_clusters}")
print(f" - Max Content Ideas: {free_plan.max_content_ideas}")
print(f" - Monthly AI Credits: {free_plan.monthly_ai_credit_limit}")
print(f" - Daily AI Request Limit: {free_plan.daily_ai_request_limit}")
print(f" - Daily Image Generation Limit: {free_plan.daily_image_generation_limit}")
except Plan.DoesNotExist:
print("❌ Free plan not found!")
sys.exit(1)
except Exception as e:
print(f"❌ Error updating free plan: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
update_free_plan()