From 72e1f25bc75404e320febca9f89f88bc89af72e3 Mon Sep 17 00:00:00 2001 From: alorig <220087330+alorig@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:07:08 +0500 Subject: [PATCH] stage 2-1 --- .../site_building/services/__init__.py | 2 + .../services/wizard_context_service.py | 136 ++++++++++++++++++ .../services/workflow_state_service.py | 97 ++++++++++++- .../modules/site_builder/serializers.py | 32 +++-- .../igny8_core/modules/site_builder/views.py | 15 ++ 5 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 backend/igny8_core/business/site_building/services/wizard_context_service.py diff --git a/backend/igny8_core/business/site_building/services/__init__.py b/backend/igny8_core/business/site_building/services/__init__.py index 3a57abc9..b4aceef1 100644 --- a/backend/igny8_core/business/site_building/services/__init__.py +++ b/backend/igny8_core/business/site_building/services/__init__.py @@ -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', ] diff --git a/backend/igny8_core/business/site_building/services/wizard_context_service.py b/backend/igny8_core/business/site_building/services/wizard_context_service.py new file mode 100644 index 00000000..257ff885 --- /dev/null +++ b/backend/igny8_core/business/site_building/services/wizard_context_service.py @@ -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, + } + 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 index dc77bd25..276961c9 100644 --- a/backend/igny8_core/business/site_building/services/workflow_state_service.py +++ b/backend/igny8_core/business/site_building/services/workflow_state_service.py @@ -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, + ) + diff --git a/backend/igny8_core/modules/site_builder/serializers.py b/backend/igny8_core/modules/site_builder/serializers.py index cb8e6098..ceda54cc 100644 --- a/backend/igny8_core/modules/site_builder/serializers.py +++ b/backend/igny8_core/modules/site_builder/serializers.py @@ -10,6 +10,7 @@ from igny8_core.business.site_building.models import ( SiteBlueprint, WorkflowState, ) +from igny8_core.business.site_building.services.workflow_state_service import WorkflowStateService class PageBlueprintSerializer(serializers.ModelSerializer): @@ -47,6 +48,7 @@ class SiteBlueprintSerializer(serializers.ModelSerializer): site_id = serializers.IntegerField(write_only=True, required=False) sector_id = serializers.IntegerField(write_only=True, required=False) workflow_state = serializers.SerializerMethodField() + gating_messages = serializers.SerializerMethodField() class Meta: model = SiteBlueprint @@ -66,6 +68,7 @@ class SiteBlueprintSerializer(serializers.ModelSerializer): 'updated_at', 'pages', 'workflow_state', + 'gating_messages', ] read_only_fields = [ 'structure_json', @@ -88,19 +91,32 @@ class SiteBlueprintSerializer(serializers.ModelSerializer): return attrs 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): return None + cache = self.context.setdefault('_workflow_state_cache', {}) + if obj.id in cache: + return cache[obj.id] try: state: WorkflowState = obj.workflow_state except WorkflowState.DoesNotExist: - return None - return { - 'current_step': state.current_step, - 'step_status': state.step_status, - 'blocking_reason': state.blocking_reason, - 'completed': state.completed, - 'updated_at': state.updated_at, - } + state = None + service = getattr(self, '_workflow_service', None) + if service is None: + service = WorkflowStateService() + self._workflow_service = service + payload = service.serialize_state(state) + cache[obj.id] = payload + return payload class MetadataOptionSerializer(serializers.Serializer): diff --git a/backend/igny8_core/modules/site_builder/views.py b/backend/igny8_core/modules/site_builder/views.py index 5b931bf0..f95edbc1 100644 --- a/backend/igny8_core/modules/site_builder/views.py +++ b/backend/igny8_core/modules/site_builder/views.py @@ -24,6 +24,7 @@ from igny8_core.business.site_building.services import ( StructureGenerationService, WorkflowStateService, TaxonomyService, + WizardContextService, ) from igny8_core.modules.site_builder.serializers import ( PageBlueprintSerializer, @@ -47,6 +48,7 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet): super().__init__(*args, **kwargs) self.workflow_service = WorkflowStateService() self.taxonomy_service = TaxonomyService() + self.wizard_context_service = WizardContextService() def get_permissions(self): """ @@ -213,6 +215,19 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet): return response except Exception as e: 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') def bulk_delete(self, request):