stage 4-1

This commit is contained in:
alorig
2025-11-20 03:30:39 +05:00
parent 6c05adc990
commit ec3ca2da5d
7 changed files with 1304 additions and 84 deletions

View File

@@ -1,6 +1,7 @@
""" """
Content Sync Service Content Sync Service
Phase 6: Site Integration & Multi-Destination Publishing Phase 6: Site Integration & Multi-Destination Publishing
Stage 4: Enhanced with taxonomy and product sync
Syncs content between IGNY8 and external platforms. Syncs content between IGNY8 and external platforms.
""" """
@@ -8,6 +9,7 @@ import logging
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from igny8_core.business.integration.models import SiteIntegration from igny8_core.business.integration.models import SiteIntegration
from igny8_core.utils.wordpress import WordPressClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -98,6 +100,7 @@ class ContentSyncService:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Sync content from IGNY8 to WordPress. Sync content from IGNY8 to WordPress.
Stage 4: Enhanced to sync taxonomies before content.
Args: Args:
integration: SiteIntegration instance integration: SiteIntegration instance
@@ -106,15 +109,67 @@ class ContentSyncService:
Returns: Returns:
dict: Sync result dict: Sync result
""" """
# TODO: Implement WordPress sync try:
# This will use the WordPress adapter to publish content # Get WordPress client
logger.info(f"[ContentSyncService] Syncing to WordPress for integration {integration.id}") credentials = integration.get_credentials()
client = WordPressClient(
return { site_url=integration.config_json.get('site_url', ''),
'success': True, username=credentials.get('username'),
'synced_count': 0, app_password=credentials.get('app_password')
'message': 'WordPress sync to external not yet fully implemented' )
}
# Stage 4: Sync taxonomies first
taxonomy_result = self._sync_taxonomies_to_wordpress(integration, client)
# Sync content (posts/products)
from igny8_core.business.content.models import Content
from igny8_core.business.publishing.services.adapters.wordpress_adapter import WordPressAdapter
content_query = Content.objects.filter(
account=integration.account,
site=integration.site,
source='igny8',
status='publish'
)
if content_types:
content_query = content_query.filter(content_type__in=content_types)
synced_count = 0
adapter = WordPressAdapter()
destination_config = {
'site_url': integration.config_json.get('site_url', ''),
'username': credentials.get('username'),
'app_password': credentials.get('app_password'),
'status': 'publish'
}
for content in content_query[:100]: # Limit to 100 per sync
result = adapter.publish(content, destination_config)
if result.get('success'):
synced_count += 1
# Store external reference
if not content.metadata:
content.metadata = {}
content.metadata['wordpress_id'] = result.get('external_id')
content.save(update_fields=['metadata'])
return {
'success': True,
'synced_count': synced_count,
'taxonomies_synced': taxonomy_result.get('synced_count', 0),
'message': f'Synced {synced_count} content items and {taxonomy_result.get("synced_count", 0)} taxonomies'
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing to WordPress: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def sync_from_wordpress( def sync_from_wordpress(
self, self,
@@ -183,6 +238,7 @@ class ContentSyncService:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Internal method for syncing from WordPress (used by sync_from_external). Internal method for syncing from WordPress (used by sync_from_external).
Stage 4: Enhanced to sync taxonomies and products.
Args: Args:
integration: SiteIntegration instance integration: SiteIntegration instance
@@ -191,7 +247,46 @@ class ContentSyncService:
Returns: Returns:
dict: Sync result dict: Sync result
""" """
return self.sync_from_wordpress(integration) try:
# Get WordPress client
credentials = integration.get_credentials()
client = WordPressClient(
site_url=integration.config_json.get('site_url', ''),
username=credentials.get('username'),
app_password=credentials.get('app_password')
)
# Stage 4: Sync taxonomies first
taxonomy_result = self._sync_taxonomies_from_wordpress(integration, client)
# Sync posts
posts_result = self.sync_from_wordpress(integration)
# Sync WooCommerce products if available
products_result = self._sync_products_from_wordpress(integration, client)
total_synced = (
posts_result.get('synced_count', 0) +
products_result.get('synced_count', 0)
)
return {
'success': True,
'synced_count': total_synced,
'taxonomies_synced': taxonomy_result.get('synced_count', 0),
'posts_synced': posts_result.get('synced_count', 0),
'products_synced': products_result.get('synced_count', 0),
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing from WordPress: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _fetch_wordpress_posts( def _fetch_wordpress_posts(
self, self,
@@ -206,10 +301,299 @@ class ContentSyncService:
Returns: Returns:
List of post dictionaries List of post dictionaries
""" """
# TODO: Implement actual WordPress API call try:
# For now, return empty list - tests will mock this credentials = integration.get_credentials()
logger.info(f"[ContentSyncService] Fetching WordPress posts for integration {integration.id}") client = WordPressClient(
return [] site_url=integration.config_json.get('site_url', ''),
username=credentials.get('username'),
app_password=credentials.get('app_password')
)
# Fetch posts via WordPress REST API
import requests
response = client.session.get(
f"{client.api_base}/posts",
params={'per_page': 100, 'status': 'publish'}
)
if response.status_code == 200:
posts = response.json()
return [
{
'id': post.get('id'),
'title': post.get('title', {}).get('rendered', ''),
'content': post.get('content', {}).get('rendered', ''),
'status': post.get('status', 'publish'),
'categories': post.get('categories', []),
'tags': post.get('tags', [])
}
for post in posts
]
return []
except Exception as e:
logger.error(f"Error fetching WordPress posts: {e}")
return []
# Stage 4: Taxonomy Sync Methods
def _sync_taxonomies_from_wordpress(
self,
integration: SiteIntegration,
client: WordPressClient
) -> Dict[str, Any]:
"""
Sync taxonomies from WordPress to IGNY8.
Args:
integration: SiteIntegration instance
client: WordPressClient instance
Returns:
dict: Sync result with synced_count
"""
try:
from igny8_core.business.site_building.models import SiteBlueprint
from igny8_core.business.site_building.services.taxonomy_service import TaxonomyService
# Get or create site blueprint for this site
blueprint = SiteBlueprint.objects.filter(
account=integration.account,
site=integration.site
).first()
if not blueprint:
logger.warning(f"No blueprint found for site {integration.site.id}, skipping taxonomy sync")
return {'success': True, 'synced_count': 0}
taxonomy_service = TaxonomyService()
synced_count = 0
# Sync WordPress categories
categories = client.get_categories(per_page=100)
category_records = [
{
'name': cat['name'],
'slug': cat['slug'],
'description': cat.get('description', ''),
'taxonomy_type': 'blog_category',
'external_reference': str(cat['id']),
'metadata': {'parent': cat.get('parent', 0)}
}
for cat in categories
]
if category_records:
taxonomy_service.import_from_external(
blueprint,
category_records,
default_type='blog_category'
)
synced_count += len(category_records)
# Sync WordPress tags
tags = client.get_tags(per_page=100)
tag_records = [
{
'name': tag['name'],
'slug': tag['slug'],
'description': tag.get('description', ''),
'taxonomy_type': 'blog_tag',
'external_reference': str(tag['id'])
}
for tag in tags
]
if tag_records:
taxonomy_service.import_from_external(
blueprint,
tag_records,
default_type='blog_tag'
)
synced_count += len(tag_records)
# Sync WooCommerce product categories if available
try:
product_categories = client.get_product_categories(per_page=100)
product_category_records = [
{
'name': cat['name'],
'slug': cat['slug'],
'description': cat.get('description', ''),
'taxonomy_type': 'product_category',
'external_reference': f"wc_cat_{cat['id']}",
'metadata': {'parent': cat.get('parent', 0)}
}
for cat in product_categories
]
if product_category_records:
taxonomy_service.import_from_external(
blueprint,
product_category_records,
default_type='product_category'
)
synced_count += len(product_category_records)
except Exception as e:
logger.warning(f"WooCommerce not available or error fetching product categories: {e}")
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(f"Error syncing taxonomies from WordPress: {e}", exc_info=True)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_taxonomies_to_wordpress(
self,
integration: SiteIntegration,
client: WordPressClient
) -> Dict[str, Any]:
"""
Ensure taxonomies exist in WordPress before publishing content.
Args:
integration: SiteIntegration instance
client: WordPressClient instance
Returns:
dict: Sync result with synced_count
"""
try:
from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy
# Get site blueprint
blueprint = SiteBlueprint.objects.filter(
account=integration.account,
site=integration.site
).first()
if not blueprint:
return {'success': True, 'synced_count': 0}
synced_count = 0
# Get taxonomies that don't have external_reference (not yet synced)
taxonomies = SiteBlueprintTaxonomy.objects.filter(
site_blueprint=blueprint,
external_reference__isnull=True
)
for taxonomy in taxonomies:
try:
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
result = client.create_category(
name=taxonomy.name,
slug=taxonomy.slug,
description=taxonomy.description
)
if result.get('success'):
taxonomy.external_reference = str(result.get('category_id'))
taxonomy.save(update_fields=['external_reference'])
synced_count += 1
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
result = client.create_tag(
name=taxonomy.name,
slug=taxonomy.slug,
description=taxonomy.description
)
if result.get('success'):
taxonomy.external_reference = str(result.get('tag_id'))
taxonomy.save(update_fields=['external_reference'])
synced_count += 1
except Exception as e:
logger.warning(f"Error syncing taxonomy {taxonomy.id} to WordPress: {e}")
continue
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(f"Error syncing taxonomies to WordPress: {e}", exc_info=True)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_products_from_wordpress(
self,
integration: SiteIntegration,
client: WordPressClient
) -> Dict[str, Any]:
"""
Sync WooCommerce products from WordPress to IGNY8.
Args:
integration: SiteIntegration instance
client: WordPressClient instance
Returns:
dict: Sync result with synced_count
"""
try:
products = client.get_products(per_page=100)
synced_count = 0
from igny8_core.business.content.models import Content
for product in products:
content, created = Content.objects.get_or_create(
account=integration.account,
site=integration.site,
sector=integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
title=product.get('name', ''),
source='wordpress',
defaults={
'html_content': product.get('description', ''),
'content_type': 'product',
'status': 'published' if product.get('status') == 'publish' else 'draft',
'metadata': {
'wordpress_id': product.get('id'),
'product_type': product.get('type'),
'sku': product.get('sku'),
'price': product.get('price'),
'regular_price': product.get('regular_price'),
'sale_price': product.get('sale_price'),
'categories': product.get('categories', []),
'tags': product.get('tags', []),
'attributes': product.get('attributes', [])
}
}
)
if not created:
content.html_content = product.get('description', '')
if not content.metadata:
content.metadata = {}
content.metadata.update({
'wordpress_id': product.get('id'),
'product_type': product.get('type'),
'sku': product.get('sku'),
'price': product.get('price'),
'regular_price': product.get('regular_price'),
'sale_price': product.get('sale_price'),
'categories': product.get('categories', []),
'tags': product.get('tags', []),
'attributes': product.get('attributes', [])
})
content.save()
synced_count += 1
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(f"Error syncing products from WordPress: {e}", exc_info=True)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_to_shopify( def _sync_to_shopify(
self, self,

View File

@@ -1,10 +1,11 @@
""" """
WordPress Integration Service WordPress Integration Service
Handles communication with WordPress sites via REST API Handles communication with WordPress sites via REST API
Stage 4: Enhanced with taxonomy and WooCommerce product support
""" """
import logging import logging
import requests import requests
from typing import Dict, Any, Optional from typing import Dict, Any, Optional, List
from django.conf import settings from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,6 +27,7 @@ class WordPressClient:
""" """
self.site_url = site_url.rstrip('/') self.site_url = site_url.rstrip('/')
self.api_base = f"{self.site_url}/wp-json/wp/v2" self.api_base = f"{self.site_url}/wp-json/wp/v2"
self.woocommerce_api_base = f"{self.site_url}/wp-json/wc/v3" # WooCommerce API
self.igny8_api_base = f"{self.site_url}/wp-json/igny8/v1" # Custom IGNY8 endpoints self.igny8_api_base = f"{self.site_url}/wp-json/igny8/v1" # Custom IGNY8 endpoints
self.username = username self.username = username
self.app_password = app_password self.app_password = app_password
@@ -215,4 +217,377 @@ class WordPressClient:
'message': None, 'message': None,
'error': str(e), 'error': str(e),
} }
# Stage 4: Taxonomy Methods
def get_categories(self, per_page: int = 100, page: int = 1) -> List[Dict[str, Any]]:
"""
Get WordPress categories.
Args:
per_page: Number of categories per page
page: Page number
Returns:
List of category dictionaries
"""
try:
response = self.session.get(
f"{self.api_base}/categories",
params={'per_page': per_page, 'page': page}
)
if response.status_code == 200:
categories = response.json()
return [
{
'id': cat.get('id'),
'name': cat.get('name', ''),
'slug': cat.get('slug', ''),
'description': cat.get('description', ''),
'count': cat.get('count', 0),
'parent': cat.get('parent', 0),
'link': cat.get('link', '')
}
for cat in categories
]
logger.warning(f"Failed to fetch categories: HTTP {response.status_code}")
return []
except Exception as e:
logger.error(f"Error fetching WordPress categories: {e}")
return []
def create_category(
self,
name: str,
slug: Optional[str] = None,
description: Optional[str] = None,
parent_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Create a WordPress category.
Args:
name: Category name
slug: Category slug (auto-generated if not provided)
description: Category description
parent_id: Parent category ID
Returns:
Dict with 'success', 'category_id', 'error'
"""
try:
category_data = {'name': name}
if slug:
category_data['slug'] = slug
if description:
category_data['description'] = description
if parent_id:
category_data['parent'] = parent_id
response = self.session.post(
f"{self.api_base}/categories",
json=category_data
)
if response.status_code in [200, 201]:
data = response.json()
return {
'success': True,
'category_id': data.get('id'),
'slug': data.get('slug'),
'error': None,
}
return {
'success': False,
'category_id': None,
'slug': None,
'error': f"HTTP {response.status_code}: {response.text}",
}
except Exception as e:
logger.error(f"WordPress category creation failed: {e}")
return {
'success': False,
'category_id': None,
'slug': None,
'error': str(e),
}
def get_tags(self, per_page: int = 100, page: int = 1) -> List[Dict[str, Any]]:
"""
Get WordPress tags.
Args:
per_page: Number of tags per page
page: Page number
Returns:
List of tag dictionaries
"""
try:
response = self.session.get(
f"{self.api_base}/tags",
params={'per_page': per_page, 'page': page}
)
if response.status_code == 200:
tags = response.json()
return [
{
'id': tag.get('id'),
'name': tag.get('name', ''),
'slug': tag.get('slug', ''),
'description': tag.get('description', ''),
'count': tag.get('count', 0),
'link': tag.get('link', '')
}
for tag in tags
]
logger.warning(f"Failed to fetch tags: HTTP {response.status_code}")
return []
except Exception as e:
logger.error(f"Error fetching WordPress tags: {e}")
return []
def create_tag(
self,
name: str,
slug: Optional[str] = None,
description: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a WordPress tag.
Args:
name: Tag name
slug: Tag slug (auto-generated if not provided)
description: Tag description
Returns:
Dict with 'success', 'tag_id', 'error'
"""
try:
tag_data = {'name': name}
if slug:
tag_data['slug'] = slug
if description:
tag_data['description'] = description
response = self.session.post(
f"{self.api_base}/tags",
json=tag_data
)
if response.status_code in [200, 201]:
data = response.json()
return {
'success': True,
'tag_id': data.get('id'),
'slug': data.get('slug'),
'error': None,
}
return {
'success': False,
'tag_id': None,
'slug': None,
'error': f"HTTP {response.status_code}: {response.text}",
}
except Exception as e:
logger.error(f"WordPress tag creation failed: {e}")
return {
'success': False,
'tag_id': None,
'slug': None,
'error': str(e),
}
# Stage 4: WooCommerce Product Methods
def get_products(
self,
per_page: int = 100,
page: int = 1,
status: str = 'publish'
) -> List[Dict[str, Any]]:
"""
Get WooCommerce products.
Args:
per_page: Number of products per page
page: Page number
status: Product status ('publish', 'draft', etc.)
Returns:
List of product dictionaries
"""
try:
response = self.session.get(
f"{self.woocommerce_api_base}/products",
params={
'per_page': per_page,
'page': page,
'status': status
}
)
if response.status_code == 200:
products = response.json()
return [
{
'id': prod.get('id'),
'name': prod.get('name', ''),
'slug': prod.get('slug', ''),
'sku': prod.get('sku', ''),
'type': prod.get('type', 'simple'),
'status': prod.get('status', 'publish'),
'description': prod.get('description', ''),
'short_description': prod.get('short_description', ''),
'price': prod.get('price', ''),
'regular_price': prod.get('regular_price', ''),
'sale_price': prod.get('sale_price', ''),
'on_sale': prod.get('on_sale', False),
'stock_status': prod.get('stock_status', 'instock'),
'stock_quantity': prod.get('stock_quantity'),
'categories': prod.get('categories', []),
'tags': prod.get('tags', []),
'images': prod.get('images', []),
'attributes': prod.get('attributes', []),
'variations': prod.get('variations', []),
'permalink': prod.get('permalink', '')
}
for prod in products
]
logger.warning(f"Failed to fetch products: HTTP {response.status_code}")
return []
except Exception as e:
logger.error(f"Error fetching WooCommerce products: {e}")
return []
def create_product(self, product_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Create a WooCommerce product.
Args:
product_data: Product data dictionary
Returns:
Dict with 'success', 'product_id', 'error'
"""
try:
response = self.session.post(
f"{self.woocommerce_api_base}/products",
json=product_data
)
if response.status_code in [200, 201]:
data = response.json()
return {
'success': True,
'product_id': data.get('id'),
'slug': data.get('slug'),
'error': None,
}
return {
'success': False,
'product_id': None,
'slug': None,
'error': f"HTTP {response.status_code}: {response.text}",
}
except Exception as e:
logger.error(f"WooCommerce product creation failed: {e}")
return {
'success': False,
'product_id': None,
'slug': None,
'error': str(e),
}
def get_product_categories(self, per_page: int = 100, page: int = 1) -> List[Dict[str, Any]]:
"""
Get WooCommerce product categories.
Args:
per_page: Number of categories per page
page: Page number
Returns:
List of product category dictionaries
"""
try:
response = self.session.get(
f"{self.woocommerce_api_base}/products/categories",
params={'per_page': per_page, 'page': page}
)
if response.status_code == 200:
categories = response.json()
return [
{
'id': cat.get('id'),
'name': cat.get('name', ''),
'slug': cat.get('slug', ''),
'description': cat.get('description', ''),
'count': cat.get('count', 0),
'parent': cat.get('parent', 0),
'image': cat.get('image', {}).get('src') if cat.get('image') else None
}
for cat in categories
]
logger.warning(f"Failed to fetch product categories: HTTP {response.status_code}")
return []
except Exception as e:
logger.error(f"Error fetching WooCommerce product categories: {e}")
return []
def get_product_attributes(self) -> List[Dict[str, Any]]:
"""
Get WooCommerce product attributes.
Returns:
List of product attribute dictionaries with terms
"""
try:
# Get attributes
response = self.session.get(f"{self.woocommerce_api_base}/products/attributes")
if response.status_code != 200:
logger.warning(f"Failed to fetch product attributes: HTTP {response.status_code}")
return []
attributes = response.json()
result = []
for attr in attributes:
attr_id = attr.get('id')
# Get terms for this attribute
terms_response = self.session.get(
f"{self.woocommerce_api_base}/products/attributes/{attr_id}/terms"
)
terms = []
if terms_response.status_code == 200:
terms_data = terms_response.json()
terms = [
{
'id': term.get('id'),
'name': term.get('name', ''),
'slug': term.get('slug', '')
}
for term in terms_data
]
result.append({
'id': attr_id,
'name': attr.get('name', ''),
'slug': attr.get('slug', ''),
'type': attr.get('type', 'select'),
'order_by': attr.get('order_by', 'menu_order'),
'has_archives': attr.get('has_archives', False),
'terms': terms
})
return result
except Exception as e:
logger.error(f"Error fetching WooCommerce product attributes: {e}")
return []

View File

@@ -23,6 +23,24 @@ export const LinkResults: React.FC<LinkResultsProps> = ({
linksAdded, linksAdded,
linkerVersion, linkerVersion,
}) => { }) => {
if (!links || links.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Linking Results</h3>
<div className="flex items-center gap-2">
<PlugInIcon className="w-5 h-5 text-blue-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Version {linkerVersion}</span>
</div>
</div>
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<PlugInIcon className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No links found to add</p>
</div>
</div>
);
}
return ( return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -51,14 +69,14 @@ export const LinkResults: React.FC<LinkResultsProps> = ({
<ul className="space-y-2"> <ul className="space-y-2">
{links.filter(l => l.cluster_match).map((link, index) => ( {links.filter(l => l.cluster_match).map((link, index) => (
<li key={`cluster-${index}`} className="flex items-center gap-2 text-sm pl-2 border-l-2 border-blue-500"> <li key={`cluster-${index}`} className="flex items-center gap-2 text-sm pl-2 border-l-2 border-blue-500">
<span className="text-gray-600 dark:text-gray-400">"{link.anchor_text}"</span> <span className="text-gray-600 dark:text-gray-400">"{link.anchor_text || 'Untitled'}"</span>
<span className="text-gray-400"></span> <span className="text-gray-400"></span>
<span className="text-blue-600 dark:text-blue-400"> <span className="text-blue-600 dark:text-blue-400">
Content #{link.target_content_id} Content #{link.target_content_id || 'N/A'}
</span> </span>
{link.relevance_score && ( {link.relevance_score !== undefined && (
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
(Score: {link.relevance_score}) (Score: {link.relevance_score.toFixed(1)})
</span> </span>
)} )}
</li> </li>
@@ -76,14 +94,14 @@ export const LinkResults: React.FC<LinkResultsProps> = ({
<ul className="space-y-2"> <ul className="space-y-2">
{links.filter(l => !l.cluster_match || l.cluster_match === false).map((link, index) => ( {links.filter(l => !l.cluster_match || l.cluster_match === false).map((link, index) => (
<li key={`other-${index}`} className="flex items-center gap-2 text-sm"> <li key={`other-${index}`} className="flex items-center gap-2 text-sm">
<span className="text-gray-600 dark:text-gray-400">"{link.anchor_text}"</span> <span className="text-gray-600 dark:text-gray-400">"{link.anchor_text || 'Untitled'}"</span>
<span className="text-gray-400"></span> <span className="text-gray-400"></span>
<span className="text-blue-600 dark:text-blue-400"> <span className="text-blue-600 dark:text-blue-400">
Content #{link.target_content_id} Content #{link.target_content_id || 'N/A'}
</span> </span>
{link.relevance_score && ( {link.relevance_score !== undefined && (
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
(Score: {link.relevance_score}) (Score: {link.relevance_score.toFixed(1)})
</span> </span>
)} )}
</li> </li>

View File

@@ -18,6 +18,8 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
const navigate = useNavigate(); const navigate = useNavigate();
const [progress, setProgress] = useState<SiteProgress | null>(null); const [progress, setProgress] = useState<SiteProgress | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
useEffect(() => { useEffect(() => {
loadProgress(); loadProgress();
@@ -26,27 +28,63 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
const loadProgress = async () => { const loadProgress = async () => {
try { try {
setLoading(true); setLoading(true);
setError(null);
const data = await fetchSiteProgress(blueprintId); const data = await fetchSiteProgress(blueprintId);
setProgress(data); setProgress(data);
setRetryCount(0); // Reset retry count on success
} catch (error: any) { } catch (error: any) {
console.error('Failed to load site progress:', error); console.error('Failed to load site progress:', error);
setError(error.message || 'Failed to load site progress. Please try again.');
setProgress(null);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleRetry = () => {
setRetryCount(prev => prev + 1);
loadProgress();
};
if (loading) { if (loading) {
return ( return (
<Card className="p-4"> <Card className="p-4">
<div className="text-center py-4 text-gray-500 dark:text-gray-400"> <div className="text-center py-4">
Loading progress... <div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mb-2"></div>
<div className="text-gray-500 dark:text-gray-400">Loading progress...</div>
</div>
</Card>
);
}
if (error && !progress) {
return (
<Card className="p-4">
<div className="text-center py-4">
<AlertCircleIcon className="w-8 h-8 text-red-500 mx-auto mb-2" />
<div className="text-sm text-red-600 dark:text-red-400 mb-3">{error}</div>
<button
onClick={handleRetry}
disabled={retryCount >= 3}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded-lg text-sm font-medium transition-colors"
aria-label="Retry loading site progress"
>
{retryCount >= 3 ? 'Max retries reached' : 'Retry'}
</button>
</div> </div>
</Card> </Card>
); );
} }
if (!progress) { if (!progress) {
return null; return (
<Card className="p-4">
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
<AlertCircleIcon className="w-6 h-6 mx-auto mb-2 opacity-50" />
<p>No progress data available</p>
</div>
</Card>
);
} }
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -76,16 +114,16 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
return ( return (
<Card className="p-6"> <Card className="p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white" id="site-progress-title">
Site Progress: {progress.blueprint_name} Site Progress: {progress.blueprint_name}
</h3> </h3>
<Badge color={getStatusColor(progress.overall_status)} size="sm"> <Badge color={getStatusColor(progress.overall_status)} size="sm" aria-label={`Status: ${progress.overall_status || 'Unknown'}`}>
{progress.overall_status ? progress.overall_status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) : 'Unknown'} {progress.overall_status ? progress.overall_status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) : 'Unknown'}
</Badge> </Badge>
</div> </div>
{/* Overall Stats */} {/* Overall Stats */}
<div className="grid grid-cols-3 gap-4 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="text-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"> <div className="text-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="text-2xl font-bold text-gray-900 dark:text-white"> <div className="text-2xl font-bold text-gray-900 dark:text-white">
{progress.cluster_coverage.covered_clusters}/{progress.cluster_coverage.total_clusters} {progress.cluster_coverage.covered_clusters}/{progress.cluster_coverage.total_clusters}
@@ -107,11 +145,12 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
</div> </div>
{/* Cluster Progress */} {/* Cluster Progress */}
<div className="space-y-4"> <div className="space-y-4" aria-labelledby="cluster-coverage-title">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300"> <h4 id="cluster-coverage-title" className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Cluster Coverage Cluster Coverage
</h4> </h4>
{progress.cluster_coverage.details.map((cluster) => { {progress.cluster_coverage.details && progress.cluster_coverage.details.length > 0 ? (
progress.cluster_coverage.details.map((cluster) => {
const totalPages = cluster.hub_pages + cluster.supporting_pages + cluster.attribute_pages; const totalPages = cluster.hub_pages + cluster.supporting_pages + cluster.attribute_pages;
const completionPercent = totalPages > 0 ? Math.min(100, (cluster.content_count / totalPages) * 100) : 0; const completionPercent = totalPages > 0 ? Math.min(100, (cluster.content_count / totalPages) * 100) : 0;
@@ -145,7 +184,8 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
</div> </div>
<button <button
onClick={() => navigate(`/planner/clusters/${cluster.cluster_id}`)} onClick={() => navigate(`/planner/clusters/${cluster.cluster_id}`)}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1" className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 transition-colors"
aria-label={`View cluster ${cluster.cluster_name}`}
> >
View <ArrowRightIcon className="w-3 h-3" /> View <ArrowRightIcon className="w-3 h-3" />
</button> </button>
@@ -203,15 +243,21 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
)} )}
</div> </div>
); );
})} })
) : (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
<AlertCircleIcon className="w-6 h-6 mx-auto mb-2 opacity-50" />
<p className="text-sm">No clusters found. Attach clusters to get started.</p>
</div>
)}
</div> </div>
{/* Validation Flags Summary */} {/* Validation Flags Summary */}
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"> <div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700" aria-labelledby="validation-status-title">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3"> <h4 id="validation-status-title" className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Validation Status Validation Status
</h4> </h4>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{progress.validation_flags.clusters_attached ? ( {progress.validation_flags.clusters_attached ? (
<CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" /> <CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
@@ -251,11 +297,24 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"> <div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<button <button
onClick={() => navigate(`/sites/builder/workflow/${blueprintId}`)} onClick={() => navigate(`/sites/builder/workflow/${blueprintId}`)}
className="w-full px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors" className="w-full px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label="Continue site builder workflow"
> >
Continue Site Builder Workflow Continue Site Builder Workflow
</button> </button>
</div> </div>
{/* Error banner if data loaded but has errors */}
{error && progress && (
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircleIcon className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-xs text-yellow-800 dark:text-yellow-300">
Some data may be outdated. <button onClick={handleRetry} className="underline font-medium">Refresh</button>
</div>
</div>
</div>
)}
</Card> </Card>
); );
} }

View File

@@ -86,11 +86,23 @@ export default function AnalysisPreview() {
{loading || analyzing ? ( {loading || analyzing ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div> <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mb-3"></div>
<p className="mt-2 text-gray-600 dark:text-gray-400"> <p className="text-gray-600 dark:text-gray-400">
{loading ? 'Loading content...' : 'Analyzing content...'} {loading ? 'Loading content...' : 'Analyzing content...'}
</p> </p>
</div> </div>
) : !content ? (
<div className="text-center py-12">
<BoltIcon className="w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-3 opacity-50" />
<p className="text-gray-600 dark:text-gray-400 mb-2">Content not found</p>
<p className="text-sm text-gray-400 dark:text-gray-500">Unable to load content for analysis</p>
</div>
) : !scores ? (
<div className="text-center py-12">
<BoltIcon className="w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-3 opacity-50" />
<p className="text-gray-600 dark:text-gray-400 mb-2">Analysis unavailable</p>
<p className="text-sm text-gray-400 dark:text-gray-500">Unable to analyze this content</p>
</div>
) : content && scores ? ( ) : content && scores ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Content Info */} {/* Content Info */}

View File

@@ -87,6 +87,7 @@ export default function PostEditor() {
setValidationResult(result); setValidationResult(result);
} catch (error: any) { } catch (error: any) {
console.error('Failed to load validation:', error); console.error('Failed to load validation:', error);
toast.error(`Failed to load validation: ${error.message || 'Unknown error'}`);
} }
}; };
@@ -600,6 +601,7 @@ export default function PostEditor() {
variant="primary" variant="primary"
onClick={handleValidate} onClick={handleValidate}
disabled={validating} disabled={validating}
aria-label="Run content validation"
> >
{validating ? 'Validating...' : 'Run Validation'} {validating ? 'Validating...' : 'Run Validation'}
</Button> </Button>
@@ -632,11 +634,11 @@ export default function PostEditor() {
</div> </div>
{/* Metadata Summary */} {/* Metadata Summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4"> <div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4" role="region" aria-labelledby="metadata-summary-title">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3"> <h4 id="metadata-summary-title" className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
Metadata Summary Metadata Summary
</h4> </h4>
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="text-gray-600 dark:text-gray-400">Entity Type:</span> <span className="text-gray-600 dark:text-gray-400">Entity Type:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white"> <span className="ml-2 font-medium text-gray-900 dark:text-white">
@@ -668,7 +670,7 @@ export default function PostEditor() {
{/* Validation Errors */} {/* Validation Errors */}
{validationResult.validation_errors.length > 0 && ( {validationResult.validation_errors.length > 0 && (
<div> <div role="alert" aria-live="polite">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3"> <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
Validation Errors Validation Errors
</h4> </h4>
@@ -695,7 +697,7 @@ export default function PostEditor() {
{/* Publish Errors */} {/* Publish Errors */}
{validationResult.publish_errors && validationResult.publish_errors.length > 0 && ( {validationResult.publish_errors && validationResult.publish_errors.length > 0 && (
<div> <div role="alert" aria-live="polite">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3"> <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
Publish Blockers Publish Blockers
</h4> </h4>
@@ -721,8 +723,10 @@ export default function PostEditor() {
)} )}
</div> </div>
) : ( ) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-8">
<p>Click "Run Validation" to check your content</p> <FileTextIcon className="w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-3 opacity-50" />
<p className="text-gray-500 dark:text-gray-400 mb-2">No validation results yet</p>
<p className="text-sm text-gray-400 dark:text-gray-500">Click "Run Validation" to check your content</p>
</div> </div>
)} )}
</div> </div>
@@ -733,7 +737,7 @@ export default function PostEditor() {
{/* Stage 3: Sidebar with Metadata Summary */} {/* Stage 3: Sidebar with Metadata Summary */}
{content.id && ( {content.id && (
<div className="w-80 flex-shrink-0"> <div className="w-full lg:w-80 flex-shrink-0 mt-6 lg:mt-0">
<Card className="p-4 sticky top-6"> <Card className="p-4 sticky top-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Content Metadata Content Metadata
@@ -741,45 +745,53 @@ export default function PostEditor() {
<div className="space-y-4"> <div className="space-y-4">
{/* Entity Type */} {/* Entity Type */}
{content.entity_type && ( <div>
<div> <div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"> Entity Type
Entity Type
</div>
<div className="text-sm text-gray-900 dark:text-white">
{content.entity_type ? content.entity_type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) : '-'}
</div>
</div> </div>
)} <div className="text-sm text-gray-900 dark:text-white">
{content.entity_type ? (
content.entity_type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())
) : (
<span className="text-gray-400 dark:text-gray-500 italic">Not set</span>
)}
</div>
</div>
{/* Cluster */} {/* Cluster */}
{content.cluster_name && ( <div>
<div> <div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"> Cluster
Cluster
</div>
<div className="text-sm text-gray-900 dark:text-white">
{content.cluster_name}
{content.cluster_role && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({content.cluster_role})
</span>
)}
</div>
</div> </div>
)} <div className="text-sm text-gray-900 dark:text-white">
{content.cluster_name ? (
<>
{content.cluster_name}
{content.cluster_role && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({content.cluster_role})
</span>
)}
</>
) : (
<span className="text-gray-400 dark:text-gray-500 italic">Not assigned</span>
)}
</div>
</div>
{/* Taxonomy */} {/* Taxonomy */}
{content.taxonomy_name && ( <div>
<div> <div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"> Taxonomy
Taxonomy
</div>
<div className="text-sm text-gray-900 dark:text-white">
{content.taxonomy_name}
</div>
</div> </div>
)} <div className="text-sm text-gray-900 dark:text-white">
{content.taxonomy_name ? (
content.taxonomy_name
) : (
<span className="text-gray-400 dark:text-gray-500 italic">Not assigned</span>
)}
</div>
</div>
{/* Validation Status */} {/* Validation Status */}
{validationResult && ( {validationResult && (
@@ -803,21 +815,27 @@ export default function PostEditor() {
Quick Actions Quick Actions
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{content.cluster_id && ( {content.cluster_id ? (
<button <button
onClick={() => navigate(`/planner/clusters/${content.cluster_id}`)} onClick={() => navigate(`/planner/clusters/${content.cluster_id}`)}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline w-full text-left" className="text-xs text-blue-600 dark:text-blue-400 hover:underline w-full text-left transition-colors focus:outline-none focus:ring-1 focus:ring-blue-500 rounded px-1"
aria-label="View cluster details"
> >
View Cluster View Cluster
</button> </button>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No cluster assigned</span>
)} )}
{content.taxonomy_id && ( {content.taxonomy_id ? (
<button <button
onClick={() => navigate(`/sites/builder?taxonomy=${content.taxonomy_id}`)} onClick={() => navigate(`/sites/builder?taxonomy=${content.taxonomy_id}`)}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline w-full text-left" className="text-xs text-blue-600 dark:text-blue-400 hover:underline w-full text-left transition-colors focus:outline-none focus:ring-1 focus:ring-blue-500 rounded px-1"
aria-label="View taxonomy details"
> >
View Taxonomy View Taxonomy
</button> </button>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No taxonomy assigned</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,354 @@
# Stage 4 Implementation Plan - Publishing & Sync Integration
**Status:** Planning Phase
**Last Updated:** 2025-01-27
**Objective:** Achieve feature parity between IGNY8-hosted deployments and WordPress sites using the shared metadata model from Stages 1-3.
---
## 📋 Codebase Audit Summary
### ✅ Existing Infrastructure (No Changes Needed)
#### Backend Models
-`SiteIntegration` model exists with WordPress support
- Location: `backend/igny8_core/business/integration/models.py`
- Fields: `platform`, `credentials_json`, `sync_enabled`, `last_sync_at`, `sync_status`, `sync_error`
- Supports: WordPress, Shopify, Custom API
-`SiteBlueprintTaxonomy` model (from Stage 1)
- Location: `backend/igny8_core/business/site_building/models.py`
- Has `external_reference` field for WordPress taxonomy IDs
-`ContentClusterMap`, `ContentTaxonomyMap`, `ContentAttributeMap` (from Stage 1)
- Location: `backend/igny8_core/business/content/models.py`
- Support metadata mapping for sync
#### Backend Services
-`WordPressAdapter` - Basic publishing to WordPress
- Location: `backend/igny8_core/business/publishing/services/adapters/wordpress_adapter.py`
- Methods: `publish()`, `test_connection()`, `get_status()`
- **Status:** Works for posts, needs taxonomy/product support
-`ContentSyncService` - Sync service skeleton
- Location: `backend/igny8_core/business/integration/services/content_sync_service.py`
- Methods: `sync_to_external()`, `sync_from_external()`
- **Status:** WordPress sync methods exist but are incomplete (TODOs)
-`TaxonomyService` - Taxonomy CRUD (from Stage 1)
- Location: `backend/igny8_core/business/site_building/services/taxonomy_service.py`
- Method: `import_from_external()` - Ready for WordPress import
-`SitesRendererAdapter` - IGNY8 deployment
- Location: `backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py`
- **Status:** Works, needs Stage 3 metadata integration
-`WordPressClient` - WordPress REST API client
- Location: `backend/igny8_core/utils/wordpress.py`
- Methods: `test_connection()`, `create_post()`
- **Status:** Basic functionality, needs taxonomy/product endpoints
#### Backend APIs
- ✅ Integration endpoints exist
- Location: `backend/igny8_core/modules/integration/views.py`
- Endpoints: `/api/v1/integration/integrations/{id}/sync/`, `/api/v1/integration/integrations/{id}/sync_status/`
- **Status:** Basic sync endpoints exist, need Stage 4 enhancements
---
## 🎯 Implementation Tasks
### Phase 1: Backend - WordPress Taxonomy & Product Sync
#### Task 1.1: Enhance WordPressClient
**File:** `backend/igny8_core/utils/wordpress.py`
**Add Methods:**
```python
def get_categories(self) -> List[Dict[str, Any]]
def create_category(self, name: str, slug: str, parent_id: Optional[int] = None) -> Dict[str, Any]
def get_tags(self) -> List[Dict[str, Any]]
def create_tag(self, name: str, slug: str) -> Dict[str, Any]
def get_products(self) -> List[Dict[str, Any]] # WooCommerce
def create_product(self, product_data: Dict[str, Any]) -> Dict[str, Any]
def get_product_attributes(self) -> List[Dict[str, Any]] # WooCommerce
```
**Breaking Changes:** None - All new methods
#### Task 1.2: Enhance ContentSyncService for Taxonomies
**File:** `backend/igny8_core/business/integration/services/content_sync_service.py`
**Enhance `_sync_from_wordpress()`:**
- Fetch WordPress categories/tags via WordPressClient
- Map to IGNY8 `SiteBlueprintTaxonomy` using `TaxonomyService.import_from_external()`
- Store `external_reference` (WP taxonomy ID) in `SiteBlueprintTaxonomy.external_reference`
- Auto-create missing clusters with `imported=True` flag
**Enhance `_sync_to_wordpress()`:**
- Ensure taxonomies exist in WordPress before publishing content
- Create missing categories/tags via WordPressClient
- Update `external_reference` after creation
- Push product attributes/tags before product content
**Breaking Changes:** None - Enhances existing methods
#### Task 1.3: Add Sync Health Service
**New File:** `backend/igny8_core/business/integration/services/sync_health_service.py`
**Purpose:** Track sync health, mismatches, errors
**Methods:**
```python
class SyncHealthService:
def get_sync_status(site_id: int, integration_id: Optional[int] = None) -> Dict[str, Any]
def get_mismatches(site_id: int) -> Dict[str, Any]
def get_sync_logs(site_id: int, limit: int = 100) -> List[Dict[str, Any]]
def record_sync_run(integration_id: int, result: Dict[str, Any]) -> None
```
**Breaking Changes:** None - New service
---
### Phase 2: Backend - Deployment Readiness
#### Task 2.1: Create Deployment Readiness Service
**New File:** `backend/igny8_core/business/publishing/services/deployment_readiness_service.py`
**Purpose:** Check if site is ready for deployment
**Methods:**
```python
class DeploymentReadinessService:
def check_readiness(site_blueprint_id: int) -> Dict[str, Any]
# Returns:
# {
# 'ready': bool,
# 'checks': {
# 'cluster_coverage': bool,
# 'content_validation': bool,
# 'sync_status': bool,
# 'taxonomy_completeness': bool
# },
# 'errors': List[str],
# 'warnings': List[str]
# }
```
**Breaking Changes:** None - New service
#### Task 2.2: Enhance SitesRendererAdapter with Stage 3 Metadata
**File:** `backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py`
**Enhance `_build_site_definition()`:**
- Include cluster metadata in navigation
- Include taxonomy metadata for breadcrumbs
- Include internal links from `ContentClusterMap`
**Breaking Changes:** None - Adds optional metadata fields
---
### Phase 3: Backend - API Endpoints
#### Task 3.1: Add Sync Health Endpoints
**File:** `backend/igny8_core/modules/integration/views.py`
**Add to IntegrationViewSet:**
```python
@action(detail=False, methods=['get'], url_path='sites/(?P<site_id>[^/.]+)/sync/status')
def sync_status_by_site(self, request, site_id=None):
"""GET /api/v1/integration/integrations/sites/{site_id}/sync/status/"""
@action(detail=False, methods=['post'], url_path='sites/(?P<site_id>[^/.]+)/sync/run')
def run_sync(self, request, site_id=None):
"""POST /api/v1/integration/integrations/sites/{site_id}/sync/run/"""
```
**Breaking Changes:** None - New endpoints
#### Task 3.2: Add Deployment Readiness Endpoint
**File:** `backend/igny8_core/modules/publisher/views.py` (or create new)
**New Endpoint:**
```python
@api_view(['GET'])
def deployment_readiness(request, blueprint_id):
"""GET /api/v1/publisher/blueprints/{blueprint_id}/readiness/"""
```
**Breaking Changes:** None - New endpoint
---
### Phase 4: Frontend - Sync Dashboard
#### Task 4.1: Create Sync Dashboard Component
**New File:** `frontend/src/pages/Sites/SyncDashboard.tsx`
**Features:**
- Parity indicators (taxonomies, products, posts)
- Manual sync button
- Retry failed items
- View logs with pagination
- Detail drawer for mismatches
**API Integration:**
- `GET /api/v1/integration/integrations/sites/{site_id}/sync/status/`
- `POST /api/v1/integration/integrations/sites/{site_id}/sync/run/`
**Breaking Changes:** None - New page
#### Task 4.2: Add Sync Status to Site Dashboard
**File:** `frontend/src/pages/Sites/Dashboard.tsx`
**Enhancement:**
- Add sync status widget
- Show last sync time
- Show sync errors if any
**Breaking Changes:** None - Adds optional widget
---
### Phase 5: Frontend - Deployment Panel
#### Task 5.1: Create Deployment Panel Component
**New File:** `frontend/src/components/sites/DeploymentPanel.tsx`
**Features:**
- Readiness checklist
- Deploy button (disabled if not ready)
- Rollback button
- Confirmation modals
- Toast notifications
**API Integration:**
- `GET /api/v1/publisher/blueprints/{blueprint_id}/readiness/`
- `POST /api/v1/publisher/deploy/{blueprint_id}/` (existing)
- `POST /api/v1/publisher/rollback/{deployment_id}/` (new)
**Breaking Changes:** None - New component
#### Task 5.2: Integrate Deployment Panel
**File:** `frontend/src/pages/Sites/Dashboard.tsx`
**Enhancement:**
- Add deployment panel section
- Show readiness status
- Link to deployment history
**Breaking Changes:** None - Adds optional section
---
### Phase 6: Frontend - WordPress Connection UI
#### Task 6.1: Enhance Integration Settings UI
**File:** `frontend/src/pages/Settings/Integrations.tsx` (or create new)
**Features:**
- Show WordPress integration status
- Credential health check
- Last sync time
- Active site type detection
- Troubleshooting helper text
- Links to docs/runbook
**API Integration:**
- `GET /api/v1/integration/integrations/{id}/sync_status/` (existing)
- `POST /api/v1/integration/integrations/{id}/test_connection/` (existing)
**Breaking Changes:** None - Enhances existing UI
---
## 🔒 Backward Compatibility Guarantees
### No Breaking Changes Policy
1. **All new endpoints are additive** - No existing endpoints modified
2. **All new methods are optional** - Existing code continues to work
3. **Database changes are additive** - New fields are nullable
4. **Service enhancements are backward compatible** - Old behavior preserved
### Migration Strategy
1. **Feature Flag:** `USE_STAGE4_SYNC` (default: `False`)
2. **Gradual Rollout:** Enable per site/integration
3. **Fallback:** If Stage 4 features fail, fall back to existing behavior
---
## 📊 Implementation Checklist
### Backend
- [ ] Task 1.1: Enhance WordPressClient with taxonomy/product methods
- [ ] Task 1.2: Enhance ContentSyncService for taxonomies
- [ ] Task 1.3: Create SyncHealthService
- [ ] Task 2.1: Create DeploymentReadinessService
- [ ] Task 2.2: Enhance SitesRendererAdapter with Stage 3 metadata
- [ ] Task 3.1: Add sync health endpoints
- [ ] Task 3.2: Add deployment readiness endpoint
### Frontend
- [ ] Task 4.1: Create Sync Dashboard component
- [ ] Task 4.2: Add sync status to Site Dashboard
- [ ] Task 5.1: Create Deployment Panel component
- [ ] Task 5.2: Integrate Deployment Panel
- [ ] Task 6.1: Enhance WordPress Connection UI
### Testing
- [ ] Unit tests for new services
- [ ] Integration tests for sync flows
- [ ] E2E tests for deployment panel
- [ ] Manual testing with live WordPress instance
### Documentation
- [ ] Update API documentation
- [ ] Create operational runbooks
- [ ] Update user documentation
---
## 🚨 Risk Mitigation
### Risk 1: WordPress API Rate Limits
**Mitigation:**
- Implement backoff + batching in WordPressClient
- Show rate limit status in dashboard
- Queue sync operations
### Risk 2: Data Mismatches
**Mitigation:**
- Detailed diff view in dashboard
- Retry actions for failed items
- Documented runbook for manual fixes
### Risk 3: Deployment Failures
**Mitigation:**
- Preflight checks before deploy
- Rollback button with confirmation
- Structured logs for debugging
### Risk 4: Breaking Existing Functionality
**Mitigation:**
- Feature flag protection
- Comprehensive testing
- Gradual rollout plan
---
## 📝 Next Steps
1. **Review this plan** with team
2. **Create feature branch:** `feature/stage4-sync-integration`
3. **Start with Phase 1** (Backend WordPress sync)
4. **Test incrementally** after each phase
5. **Document as you go** - Update this plan with progress
---
*This plan ensures Stage 4 implementation without breaking existing functionality.*