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.page_generation_service import PageGenerationService
from igny8_core.business.site_building.services.workflow_state_service import WorkflowStateService 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.taxonomy_service import TaxonomyService
from igny8_core.business.site_building.services.wizard_context_service import WizardContextService
__all__ = [ __all__ = [
'SiteBuilderFileService', 'SiteBuilderFileService',
@@ -14,4 +15,5 @@ __all__ = [
'PageGenerationService', 'PageGenerationService',
'WorkflowStateService', 'WorkflowStateService',
'TaxonomyService', '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.conf import settings
from django.core.exceptions import ValidationError 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.models import SiteBlueprint, WorkflowState
from igny8_core.business.site_building.services import validators from igny8_core.business.site_building.services import validators
@@ -32,6 +33,15 @@ STEP_VALIDATORS = {
'ideas': validators.ensure_ideas_ready, '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: class WorkflowStateService:
"""Centralizes workflow persistence + validation logic.""" """Centralizes workflow persistence + validation logic."""
@@ -57,7 +67,7 @@ class WorkflowStateService:
state = self.initialize(site_blueprint) state = self.initialize(site_blueprint)
if not state: if not state:
return None return None
timestamp = timezone.now().isoformat()
step_status: Dict[str, Dict[str, str]] = state.step_status or {} step_status: Dict[str, Dict[str, str]] = state.step_status or {}
blocking_reason = None blocking_reason = None
@@ -66,12 +76,26 @@ class WorkflowStateService:
try: try:
if validator: if validator:
validator(site_blueprint) 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: except ValidationError as exc:
message = str(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: if not blocking_reason:
blocking_reason = message blocking_reason = message
self._emit_event(site_blueprint, 'wizard_blocking_issue', {
'step': step,
'message': message,
})
state.step_status = step_status state.step_status = step_status
state.blocking_reason = blocking_reason state.blocking_reason = blocking_reason
@@ -92,8 +116,16 @@ class WorkflowStateService:
return None return None
metadata = metadata or {} metadata = metadata or {}
timestamp = timezone.now().isoformat()
step_status = state.step_status or {} 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: if step in DEFAULT_STEPS:
state.current_step = step state.current_step = step
@@ -102,7 +134,10 @@ class WorkflowStateService:
state.blocking_reason = metadata.get('message') state.blocking_reason = metadata.get('message')
state.completed = all(value.get('status') == 'ready' for value in step_status.values()) 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']) 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 return state
def validate_step(self, site_blueprint: SiteBlueprint, step: str) -> None: def validate_step(self, site_blueprint: SiteBlueprint, step: str) -> None:
@@ -116,3 +151,55 @@ class WorkflowStateService:
validator(site_blueprint) 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,
)

View File

@@ -10,6 +10,7 @@ from igny8_core.business.site_building.models import (
SiteBlueprint, SiteBlueprint,
WorkflowState, WorkflowState,
) )
from igny8_core.business.site_building.services.workflow_state_service import WorkflowStateService
class PageBlueprintSerializer(serializers.ModelSerializer): class PageBlueprintSerializer(serializers.ModelSerializer):
@@ -47,6 +48,7 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
site_id = serializers.IntegerField(write_only=True, required=False) site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False) sector_id = serializers.IntegerField(write_only=True, required=False)
workflow_state = serializers.SerializerMethodField() workflow_state = serializers.SerializerMethodField()
gating_messages = serializers.SerializerMethodField()
class Meta: class Meta:
model = SiteBlueprint model = SiteBlueprint
@@ -66,6 +68,7 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
'updated_at', 'updated_at',
'pages', 'pages',
'workflow_state', 'workflow_state',
'gating_messages',
] ]
read_only_fields = [ read_only_fields = [
'structure_json', 'structure_json',
@@ -88,19 +91,32 @@ class SiteBlueprintSerializer(serializers.ModelSerializer):
return attrs return attrs
def get_workflow_state(self, obj): def get_workflow_state(self, obj):
return self._get_workflow_payload(obj)
def get_gating_messages(self, obj):
workflow_payload = self._get_workflow_payload(obj)
if not workflow_payload:
return None
blocked = [step for step in workflow_payload.get('steps', []) if step.get('status') == 'blocked']
return blocked or None
def _get_workflow_payload(self, obj):
if not getattr(settings, 'USE_SITE_BUILDER_REFACTOR', False): if not getattr(settings, 'USE_SITE_BUILDER_REFACTOR', False):
return None return None
cache = self.context.setdefault('_workflow_state_cache', {})
if obj.id in cache:
return cache[obj.id]
try: try:
state: WorkflowState = obj.workflow_state state: WorkflowState = obj.workflow_state
except WorkflowState.DoesNotExist: except WorkflowState.DoesNotExist:
return None state = None
return { service = getattr(self, '_workflow_service', None)
'current_step': state.current_step, if service is None:
'step_status': state.step_status, service = WorkflowStateService()
'blocking_reason': state.blocking_reason, self._workflow_service = service
'completed': state.completed, payload = service.serialize_state(state)
'updated_at': state.updated_at, cache[obj.id] = payload
} return payload
class MetadataOptionSerializer(serializers.Serializer): class MetadataOptionSerializer(serializers.Serializer):

View File

@@ -24,6 +24,7 @@ from igny8_core.business.site_building.services import (
StructureGenerationService, StructureGenerationService,
WorkflowStateService, WorkflowStateService,
TaxonomyService, TaxonomyService,
WizardContextService,
) )
from igny8_core.modules.site_builder.serializers import ( from igny8_core.modules.site_builder.serializers import (
PageBlueprintSerializer, PageBlueprintSerializer,
@@ -47,6 +48,7 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.workflow_service = WorkflowStateService() self.workflow_service = WorkflowStateService()
self.taxonomy_service = TaxonomyService() self.taxonomy_service = TaxonomyService()
self.wizard_context_service = WizardContextService()
def get_permissions(self): def get_permissions(self):
""" """
@@ -213,6 +215,19 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet):
return response return response
except Exception as e: except Exception as e:
return error_response(str(e), status.HTTP_400_BAD_REQUEST, request) return error_response(str(e), status.HTTP_400_BAD_REQUEST, request)
@action(detail=True, methods=['get'], url_path='workflow/context')
def workflow_context(self, request, pk=None):
"""Return aggregated wizard context (steps, clusters, taxonomies, coverage)."""
blueprint = self.get_object()
if not self.workflow_service.enabled:
return success_response(
data={'workflow': None, 'clusters': {}, 'taxonomies': {}, 'coverage': {}},
request=request,
)
payload = self.wizard_context_service.build_context(blueprint)
return success_response(payload, request=request)
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
def bulk_delete(self, request): def bulk_delete(self, request):