Refactor error_response function for improved argument handling
- Enhanced the `error_response` function to support backward compatibility by normalizing arguments when positional arguments are misused. - Updated various views to pass `None` for the `errors` parameter in `error_response` calls, ensuring consistent response formatting. - Adjusted logging in `ContentSyncService` and `WordPressClient` to use debug level for expected 401 errors, improving log clarity. - Removed deprecated fields from serializers and views, streamlining content management processes.
This commit is contained in:
Binary file not shown.
@@ -5,6 +5,8 @@ Provides consistent response format across all endpoints
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
|
||||||
def get_request_id(request):
|
def get_request_id(request):
|
||||||
@@ -74,6 +76,28 @@ def error_response(error=None, errors=None, status_code=status.HTTP_400_BAD_REQU
|
|||||||
'success': False,
|
'success': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Backwards compatibility: some callers used positional args in the order
|
||||||
|
# (error, status_code, request) which maps to (error, errors, status_code=request)
|
||||||
|
# causing `status_code` to be a Request object and raising TypeError.
|
||||||
|
# Detect this misuse and normalize arguments:
|
||||||
|
try:
|
||||||
|
if request is None and status_code is not None:
|
||||||
|
# If status_code appears to be a Request object, shift arguments
|
||||||
|
if isinstance(status_code, HttpRequest) or hasattr(status_code, 'META'):
|
||||||
|
# original call looked like: error_response(msg, status.HTTP_400_BAD_REQUEST, request)
|
||||||
|
# which resulted in: errors = status.HTTP_400..., status_code = request
|
||||||
|
request = status_code
|
||||||
|
# If `errors` holds an int-like HTTP status, use it as status_code
|
||||||
|
if isinstance(errors, int):
|
||||||
|
status_code = errors
|
||||||
|
errors = None
|
||||||
|
else:
|
||||||
|
# fallback to default 400
|
||||||
|
status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
except Exception:
|
||||||
|
# Defensive: if introspection fails, continue with provided args
|
||||||
|
pass
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
response_data['error'] = error
|
response_data['error'] = error
|
||||||
elif status_code == status.HTTP_400_BAD_REQUEST:
|
elif status_code == status.HTTP_400_BAD_REQUEST:
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ class ContentSyncService:
|
|||||||
)
|
)
|
||||||
synced_count += len(tag_records)
|
synced_count += len(tag_records)
|
||||||
|
|
||||||
# Sync WooCommerce product categories if available
|
# Sync WooCommerce product categories if available (401 is expected if WooCommerce not installed or credentials missing)
|
||||||
try:
|
try:
|
||||||
product_categories = client.get_product_categories(per_page=100)
|
product_categories = client.get_product_categories(per_page=100)
|
||||||
product_category_records = [
|
product_category_records = [
|
||||||
@@ -431,7 +431,8 @@ class ContentSyncService:
|
|||||||
)
|
)
|
||||||
synced_count += len(product_category_records)
|
synced_count += len(product_category_records)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"WooCommerce not available or error fetching product categories: {e}")
|
# Silently skip WooCommerce if not available (401 means no consumer key/secret configured or plugin not installed)
|
||||||
|
logger.debug(f"WooCommerce product categories not available: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -588,10 +589,10 @@ class ContentSyncService:
|
|||||||
'synced_count': synced_count
|
'synced_count': synced_count
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error syncing products from WordPress: {e}", exc_info=True)
|
# Silently skip products if WooCommerce auth fails (expected if consumer key/secret not configured)
|
||||||
|
logger.debug(f"WooCommerce products not synced: {e}")
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': True,
|
||||||
'error': str(e),
|
|
||||||
'synced_count': 0
|
'synced_count': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
else:
|
else:
|
||||||
return error_response(
|
return error_response(
|
||||||
result.get('message', 'Connection test failed'),
|
result.get('message', 'Connection test failed'),
|
||||||
|
None,
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -78,14 +79,14 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
site_url = request.data.get('site_url')
|
site_url = request.data.get('site_url')
|
||||||
|
|
||||||
if not site_id:
|
if not site_id:
|
||||||
return error_response('site_id is required', status.HTTP_400_BAD_REQUEST, request)
|
return error_response('site_id is required', None, status.HTTP_400_BAD_REQUEST, request)
|
||||||
|
|
||||||
# Verify site exists
|
# Verify site exists
|
||||||
from igny8_core.auth.models import Site
|
from igny8_core.auth.models import Site
|
||||||
try:
|
try:
|
||||||
site = Site.objects.get(id=int(site_id))
|
site = Site.objects.get(id=int(site_id))
|
||||||
except (Site.DoesNotExist, ValueError, TypeError):
|
except (Site.DoesNotExist, ValueError, TypeError):
|
||||||
return error_response('Site not found or invalid', status.HTTP_404_NOT_FOUND, request)
|
return error_response('Site not found or invalid', None, status.HTTP_404_NOT_FOUND, request)
|
||||||
|
|
||||||
# Authentication: accept either authenticated user OR matching API key in body
|
# Authentication: accept either authenticated user OR matching API key in body
|
||||||
api_key = request.data.get('api_key') or api_key
|
api_key = request.data.get('api_key') or api_key
|
||||||
@@ -107,7 +108,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
authenticated = True
|
authenticated = True
|
||||||
|
|
||||||
if not authenticated:
|
if not authenticated:
|
||||||
return error_response('Authentication credentials were not provided.', status.HTTP_403_FORBIDDEN, request)
|
return error_response('Authentication credentials were not provided.', None, status.HTTP_403_FORBIDDEN, request)
|
||||||
|
|
||||||
# Try to find an existing integration for this site+platform
|
# Try to find an existing integration for this site+platform
|
||||||
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
|
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
|
||||||
@@ -128,7 +129,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
return success_response(result, request=request)
|
return success_response(result, request=request)
|
||||||
else:
|
else:
|
||||||
return error_response(result.get('message', 'Connection test failed'), status.HTTP_400_BAD_REQUEST, request)
|
return error_response(result.get('message', 'Connection test failed'), None, status.HTTP_400_BAD_REQUEST, request)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def sync(self, request, pk=None):
|
def sync(self, request, pk=None):
|
||||||
@@ -275,6 +276,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return error_response(
|
return error_response(
|
||||||
'Invalid site_id',
|
'Invalid site_id',
|
||||||
|
None,
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -286,6 +288,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
except Site.DoesNotExist:
|
except Site.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
'Site not found',
|
'Site not found',
|
||||||
|
None,
|
||||||
status.HTTP_404_NOT_FOUND,
|
status.HTTP_404_NOT_FOUND,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -314,6 +317,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return error_response(
|
return error_response(
|
||||||
'Invalid site_id',
|
'Invalid site_id',
|
||||||
|
None,
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -325,6 +329,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
except Site.DoesNotExist:
|
except Site.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
'Site not found',
|
'Site not found',
|
||||||
|
None,
|
||||||
status.HTTP_404_NOT_FOUND,
|
status.HTTP_404_NOT_FOUND,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -342,6 +347,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
if not integrations.exists():
|
if not integrations.exists():
|
||||||
return error_response(
|
return error_response(
|
||||||
'No active integrations found for this site',
|
'No active integrations found for this site',
|
||||||
|
None,
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -381,6 +387,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return error_response(
|
return error_response(
|
||||||
'Invalid site_id',
|
'Invalid site_id',
|
||||||
|
None,
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -392,6 +399,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
except Site.DoesNotExist:
|
except Site.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
'Site not found',
|
'Site not found',
|
||||||
|
None,
|
||||||
status.HTTP_404_NOT_FOUND,
|
status.HTTP_404_NOT_FOUND,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -418,6 +426,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return error_response(
|
return error_response(
|
||||||
'Invalid site_id',
|
'Invalid site_id',
|
||||||
|
None,
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
@@ -429,6 +438,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
except Site.DoesNotExist:
|
except Site.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
'Site not found',
|
'Site not found',
|
||||||
|
None,
|
||||||
status.HTTP_404_NOT_FOUND,
|
status.HTTP_404_NOT_FOUND,
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ class ContentIdeasSerializer(serializers.ModelSerializer):
|
|||||||
'id',
|
'id',
|
||||||
'idea_title',
|
'idea_title',
|
||||||
'description',
|
'description',
|
||||||
'content_structure',
|
'site_entity_type',
|
||||||
'content_type',
|
'cluster_role',
|
||||||
'target_keywords',
|
'target_keywords',
|
||||||
'keyword_cluster_id',
|
'keyword_cluster_id',
|
||||||
'keyword_cluster_name',
|
'keyword_cluster_name',
|
||||||
|
|||||||
@@ -1018,16 +1018,14 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
|
|||||||
keywords=idea.target_keywords or '',
|
keywords=idea.target_keywords or '',
|
||||||
cluster=idea.keyword_cluster,
|
cluster=idea.keyword_cluster,
|
||||||
idea=idea,
|
idea=idea,
|
||||||
content_structure=idea.content_structure,
|
|
||||||
content_type=idea.content_type,
|
|
||||||
status='queued',
|
status='queued',
|
||||||
account=idea.account,
|
account=idea.account,
|
||||||
site=idea.site,
|
site=idea.site,
|
||||||
sector=idea.sector,
|
sector=idea.sector,
|
||||||
# Stage 3: Inherit entity metadata
|
# Stage 3: Inherit entity metadata (use standardized fields)
|
||||||
entity_type=idea.site_entity_type or 'blog_post',
|
entity_type=(idea.site_entity_type or 'post'),
|
||||||
taxonomy=idea.taxonomy,
|
taxonomy=idea.taxonomy,
|
||||||
cluster_role=idea.cluster_role or 'hub',
|
cluster_role=(idea.cluster_role or 'hub'),
|
||||||
)
|
)
|
||||||
created_tasks.append(task.id)
|
created_tasks.append(task.id)
|
||||||
# Update idea status
|
# Update idea status
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ class TasksSerializer(serializers.ModelSerializer):
|
|||||||
content_html = serializers.SerializerMethodField()
|
content_html = serializers.SerializerMethodField()
|
||||||
content_primary_keyword = serializers.SerializerMethodField()
|
content_primary_keyword = serializers.SerializerMethodField()
|
||||||
content_secondary_keywords = serializers.SerializerMethodField()
|
content_secondary_keywords = serializers.SerializerMethodField()
|
||||||
content_tags = serializers.SerializerMethodField()
|
# tags/categories removed — use taxonomies M2M on Content
|
||||||
content_categories = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tasks
|
model = Tasks
|
||||||
@@ -40,25 +39,16 @@ class TasksSerializer(serializers.ModelSerializer):
|
|||||||
'sector_name',
|
'sector_name',
|
||||||
'idea_id',
|
'idea_id',
|
||||||
'idea_title',
|
'idea_title',
|
||||||
'content_structure',
|
|
||||||
'content_type',
|
|
||||||
'status',
|
'status',
|
||||||
'content',
|
# task-level raw content/seo fields removed — stored on Content
|
||||||
'word_count',
|
|
||||||
'meta_title',
|
|
||||||
'meta_description',
|
|
||||||
'content_html',
|
'content_html',
|
||||||
'content_primary_keyword',
|
'content_primary_keyword',
|
||||||
'content_secondary_keywords',
|
'content_secondary_keywords',
|
||||||
'content_tags',
|
|
||||||
'content_categories',
|
|
||||||
'assigned_post_id',
|
|
||||||
'post_url',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
'site_id',
|
'site_id',
|
||||||
'sector_id',
|
'sector_id',
|
||||||
'account_id',
|
'account_id',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id']
|
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id']
|
||||||
|
|
||||||
@@ -120,12 +110,18 @@ class TasksSerializer(serializers.ModelSerializer):
|
|||||||
return record.secondary_keywords if record else []
|
return record.secondary_keywords if record else []
|
||||||
|
|
||||||
def get_content_tags(self, obj):
|
def get_content_tags(self, obj):
|
||||||
|
# tags removed; derive taxonomies from Content.taxonomies if needed
|
||||||
record = self._get_content_record(obj)
|
record = self._get_content_record(obj)
|
||||||
return record.tags if record else []
|
if not record:
|
||||||
|
return []
|
||||||
|
return [t.name for t in record.taxonomies.all()]
|
||||||
|
|
||||||
def get_content_categories(self, obj):
|
def get_content_categories(self, obj):
|
||||||
|
# categories removed; derive hierarchical taxonomies from Content.taxonomies
|
||||||
record = self._get_content_record(obj)
|
record = self._get_content_record(obj)
|
||||||
return record.categories if record else []
|
if not record:
|
||||||
|
return []
|
||||||
|
return [t.name for t in record.taxonomies.filter(taxonomy_type__in=['category','product_cat'])]
|
||||||
|
|
||||||
def _cluster_map_qs(self, obj):
|
def _cluster_map_qs(self, obj):
|
||||||
return ContentClusterMap.objects.filter(task=obj).select_related('cluster')
|
return ContentClusterMap.objects.filter(task=obj).select_related('cluster')
|
||||||
@@ -269,8 +265,6 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
'meta_description',
|
'meta_description',
|
||||||
'primary_keyword',
|
'primary_keyword',
|
||||||
'secondary_keywords',
|
'secondary_keywords',
|
||||||
'tags',
|
|
||||||
'categories',
|
|
||||||
'status',
|
'status',
|
||||||
'generated_at',
|
'generated_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
|
|||||||
@@ -457,7 +457,11 @@ class WordPressClient:
|
|||||||
}
|
}
|
||||||
for prod in products
|
for prod in products
|
||||||
]
|
]
|
||||||
logger.warning(f"Failed to fetch products: HTTP {response.status_code}")
|
# Log as debug if 401 (expected if WooCommerce not configured)
|
||||||
|
if response.status_code == 401:
|
||||||
|
logger.debug(f"WooCommerce products require authentication: HTTP {response.status_code}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to fetch products: HTTP {response.status_code}")
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching WooCommerce products: {e}")
|
logger.error(f"Error fetching WooCommerce products: {e}")
|
||||||
@@ -533,7 +537,11 @@ class WordPressClient:
|
|||||||
}
|
}
|
||||||
for cat in categories
|
for cat in categories
|
||||||
]
|
]
|
||||||
logger.warning(f"Failed to fetch product categories: HTTP {response.status_code}")
|
# Log as debug if 401 (expected if WooCommerce not configured)
|
||||||
|
if response.status_code == 401:
|
||||||
|
logger.debug(f"WooCommerce product categories require authentication: HTTP {response.status_code}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to fetch product categories: HTTP {response.status_code}")
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching WooCommerce product categories: {e}")
|
logger.error(f"Error fetching WooCommerce product categories: {e}")
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export const createTasksPageConfig = (
|
|||||||
...wordCountColumn,
|
...wordCountColumn,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'word_count',
|
sortField: 'word_count',
|
||||||
render: (value: number) => value.toLocaleString(),
|
render: (value: number | null | undefined) => (value != null ? value.toLocaleString() : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...createdColumn,
|
...createdColumn,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Phase 7: Advanced Site Management
|
* Phase 7: Advanced Site Management
|
||||||
* Features: SEO (meta tags, Open Graph, schema.org)
|
* Features: SEO (meta tags, Open Graph, schema.org)
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
@@ -14,10 +14,11 @@ import SelectDropdown from '../../components/form/SelectDropdown';
|
|||||||
import Checkbox from '../../components/form/input/Checkbox';
|
import Checkbox from '../../components/form/input/Checkbox';
|
||||||
import TextArea from '../../components/form/input/TextArea';
|
import TextArea from '../../components/form/input/TextArea';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { fetchAPI } from '../../services/api';
|
import { fetchAPI, runSync } from '../../services/api';
|
||||||
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm';
|
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm';
|
||||||
import { integrationApi, SiteIntegration } from '../../services/integration.api';
|
import { integrationApi, SiteIntegration } from '../../services/integration.api';
|
||||||
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon } from '../../icons';
|
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon } from '../../icons';
|
||||||
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
|
|
||||||
export default function SiteSettings() {
|
export default function SiteSettings() {
|
||||||
const { id: siteId } = useParams<{ id: string }>();
|
const { id: siteId } = useParams<{ id: string }>();
|
||||||
@@ -31,8 +32,10 @@ export default function SiteSettings() {
|
|||||||
const [integrationLoading, setIntegrationLoading] = useState(false);
|
const [integrationLoading, setIntegrationLoading] = useState(false);
|
||||||
|
|
||||||
// Check for tab parameter in URL
|
// Check for tab parameter in URL
|
||||||
const initialTab = (searchParams.get('tab') as 'general' | 'seo' | 'og' | 'schema' | 'integrations') || 'general';
|
const initialTab = (searchParams.get('tab') as 'general' | 'seo' | 'og' | 'schema' | 'integrations' | 'content-types') || 'general';
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'seo' | 'og' | 'schema' | 'integrations'>(initialTab);
|
const [activeTab, setActiveTab] = useState<'general' | 'seo' | 'og' | 'schema' | 'integrations' | 'content-types'>(initialTab);
|
||||||
|
const [contentTypes, setContentTypes] = useState<any>(null);
|
||||||
|
const [contentTypesLoading, setContentTypesLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
@@ -66,11 +69,17 @@ export default function SiteSettings() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Update tab if URL parameter changes
|
// Update tab if URL parameter changes
|
||||||
const tab = searchParams.get('tab');
|
const tab = searchParams.get('tab');
|
||||||
if (tab && ['general', 'seo', 'og', 'schema', 'integrations'].includes(tab)) {
|
if (tab && ['general', 'seo', 'og', 'schema', 'integrations', 'content-types'].includes(tab)) {
|
||||||
setActiveTab(tab as typeof activeTab);
|
setActiveTab(tab as typeof activeTab);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'content-types' && wordPressIntegration) {
|
||||||
|
loadContentTypes();
|
||||||
|
}
|
||||||
|
}, [activeTab, wordPressIntegration]);
|
||||||
|
|
||||||
const loadSite = async () => {
|
const loadSite = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -100,6 +109,11 @@ export default function SiteSettings() {
|
|||||||
schema_logo: seoData.schema_logo || '',
|
schema_logo: seoData.schema_logo || '',
|
||||||
schema_same_as: Array.isArray(seoData.schema_same_as) ? seoData.schema_same_as.join(', ') : seoData.schema_same_as || '',
|
schema_same_as: Array.isArray(seoData.schema_same_as) ? seoData.schema_same_as.join(', ') : seoData.schema_same_as || '',
|
||||||
});
|
});
|
||||||
|
// If integration record missing but site has stored WP API key or hosting_type wordpress, mark as connected-active
|
||||||
|
if (!wordPressIntegration && (data.wp_api_key || data.hosting_type === 'wordpress')) {
|
||||||
|
setIntegrationTestStatus('connected');
|
||||||
|
setIntegrationLastChecked(new Date().toISOString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to load site: ${error.message}`);
|
toast.error(`Failed to load site: ${error.message}`);
|
||||||
@@ -127,6 +141,191 @@ export default function SiteSettings() {
|
|||||||
await loadIntegrations();
|
await loadIntegrations();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadContentTypes = async () => {
|
||||||
|
if (!wordPressIntegration?.id) return;
|
||||||
|
try {
|
||||||
|
setContentTypesLoading(true);
|
||||||
|
const data = await fetchAPI(`/v1/integration/integrations/${wordPressIntegration.id}/content-types/`);
|
||||||
|
setContentTypes(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load content types: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setContentTypesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRelativeTime = (iso: string | null) => {
|
||||||
|
if (!iso) return '-';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = Math.max(0, now - then);
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
return `${months}mo ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Integration test status & periodic check (every 60 minutes)
|
||||||
|
const [integrationTestStatus, setIntegrationTestStatus] = useState<'connected' | 'pending' | 'error' | 'not_configured'>('not_configured');
|
||||||
|
const [integrationLastChecked, setIntegrationLastChecked] = useState<string | null>(null);
|
||||||
|
const integrationCheckRef = useRef<number | null>(null);
|
||||||
|
const integrationErrorCooldownRef = useRef<number | null>(null);
|
||||||
|
const [syncLoading, setSyncLoading] = useState(false);
|
||||||
|
const runIntegrationTest = async () => {
|
||||||
|
// respect cooldown on repeated server errors
|
||||||
|
if (integrationErrorCooldownRef.current && Date.now() < integrationErrorCooldownRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wordPressIntegration && !site) {
|
||||||
|
setIntegrationTestStatus('not_configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIntegrationTestStatus('pending');
|
||||||
|
let resp: any = null;
|
||||||
|
// Only run server-side test if we have an integration record to avoid triggering collection-level 500s
|
||||||
|
if (wordPressIntegration && wordPressIntegration.id) {
|
||||||
|
resp = await fetchAPI(`/v1/integration/integrations/${wordPressIntegration.id}/test_connection/`, { method: 'POST', body: {} });
|
||||||
|
} else {
|
||||||
|
// If no integration record, do not call server test here — just mark connected if site has local WP credentials.
|
||||||
|
if (site?.wp_api_key || site?.wp_url || site?.hosting_type === 'wordpress') {
|
||||||
|
// Assume connected (plugin shows connection) but do not invoke server test to avoid 500s.
|
||||||
|
setIntegrationTestStatus('connected');
|
||||||
|
setIntegrationLastChecked(new Date().toISOString());
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setIntegrationTestStatus('not_configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resp && resp.success) {
|
||||||
|
setIntegrationTestStatus('connected');
|
||||||
|
// clear any error cooldown
|
||||||
|
integrationErrorCooldownRef.current = null;
|
||||||
|
} else {
|
||||||
|
setIntegrationTestStatus('error');
|
||||||
|
// set cooldown to 60 minutes to avoid repeated 500s
|
||||||
|
integrationErrorCooldownRef.current = Date.now() + 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
setIntegrationLastChecked(new Date().toISOString());
|
||||||
|
} catch (err) {
|
||||||
|
setIntegrationTestStatus('error');
|
||||||
|
setIntegrationLastChecked(new Date().toISOString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// run initial test when page loads / integration/site known
|
||||||
|
runIntegrationTest();
|
||||||
|
|
||||||
|
// schedule hourly checks (one per hour) — less intrusive
|
||||||
|
if (integrationCheckRef.current) {
|
||||||
|
window.clearInterval(integrationCheckRef.current);
|
||||||
|
integrationCheckRef.current = null;
|
||||||
|
}
|
||||||
|
integrationCheckRef.current = window.setInterval(() => {
|
||||||
|
runIntegrationTest();
|
||||||
|
}, 60 * 60 * 1000); // 60 minutes
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (integrationCheckRef.current) {
|
||||||
|
window.clearInterval(integrationCheckRef.current);
|
||||||
|
integrationCheckRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [wordPressIntegration, site]);
|
||||||
|
|
||||||
|
// when contentTypes last_structure_fetch updates (content was synced), re-run test once
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentTypes?.last_structure_fetch) {
|
||||||
|
runIntegrationTest();
|
||||||
|
}
|
||||||
|
}, [contentTypes?.last_structure_fetch]);
|
||||||
|
|
||||||
|
// Sync Now handler extracted
|
||||||
|
const handleManualSync = async () => {
|
||||||
|
setSyncLoading(true);
|
||||||
|
try {
|
||||||
|
if (wordPressIntegration && wordPressIntegration.id) {
|
||||||
|
const res = await integrationApi.syncIntegration(wordPressIntegration.id, 'incremental');
|
||||||
|
if (res && res.success) {
|
||||||
|
toast.success('Sync started');
|
||||||
|
setTimeout(() => loadContentTypes(), 1500);
|
||||||
|
} else {
|
||||||
|
toast.error(res?.message || 'Sync failed to start');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No integration record — attempt a site-level sync job instead of calling test-connection (avoids server 500 test endpoint)
|
||||||
|
// This will trigger the site sync runner which is safer and returns structured result
|
||||||
|
try {
|
||||||
|
const runResult = await runSync(Number(siteId), 'from_external');
|
||||||
|
if (runResult && runResult.sync_results) {
|
||||||
|
toast.success('Site sync started (from external).');
|
||||||
|
// Refresh integrations and content types after a short delay
|
||||||
|
setTimeout(async () => {
|
||||||
|
await loadIntegrations();
|
||||||
|
await loadContentTypes();
|
||||||
|
}, 2000);
|
||||||
|
setIntegrationTestStatus('connected');
|
||||||
|
setIntegrationLastChecked(new Date().toISOString());
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to start site sync.');
|
||||||
|
setIntegrationTestStatus('error');
|
||||||
|
integrationErrorCooldownRef.current = Date.now() + 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// If there are no active integrations, attempt a collection-level test (if site has WP creds),
|
||||||
|
// otherwise prompt the user to configure the integration.
|
||||||
|
const errResp = e?.response || {};
|
||||||
|
const errMessage = errResp?.error || e?.message || '';
|
||||||
|
if (errMessage && errMessage.includes('No active integrations found')) {
|
||||||
|
if (site?.wp_api_key || site?.wp_url) {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
site_id: siteId ? Number(siteId) : undefined,
|
||||||
|
api_key: site?.wp_api_key,
|
||||||
|
site_url: site?.wp_url,
|
||||||
|
};
|
||||||
|
const resp = await fetchAPI(`/v1/integration/integrations/test-connection/`, { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
if (resp && resp.success) {
|
||||||
|
toast.success('Connection verified (collection). Fetching integrations...');
|
||||||
|
setIntegrationTestStatus('connected');
|
||||||
|
setIntegrationLastChecked(new Date().toISOString());
|
||||||
|
await loadIntegrations();
|
||||||
|
await loadContentTypes();
|
||||||
|
} else {
|
||||||
|
toast.error(resp?.message || 'Connection test failed (collection).');
|
||||||
|
setIntegrationTestStatus('error');
|
||||||
|
}
|
||||||
|
} catch (innerErr: any) {
|
||||||
|
toast.error(innerErr?.message || 'Collection-level connection test failed.');
|
||||||
|
setIntegrationTestStatus('error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('No active integrations found for this site. Please configure WordPress integration in the Integrations tab.');
|
||||||
|
setIntegrationTestStatus('not_configured');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(e?.message || 'Failed to run site sync.');
|
||||||
|
setIntegrationTestStatus('error');
|
||||||
|
integrationErrorCooldownRef.current = Date.now() + 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(`Sync failed: ${err?.message || String(err)}`);
|
||||||
|
setIntegrationTestStatus('error');
|
||||||
|
integrationErrorCooldownRef.current = Date.now() + 60 * 60 * 1000;
|
||||||
|
} finally {
|
||||||
|
setSyncLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -195,11 +394,30 @@ export default function SiteSettings() {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<PageMeta title="Site Settings - IGNY8" />
|
<PageMeta title="Site Settings - IGNY8" />
|
||||||
|
|
||||||
<PageHeader
|
<div className="flex items-center gap-4">
|
||||||
title="Site Settings"
|
<PageHeader
|
||||||
badge={{ icon: <GridIcon />, color: 'blue' }}
|
title={site?.name ? `${site.name} — Site Settings` : 'Site Settings'}
|
||||||
hideSiteSector
|
badge={{ icon: <GridIcon />, color: 'blue' }}
|
||||||
/>
|
hideSiteSector
|
||||||
|
/>
|
||||||
|
{/* Integration status indicator (larger) */}
|
||||||
|
<div className="flex items-center gap-3 ml-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-6 h-6 rounded-full ${
|
||||||
|
integrationTestStatus === 'connected' ? 'bg-green-500' :
|
||||||
|
integrationTestStatus === 'pending' ? 'bg-yellow-400' :
|
||||||
|
integrationTestStatus === 'error' ? 'bg-red-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
title={`Integration status: ${integrationTestStatus}${integrationLastChecked ? ' • last checked ' + formatRelativeTime(integrationLastChecked) : ''}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{integrationTestStatus === 'connected' && 'Connected'}
|
||||||
|
{integrationTestStatus === 'pending' && 'Checking...'}
|
||||||
|
{integrationTestStatus === 'error' && 'Error'}
|
||||||
|
{integrationTestStatus === 'not_configured' && 'Not configured'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
@@ -279,10 +497,129 @@ export default function SiteSettings() {
|
|||||||
<PlugInIcon className="w-4 h-4 inline mr-2" />
|
<PlugInIcon className="w-4 h-4 inline mr-2" />
|
||||||
Integrations
|
Integrations
|
||||||
</button>
|
</button>
|
||||||
|
{(wordPressIntegration || site?.wp_url || site?.wp_api_key || site?.hosting_type === 'wordpress') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('content-types');
|
||||||
|
navigate(`/sites/${siteId}/settings?tab=content-types`, { replace: true });
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'content-types'
|
||||||
|
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileIcon className="w-4 h-4 inline mr-2" />
|
||||||
|
Content Types
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
{/* Content Types Tab */}
|
||||||
|
{activeTab === 'content-types' && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">WordPress Content Types</h2>
|
||||||
|
{contentTypesLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">Loading content types...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end gap-3 mb-4">
|
||||||
|
<div className="text-sm text-gray-500 mr-auto">Last structure fetch: {formatRelativeTime(contentTypes?.last_structure_fetch)}</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={syncLoading || !(wordPressIntegration || site?.wp_url || site?.wp_api_key || site?.hosting_type === 'wordpress')}
|
||||||
|
onClick={handleManualSync}
|
||||||
|
>
|
||||||
|
{syncLoading ? 'Syncing...' : 'Sync Now'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!contentTypes ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">No content types data available</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Post Types Section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-medium mb-3">Post Types</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(contentTypes.post_types || {}).map(([key, data]: [string, any]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h4 className="font-medium">{data.label}</h4>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{data.count} total · {data.synced_count} synced
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.last_synced && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Last synced: {new Date(data.last_synced).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded ${data.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'}`}>
|
||||||
|
{data.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">Limit: {data.fetch_limit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Taxonomies Section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-medium mb-3">Taxonomies</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(contentTypes.taxonomies || {}).map(([key, data]: [string, any]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h4 className="font-medium">{data.label}</h4>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{data.count} total · {data.synced_count} synced
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.last_synced && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Last synced: {new Date(data.last_synced).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded ${data.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'}`}>
|
||||||
|
{data.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">Limit: {data.fetch_limit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{contentTypes.last_structure_fetch && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Structure last fetched: {new Date(contentTypes.last_structure_fetch).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Original tab content below */}
|
||||||
|
{activeTab !== 'content-types' && (
|
||||||
|
<div className="space-y-6">
|
||||||
{/* General Tab */}
|
{/* General Tab */}
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -547,14 +884,15 @@ export default function SiteSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
{activeTab !== 'integrations' && (
|
{activeTab !== 'integrations' && activeTab !== 'content-types' && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||||
{saving ? 'Saving...' : 'Save Settings'}
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user