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

@@ -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})"
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.

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.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',
]

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)