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