refactor stage 1
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user