diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py index 3ddb088c..258252af 100644 --- a/backend/igny8_core/business/content/models.py +++ b/backend/igny8_core/business/content/models.py @@ -287,3 +287,171 @@ class Images(SiteSectorBaseModel): title = content_title or task_title or 'Unknown' 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}" diff --git a/backend/igny8_core/business/planning/models.py b/backend/igny8_core/business/planning/models.py index 25b35b62..dbf6e919 100644 --- a/backend/igny8_core/business/planning/models.py +++ b/backend/igny8_core/business/planning/models.py @@ -5,12 +5,29 @@ from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword class Clusters(SiteSectorBaseModel): """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) description = models.TextField(blank=True, null=True) keywords_count = models.IntegerField(default=0) volume = models.IntegerField(default=0) mapped_pages = models.IntegerField(default=0) 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) updated_at = models.DateTimeField(auto_now=True) @@ -24,6 +41,7 @@ class Clusters(SiteSectorBaseModel): models.Index(fields=['name']), models.Index(fields=['status']), models.Index(fields=['site', 'sector']), + models.Index(fields=['context_type']), ] def __str__(self): @@ -61,6 +79,11 @@ class Keywords(SiteSectorBaseModel): blank=True, 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( 'Clusters', @@ -154,6 +177,20 @@ class ContentIdeas(SiteSectorBaseModel): ('guide', 'Guide'), ('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) description = models.TextField(blank=True, null=True) @@ -174,8 +211,28 @@ class ContentIdeas(SiteSectorBaseModel): related_name='ideas', 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') 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) updated_at = models.DateTimeField(auto_now=True) @@ -190,6 +247,8 @@ class ContentIdeas(SiteSectorBaseModel): models.Index(fields=['status']), models.Index(fields=['keyword_cluster']), models.Index(fields=['content_structure']), + models.Index(fields=['site_entity_type']), + models.Index(fields=['cluster_role']), models.Index(fields=['site', 'sector']), ] diff --git a/backend/igny8_core/business/site_building/migrations/0003_workflow_and_taxonomies.py b/backend/igny8_core/business/site_building/migrations/0003_workflow_and_taxonomies.py new file mode 100644 index 00000000..5d178df5 --- /dev/null +++ b/backend/igny8_core/business/site_building/migrations/0003_workflow_and_taxonomies.py @@ -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'), + ), + ] + diff --git a/backend/igny8_core/business/site_building/models.py b/backend/igny8_core/business/site_building/models.py index 5fb9dd93..217cfbd5 100644 --- a/backend/igny8_core/business/site_building/models.py +++ b/backend/igny8_core/business/site_building/models.py @@ -167,6 +167,178 @@ class PageBlueprint(SiteSectorBaseModel): 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): """ Base model for Site Builder dropdown metadata. diff --git a/backend/igny8_core/business/site_building/services/__init__.py b/backend/igny8_core/business/site_building/services/__init__.py index 75204e0a..3a57abc9 100644 --- a/backend/igny8_core/business/site_building/services/__init__.py +++ b/backend/igny8_core/business/site_building/services/__init__.py @@ -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.structure_generation_service import StructureGenerationService 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__ = [ 'SiteBuilderFileService', 'StructureGenerationService', 'PageGenerationService', + 'WorkflowStateService', + 'TaxonomyService', ] diff --git a/backend/igny8_core/business/site_building/services/taxonomy_service.py b/backend/igny8_core/business/site_building/services/taxonomy_service.py new file mode 100644 index 00000000..2eed385f --- /dev/null +++ b/backend/igny8_core/business/site_building/services/taxonomy_service.py @@ -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 + diff --git a/backend/igny8_core/business/site_building/services/validators.py b/backend/igny8_core/business/site_building/services/validators.py new file mode 100644 index 00000000..e21e2823 --- /dev/null +++ b/backend/igny8_core/business/site_building/services/validators.py @@ -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 + + diff --git a/backend/igny8_core/business/site_building/services/workflow_state_service.py b/backend/igny8_core/business/site_building/services/workflow_state_service.py new file mode 100644 index 00000000..dc77bd25 --- /dev/null +++ b/backend/igny8_core/business/site_building/services/workflow_state_service.py @@ -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) + diff --git a/backend/igny8_core/modules/planner/migrations/0008_stage1_site_builder_fields.py b/backend/igny8_core/modules/planner/migrations/0008_stage1_site_builder_fields.py new file mode 100644 index 00000000..65910440 --- /dev/null +++ b/backend/igny8_core/modules/planner/migrations/0008_stage1_site_builder_fields.py @@ -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'), + ), + ] + diff --git a/backend/igny8_core/modules/writer/migrations/0012_metadata_mapping_tables.py b/backend/igny8_core/modules/writer/migrations/0012_metadata_mapping_tables.py new file mode 100644 index 00000000..22faca5c --- /dev/null +++ b/backend/igny8_core/modules/writer/migrations/0012_metadata_mapping_tables.py @@ -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'), + ), + ] + diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index cf5983cd..55fea18e 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -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_DEBUG_THROTTLE=True to bypass rate limiting in development 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 = [ '*', # Allow all hosts for flexibility