refactor stage 1

This commit is contained in:
alorig
2025-11-19 19:33:26 +05:00
parent 142077ce85
commit 8b7ed02759
11 changed files with 994 additions and 0 deletions

View File

@@ -287,3 +287,171 @@ class Images(SiteSectorBaseModel):
title = content_title or task_title or 'Unknown' title = content_title or task_title or 'Unknown'
return f"{title} - {self.image_type}" return f"{title} - {self.image_type}"
class ContentClusterMap(SiteSectorBaseModel):
"""Associates generated content with planner clusters + roles."""
ROLE_CHOICES = [
('hub', 'Hub Page'),
('supporting', 'Supporting Page'),
('attribute', 'Attribute Page'),
]
SOURCE_CHOICES = [
('blueprint', 'Blueprint'),
('manual', 'Manual'),
('import', 'Import'),
]
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='cluster_mappings',
null=True,
blank=True,
)
task = models.ForeignKey(
Tasks,
on_delete=models.CASCADE,
related_name='cluster_mappings',
null=True,
blank=True,
)
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.CASCADE,
related_name='content_mappings',
)
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='hub')
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_cluster_map'
unique_together = [['content', 'cluster', 'role']]
indexes = [
models.Index(fields=['cluster', 'role']),
models.Index(fields=['content', 'role']),
models.Index(fields=['task', 'role']),
]
def save(self, *args, **kwargs):
provider = self.content or self.task
if provider:
self.account = provider.account
self.site = provider.site
self.sector = provider.sector
super().save(*args, **kwargs)
def __str__(self):
return f"{self.cluster.name} ({self.get_role_display()})"
class ContentTaxonomyMap(SiteSectorBaseModel):
"""Maps content entities to blueprint taxonomies for syncing/publishing."""
SOURCE_CHOICES = [
('blueprint', 'Blueprint'),
('manual', 'Manual'),
('import', 'Import'),
]
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='taxonomy_mappings',
null=True,
blank=True,
)
task = models.ForeignKey(
Tasks,
on_delete=models.CASCADE,
related_name='taxonomy_mappings',
null=True,
blank=True,
)
taxonomy = models.ForeignKey(
'site_building.SiteBlueprintTaxonomy',
on_delete=models.CASCADE,
related_name='content_mappings',
)
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_taxonomy_map'
unique_together = [['content', 'taxonomy']]
indexes = [
models.Index(fields=['taxonomy']),
models.Index(fields=['content', 'taxonomy']),
models.Index(fields=['task', 'taxonomy']),
]
def save(self, *args, **kwargs):
provider = self.content or self.task
if provider:
self.account = provider.account
self.site = provider.site
self.sector = provider.sector
super().save(*args, **kwargs)
def __str__(self):
return f"{self.taxonomy.name}"
class ContentAttributeMap(SiteSectorBaseModel):
"""Stores structured attribute data tied to content/task records."""
SOURCE_CHOICES = [
('blueprint', 'Blueprint'),
('manual', 'Manual'),
('import', 'Import'),
]
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='attribute_mappings',
null=True,
blank=True,
)
task = models.ForeignKey(
Tasks,
on_delete=models.CASCADE,
related_name='attribute_mappings',
null=True,
blank=True,
)
name = models.CharField(max_length=120)
value = models.CharField(max_length=255, blank=True, null=True)
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_attribute_map'
indexes = [
models.Index(fields=['name']),
models.Index(fields=['content', 'name']),
models.Index(fields=['task', 'name']),
]
def save(self, *args, **kwargs):
provider = self.content or self.task
if provider:
self.account = provider.account
self.site = provider.site
self.sector = provider.sector
super().save(*args, **kwargs)
def __str__(self):
target = self.content or self.task
return f"{target} {self.name}"

View File

