stage 4-1
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Content Sync Service
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
Stage 4: Enhanced with taxonomy and product sync
|
||||
|
||||
Syncs content between IGNY8 and external platforms.
|
||||
"""
|
||||
@@ -8,6 +9,7 @@ import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.utils.wordpress import WordPressClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -98,6 +100,7 @@ class ContentSyncService:
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync content from IGNY8 to WordPress.
|
||||
Stage 4: Enhanced to sync taxonomies before content.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
@@ -106,15 +109,67 @@ class ContentSyncService:
|
||||
Returns:
|
||||
dict: Sync result
|
||||
"""
|
||||
# TODO: Implement WordPress sync
|
||||
# This will use the WordPress adapter to publish content
|
||||
logger.info(f"[ContentSyncService] Syncing to WordPress for integration {integration.id}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': 0,
|
||||
'message': 'WordPress sync to external not yet fully implemented'
|
||||
}
|
||||
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_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(
|
||||
self,
|
||||
@@ -183,6 +238,7 @@ class ContentSyncService:
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Internal method for syncing from WordPress (used by sync_from_external).
|
||||
Stage 4: Enhanced to sync taxonomies and products.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
@@ -191,7 +247,46 @@ class ContentSyncService:
|
||||
Returns:
|
||||
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(
|
||||
self,
|
||||
@@ -206,10 +301,299 @@ class ContentSyncService:
|
||||
Returns:
|
||||
List of post dictionaries
|
||||
"""
|
||||
# TODO: Implement actual WordPress API call
|
||||
# For now, return empty list - tests will mock this
|
||||
logger.info(f"[ContentSyncService] Fetching WordPress posts for integration {integration.id}")
|
||||
return []
|
||||
try:
|
||||
credentials = integration.get_credentials()
|
||||
client = WordPressClient(
|
||||
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(
|
||||
self,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
WordPress Integration Service
|
||||
Handles communication with WordPress sites via REST API
|
||||
Stage 4: Enhanced with taxonomy and WooCommerce product support
|
||||
"""
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, List
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +27,7 @@ class WordPressClient:
|
||||
"""
|
||||
self.site_url = site_url.rstrip('/')
|
||||
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.username = username
|
||||
self.app_password = app_password
|
||||
@@ -215,4 +217,377 @@ class WordPressClient:
|
||||
'message': None,
|
||||
'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 []
|
||||
|
||||
|
||||
@@ -23,6 +23,24 @@ export const LinkResults: React.FC<LinkResultsProps> = ({
|
||||
linksAdded,
|
||||
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 (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -51,14 +69,14 @@ export const LinkResults: React.FC<LinkResultsProps> = ({
|
||||
<ul className="space-y-2">
|
||||
{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">
|
||||
<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-blue-600 dark:text-blue-400">
|
||||
Content #{link.target_content_id}
|
||||
Content #{link.target_content_id || 'N/A'}
|
||||
</span>
|
||||
{link.relevance_score && (
|
||||
{link.relevance_score !== undefined && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
(Score: {link.relevance_score})
|
||||
(Score: {link.relevance_score.toFixed(1)})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
@@ -76,14 +94,14 @@ export const LinkResults: React.FC<LinkResultsProps> = ({
|
||||
<ul className="space-y-2">
|
||||
{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">
|
||||
<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-blue-600 dark:text-blue-400">
|
||||
Content #{link.target_content_id}
|
||||
Content #{link.target_content_id || 'N/A'}
|
||||
</span>
|
||||
{link.relevance_score && (
|
||||
{link.relevance_score !== undefined && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
(Score: {link.relevance_score})
|
||||
(Score: {link.relevance_score.toFixed(1)})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -18,6 +18,8 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
|
||||
const navigate = useNavigate();
|
||||
const [progress, setProgress] = useState<SiteProgress | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadProgress();
|
||||
@@ -26,27 +28,63 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
|
||||
const loadProgress = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchSiteProgress(blueprintId);
|
||||
setProgress(data);
|
||||
setRetryCount(0); // Reset retry count on success
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load site progress:', error);
|
||||
setError(error.message || 'Failed to load site progress. Please try again.');
|
||||
setProgress(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount(prev => prev + 1);
|
||||
loadProgress();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
Loading progress...
|
||||
<div className="text-center py-4">
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -76,16 +114,16 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<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}
|
||||
</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'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 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-2xl font-bold text-gray-900 dark:text-white">
|
||||
{progress.cluster_coverage.covered_clusters}/{progress.cluster_coverage.total_clusters}
|
||||
@@ -107,11 +145,12 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
|
||||
</div>
|
||||
|
||||
{/* Cluster Progress */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
<div className="space-y-4" aria-labelledby="cluster-coverage-title">
|
||||
<h4 id="cluster-coverage-title" className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Cluster Coverage
|
||||
</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 completionPercent = totalPages > 0 ? Math.min(100, (cluster.content_count / totalPages) * 100) : 0;
|
||||
|
||||
@@ -145,7 +184,8 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
|
||||
</div>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
@@ -203,15 +243,21 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Validation Flags Summary */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700" aria-labelledby="validation-status-title">
|
||||
<h4 id="validation-status-title" className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Validation Status
|
||||
</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">
|
||||
{progress.validation_flags.clusters_attached ? (
|
||||
<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">
|
||||
<button
|
||||
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 →
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,11 +86,23 @@ export default function AnalysisPreview() {
|
||||
|
||||
{loading || analyzing ? (
|
||||
<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>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mb-3"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{loading ? 'Loading content...' : 'Analyzing content...'}
|
||||
</p>
|
||||
</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 ? (
|
||||
<div className="space-y-6">
|
||||
{/* Content Info */}
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function PostEditor() {
|
||||
setValidationResult(result);
|
||||
} catch (error: any) {
|
||||
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"
|
||||
onClick={handleValidate}
|
||||
disabled={validating}
|
||||
aria-label="Run content validation"
|
||||
>
|
||||
{validating ? 'Validating...' : 'Run Validation'}
|
||||
</Button>
|
||||
@@ -632,11 +634,11 @@ export default function PostEditor() {
|
||||
</div>
|
||||
|
||||
{/* Metadata Summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4" role="region" aria-labelledby="metadata-summary-title">
|
||||
<h4 id="metadata-summary-title" className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Metadata Summary
|
||||
</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>
|
||||
<span className="text-gray-600 dark:text-gray-400">Entity Type:</span>
|
||||
<span className="ml-2 font-medium text-gray-900 dark:text-white">
|
||||
@@ -668,7 +670,7 @@ export default function PostEditor() {
|
||||
|
||||
{/* Validation Errors */}
|
||||
{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">
|
||||
Validation Errors
|
||||
</h4>
|
||||
@@ -695,7 +697,7 @@ export default function PostEditor() {
|
||||
|
||||
{/* Publish Errors */}
|
||||
{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">
|
||||
Publish Blockers
|
||||
</h4>
|
||||
@@ -721,8 +723,10 @@ export default function PostEditor() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>Click "Run Validation" to check your content</p>
|
||||
<div className="text-center py-8">
|
||||
<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>
|
||||
@@ -733,7 +737,7 @@ export default function PostEditor() {
|
||||
|
||||
{/* Stage 3: Sidebar with Metadata Summary */}
|
||||
{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">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Content Metadata
|
||||
@@ -741,45 +745,53 @@ export default function PostEditor() {
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Entity Type */}
|
||||
{content.entity_type && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
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 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
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())
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500 italic">Not set</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cluster */}
|
||||
{content.cluster_name && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
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 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Cluster
|
||||
</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 */}
|
||||
{content.taxonomy_name && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Taxonomy
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
{content.taxonomy_name}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Taxonomy
|
||||
</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 */}
|
||||
{validationResult && (
|
||||
@@ -803,21 +815,27 @@ export default function PostEditor() {
|
||||
Quick Actions
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{content.cluster_id && (
|
||||
{content.cluster_id ? (
|
||||
<button
|
||||
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 →
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No cluster assigned</span>
|
||||
)}
|
||||
{content.taxonomy_id && (
|
||||
{content.taxonomy_id ? (
|
||||
<button
|
||||
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 →
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No taxonomy assigned</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
354
refactor-plan/STAGE4_IMPLEMENTATION_PLAN.md
Normal file
354
refactor-plan/STAGE4_IMPLEMENTATION_PLAN.md
Normal 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.*
|
||||
|
||||
Reference in New Issue
Block a user