stage 2-1

This commit is contained in:
alorig
2025-11-19 21:07:08 +05:00
parent 4ca85ae0e5
commit 72e1f25bc7
5 changed files with 269 additions and 13 deletions

View File

@@ -7,6 +7,7 @@ from igny8_core.business.site_building.services.structure_generation_service imp
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
from igny8_core.business.site_building.services.wizard_context_service import WizardContextService
__all__ = [
'SiteBuilderFileService',
@@ -14,4 +15,5 @@ __all__ = [
'PageGenerationService',
'WorkflowStateService',
'TaxonomyService',
'WizardContextService',
]

View File

@@ -0,0 +1,136 @@
"""
Wizard Context Service
Provides aggregated data for the site builder wizard UI.
"""
from __future__ import annotations
from collections import Counter
from typing import Dict, List, Optional
from django.db.models import Prefetch
from igny8_core.business.planning.models import Clusters
from igny8_core.business.site_building.models import (
PageBlueprint,
SiteBlueprint,
SiteBlueprintCluster,
SiteBlueprintTaxonomy,
)
from igny8_core.business.site_building.services.workflow_state_service import WorkflowStateService
class WizardContextService:
"""Builds blueprint-centric context for the guided wizard experience."""
def __init__(self):
self.workflow_service = WorkflowStateService()
def build_context(self, site_blueprint: SiteBlueprint) -> Dict[str, object]:
if not site_blueprint:
return {}
workflow_state = None
if self.workflow_service.enabled:
workflow_state = self.workflow_service.refresh_state(site_blueprint)
workflow_payload = self.workflow_service.serialize_state(workflow_state) if workflow_state else None
context = {
'workflow': workflow_payload,
'clusters': self._cluster_summary(site_blueprint),
'taxonomies': self._taxonomy_summary(site_blueprint),
'coverage': self._coverage_summary(site_blueprint),
}
context['next_actions'] = self._next_actions(workflow_payload)
return context
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _cluster_summary(self, site_blueprint: SiteBlueprint) -> Dict[str, object]:
cluster_links: List[SiteBlueprintCluster] = list(
site_blueprint.cluster_links.select_related('cluster')
)
coverage_counts = Counter(link.coverage_status for link in cluster_links)
clusters_payload = []
for link in cluster_links:
cluster = link.cluster
dimension_meta = cluster.dimension_meta or {}
clusters_payload.append({
'id': cluster.id,
'name': cluster.name,
'context_type': cluster.context_type,
'dimension_meta': dimension_meta,
'keyword_count': cluster.keywords_count,
'volume': cluster.volume,
'coverage_status': link.coverage_status,
'role': link.role,
'metadata': link.metadata or {},
'suggested_taxonomies': dimension_meta.get('suggested_taxonomies', []),
'attribute_hints': dimension_meta.get('attributes', []),
})
return {
'attached_count': len(cluster_links),
'coverage_counts': dict(coverage_counts),
'clusters': clusters_payload,
}
def _taxonomy_summary(self, site_blueprint: SiteBlueprint) -> Dict[str, object]:
taxonomies: List[SiteBlueprintTaxonomy] = list(
site_blueprint.taxonomies.prefetch_related(
Prefetch('clusters', queryset=Clusters.objects.only('id'))
)
)
counts_by_type = Counter(taxonomy.taxonomy_type for taxonomy in taxonomies)
taxonomy_payload = []
for taxonomy in taxonomies:
taxonomy_payload.append({
'id': taxonomy.id,
'name': taxonomy.name,
'slug': taxonomy.slug,
'taxonomy_type': taxonomy.taxonomy_type,
'description': taxonomy.description,
'cluster_ids': [cluster.id for cluster in taxonomy.clusters.all()],
'metadata': taxonomy.metadata or {},
'external_reference': taxonomy.external_reference,
})
return {
'total_taxonomies': len(taxonomies),
'counts_by_type': dict(counts_by_type),
'taxonomies': taxonomy_payload,
}
def _coverage_summary(self, site_blueprint: SiteBlueprint) -> Dict[str, object]:
pages: List[PageBlueprint] = list(site_blueprint.pages.all())
per_status = Counter(page.status for page in pages)
per_type = Counter(page.type for page in pages)
return {
'pages_total': len(pages),
'pages_by_status': dict(per_status),
'pages_by_type': dict(per_type),
}
def _next_actions(self, workflow_payload: Optional[Dict[str, object]]) -> Optional[Dict[str, object]]:
if not workflow_payload:
return None
for step in workflow_payload.get('steps', []):
if step.get('status') != 'ready':
return {
'step': step.get('step'),
'status': step.get('status'),
'message': step.get('message'),
'code': step.get('code'),
}
return {
'step': None,
'status': 'ready',
'message': None,
'code': None,
}

View File

@@ -9,6 +9,7 @@ from typing import Dict, List, Optional
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
from igny8_core.business.site_building.models import SiteBlueprint, WorkflowState
from igny8_core.business.site_building.services import validators
@@ -32,6 +33,15 @@ STEP_VALIDATORS = {
'ideas': validators.ensure_ideas_ready,
}
STEP_CODES = {
'business_details': 'missing_business_details',
'clusters': 'missing_clusters',
'taxonomies': 'missing_taxonomies',
'sitemap': 'sitemap_not_generated',
'coverage': 'coverage_incomplete',
'ideas': 'ideas_not_ready',
}
class WorkflowStateService:
"""Centralizes workflow persistence + validation logic."""
@@ -57,7 +67,7 @@ class WorkflowStateService:
state = self.initialize(site_blueprint)
if not state:
return None
timestamp = timezone.now().isoformat()
step_status: Dict[str, Dict[str, str]] = state.step_status or {}
blocking_reason = None
@@ -66,12 +76,26 @@ class WorkflowStateService:
try:
if validator:
validator(site_blueprint)
step_status[step] = {'status': 'ready'}
step_status[step] = self._build_step_entry(
step=step,
status='ready',
message=None,
timestamp=timestamp,
)
except ValidationError as exc:
message = str(exc)
step_status[step] = {'status': 'blocked', 'message': message}
step_status[step] = self._build_step_entry(
step=step,
status='blocked',
message=message,
timestamp=timestamp,
)
if not blocking_reason:
blocking_reason = message
self._emit_event(site_blueprint, 'wizard_blocking_issue', {
'step': step,
'message': message,
})
state.step_status = step_status
state.blocking_reason = blocking_reason
@@ -92,8 +116,16 @@ class WorkflowStateService:
return None
metadata = metadata or {}
timestamp = timezone.now().isoformat()
step_status = state.step_status or {}
step_status[step] = {'status': status, **metadata}
entry = self._build_step_entry(
step=step,
status=status,
message=metadata.get('message'),
timestamp=timestamp,
)
entry.update({k: v for k, v in metadata.items() if k not in entry})
step_status[step] = entry
if step in DEFAULT_STEPS:
state.current_step = step
@@ -102,7 +134,10 @@ class WorkflowStateService:
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)
self._emit_event(site_blueprint, 'wizard_step_updated', {
'step': step,
'status': status,
})
return state
def validate_step(self, site_blueprint: SiteBlueprint, step: str) -> None:
@@ -116,3 +151,55 @@ class WorkflowStateService:
validator(site_blueprint)
def serialize_state(self, state: Optional[WorkflowState]) -> Optional[Dict[str, object]]:
"""Return a stable payload for API consumers."""
if not self.enabled or not state:
return None
step_status = state.step_status or {}
steps_payload = []
for step in DEFAULT_STEPS:
meta = step_status.get(step, {})
steps_payload.append({
'step': step,
'status': meta.get('status', 'pending'),
'code': meta.get('code') or STEP_CODES.get(step),
'message': meta.get('message'),
'updated_at': meta.get('updated_at') or state.updated_at.isoformat(),
})
return {
'current_step': state.current_step,
'completed': state.completed,
'blocking_reason': state.blocking_reason,
'steps': steps_payload,
'updated_at': state.updated_at,
}
def _build_step_entry(
self,
step: str,
status: str,
message: Optional[str],
timestamp: str,
) -> Dict[str, Optional[str]]:
return {
'status': status,
'code': STEP_CODES.get(step),
'message': message,
'updated_at': timestamp,
}
def _emit_event(self, site_blueprint: SiteBlueprint, event: str, payload: Optional[Dict[str, object]] = None) -> None:
if not self.enabled:
return
payload = payload or {}
logger.info(
"Wizard event: %s blueprint=%s site=%s account=%s payload=%s",
event,
site_blueprint.id,
site_blueprint.site_id,
site_blueprint.account_id,
payload,
)