@@ -5,12 +5,29 @@ from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
class Clusters(SiteSectorBaseModel): class Clusters(SiteSectorBaseModel):
"""Clusters model for keyword grouping""" """Clusters model for keyword grouping"""
CONTEXT_TYPE_CHOICES = [
('topic', 'Topic Cluster'),
('attribute', 'Attribute Cluster'),
('service_line', 'Service Line'),
]
name = models.CharField(max_length=255, unique=True, db_index=True) name = models.CharField(max_length=255, unique=True, db_index=True)
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
keywords_count = models.IntegerField(default=0) keywords_count = models.IntegerField(default=0)
volume = models.IntegerField(default=0) volume = models.IntegerField(default=0)
mapped_pages = models.IntegerField(default=0) mapped_pages = models.IntegerField(default=0)
status = models.CharField(max_length=50, default='active') status = models.CharField(max_length=50, default='active')
context_type = models.CharField(
max_length=50,
choices=CONTEXT_TYPE_CHOICES,
default='topic',
help_text="Primary dimension for this cluster (topic, attribute, service line)"
)
dimension_meta = models.JSONField(
default=dict,
blank=True,
help_text="Extended metadata (taxonomy hints, attribute suggestions, coverage targets)"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -24,6 +41,7 @@ class Clusters(SiteSectorBaseModel):
models.Index(fields=['name']), models.Index(fields=['name']),
models.Index(fields=['status']), models.Index(fields=['status']),
models.Index(fields=['site', 'sector']), models.Index(fields=['site', 'sector']),
models.Index(fields=['context_type']),
] ]
def __str__(self): def __str__(self):
@@ -61,6 +79,11 @@ class Keywords(SiteSectorBaseModel):
blank=True, blank=True,
help_text="Site-specific difficulty override (uses seed_keyword.difficulty if not set)" help_text="Site-specific difficulty override (uses seed_keyword.difficulty if not set)"
) )
attribute_values = models.JSONField(
default=list,
blank=True,
help_text="Optional attribute metadata (e.g., product specs, service modifiers)"
)
cluster = models.ForeignKey( cluster = models.ForeignKey(
'Clusters', 'Clusters',
@@ -154,6 +177,20 @@ class ContentIdeas(SiteSectorBaseModel):
('guide', 'Guide'), ('guide', 'Guide'),
('tutorial', 'Tutorial'), ('tutorial', 'Tutorial'),
] ]
SITE_ENTITY_TYPE_CHOICES = [
('page', 'Site Page'),
('blog_post', 'Blog Post'),
('product', 'Product'),
('service', 'Service'),
('taxonomy', 'Taxonomy Page'),
]
CLUSTER_ROLE_CHOICES = [
('hub', 'Hub Page'),
('supporting', 'Supporting Page'),
('attribute', 'Attribute Page'),
]
idea_title = models.CharField(max_length=255, db_index=True) idea_title = models.CharField(max_length=255, db_index=True)
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
@@ -174,8 +211,28 @@ class ContentIdeas(SiteSectorBaseModel):
related_name='ideas', related_name='ideas',
limit_choices_to={'sector': models.F('sector')} limit_choices_to={'sector': models.F('sector')}
) )
taxonomy = models.ForeignKey(
'site_building.SiteBlueprintTaxonomy',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='content_ideas',
help_text="Optional taxonomy association when derived from blueprint planning"
)
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new') status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
estimated_word_count = models.IntegerField(default=1000) estimated_word_count = models.IntegerField(default=1000)
site_entity_type = models.CharField(
max_length=50,
choices=SITE_ENTITY_TYPE_CHOICES,
default='page',
help_text="Target entity type when promoting idea into tasks/pages"
)
cluster_role = models.CharField(
max_length=50,
choices=CLUSTER_ROLE_CHOICES,
default='hub',
help_text="Role within the cluster-driven sitemap"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -190,6 +247,8 @@ class ContentIdeas(SiteSectorBaseModel):
models.Index(fields=['status']), models.Index(fields=['status']),
models.Index(fields=['keyword_cluster']), models.Index(fields=['keyword_cluster']),
models.Index(fields=['content_structure']), models.Index(fields=['content_structure']),
models.Index(fields=['site_entity_type']),
models.Index(fields=['cluster_role']),
models.Index(fields=['site', 'sector']), models.Index(fields=['site', 'sector']),
] ]

