From ec3ca2da5d7e17c5e925be438a737d677143472d Mon Sep 17 00:00:00 2001 From: alorig <220087330+alorig@users.noreply.github.com> Date: Thu, 20 Nov 2025 03:30:39 +0500 Subject: [PATCH] stage 4-1 --- .../services/content_sync_service.py | 412 +++++++++++++++++- backend/igny8_core/utils/wordpress.py | 377 +++++++++++++++- .../src/components/linker/LinkResults.tsx | 34 +- .../components/sites/SiteProgressWidget.tsx | 89 +++- .../src/pages/Optimizer/AnalysisPreview.tsx | 16 +- frontend/src/pages/Sites/PostEditor.tsx | 106 +++-- refactor-plan/STAGE4_IMPLEMENTATION_PLAN.md | 354 +++++++++++++++ 7 files changed, 1304 insertions(+), 84 deletions(-) create mode 100644 refactor-plan/STAGE4_IMPLEMENTATION_PLAN.md diff --git a/backend/igny8_core/business/integration/services/content_sync_service.py b/backend/igny8_core/business/integration/services/content_sync_service.py index 19c78e9b..247293dd 100644 --- a/backend/igny8_core/business/integration/services/content_sync_service.py +++ b/backend/igny8_core/business/integration/services/content_sync_service.py @@ -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, diff --git a/backend/igny8_core/utils/wordpress.py b/backend/igny8_core/utils/wordpress.py index 40131435..e02d2c89 100644 --- a/backend/igny8_core/utils/wordpress.py +++ b/backend/igny8_core/utils/wordpress.py @@ -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 [] diff --git a/frontend/src/components/linker/LinkResults.tsx b/frontend/src/components/linker/LinkResults.tsx index acf6b30c..68193e32 100644 --- a/frontend/src/components/linker/LinkResults.tsx +++ b/frontend/src/components/linker/LinkResults.tsx @@ -23,6 +23,24 @@ export const LinkResults: React.FC = ({ linksAdded, linkerVersion, }) => { + if (!links || links.length === 0) { + return ( +
+
+

Linking Results

+
+ + Version {linkerVersion} +
+
+
+ +

No links found to add

+
+
+ ); + } + return (
@@ -51,14 +69,14 @@ export const LinkResults: React.FC = ({
    {links.filter(l => l.cluster_match).map((link, index) => (
  • - "{link.anchor_text}" + "{link.anchor_text || 'Untitled'}" - Content #{link.target_content_id} + Content #{link.target_content_id || 'N/A'} - {link.relevance_score && ( + {link.relevance_score !== undefined && ( - (Score: {link.relevance_score}) + (Score: {link.relevance_score.toFixed(1)}) )}
  • @@ -76,14 +94,14 @@ export const LinkResults: React.FC = ({
      {links.filter(l => !l.cluster_match || l.cluster_match === false).map((link, index) => (
    • - "{link.anchor_text}" + "{link.anchor_text || 'Untitled'}" - Content #{link.target_content_id} + Content #{link.target_content_id || 'N/A'} - {link.relevance_score && ( + {link.relevance_score !== undefined && ( - (Score: {link.relevance_score}) + (Score: {link.relevance_score.toFixed(1)}) )}
    • diff --git a/frontend/src/components/sites/SiteProgressWidget.tsx b/frontend/src/components/sites/SiteProgressWidget.tsx index d56cd47d..0f123149 100644 --- a/frontend/src/components/sites/SiteProgressWidget.tsx +++ b/frontend/src/components/sites/SiteProgressWidget.tsx @@ -18,6 +18,8 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress const navigate = useNavigate(); const [progress, setProgress] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( -
      - Loading progress... +
      +
      +
      Loading progress...
      +
      + + ); + } + + if (error && !progress) { + return ( + +
      + +
      {error}
      +
      ); } if (!progress) { - return null; + return ( + +
      + +

      No progress data available

      +
      +
      + ); } const getStatusColor = (status: string) => { @@ -76,16 +114,16 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress return (
      -

      +

      Site Progress: {progress.blueprint_name}

      - + {progress.overall_status ? progress.overall_status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) : 'Unknown'}
      {/* Overall Stats */} -
      +
      {progress.cluster_coverage.covered_clusters}/{progress.cluster_coverage.total_clusters} @@ -107,11 +145,12 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
      {/* Cluster Progress */} -
      -

      +
      +

      Cluster Coverage

      - {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
      @@ -203,15 +243,21 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress )}

      ); - })} + }) + ) : ( +
      + +

      No clusters found. Attach clusters to get started.

      +
      + )}
      {/* Validation Flags Summary */} -
      -

      +
      +

      Validation Status

      -
      +
      {progress.validation_flags.clusters_attached ? ( @@ -251,11 +297,24 @@ export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgress
      + + {/* Error banner if data loaded but has errors */} + {error && progress && ( +
      +
      + +
      + Some data may be outdated. +
      +
      +
      + )} ); } diff --git a/frontend/src/pages/Optimizer/AnalysisPreview.tsx b/frontend/src/pages/Optimizer/AnalysisPreview.tsx index ebbdb795..b20b4e16 100644 --- a/frontend/src/pages/Optimizer/AnalysisPreview.tsx +++ b/frontend/src/pages/Optimizer/AnalysisPreview.tsx @@ -86,11 +86,23 @@ export default function AnalysisPreview() { {loading || analyzing ? (
      -
      -

      +

      +

      {loading ? 'Loading content...' : 'Analyzing content...'}

      + ) : !content ? ( +
      + +

      Content not found

      +

      Unable to load content for analysis

      +
      + ) : !scores ? ( +
      + +

      Analysis unavailable

      +

      Unable to analyze this content

      +
      ) : content && scores ? (
      {/* Content Info */} diff --git a/frontend/src/pages/Sites/PostEditor.tsx b/frontend/src/pages/Sites/PostEditor.tsx index d51bfaca..a716c7c6 100644 --- a/frontend/src/pages/Sites/PostEditor.tsx +++ b/frontend/src/pages/Sites/PostEditor.tsx @@ -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'} @@ -632,11 +634,11 @@ export default function PostEditor() {
      {/* Metadata Summary */} -
      -

      +
      +

      Metadata Summary

      -
      +
      Entity Type: @@ -668,7 +670,7 @@ export default function PostEditor() { {/* Validation Errors */} {validationResult.validation_errors.length > 0 && ( -
      +

      Validation Errors

      @@ -695,7 +697,7 @@ export default function PostEditor() { {/* Publish Errors */} {validationResult.publish_errors && validationResult.publish_errors.length > 0 && ( -
      +

      Publish Blockers

      @@ -721,8 +723,10 @@ export default function PostEditor() { )}
      ) : ( -
      -

      Click "Run Validation" to check your content

      +
      + +

      No validation results yet

      +

      Click "Run Validation" to check your content

      )}
      @@ -733,7 +737,7 @@ export default function PostEditor() { {/* Stage 3: Sidebar with Metadata Summary */} {content.id && ( -
      +

      Content Metadata @@ -741,45 +745,53 @@ export default function PostEditor() {
      {/* Entity Type */} - {content.entity_type && ( -
      -
      - Entity Type -
      -
      - {content.entity_type ? content.entity_type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) : '-'} -
      +
      +
      + Entity Type
      - )} +
      + {content.entity_type ? ( + content.entity_type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) + ) : ( + Not set + )} +
      +
      {/* Cluster */} - {content.cluster_name && ( -
      -
      - Cluster -
      -
      - {content.cluster_name} - {content.cluster_role && ( - - ({content.cluster_role}) - - )} -
      +
      +
      + Cluster
      - )} +
      + {content.cluster_name ? ( + <> + {content.cluster_name} + {content.cluster_role && ( + + ({content.cluster_role}) + + )} + + ) : ( + Not assigned + )} +
      +
      {/* Taxonomy */} - {content.taxonomy_name && ( -
      -
      - Taxonomy -
      -
      - {content.taxonomy_name} -
      +
      +
      + Taxonomy
      - )} +
      + {content.taxonomy_name ? ( + content.taxonomy_name + ) : ( + Not assigned + )} +
      +
      {/* Validation Status */} {validationResult && ( @@ -803,21 +815,27 @@ export default function PostEditor() { Quick Actions
      - {content.cluster_id && ( + {content.cluster_id ? ( + ) : ( + No cluster assigned )} - {content.taxonomy_id && ( + {content.taxonomy_id ? ( + ) : ( + No taxonomy assigned )}
      diff --git a/refactor-plan/STAGE4_IMPLEMENTATION_PLAN.md b/refactor-plan/STAGE4_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..d824588d --- /dev/null +++ b/refactor-plan/STAGE4_IMPLEMENTATION_PLAN.md @@ -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[^/.]+)/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[^/.]+)/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.* +