8 Phases refactor
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user