View File

@@ -0,0 +1,118 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('planner', '0008_stage1_site_builder_fields'),
('site_building', '0002_sitebuilder_metadata'),
]
operations = [
migrations.CreateModel(
name='SiteBlueprintCluster',
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)),
('role', models.CharField(choices=[('hub', 'Hub Page'), ('supporting', 'Supporting Page'), ('attribute', 'Attribute Page')], default='hub', max_length=50)),
('coverage_status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('complete', 'Complete')], default='pending', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional coverage metadata (target pages, keyword counts, ai hints)')),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprintcluster_set', to='igny8_core_auth.account')),
('cluster', models.ForeignKey(help_text='Planner cluster being mapped into the site blueprint', on_delete=django.db.models.deletion.CASCADE, related_name='blueprint_links', to='planner.clusters')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprintcluster_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprintcluster_set', to='igny8_core_auth.site')),
('site_blueprint', models.ForeignKey(help_text='Site blueprint that is planning coverage for the cluster', on_delete=django.db.models.deletion.CASCADE, related_name='cluster_links', to='site_building.siteblueprint')),
],
options={
'verbose_name': 'Site Blueprint Cluster',
'verbose_name_plural': 'Site Blueprint Clusters',
'db_table': 'igny8_site_blueprint_clusters',
'ordering': ['-created_at'],
'unique_together': {('site_blueprint', 'cluster', 'role')},
},
),
migrations.CreateModel(
name='WorkflowState',
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)),
('current_step', models.CharField(default='business_details', max_length=50)),
('step_status', models.JSONField(blank=True, default=dict, help_text='Dictionary of step → status/progress metadata')),
('blocking_reason', models.TextField(blank=True, help_text='Human-readable explanation when blocked', null=True)),
('completed', models.BooleanField(default=False, help_text='Marks wizard completion')),
('metadata', models.JSONField(blank=True, default=dict)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='workflowstate_set', to='igny8_core_auth.account')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflowstate_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflowstate_set', to='igny8_core_auth.site')),
('site_blueprint', models.OneToOneField(help_text='Blueprint whose progress is being tracked', on_delete=django.db.models.deletion.CASCADE, related_name='workflow_state', to='site_building.siteblueprint')),
],
options={
'verbose_name': 'Workflow State',
'verbose_name_plural': 'Workflow States',
'db_table': 'igny8_site_blueprint_workflow_states',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SiteBlueprintTaxonomy',
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='Display name', max_length=255)),
('slug', models.SlugField(help_text='Slug/identifier within the site blueprint', max_length=255)),
('taxonomy_type', models.CharField(choices=[('blog_category', 'Blog Category'), ('blog_tag', 'Blog Tag'), ('product_category', 'Product Category'), ('product_tag', 'Product Tag'), ('product_attribute', 'Product Attribute'), ('service_category', 'Service Category')], default='blog_category', max_length=50)),
('description', models.TextField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional taxonomy metadata or AI hints')),
('external_reference', models.CharField(blank=True, help_text='External system ID (WordPress/WooCommerce/etc.)', max_length=255, null=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprinttaxonomy_set', to='igny8_core_auth.account')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprinttaxonomy_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='siteblueprinttaxonomy_set', to='igny8_core_auth.site')),
('site_blueprint', models.ForeignKey(help_text='Site blueprint this taxonomy belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='taxonomies', to='site_building.siteblueprint')),
('clusters', models.ManyToManyField(blank=True, help_text='Planner clusters that this taxonomy maps to', related_name='blueprint_taxonomies', to='planner.clusters')),
],
options={
'verbose_name': 'Site Blueprint Taxonomy',
'verbose_name_plural': 'Site Blueprint Taxonomies',
'db_table': 'igny8_site_blueprint_taxonomies',
'ordering': ['-created_at'],
'unique_together': {('site_blueprint', 'slug')},
},
),
migrations.AddIndex(
model_name='siteblueprintcluster',
index=models.Index(fields=['site_blueprint', 'cluster'], name='site_buildi_site_bl_4234c0_idx'),
),
migrations.AddIndex(
model_name='siteblueprintcluster',
index=models.Index(fields=['cluster', 'role'], name='site_buildi_cluster__9a078f_idx'),
),
migrations.AddIndex(
model_name='siteblueprintcluster',
index=models.Index(fields=['site_blueprint', 'coverage_status'], name='site_buildi_site_bl_459d80_idx'),
),
migrations.AddIndex(
model_name='workflowstate',
index=models.Index(fields=['site_blueprint'], name='site_buildi_site_bl_312cd0_idx'),
),
migrations.AddIndex(
model_name='workflowstate',
index=models.Index(fields=['current_step'], name='site_buildi_current_a25dce_idx'),
),
migrations.AddIndex(
model_name='workflowstate',
index=models.Index(fields=['completed'], name='site_buildi_complet_4649af_idx'),
),
migrations.AddIndex(
model_name='siteblueprinttaxonomy',
index=models.Index(fields=['site_blueprint', 'taxonomy_type'], name='site_buildi_site_bl_33fadc_idx'),
),
migrations.AddIndex(
model_name='siteblueprinttaxonomy',
index=models.Index(fields=['taxonomy_type'], name='site_buildi_taxonom_4a7dde_idx'),
),
]

View File

@@ -167,6 +167,178 @@ class PageBlueprint(SiteSectorBaseModel):
return f"{self.title} ({self.site_blueprint.name})" return f"{self.title} ({self.site_blueprint.name})"
class SiteBlueprintCluster(SiteSectorBaseModel):
"""
Mapping table that connects planner clusters to site blueprints
with role/coverage metadata.
"""
ROLE_CHOICES = [
('hub', 'Hub Page'),
('supporting', 'Supporting Page'),
('attribute', 'Attribute Page'),
]
COVERAGE_CHOICES = [
('pending', 'Pending'),
('in_progress', 'In Progress'),
('complete', 'Complete'),
]
site_blueprint = models.ForeignKey(
SiteBlueprint,
on_delete=models.CASCADE,
related_name='cluster_links',
help_text="Site blueprint that is planning coverage for the cluster",
)
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.CASCADE,
related_name='blueprint_links',
help_text="Planner cluster being mapped into the site blueprint",
)
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='hub')
coverage_status = models.CharField(max_length=50, choices=COVERAGE_CHOICES, default='pending')
metadata = models.JSONField(
default=dict,
blank=True,
help_text="Additional coverage metadata (target pages, keyword counts, ai hints)",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'site_building'
db_table = 'igny8_site_blueprint_clusters'
unique_together = [['site_blueprint', 'cluster', 'role']]
verbose_name = 'Site Blueprint Cluster'
verbose_name_plural = 'Site Blueprint Clusters'
indexes = [
models.Index(fields=['site_blueprint', 'cluster']),
models.Index(fields=['site_blueprint', 'coverage_status']),
models.Index(fields=['cluster', 'role']),
]
def save(self, *args, **kwargs):
if self.site_blueprint:
self.account = self.site_blueprint.account
self.site = self.site_blueprint.site
self.sector = self.site_blueprint.sector
super().save(*args, **kwargs)
def __str__(self):
return f"{self.site_blueprint.name}{self.cluster.name}"
class SiteBlueprintTaxonomy(SiteSectorBaseModel):
"""
Taxonomy blueprint entity for categories/tags/product attributes tied to clusters.
"""
TAXONOMY_TYPE_CHOICES = [
('blog_category', 'Blog Category'),
('blog_tag', 'Blog Tag'),
('product_category', 'Product Category'),
('product_tag', 'Product Tag'),
('product_attribute', 'Product Attribute'),
('service_category', 'Service Category'),
]
site_blueprint = models.ForeignKey(
SiteBlueprint,
on_delete=models.CASCADE,
related_name='taxonomies',
help_text="Site blueprint this taxonomy belongs to",
)
name = models.CharField(max_length=255, help_text="Display name")
slug = models.SlugField(max_length=255, help_text="Slug/identifier within the site blueprint")
taxonomy_type = models.CharField(max_length=50, choices=TAXONOMY_TYPE_CHOICES, default='blog_category')
description = models.TextField(blank=True, null=True)
metadata = models.JSONField(default=dict, blank=True, help_text="Additional taxonomy metadata or AI hints")
clusters = models.ManyToManyField(
'planner.Clusters',
blank=True,
related_name='blueprint_taxonomies',
help_text="Planner clusters that this taxonomy maps to",
)
external_reference = models.CharField(
max_length=255,
blank=True,
null=True,
help_text="External system ID (WordPress/WooCommerce/etc.)",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'site_building'
db_table = 'igny8_site_blueprint_taxonomies'
unique_together = [['site_blueprint', 'slug']]
verbose_name = 'Site Blueprint Taxonomy'
verbose_name_plural = 'Site Blueprint Taxonomies'
indexes = [
models.Index(fields=['site_blueprint', 'taxonomy_type']),
models.Index(fields=['taxonomy_type']),
]
def save(self, *args, **kwargs):
if self.site_blueprint:
self.account = self.site_blueprint.account
self.site = self.site_blueprint.site
self.sector = self.site_blueprint.sector
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name} ({self.get_taxonomy_type_display()})"
class WorkflowState(SiteSectorBaseModel):
"""
Persists wizard progress + gating data for each site blueprint.
"""
DEFAULT_STEP = 'business_details'
site_blueprint = models.OneToOneField(
SiteBlueprint,
on_delete=models.CASCADE,
related_name='workflow_state',
help_text="Blueprint whose progress is being tracked",
)
current_step = models.CharField(max_length=50, default=DEFAULT_STEP)
step_status = models.JSONField(
default=dict,
blank=True,
help_text="Dictionary of step → status/progress metadata",
)
blocking_reason = models.TextField(blank=True, null=True, help_text="Human-readable explanation when blocked")
completed = models.BooleanField(default=False, help_text="Marks wizard completion")
metadata = models.JSONField(default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'site_building'
db_table = 'igny8_site_blueprint_workflow_states'
verbose_name = 'Workflow State'
verbose_name_plural = 'Workflow States'
indexes = [
models.Index(fields=['site_blueprint']),
models.Index(fields=['current_step']),
models.Index(fields=['completed']),
]
def save(self, *args, **kwargs):
if self.site_blueprint:
self.account = self.site_blueprint.account
self.site = self.site_blueprint.site
self.sector = self.site_blueprint.sector
super().save(*args, **kwargs)
def __str__(self):
return f"Workflow for {self.site_blueprint.name} ({self.current_step})"
class SiteBuilderOption(models.Model): class SiteBuilderOption(models.Model):
""" """
Base model for Site Builder dropdown metadata. Base model for Site Builder dropdown metadata.

View File

@@ -5,9 +5,13 @@ Site Building Services
from igny8_core.business.site_building.services.file_management_service import SiteBuilderFileService 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.structure_generation_service import StructureGenerationService
from igny8_core.business.site_building.services.page_generation_service import PageGenerationService from igny8_core.business.site_building.services.page_generation_service import PageGenerationService
from igny8_core.business.site_building.services.workflow_state_service import WorkflowStateService
from igny8_core.business.site_building.services.taxonomy_service import TaxonomyService
__all__ = [ __all__ = [
'SiteBuilderFileService', 'SiteBuilderFileService',
'StructureGenerationService', 'StructureGenerationService',
'PageGenerationService', 'PageGenerationService',
'WorkflowStateService',
'TaxonomyService',
] ]

View File

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

View File

@@ -0,0 +1,41 @@
"""
Reusable validation helpers for the site builder workflow.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from igny8_core.business.site_building.models import SiteBlueprint
def ensure_clusters_attached(site_blueprint: SiteBlueprint) -> bool:
if not site_blueprint.cluster_links.exists():
raise ValidationError("Attach at least one planner cluster before proceeding.")
return True
def ensure_taxonomies_defined(site_blueprint: SiteBlueprint) -> bool:
if not site_blueprint.taxonomies.exists():
raise ValidationError("Define or import at least one taxonomy to continue.")
return True
def ensure_sitemap_ready(site_blueprint: SiteBlueprint) -> bool:
if not site_blueprint.pages.exists():
raise ValidationError("Generate the AI sitemap before reviewing this step.")
return True
def ensure_coverage_ready(site_blueprint: SiteBlueprint) -> bool:
incomplete = site_blueprint.cluster_links.exclude(coverage_status='complete').exists()
if incomplete:
raise ValidationError("Complete coverage for all attached clusters.")
return True
def ensure_ideas_ready(site_blueprint: SiteBlueprint) -> bool:
if not site_blueprint.cluster_links.exists() or not site_blueprint.pages.exists():
raise ValidationError("Attach clusters and generate pages before sending ideas.")
return True

View File

@@ -0,0 +1,118 @@
"""
Workflow State Service
Manages wizard progress + gating checks for site blueprints.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
from django.conf import settings
from django.core.exceptions import ValidationError
from igny8_core.business.site_building.models import SiteBlueprint, WorkflowState
from igny8_core.business.site_building.services import validators
logger = logging.getLogger(__name__)
DEFAULT_STEPS: List[str] = [
'business_details',
'clusters',
'taxonomies',
'sitemap',
'coverage',
'ideas',
]
STEP_VALIDATORS = {
'clusters': validators.ensure_clusters_attached,
'taxonomies': validators.ensure_taxonomies_defined,
'sitemap': validators.ensure_sitemap_ready,
'coverage': validators.ensure_coverage_ready,
'ideas': validators.ensure_ideas_ready,
}
class WorkflowStateService:
"""Centralizes workflow persistence + validation logic."""
def __init__(self):
self.enabled = getattr(settings, 'USE_SITE_BUILDER_REFACTOR', False)
def initialize(self, site_blueprint: SiteBlueprint) -> Optional[WorkflowState]:
if not self.enabled or not site_blueprint:
return None
state, _ = WorkflowState.objects.get_or_create(
site_blueprint=site_blueprint,
defaults={
'current_step': WorkflowState.DEFAULT_STEP,
'step_status': {},
},
)
return state
def refresh_state(self, site_blueprint: SiteBlueprint) -> Optional[WorkflowState]:
"""Re-run validators to keep the state snapshot fresh."""
state = self.initialize(site_blueprint)
if not state:
return None
step_status: Dict[str, Dict[str, str]] = state.step_status or {}
blocking_reason = None
for step in DEFAULT_STEPS:
validator = STEP_VALIDATORS.get(step)
try:
if validator:
validator(site_blueprint)
step_status[step] = {'status': 'ready'}
except ValidationError as exc:
message = str(exc)
step_status[step] = {'status': 'blocked', 'message': message}
if not blocking_reason:
blocking_reason = message
state.step_status = step_status
state.blocking_reason = blocking_reason
state.completed = all(value.get('status') == 'ready' for value in step_status.values())
state.save(update_fields=['step_status', 'blocking_reason', 'completed', 'updated_at'])
return state
def update_step(
self,
site_blueprint: SiteBlueprint,
step: str,
status: str,
metadata: Optional[Dict[str, str]] = None,
) -> Optional[WorkflowState]:
"""Persist explicit step updates coming from the wizard."""
state = self.initialize(site_blueprint)
if not state:
return None
metadata = metadata or {}
step_status = state.step_status or {}
step_status[step] = {'status': status, **metadata}
if step in DEFAULT_STEPS:
state.current_step = step
state.step_status = step_status
state.blocking_reason = metadata.get('message')
state.completed = all(value.get('status') == 'ready' for value in step_status.values())
state.save(update_fields=['current_step', 'step_status', 'blocking_reason', 'completed', 'updated_at'])
logger.debug("Workflow step updated: blueprint=%s step=%s status=%s", site_blueprint.id, step, status)
return state
def validate_step(self, site_blueprint: SiteBlueprint, step: str) -> None:
"""Run validator for a single step (raises ValidationError when blocked)."""
if not self.enabled:
return
validator = STEP_VALIDATORS.get(step)
if not validator:
return
validator(site_blueprint)

View File

@@ -0,0 +1,67 @@
#
# Generated by Django 5.2.8 on 2025-11-19
from django.db import migrations, models
import django.db.models.deletion
def default_json():
return {}
def default_list():
return []
class Migration(migrations.Migration):
dependencies = [
('site_building', '0002_sitebuilder_metadata'),
('planner', '0007_merge_20251109_2138'),
]
operations = [
migrations.AddField(
model_name='clusters',
name='context_type',
field=models.CharField(choices=[('topic', 'Topic Cluster'), ('attribute', 'Attribute Cluster'), ('service_line', 'Service Line')], default='topic', help_text='Primary dimension for this cluster (topic, attribute, service line)', max_length=50),
),
migrations.AddField(
model_name='clusters',
name='dimension_meta',
field=models.JSONField(blank=True, default=default_json, help_text='Extended metadata (taxonomy hints, attribute suggestions, coverage targets)'),
),
migrations.AddField(
model_name='contentideas',
name='cluster_role',
field=models.CharField(choices=[('hub', 'Hub Page'), ('supporting', 'Supporting Page'), ('attribute', 'Attribute Page')], default='hub', help_text='Role within the cluster-driven sitemap', max_length=50),
),
migrations.AddField(
model_name='contentideas',
name='site_entity_type',
field=models.CharField(choices=[('page', 'Site Page'), ('blog_post', 'Blog Post'), ('product', 'Product'), ('service', 'Service'), ('taxonomy', 'Taxonomy Page')], default='page', help_text='Target entity type when promoting idea into tasks/pages', max_length=50),
),
migrations.AddField(
model_name='contentideas',
name='taxonomy',
field=models.ForeignKey(blank=True, help_text='Optional taxonomy association when derived from blueprint planning', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='content_ideas', to='site_building.siteblueprinttaxonomy'),
),
migrations.AddField(
model_name='keywords',
name='attribute_values',
field=models.JSONField(blank=True, default=default_list, help_text='Optional attribute metadata (e.g., product specs, service modifiers)'),
),
migrations.AddIndex(
model_name='clusters',
index=models.Index(fields=['context_type'], name='planner_cl_context__2ed54f_idx'),
),
migrations.AddIndex(
model_name='contentideas',
index=models.Index(fields=['site_entity_type'], name='planner_co_site_ent_d3183c_idx'),
),
migrations.AddIndex(
model_name='contentideas',
index=models.Index(fields=['cluster_role'], name='planner_co_cluster__f97a65_idx'),
),
]

View File

@@ -0,0 +1,121 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('site_building', '0003_workflow_and_taxonomies'),
('planner', '0008_stage1_site_builder_fields'),
('writer', '0011_add_universal_content_types'),
]
operations = [
migrations.CreateModel(
name='ContentClusterMap',
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)),
('role', models.CharField(choices=[('hub', 'Hub Page'), ('supporting', 'Supporting Page'), ('attribute', 'Attribute Page')], default='hub', max_length=50)),
('source', models.CharField(choices=[('blueprint', 'Blueprint'), ('manual', 'Manual'), ('import', 'Import')], default='blueprint', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='contentclustermap_set', to='igny8_core_auth.account')),
('cluster', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_mappings', to='planner.clusters')),
('content', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cluster_mappings', to='writer.content')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contentclustermap_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contentclustermap_set', to='igny8_core_auth.site')),
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cluster_mappings', to='writer.tasks')),
],
options={
'verbose_name': 'Content Cluster Map',
'verbose_name_plural': 'Content Cluster Maps',
'db_table': 'igny8_content_cluster_map',
'ordering': ['-created_at'],
'unique_together': {('content', 'cluster', 'role')},
},
),
migrations.CreateModel(
name='ContentTaxonomyMap',
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)),
('source', models.CharField(choices=[('blueprint', 'Blueprint'), ('manual', 'Manual'), ('import', 'Import')], default='blueprint', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='contenttaxonomymap_set', to='igny8_core_auth.account')),
('content', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='taxonomy_mappings', to='writer.content')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contenttaxonomymap_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contenttaxonomymap_set', to='igny8_core_auth.site')),
('taxonomy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_mappings', to='site_building.siteblueprinttaxonomy')),
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='taxonomy_mappings', to='writer.tasks')),
],
options={
'verbose_name': 'Content Taxonomy Map',
'verbose_name_plural': 'Content Taxonomy Maps',
'db_table': 'igny8_content_taxonomy_map',
'ordering': ['-created_at'],
'unique_together': {('content', 'taxonomy')},
},
),
migrations.CreateModel(
name='ContentAttributeMap',
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(max_length=120)),
('value', models.CharField(blank=True, max_length=255, null=True)),
('source', models.CharField(choices=[('blueprint', 'Blueprint'), ('manual', 'Manual'), ('import', 'Import')], default='blueprint', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='contentattributemap_set', to='igny8_core_auth.account')),
('content', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attribute_mappings', to='writer.content')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contentattributemap_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contentattributemap_set', to='igny8_core_auth.site')),
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attribute_mappings', to='writer.tasks')),
],
options={
'verbose_name': 'Content Attribute Map',
'verbose_name_plural': 'Content Attribute Maps',
'db_table': 'igny8_content_attribute_map',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='contentclustermap',
index=models.Index(fields=['cluster', 'role'], name='writer_con_cluster__d06bd6_idx'),
),
migrations.AddIndex(
model_name='contentclustermap',
index=models.Index(fields=['content', 'role'], name='writer_con_content__bb02dd_idx'),
),
migrations.AddIndex(
model_name='contentclustermap',
index=models.Index(fields=['task', 'role'], name='writer_con_task__role_828ce1_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomymap',
index=models.Index(fields=['taxonomy'], name='writer_con_taxonomy_d55410_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomymap',
index=models.Index(fields=['content', 'taxonomy'], name='writer_con_content__0af6a6_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomymap',
index=models.Index(fields=['task', 'taxonomy'], name='writer_con_task__taxon_e3bdad_idx'),
),
migrations.AddIndex(
model_name='contentattributemap',
index=models.Index(fields=['name'], name='writer_con_name_a9671a_idx'),
),
migrations.AddIndex(
model_name='contentattributemap',
index=models.Index(fields=['content', 'name'], name='writer_con_content__34a91e_idx'),
),
migrations.AddIndex(
model_name='contentattributemap',
index=models.Index(fields=['task', 'name'], name='writer_con_task__name_fa4a4e_idx'),
),
]

View File

@@ -21,6 +21,7 @@ DEBUG = os.getenv('DEBUG', 'False').lower() == 'true'
# Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=True to enable unified exception handler # Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=True to enable unified exception handler
# Set IGNY8_DEBUG_THROTTLE=True to bypass rate limiting in development # Set IGNY8_DEBUG_THROTTLE=True to bypass rate limiting in development
IGNY8_DEBUG_THROTTLE = os.getenv('IGNY8_DEBUG_THROTTLE', str(DEBUG)).lower() == 'true' IGNY8_DEBUG_THROTTLE = os.getenv('IGNY8_DEBUG_THROTTLE', str(DEBUG)).lower() == 'true'
USE_SITE_BUILDER_REFACTOR = os.getenv('USE_SITE_BUILDER_REFACTOR', 'false').lower() == 'true'
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
'*', # Allow all hosts for flexibility '*', # Allow all hosts for flexibility