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.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',
|
||||
]
|
||||
|
||||
@@ -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.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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user