8 Phases refactor

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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