Phase 6
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
# Generated manually for Phase 6: Site Model Extensions
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0014_remove_plan_operation_limits_phase0'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='site_type',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('marketing', 'Marketing Site'),
|
||||||
|
('ecommerce', 'Ecommerce Site'),
|
||||||
|
('blog', 'Blog'),
|
||||||
|
('portfolio', 'Portfolio'),
|
||||||
|
('corporate', 'Corporate'),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default='marketing',
|
||||||
|
help_text='Type of site',
|
||||||
|
max_length=50
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='hosting_type',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('igny8_sites', 'IGNY8 Sites'),
|
||||||
|
('wordpress', 'WordPress'),
|
||||||
|
('shopify', 'Shopify'),
|
||||||
|
('multi', 'Multi-Destination'),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default='igny8_sites',
|
||||||
|
help_text='Target hosting platform',
|
||||||
|
max_length=50
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='site',
|
||||||
|
index=models.Index(fields=['site_type'], name='igny8_sites_site_ty_123abc_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='site',
|
||||||
|
index=models.Index(fields=['hosting_type'], name='igny8_sites_hostin_456def_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -213,11 +213,43 @@ class Site(AccountBaseModel):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
# WordPress integration fields
|
# WordPress integration fields (legacy - use SiteIntegration instead)
|
||||||
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL")
|
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL (legacy - use SiteIntegration)")
|
||||||
wp_username = models.CharField(max_length=255, blank=True, null=True)
|
wp_username = models.CharField(max_length=255, blank=True, null=True)
|
||||||
wp_app_password = models.CharField(max_length=255, blank=True, null=True)
|
wp_app_password = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
|
||||||
|
# Site type and hosting (Phase 6)
|
||||||
|
SITE_TYPE_CHOICES = [
|
||||||
|
('marketing', 'Marketing Site'),
|
||||||
|
('ecommerce', 'Ecommerce Site'),
|
||||||
|
('blog', 'Blog'),
|
||||||
|
('portfolio', 'Portfolio'),
|
||||||
|
('corporate', 'Corporate'),
|
||||||
|
]
|
||||||
|
|
||||||
|
HOSTING_TYPE_CHOICES = [
|
||||||
|
('igny8_sites', 'IGNY8 Sites'),
|
||||||
|
('wordpress', 'WordPress'),
|
||||||
|
('shopify', 'Shopify'),
|
||||||
|
('multi', 'Multi-Destination'),
|
||||||
|
]
|
||||||
|
|
||||||
|
site_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=SITE_TYPE_CHOICES,
|
||||||
|
default='marketing',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Type of site"
|
||||||
|
)
|
||||||
|
|
||||||
|
hosting_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=HOSTING_TYPE_CHOICES,
|
||||||
|
default='igny8_sites',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Target hosting platform"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'igny8_sites'
|
db_table = 'igny8_sites'
|
||||||
unique_together = [['account', 'slug']] # Slug unique per account
|
unique_together = [['account', 'slug']] # Slug unique per account
|
||||||
@@ -226,6 +258,8 @@ class Site(AccountBaseModel):
|
|||||||
models.Index(fields=['account', 'is_active']),
|
models.Index(fields=['account', 'is_active']),
|
||||||
models.Index(fields=['account', 'status']),
|
models.Index(fields=['account', 'status']),
|
||||||
models.Index(fields=['industry']),
|
models.Index(fields=['industry']),
|
||||||
|
models.Index(fields=['site_type']),
|
||||||
|
models.Index(fields=['hosting_type']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
12
backend/igny8_core/business/integration/apps.py
Normal file
12
backend/igny8_core/business/integration/apps.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Integration App Configuration
|
||||||
|
"""
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'igny8_core.business.integration'
|
||||||
|
label = 'integration'
|
||||||
|
verbose_name = 'Integration'
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Generated manually for Phase 6: Integration System
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0014_remove_plan_operation_limits_phase0'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SiteIntegration',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('platform', models.CharField(choices=[('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('custom', 'Custom API')], db_index=True, help_text="Platform name: 'wordpress', 'shopify', 'custom'", max_length=50)),
|
||||||
|
('platform_type', models.CharField(choices=[('cms', 'CMS'), ('ecommerce', 'Ecommerce'), ('custom_api', 'Custom API')], default='cms', help_text="Platform type: 'cms', 'ecommerce', 'custom_api'", max_length=50)),
|
||||||
|
('config_json', models.JSONField(default=dict, help_text='Platform-specific configuration (URLs, endpoints, etc.)')),
|
||||||
|
('credentials_json', models.JSONField(default=dict, help_text='Encrypted credentials (API keys, tokens, etc.)')),
|
||||||
|
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this integration is active')),
|
||||||
|
('sync_enabled', models.BooleanField(default=False, help_text='Whether two-way sync is enabled')),
|
||||||
|
('last_sync_at', models.DateTimeField(blank=True, help_text='Last successful sync timestamp', null=True)),
|
||||||
|
('sync_status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed'), ('pending', 'Pending'), ('syncing', 'Syncing')], db_index=True, default='pending', help_text='Current sync status', max_length=20)),
|
||||||
|
('sync_error', models.TextField(blank=True, help_text='Last sync error message', null=True)),
|
||||||
|
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
|
||||||
|
('site', models.ForeignKey(help_text='Site this integration belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to='igny8_core_auth.site')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'igny8_site_integrations',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='siteintegration',
|
||||||
|
index=models.Index(fields=['site', 'platform'], name='igny8_integ_site_pl_123abc_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='siteintegration',
|
||||||
|
index=models.Index(fields=['site', 'is_active'], name='igny8_integ_site_is_456def_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='siteintegration',
|
||||||
|
index=models.Index(fields=['account', 'platform'], name='igny8_integ_account_789ghi_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='siteintegration',
|
||||||
|
index=models.Index(fields=['sync_status'], name='igny8_integ_sync_st_012jkl_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='siteintegration',
|
||||||
|
unique_together={('site', 'platform')},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Integration Migrations
|
||||||
|
"""
|
||||||
|
|
||||||
131
backend/igny8_core/business/integration/models.py
Normal file
131
backend/igny8_core/business/integration/models.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Integration Models
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from igny8_core.auth.models import AccountBaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SiteIntegration(AccountBaseModel):
|
||||||
|
"""
|
||||||
|
Store integration configurations for sites.
|
||||||
|
Each site can have multiple integrations (WordPress, Shopify, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLATFORM_CHOICES = [
|
||||||
|
('wordpress', 'WordPress'),
|
||||||
|
('shopify', 'Shopify'),
|
||||||
|
('custom', 'Custom API'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PLATFORM_TYPE_CHOICES = [
|
||||||
|
('cms', 'CMS'),
|
||||||
|
('ecommerce', 'Ecommerce'),
|
||||||
|
('custom_api', 'Custom API'),
|
||||||
|
]
|
||||||
|
|
||||||
|
SYNC_STATUS_CHOICES = [
|
||||||
|
('success', 'Success'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('syncing', 'Syncing'),
|
||||||
|
]
|
||||||
|
|
||||||
|
site = models.ForeignKey(
|
||||||
|
'igny8_core_auth.Site',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='integrations',
|
||||||
|
help_text="Site this integration belongs to"
|
||||||
|
)
|
||||||
|
|
||||||
|
platform = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=PLATFORM_CHOICES,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Platform name: 'wordpress', 'shopify', 'custom'"
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=PLATFORM_TYPE_CHOICES,
|
||||||
|
default='cms',
|
||||||
|
help_text="Platform type: 'cms', 'ecommerce', 'custom_api'"
|
||||||
|
)
|
||||||
|
|
||||||
|
config_json = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
help_text="Platform-specific configuration (URLs, endpoints, etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Credentials stored as JSON (encryption handled at application level)
|
||||||
|
credentials_json = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
help_text="Encrypted credentials (API keys, tokens, etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Whether this integration is active"
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_enabled = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether two-way sync is enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_sync_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Last successful sync timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SYNC_STATUS_CHOICES,
|
||||||
|
default='pending',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Current sync status"
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_error = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="Last sync error message"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'integration'
|
||||||
|
db_table = 'igny8_site_integrations'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
unique_together = [['site', 'platform']]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['site', 'platform']),
|
||||||
|
models.Index(fields=['site', 'is_active']),
|
||||||
|
models.Index(fields=['account', 'platform']),
|
||||||
|
models.Index(fields=['sync_status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.site.name} - {self.get_platform_display()}"
|
||||||
|
|
||||||
|
def get_credentials(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get decrypted credentials.
|
||||||
|
In production, this should decrypt credentials_json.
|
||||||
|
For now, return as-is (encryption to be implemented).
|
||||||
|
"""
|
||||||
|
return self.credentials_json or {}
|
||||||
|
|
||||||
|
def set_credentials(self, credentials: dict):
|
||||||
|
"""
|
||||||
|
Set encrypted credentials.
|
||||||
|
In production, this should encrypt before storing.
|
||||||
|
For now, store as-is (encryption to be implemented).
|
||||||
|
"""
|
||||||
|
self.credentials_json = credentials
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Integration Services
|
||||||
|
"""
|
||||||
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
Content Sync Service
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
|
||||||
|
Syncs content between IGNY8 and external platforms.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
from igny8_core.business.integration.models import SiteIntegration
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentSyncService:
|
||||||
|
"""
|
||||||
|
Service for syncing content to/from external platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def sync_to_external(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
content_types: Optional[List[str]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync content from IGNY8 to external platform.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
content_types: List of content types to sync (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if integration.platform == 'wordpress':
|
||||||
|
return self._sync_to_wordpress(integration, content_types)
|
||||||
|
elif integration.platform == 'shopify':
|
||||||
|
return self._sync_to_shopify(integration, content_types)
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f'Sync to {integration.platform} not implemented',
|
||||||
|
'synced_count': 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[ContentSyncService] Error syncing to {integration.platform}: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'synced_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def sync_from_external(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
content_types: Optional[List[str]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync content from external platform to IGNY8.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
content_types: List of content types to sync (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if integration.platform == 'wordpress':
|
||||||
|
return self._sync_from_wordpress(integration, content_types)
|
||||||
|
elif integration.platform == 'shopify':
|
||||||
|
return self._sync_from_shopify(integration, content_types)
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f'Sync from {integration.platform} not implemented',
|
||||||
|
'synced_count': 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[ContentSyncService] Error syncing from {integration.platform}: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'synced_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sync_to_wordpress(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
content_types: Optional[List[str]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync content from IGNY8 to WordPress.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
content_types: List of content types to sync
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sync_from_wordpress(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
content_types: Optional[List[str]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync content from WordPress to IGNY8.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
content_types: List of content types to sync
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result
|
||||||
|
"""
|
||||||
|
# TODO: Implement WordPress import
|
||||||
|
# This will fetch posts/pages from WordPress and create Content records
|
||||||
|
logger.info(f"[ContentSyncService] Syncing from WordPress for integration {integration.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'synced_count': 0,
|
||||||
|
'message': 'WordPress sync from external not yet fully implemented'
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sync_to_shopify(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
content_types: Optional[List[str]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync content from IGNY8 to Shopify.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
content_types: List of content types to sync
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result
|
||||||
|
"""
|
||||||
|
# TODO: Implement Shopify sync
|
||||||
|
logger.info(f"[ContentSyncService] Syncing to Shopify for integration {integration.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'synced_count': 0,
|
||||||
|
'message': 'Shopify sync not yet implemented'
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sync_from_shopify(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
content_types: Optional[List[str]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync content from Shopify to IGNY8.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
content_types: List of content types to sync
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result
|
||||||
|
"""
|
||||||
|
# TODO: Implement Shopify import
|
||||||
|
logger.info(f"[ContentSyncService] Syncing from Shopify for integration {integration.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'synced_count': 0,
|
||||||
|
'message': 'Shopify sync not yet implemented'
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Integration Service
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
|
||||||
|
Manages site integrations (WordPress, Shopify, etc.).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from igny8_core.business.integration.models import SiteIntegration
|
||||||
|
from igny8_core.auth.models import Site
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationService:
|
||||||
|
"""
|
||||||
|
Service for managing site integrations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def create_integration(
|
||||||
|
self,
|
||||||
|
site: Site,
|
||||||
|
platform: str,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
credentials: Dict[str, Any],
|
||||||
|
platform_type: str = 'cms'
|
||||||
|
) -> SiteIntegration:
|
||||||
|
"""
|
||||||
|
Create a new site integration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site: Site instance
|
||||||
|
platform: Platform name ('wordpress', 'shopify', 'custom')
|
||||||
|
config: Platform-specific configuration
|
||||||
|
credentials: Platform credentials (will be encrypted)
|
||||||
|
platform_type: Platform type ('cms', 'ecommerce', 'custom_api')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SiteIntegration instance
|
||||||
|
"""
|
||||||
|
integration = SiteIntegration.objects.create(
|
||||||
|
account=site.account,
|
||||||
|
site=site,
|
||||||
|
platform=platform,
|
||||||
|
platform_type=platform_type,
|
||||||
|
config_json=config,
|
||||||
|
credentials_json=credentials,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[IntegrationService] Created integration {integration.id} for site {site.id}, platform {platform}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return integration
|
||||||
|
|
||||||
|
def update_integration(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
credentials: Optional[Dict[str, Any]] = None,
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
) -> SiteIntegration:
|
||||||
|
"""
|
||||||
|
Update an existing integration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
config: Updated configuration (optional)
|
||||||
|
credentials: Updated credentials (optional)
|
||||||
|
is_active: Active status (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated SiteIntegration instance
|
||||||
|
"""
|
||||||
|
if config is not None:
|
||||||
|
integration.config_json = config
|
||||||
|
|
||||||
|
if credentials is not None:
|
||||||
|
integration.set_credentials(credentials)
|
||||||
|
|
||||||
|
if is_active is not None:
|
||||||
|
integration.is_active = is_active
|
||||||
|
|
||||||
|
integration.save()
|
||||||
|
|
||||||
|
logger.info(f"[IntegrationService] Updated integration {integration.id}")
|
||||||
|
|
||||||
|
return integration
|
||||||
|
|
||||||
|
def delete_integration(self, integration: SiteIntegration):
|
||||||
|
"""
|
||||||
|
Delete an integration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
"""
|
||||||
|
integration_id = integration.id
|
||||||
|
integration.delete()
|
||||||
|
|
||||||
|
logger.info(f"[IntegrationService] Deleted integration {integration_id}")
|
||||||
|
|
||||||
|
def get_integration(
|
||||||
|
self,
|
||||||
|
site: Site,
|
||||||
|
platform: str
|
||||||
|
) -> Optional[SiteIntegration]:
|
||||||
|
"""
|
||||||
|
Get integration for a site and platform.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site: Site instance
|
||||||
|
platform: Platform name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SiteIntegration or None
|
||||||
|
"""
|
||||||
|
return SiteIntegration.objects.filter(
|
||||||
|
site=site,
|
||||||
|
platform=platform,
|
||||||
|
is_active=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def list_integrations(
|
||||||
|
self,
|
||||||
|
site: Site,
|
||||||
|
active_only: bool = True
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
List all integrations for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site: Site instance
|
||||||
|
active_only: Only return active integrations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SiteIntegration instances
|
||||||
|
"""
|
||||||
|
queryset = SiteIntegration.objects.filter(site=site)
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
queryset = queryset.filter(is_active=True)
|
||||||
|
|
||||||
|
return list(queryset.order_by('-created_at'))
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test connection to the integrated platform.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
|
'details': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if integration.platform == 'wordpress':
|
||||||
|
return self._test_wordpress_connection(integration)
|
||||||
|
elif integration.platform == 'shopify':
|
||||||
|
return self._test_shopify_connection(integration)
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Connection testing not implemented for platform: {integration.platform}',
|
||||||
|
'details': {}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[IntegrationService] Error testing connection for integration {integration.id}: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': str(e),
|
||||||
|
'details': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _test_wordpress_connection(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test WordPress connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Connection test result
|
||||||
|
"""
|
||||||
|
from igny8_core.utils.wordpress import WordPressClient
|
||||||
|
|
||||||
|
config = integration.config_json
|
||||||
|
credentials = integration.get_credentials()
|
||||||
|
|
||||||
|
site_url = config.get('site_url')
|
||||||
|
username = credentials.get('username')
|
||||||
|
app_password = credentials.get('app_password')
|
||||||
|
|
||||||
|
if not site_url:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': 'WordPress site URL not configured',
|
||||||
|
'details': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = WordPressClient(site_url, username, app_password)
|
||||||
|
result = client.test_connection()
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'WordPress connection failed: {str(e)}',
|
||||||
|
'details': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _test_shopify_connection(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test Shopify connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Connection test result
|
||||||
|
"""
|
||||||
|
# TODO: Implement Shopify connection testing
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': 'Shopify connection testing not yet implemented',
|
||||||
|
'details': {}
|
||||||
|
}
|
||||||
|
|
||||||
161
backend/igny8_core/business/integration/services/sync_service.py
Normal file
161
backend/igny8_core/business/integration/services/sync_service.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Sync Service
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
|
||||||
|
Handles two-way synchronization between IGNY8 and external platforms.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from igny8_core.business.integration.models import SiteIntegration
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncService:
|
||||||
|
"""
|
||||||
|
Service for handling two-way sync between IGNY8 and external platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def sync(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
direction: str = 'both',
|
||||||
|
content_types: Optional[list] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Perform synchronization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
direction: 'both', 'to_external', 'from_external'
|
||||||
|
content_types: List of content types to sync (optional, syncs all if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result
|
||||||
|
"""
|
||||||
|
if not integration.sync_enabled:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': 'Sync is not enabled for this integration',
|
||||||
|
'synced_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update sync status
|
||||||
|
integration.sync_status = 'syncing'
|
||||||
|
integration.save(update_fields=['sync_status', 'updated_at'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
if direction in ('both', 'to_external'):
|
||||||
|
# Sync from IGNY8 to external platform
|
||||||
|
to_result = self._sync_to_external(integration, content_types)
|
||||||
|
else:
|
||||||
|
to_result = {'success': True, 'synced_count': 0}
|
||||||
|
|
||||||
|
if direction in ('both', 'from_external'):
|
||||||
|
# Sync from external platform to IGNY8
|
||||||
|
from_result = self._sync_from_external(integration, content_types)
|
||||||
|
else:
|
||||||
|
from_result = {'success': True, 'synced_count': 0}
|
||||||
|
|
||||||
|
# Update sync status
|
||||||
|
if to_result.get('success') and from_result.get('success'):
|
||||||
|
integration.sync_status = 'success'
|
||||||
|
integration.sync_error = None
|
||||||
|
else:
|
||||||
|
integration.sync_status = 'failed'
|
||||||
|
integration.sync_error = (
|
||||||
|
to_result.get('error', '') + ' ' + from_result.get('error', '')
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
integration.last_sync_at = datetime.now()
|
||||||
|
integration.save(update_fields=['sync_status', 'sync_error', 'last_sync_at', 'updated_at'])
|
||||||
|
|
||||||
|
total_synced = to_result.get('synced_count', 0) + from_result.get('synced_count', 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': to_result.get('success') and from_result.get('success'),
|
||||||
|
'synced_count': total_synced,
|
||||||
|
'to_external': to_result,
|
||||||
|
'from_external': from_result
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[SyncService] Error syncing integration {integration.id}: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
integration.sync_status = 'failed'
|
||||||
|
integration.sync_error = str(e)
|
||||||
|
integration.save(update_fields=['sync_status', 'sync_error', 'updated_at'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'synced_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sync_to_external(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
content_types: Optional[list] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync content from IGNY8 to external platform.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
content_types: List of content types to sync
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result
|
||||||
|
"""
|
||||||
|
# This will be implemented by ContentSyncService
|
||||||
|
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
|
||||||
|
|
||||||
|
sync_service = ContentSyncService()
|
||||||
|
return sync_service.sync_to_external(integration, content_types)
|
||||||
|
|
||||||
|
def _sync_from_external(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration,
|
||||||
|
content_types: Optional[list] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync content from external platform to IGNY8.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
content_types: List of content types to sync
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result
|
||||||
|
"""
|
||||||
|
# This will be implemented by ContentSyncService
|
||||||
|
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
|
||||||
|
|
||||||
|
sync_service = ContentSyncService()
|
||||||
|
return sync_service.sync_from_external(integration, content_types)
|
||||||
|
|
||||||
|
def get_sync_status(
|
||||||
|
self,
|
||||||
|
integration: SiteIntegration
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get current sync status for an integration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
integration: SiteIntegration instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync status information
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'sync_enabled': integration.sync_enabled,
|
||||||
|
'sync_status': integration.sync_status,
|
||||||
|
'last_sync_at': integration.last_sync_at.isoformat() if integration.last_sync_at else None,
|
||||||
|
'sync_error': integration.sync_error
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Base Adapter
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
|
||||||
|
Abstract base class for publishing adapters.
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAdapter(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for publishing adapters.
|
||||||
|
All platform-specific adapters must inherit from this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
content: Any,
|
||||||
|
destination_config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Publish content to destination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content or SiteBlueprint to publish
|
||||||
|
destination_config: Destination-specific configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'external_id': str, # External platform ID
|
||||||
|
'url': str, # Published content URL
|
||||||
|
'published_at': datetime,
|
||||||
|
'metadata': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def test_connection(
|
||||||
|
self,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test connection to destination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Destination configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
|
'details': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_status(
|
||||||
|
self,
|
||||||
|
published_id: str,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get publishing status for published content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
published_id: External platform ID
|
||||||
|
config: Destination configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'status': str, # 'published', 'draft', 'deleted', etc.
|
||||||
|
'url': str,
|
||||||
|
'updated_at': datetime,
|
||||||
|
'metadata': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def validate_config(
|
||||||
|
self,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Validate destination configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Destination configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
# Default implementation - can be overridden
|
||||||
|
return True, None
|
||||||
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Shopify Adapter
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
|
||||||
|
Adapter for publishing content to Shopify.
|
||||||
|
Skeleton implementation - to be fully implemented in future.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from igny8_core.business.publishing.services.adapters.base_adapter import BaseAdapter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ShopifyAdapter(BaseAdapter):
|
||||||
|
"""
|
||||||
|
Adapter for publishing content to Shopify.
|
||||||
|
Skeleton implementation - full implementation pending.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
content: Any,
|
||||||
|
destination_config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Publish content to Shopify.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance or dict with content data
|
||||||
|
destination_config: {
|
||||||
|
'shop_domain': str,
|
||||||
|
'access_token': str,
|
||||||
|
'content_type': str, # 'page', 'blog_post', 'product'
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'external_id': str, # Shopify resource ID
|
||||||
|
'url': str, # Published resource URL
|
||||||
|
'published_at': datetime,
|
||||||
|
'metadata': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# TODO: Implement Shopify publishing
|
||||||
|
logger.warning("[ShopifyAdapter] Shopify publishing not yet implemented")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'external_id': None,
|
||||||
|
'url': None,
|
||||||
|
'published_at': None,
|
||||||
|
'metadata': {
|
||||||
|
'error': 'Shopify publishing not yet implemented'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
self,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test connection to Shopify.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: {
|
||||||
|
'shop_domain': str,
|
||||||
|
'access_token': str
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
|
'details': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# TODO: Implement Shopify connection testing
|
||||||
|
logger.warning("[ShopifyAdapter] Shopify connection testing not yet implemented")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': 'Shopify connection testing not yet implemented',
|
||||||
|
'details': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_status(
|
||||||
|
self,
|
||||||
|
published_id: str,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get publishing status for published content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
published_id: Shopify resource ID
|
||||||
|
config: Shopify configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'status': str, # 'published', 'draft', 'deleted', etc.
|
||||||
|
'url': str,
|
||||||
|
'updated_at': datetime,
|
||||||
|
'metadata': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# TODO: Implement Shopify status retrieval
|
||||||
|
logger.warning("[ShopifyAdapter] Shopify status retrieval not yet implemented")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'unknown',
|
||||||
|
'url': None,
|
||||||
|
'updated_at': None,
|
||||||
|
'metadata': {
|
||||||
|
'error': 'Shopify status retrieval not yet implemented'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_config(
|
||||||
|
self,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Validate Shopify configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Shopify configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
required_fields = ['shop_domain', 'access_token']
|
||||||
|
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in config or not config[field]:
|
||||||
|
return False, f"Missing required field: {field}"
|
||||||
|
|
||||||
|
# Validate domain format
|
||||||
|
shop_domain = config.get('shop_domain', '')
|
||||||
|
if not shop_domain.endswith('.myshopify.com'):
|
||||||
|
return False, "shop_domain must end with .myshopify.com"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""
|
||||||
|
WordPress Adapter
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
|
||||||
|
Adapter for publishing content to WordPress.
|
||||||
|
Refactored to use BaseAdapter interface while preserving existing functionality.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from igny8_core.business.publishing.services.adapters.base_adapter import BaseAdapter
|
||||||
|
from igny8_core.utils.wordpress import WordPressClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WordPressAdapter(BaseAdapter):
|
||||||
|
"""
|
||||||
|
Adapter for publishing content to WordPress.
|
||||||
|
Uses WordPressClient internally to preserve existing functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
content: Any,
|
||||||
|
destination_config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Publish content to WordPress.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance or dict with content data
|
||||||
|
destination_config: {
|
||||||
|
'site_url': str,
|
||||||
|
'username': str,
|
||||||
|
'app_password': str,
|
||||||
|
'status': str (optional, default 'draft'),
|
||||||
|
'featured_image_url': str (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'external_id': str, # WordPress post ID
|
||||||
|
'url': str, # Published post URL
|
||||||
|
'published_at': datetime,
|
||||||
|
'metadata': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get WordPress client
|
||||||
|
client = self._get_client(destination_config)
|
||||||
|
|
||||||
|
# Extract content data
|
||||||
|
if hasattr(content, 'title') and hasattr(content, 'content'):
|
||||||
|
# Content model instance
|
||||||
|
title = content.title
|
||||||
|
content_html = content.content
|
||||||
|
elif isinstance(content, dict):
|
||||||
|
# Dict with content data
|
||||||
|
title = content.get('title', '')
|
||||||
|
content_html = content.get('content', '')
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported content type: {type(content)}")
|
||||||
|
|
||||||
|
# Get publishing options
|
||||||
|
status = destination_config.get('status', 'draft')
|
||||||
|
featured_image_url = destination_config.get('featured_image_url')
|
||||||
|
|
||||||
|
# Publish to WordPress
|
||||||
|
result = client.create_post(
|
||||||
|
title=title,
|
||||||
|
content=content_html,
|
||||||
|
status=status,
|
||||||
|
featured_image_url=featured_image_url
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'external_id': str(result.get('post_id')),
|
||||||
|
'url': result.get('url'),
|
||||||
|
'published_at': datetime.now(),
|
||||||
|
'metadata': {
|
||||||
|
'post_id': result.get('post_id'),
|
||||||
|
'status': status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'external_id': None,
|
||||||
|
'url': None,
|
||||||
|
'published_at': None,
|
||||||
|
'metadata': {
|
||||||
|
'error': result.get('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[WordPressAdapter] Error publishing content: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'external_id': None,
|
||||||
|
'url': None,
|
||||||
|
'published_at': None,
|
||||||
|
'metadata': {
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_connection(
|
||||||
|
self,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test connection to WordPress.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: {
|
||||||
|
'site_url': str,
|
||||||
|
'username': str,
|
||||||
|
'app_password': str
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
|
'details': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = self._get_client(config)
|
||||||
|
result = client.test_connection()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': result.get('success', False),
|
||||||
|
'message': result.get('message', ''),
|
||||||
|
'details': {
|
||||||
|
'wp_version': result.get('wp_version')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[WordPressAdapter] Connection test failed: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': str(e),
|
||||||
|
'details': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_status(
|
||||||
|
self,
|
||||||
|
published_id: str,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get publishing status for published content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
published_id: WordPress post ID
|
||||||
|
config: WordPress configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'status': str, # 'published', 'draft', 'deleted', etc.
|
||||||
|
'url': str,
|
||||||
|
'updated_at': datetime,
|
||||||
|
'metadata': dict
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = self._get_client(config)
|
||||||
|
|
||||||
|
# Fetch post from WordPress
|
||||||
|
response = client.session.get(
|
||||||
|
f"{client.api_base}/posts/{published_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return {
|
||||||
|
'status': data.get('status', 'unknown'),
|
||||||
|
'url': data.get('link'),
|
||||||
|
'updated_at': datetime.fromisoformat(
|
||||||
|
data.get('modified', '').replace('Z', '+00:00')
|
||||||
|
) if data.get('modified') else None,
|
||||||
|
'metadata': {
|
||||||
|
'title': data.get('title', {}).get('rendered'),
|
||||||
|
'author': data.get('author'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'status': 'deleted' if response.status_code == 404 else 'unknown',
|
||||||
|
'url': None,
|
||||||
|
'updated_at': None,
|
||||||
|
'metadata': {
|
||||||
|
'error': f"HTTP {response.status_code}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[WordPressAdapter] Error getting status: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'status': 'unknown',
|
||||||
|
'url': None,
|
||||||
|
'updated_at': None,
|
||||||
|
'metadata': {
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_config(
|
||||||
|
self,
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Validate WordPress configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: WordPress configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
required_fields = ['site_url']
|
||||||
|
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in config or not config[field]:
|
||||||
|
return False, f"Missing required field: {field}"
|
||||||
|
|
||||||
|
# Validate URL format
|
||||||
|
site_url = config.get('site_url', '')
|
||||||
|
if not site_url.startswith(('http://', 'https://')):
|
||||||
|
return False, "site_url must start with http:// or https://"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def _get_client(self, config: Dict[str, Any]) -> WordPressClient:
|
||||||
|
"""
|
||||||
|
Get WordPress client from configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: WordPress configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WordPressClient instance
|
||||||
|
"""
|
||||||
|
site_url = config.get('site_url')
|
||||||
|
username = config.get('username')
|
||||||
|
app_password = config.get('app_password')
|
||||||
|
|
||||||
|
return WordPressClient(site_url, username, app_password)
|
||||||
|
|
||||||
@@ -138,8 +138,24 @@ class PublisherService:
|
|||||||
if not adapter:
|
if not adapter:
|
||||||
raise ValueError(f"No adapter found for destination: {destination}")
|
raise ValueError(f"No adapter found for destination: {destination}")
|
||||||
|
|
||||||
|
# Get destination config (for now, basic config - can be extended)
|
||||||
|
destination_config = {'account': account}
|
||||||
|
|
||||||
|
# If content has site, try to get integration config
|
||||||
|
if hasattr(content, 'site') and content.site:
|
||||||
|
from igny8_core.business.integration.models import SiteIntegration
|
||||||
|
integration = SiteIntegration.objects.filter(
|
||||||
|
site=content.site,
|
||||||
|
platform=destination,
|
||||||
|
is_active=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if integration:
|
||||||
|
destination_config.update(integration.config_json)
|
||||||
|
destination_config.update(integration.get_credentials())
|
||||||
|
|
||||||
# Publish via adapter
|
# Publish via adapter
|
||||||
result = adapter.publish(content, {'account': account})
|
result = adapter.publish(content, destination_config)
|
||||||
|
|
||||||
# Update record
|
# Update record
|
||||||
record.status = 'published' if result.get('success') else 'failed'
|
record.status = 'published' if result.get('success') else 'failed'
|
||||||
@@ -163,6 +179,156 @@ class PublisherService:
|
|||||||
record.save()
|
record.save()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def publish_to_multiple_destinations(
|
||||||
|
self,
|
||||||
|
content: Any,
|
||||||
|
destinations: List[Dict[str, Any]],
|
||||||
|
account
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Publish content to multiple destinations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance or SiteBlueprint
|
||||||
|
destinations: List of destination configs, e.g.:
|
||||||
|
[
|
||||||
|
{'platform': 'wordpress', 'site_url': '...', 'username': '...', 'app_password': '...'},
|
||||||
|
{'platform': 'sites'},
|
||||||
|
{'platform': 'shopify', 'shop_domain': '...', 'access_token': '...'}
|
||||||
|
]
|
||||||
|
account: Account instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'results': list of publishing results per destination
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for destination_config in destinations:
|
||||||
|
platform = destination_config.get('platform')
|
||||||
|
if not platform:
|
||||||
|
results.append({
|
||||||
|
'platform': 'unknown',
|
||||||
|
'success': False,
|
||||||
|
'error': 'Platform not specified'
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
adapter = self._get_adapter(platform)
|
||||||
|
if not adapter:
|
||||||
|
results.append({
|
||||||
|
'platform': platform,
|
||||||
|
'success': False,
|
||||||
|
'error': f'No adapter found for platform: {platform}'
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate config
|
||||||
|
is_valid, error_msg = adapter.validate_config(destination_config)
|
||||||
|
if not is_valid:
|
||||||
|
results.append({
|
||||||
|
'platform': platform,
|
||||||
|
'success': False,
|
||||||
|
'error': error_msg or 'Invalid configuration'
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Publish via adapter
|
||||||
|
result = adapter.publish(content, destination_config)
|
||||||
|
|
||||||
|
# Create publishing record if content has site/sector
|
||||||
|
if hasattr(content, 'site') and hasattr(content, 'sector'):
|
||||||
|
record = PublishingRecord.objects.create(
|
||||||
|
account=account,
|
||||||
|
site=content.site,
|
||||||
|
sector=content.sector,
|
||||||
|
content=content if hasattr(content, 'id') and not isinstance(content, SiteBlueprint) else None,
|
||||||
|
site_blueprint=content if isinstance(content, SiteBlueprint) else None,
|
||||||
|
destination=platform,
|
||||||
|
status='published' if result.get('success') else 'failed',
|
||||||
|
destination_id=result.get('external_id'),
|
||||||
|
destination_url=result.get('url'),
|
||||||
|
published_at=result.get('published_at'),
|
||||||
|
error_message=result.get('metadata', {}).get('error'),
|
||||||
|
metadata=result.get('metadata', {})
|
||||||
|
)
|
||||||
|
result['publishing_record_id'] = record.id
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'platform': platform,
|
||||||
|
'success': result.get('success', False),
|
||||||
|
'external_id': result.get('external_id'),
|
||||||
|
'url': result.get('url'),
|
||||||
|
'error': result.get('metadata', {}).get('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error publishing to {platform}: {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
'platform': platform,
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': all(r.get('success', False) for r in results),
|
||||||
|
'results': results
|
||||||
|
}
|
||||||
|
|
||||||
|
def publish_with_integrations(
|
||||||
|
self,
|
||||||
|
content: Any,
|
||||||
|
site,
|
||||||
|
account,
|
||||||
|
platforms: Optional[List[str]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Publish content using site integrations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance or SiteBlueprint
|
||||||
|
site: Site instance
|
||||||
|
account: Account instance
|
||||||
|
platforms: Optional list of platforms to publish to (all active if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Publishing results
|
||||||
|
"""
|
||||||
|
from igny8_core.business.integration.models import SiteIntegration
|
||||||
|
|
||||||
|
# Get active integrations for site
|
||||||
|
integrations = SiteIntegration.objects.filter(
|
||||||
|
site=site,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if platforms:
|
||||||
|
integrations = integrations.filter(platform__in=platforms)
|
||||||
|
|
||||||
|
destinations = []
|
||||||
|
for integration in integrations:
|
||||||
|
config = integration.config_json.copy()
|
||||||
|
credentials = integration.get_credentials()
|
||||||
|
|
||||||
|
destination_config = {
|
||||||
|
'platform': integration.platform,
|
||||||
|
**config,
|
||||||
|
**credentials
|
||||||
|
}
|
||||||
|
destinations.append(destination_config)
|
||||||
|
|
||||||
|
# Also add 'sites' destination if not in platforms filter or if platforms is None
|
||||||
|
if not platforms or 'sites' in platforms:
|
||||||
|
destinations.append({'platform': 'sites'})
|
||||||
|
|
||||||
|
return self.publish_to_multiple_destinations(content, destinations, account)
|
||||||
|
|
||||||
def _get_adapter(self, destination: str):
|
def _get_adapter(self, destination: str):
|
||||||
"""
|
"""
|
||||||
Get adapter for destination platform.
|
Get adapter for destination platform.
|
||||||
@@ -178,10 +344,10 @@ class PublisherService:
|
|||||||
from igny8_core.business.publishing.services.adapters.sites_renderer_adapter import SitesRendererAdapter
|
from igny8_core.business.publishing.services.adapters.sites_renderer_adapter import SitesRendererAdapter
|
||||||
return SitesRendererAdapter()
|
return SitesRendererAdapter()
|
||||||
elif destination == 'wordpress':
|
elif destination == 'wordpress':
|
||||||
# Will be implemented in Phase 6
|
from igny8_core.business.publishing.services.adapters.wordpress_adapter import WordPressAdapter
|
||||||
return None
|
return WordPressAdapter()
|
||||||
elif destination == 'shopify':
|
elif destination == 'shopify':
|
||||||
# Will be implemented in Phase 6
|
from igny8_core.business.publishing.services.adapters.shopify_adapter import ShopifyAdapter
|
||||||
return None
|
return ShopifyAdapter()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
5
backend/igny8_core/modules/integration/__init__.py
Normal file
5
backend/igny8_core/modules/integration/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Integration Module
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
"""
|
||||||
|
|
||||||
12
backend/igny8_core/modules/integration/apps.py
Normal file
12
backend/igny8_core/modules/integration/apps.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Integration Module App Configuration
|
||||||
|
"""
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'igny8_core.modules.integration'
|
||||||
|
label = 'integration'
|
||||||
|
verbose_name = 'Integration'
|
||||||
|
|
||||||
16
backend/igny8_core/modules/integration/urls.py
Normal file
16
backend/igny8_core/modules/integration/urls.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Integration URLs
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
"""
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from igny8_core.modules.integration.views import IntegrationViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'integrations', IntegrationViewSet, basename='integration')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
96
backend/igny8_core/modules/integration/views.py
Normal file
96
backend/igny8_core/modules/integration/views.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Integration ViewSet
|
||||||
|
Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
"""
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from igny8_core.api.base import SiteSectorModelViewSet
|
||||||
|
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||||
|
from igny8_core.api.response import success_response, error_response
|
||||||
|
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||||
|
from igny8_core.business.integration.models import SiteIntegration
|
||||||
|
from igny8_core.business.integration.services.integration_service import IntegrationService
|
||||||
|
from igny8_core.business.integration.services.sync_service import SyncService
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationViewSet(SiteSectorModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for SiteIntegration model.
|
||||||
|
"""
|
||||||
|
queryset = SiteIntegration.objects.select_related('site')
|
||||||
|
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||||
|
throttle_scope = 'integration'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
class SiteIntegrationSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SiteIntegration
|
||||||
|
fields = '__all__'
|
||||||
|
read_only_fields = ['created_at', 'updated_at', 'last_sync_at']
|
||||||
|
|
||||||
|
return SiteIntegrationSerializer
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def test_connection(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Test connection to integrated platform.
|
||||||
|
|
||||||
|
POST /api/v1/integration/integrations/{id}/test_connection/
|
||||||
|
"""
|
||||||
|
integration = self.get_object()
|
||||||
|
|
||||||
|
service = IntegrationService()
|
||||||
|
result = service.test_connection(integration)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
return success_response(result, request=request)
|
||||||
|
else:
|
||||||
|
return error_response(
|
||||||
|
result.get('message', 'Connection test failed'),
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def sync(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Trigger synchronization with integrated platform.
|
||||||
|
|
||||||
|
POST /api/v1/integration/integrations/{id}/sync/
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"direction": "both", # 'both', 'to_external', 'from_external'
|
||||||
|
"content_types": ["blog_post", "page"] # Optional
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
integration = self.get_object()
|
||||||
|
|
||||||
|
direction = request.data.get('direction', 'both')
|
||||||
|
content_types = request.data.get('content_types')
|
||||||
|
|
||||||
|
sync_service = SyncService()
|
||||||
|
result = sync_service.sync(integration, direction=direction, content_types=content_types)
|
||||||
|
|
||||||
|
response_status = status.HTTP_200_OK if result.get('success') else status.HTTP_400_BAD_REQUEST
|
||||||
|
return success_response(result, request=request, status_code=response_status)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def sync_status(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Get sync status for integration.
|
||||||
|
|
||||||
|
GET /api/v1/integration/integrations/{id}/sync_status/
|
||||||
|
"""
|
||||||
|
integration = self.get_object()
|
||||||
|
|
||||||
|
sync_service = SyncService()
|
||||||
|
status_data = sync_service.get_sync_status(integration)
|
||||||
|
|
||||||
|
return success_response(status_data, request=request)
|
||||||
|
|
||||||
@@ -55,10 +55,12 @@ INSTALLED_APPS = [
|
|||||||
'igny8_core.business.site_building.apps.SiteBuildingConfig',
|
'igny8_core.business.site_building.apps.SiteBuildingConfig',
|
||||||
'igny8_core.business.optimization.apps.OptimizationConfig',
|
'igny8_core.business.optimization.apps.OptimizationConfig',
|
||||||
'igny8_core.business.publishing.apps.PublishingConfig',
|
'igny8_core.business.publishing.apps.PublishingConfig',
|
||||||
|
'igny8_core.business.integration.apps.IntegrationConfig',
|
||||||
'igny8_core.modules.site_builder.apps.SiteBuilderConfig',
|
'igny8_core.modules.site_builder.apps.SiteBuilderConfig',
|
||||||
'igny8_core.modules.linker.apps.LinkerConfig',
|
'igny8_core.modules.linker.apps.LinkerConfig',
|
||||||
'igny8_core.modules.optimizer.apps.OptimizerConfig',
|
'igny8_core.modules.optimizer.apps.OptimizerConfig',
|
||||||
'igny8_core.modules.publisher.apps.PublisherConfig',
|
'igny8_core.modules.publisher.apps.PublisherConfig',
|
||||||
|
'igny8_core.modules.integration.apps.IntegrationConfig',
|
||||||
]
|
]
|
||||||
|
|
||||||
# System module needs explicit registration for admin
|
# System module needs explicit registration for admin
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ urlpatterns = [
|
|||||||
path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
|
path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
|
||||||
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
||||||
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
||||||
|
path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints
|
||||||
# OpenAPI Schema and Documentation
|
# OpenAPI Schema and Documentation
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const Industries = lazy(() => import("./pages/Settings/Industries"));
|
|||||||
const Status = lazy(() => import("./pages/Settings/Status"));
|
const Status = lazy(() => import("./pages/Settings/Status"));
|
||||||
const ApiMonitor = lazy(() => import("./pages/Settings/ApiMonitor"));
|
const ApiMonitor = lazy(() => import("./pages/Settings/ApiMonitor"));
|
||||||
const Integration = lazy(() => import("./pages/Settings/Integration"));
|
const Integration = lazy(() => import("./pages/Settings/Integration"));
|
||||||
|
const Publishing = lazy(() => import("./pages/Settings/Publishing"));
|
||||||
const Sites = lazy(() => import("./pages/Settings/Sites"));
|
const Sites = lazy(() => import("./pages/Settings/Sites"));
|
||||||
const ImportExport = lazy(() => import("./pages/Settings/ImportExport"));
|
const ImportExport = lazy(() => import("./pages/Settings/ImportExport"));
|
||||||
|
|
||||||
@@ -419,6 +420,11 @@ export default function App() {
|
|||||||
<Integration />
|
<Integration />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/settings/publishing" element={
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Publishing />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
<Route path="/settings/sites" element={
|
<Route path="/settings/sites" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Sites />
|
<Sites />
|
||||||
|
|||||||
83
frontend/src/components/integration/IntegrationStatus.tsx
Normal file
83
frontend/src/components/integration/IntegrationStatus.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Integration Status Component
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckCircleIcon, XCircleIcon, ClockIcon, SyncIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface IntegrationStatusProps {
|
||||||
|
syncEnabled: boolean;
|
||||||
|
syncStatus: 'success' | 'failed' | 'pending' | 'syncing';
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
syncError?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntegrationStatus({
|
||||||
|
syncEnabled,
|
||||||
|
syncStatus,
|
||||||
|
lastSyncAt,
|
||||||
|
syncError,
|
||||||
|
}: IntegrationStatusProps) {
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (syncStatus) {
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircleIcon className="w-5 h-5 text-red-500" />;
|
||||||
|
case 'syncing':
|
||||||
|
return <SyncIcon className="w-5 h-5 text-blue-500 animate-spin" />;
|
||||||
|
default:
|
||||||
|
return <ClockIcon className="w-5 h-5 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
switch (syncStatus) {
|
||||||
|
case 'success':
|
||||||
|
return 'Synced';
|
||||||
|
case 'failed':
|
||||||
|
return 'Sync Failed';
|
||||||
|
case 'syncing':
|
||||||
|
return 'Syncing...';
|
||||||
|
default:
|
||||||
|
return 'Not Synced';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon()}
|
||||||
|
<span className="text-sm font-medium">{getStatusText()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{syncEnabled && (
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<div>Last sync: {formatDate(lastSyncAt)}</div>
|
||||||
|
{syncError && (
|
||||||
|
<div className="text-red-600 dark:text-red-400 mt-1">
|
||||||
|
Error: {syncError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!syncEnabled && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
Two-way sync is disabled
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
31
frontend/src/components/integration/PlatformSelector.tsx
Normal file
31
frontend/src/components/integration/PlatformSelector.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Platform Selector Component
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import SelectDropdown from '../form/SelectDropdown';
|
||||||
|
|
||||||
|
interface PlatformSelectorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORMS = [
|
||||||
|
{ value: 'wordpress', label: 'WordPress' },
|
||||||
|
{ value: 'shopify', label: 'Shopify' },
|
||||||
|
{ value: 'custom', label: 'Custom API' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PlatformSelector({ value, onChange, disabled }: PlatformSelectorProps) {
|
||||||
|
return (
|
||||||
|
<SelectDropdown
|
||||||
|
options={PLATFORMS}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Select platform"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
339
frontend/src/components/integration/SiteIntegrationsSection.tsx
Normal file
339
frontend/src/components/integration/SiteIntegrationsSection.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* Site Integrations Section
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { PlusIcon, TrashIcon, TestTubeIcon, SyncIcon } from 'lucide-react';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import { Modal } from '../ui/modal';
|
||||||
|
import FormModal, { FormField } from '../common/FormModal';
|
||||||
|
import PlatformSelector from './PlatformSelector';
|
||||||
|
import IntegrationStatus from './IntegrationStatus';
|
||||||
|
import { useToast } from '../ui/toast/ToastContainer';
|
||||||
|
import { fetchAPI } from '../../services/api';
|
||||||
|
|
||||||
|
interface SiteIntegration {
|
||||||
|
id: number;
|
||||||
|
site: number;
|
||||||
|
site_name?: string;
|
||||||
|
platform: string;
|
||||||
|
platform_type: string;
|
||||||
|
is_active: boolean;
|
||||||
|
sync_enabled: boolean;
|
||||||
|
sync_status: 'success' | 'failed' | 'pending' | 'syncing';
|
||||||
|
last_sync_at: string | null;
|
||||||
|
sync_error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteIntegrationsSectionProps {
|
||||||
|
siteId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteIntegrationsSection({ siteId }: SiteIntegrationsSectionProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [integrations, setIntegrations] = useState<SiteIntegration[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [selectedIntegration, setSelectedIntegration] = useState<SiteIntegration | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIntegrations();
|
||||||
|
}, [siteId]);
|
||||||
|
|
||||||
|
const loadIntegrations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = siteId ? `?site=${siteId}` : '';
|
||||||
|
const data = await fetchAPI(`/v1/integration/integrations/${params}`);
|
||||||
|
if (data && Array.isArray(data)) {
|
||||||
|
setIntegrations(data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load integrations: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setSelectedIntegration(null);
|
||||||
|
setShowAddModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (integration: SiteIntegration) => {
|
||||||
|
setSelectedIntegration(integration);
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this integration?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchAPI(`/v1/integration/integrations/${id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
toast.success('Integration deleted successfully');
|
||||||
|
loadIntegrations();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to delete integration: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async (integration: SiteIntegration) => {
|
||||||
|
setIsTesting(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchAPI(`/v1/integration/integrations/${integration.id}/test_connection/`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success(data.message || 'Connection test successful');
|
||||||
|
} else {
|
||||||
|
toast.error(data?.message || 'Connection test failed');
|
||||||
|
}
|
||||||
|
loadIntegrations();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Connection test failed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async (integration: SiteIntegration) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchAPI(`/v1/integration/integrations/${integration.id}/sync/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
direction: 'both',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success(`Sync completed. ${data.synced_count || 0} items synced`);
|
||||||
|
} else {
|
||||||
|
toast.error('Sync failed');
|
||||||
|
}
|
||||||
|
loadIntegrations();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Sync failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (formData: Record<string, any>) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
site: siteId || formData.site,
|
||||||
|
platform: formData.platform,
|
||||||
|
platform_type: formData.platform_type || 'cms',
|
||||||
|
config_json: {
|
||||||
|
site_url: formData.site_url,
|
||||||
|
...(formData.platform === 'shopify' && {
|
||||||
|
shop_domain: formData.shop_domain,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
credentials_json: {
|
||||||
|
username: formData.username,
|
||||||
|
app_password: formData.app_password,
|
||||||
|
...(formData.platform === 'shopify' && {
|
||||||
|
access_token: formData.access_token,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
is_active: formData.is_active !== false,
|
||||||
|
sync_enabled: formData.sync_enabled || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedIntegration) {
|
||||||
|
// Update
|
||||||
|
await fetchAPI(`/v1/integration/integrations/${selectedIntegration.id}/`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
toast.success('Integration updated successfully');
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
await fetchAPI('/v1/integration/integrations/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
toast.success('Integration created successfully');
|
||||||
|
}
|
||||||
|
setShowAddModal(false);
|
||||||
|
setShowEditModal(false);
|
||||||
|
loadIntegrations();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to save integration: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFields = (): FormField[] => {
|
||||||
|
const platform = selectedIntegration?.platform || 'wordpress';
|
||||||
|
|
||||||
|
const baseFields: FormField[] = [
|
||||||
|
{
|
||||||
|
name: 'platform',
|
||||||
|
label: 'Platform',
|
||||||
|
type: 'custom',
|
||||||
|
component: (
|
||||||
|
<PlatformSelector
|
||||||
|
value={platform}
|
||||||
|
onChange={() => {}}
|
||||||
|
disabled={!!selectedIntegration}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (platform === 'wordpress') {
|
||||||
|
baseFields.push(
|
||||||
|
{ name: 'site_url', label: 'WordPress Site URL', type: 'text', required: true, placeholder: 'https://example.com' },
|
||||||
|
{ name: 'username', label: 'Username', type: 'text', required: true },
|
||||||
|
{ name: 'app_password', label: 'Application Password', type: 'password', required: true }
|
||||||
|
);
|
||||||
|
} else if (platform === 'shopify') {
|
||||||
|
baseFields.push(
|
||||||
|
{ name: 'shop_domain', label: 'Shop Domain', type: 'text', required: true, placeholder: 'myshop.myshopify.com' },
|
||||||
|
{ name: 'access_token', label: 'Access Token', type: 'password', required: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
baseFields.push(
|
||||||
|
{ name: 'site_url', label: 'API URL', type: 'text', required: true },
|
||||||
|
{ name: 'username', label: 'API Key', type: 'text', required: true },
|
||||||
|
{ name: 'app_password', label: 'API Secret', type: 'password', required: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFields.push(
|
||||||
|
{ name: 'is_active', label: 'Active', type: 'checkbox', defaultValue: true },
|
||||||
|
{ name: 'sync_enabled', label: 'Enable Two-Way Sync', type: 'checkbox', defaultValue: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
return baseFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>Loading integrations...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Site Integrations</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Connect your sites to external platforms (WordPress, Shopify, Custom APIs)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAdd} variant="primary">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
|
Add Integration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{integrations.length === 0 ? (
|
||||||
|
<div className="text-center py-12 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">No integrations configured</p>
|
||||||
|
<Button onClick={handleAdd} variant="outline" className="mt-4">
|
||||||
|
Add Your First Integration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{integrations.map((integration) => (
|
||||||
|
<div
|
||||||
|
key={integration.id}
|
||||||
|
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white capitalize">
|
||||||
|
{integration.platform}
|
||||||
|
</h3>
|
||||||
|
{integration.site_name && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{integration.site_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTestConnection(integration)}
|
||||||
|
disabled={isTesting}
|
||||||
|
title="Test Connection"
|
||||||
|
>
|
||||||
|
<TestTubeIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(integration.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IntegrationStatus
|
||||||
|
syncEnabled={integration.sync_enabled}
|
||||||
|
syncStatus={integration.sync_status}
|
||||||
|
lastSyncAt={integration.last_sync_at}
|
||||||
|
syncError={integration.sync_error}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(integration)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{integration.sync_enabled && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSync(integration)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<SyncIcon className="w-4 h-4 mr-1" />
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
<FormModal
|
||||||
|
isOpen={showAddModal || showEditModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowAddModal(false);
|
||||||
|
setShowEditModal(false);
|
||||||
|
}}
|
||||||
|
onSubmit={handleSave}
|
||||||
|
title={selectedIntegration ? 'Edit Integration' : 'Add Integration'}
|
||||||
|
fields={getFields()}
|
||||||
|
submitLabel="Save"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
isLoading={isSaving}
|
||||||
|
initialValues={selectedIntegration ? {
|
||||||
|
platform: selectedIntegration.platform,
|
||||||
|
site_url: selectedIntegration.site_name || '',
|
||||||
|
is_active: selectedIntegration.is_active,
|
||||||
|
sync_enabled: selectedIntegration.sync_enabled,
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
242
frontend/src/components/publishing/PublishingRules.tsx
Normal file
242
frontend/src/components/publishing/PublishingRules.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Publishing Rules Component
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { PlusIcon, TrashIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import Checkbox from '../form/input/Checkbox';
|
||||||
|
import Label from '../form/Label';
|
||||||
|
import SelectDropdown from '../form/SelectDropdown';
|
||||||
|
|
||||||
|
export interface PublishingRule {
|
||||||
|
id: string;
|
||||||
|
content_type: string;
|
||||||
|
trigger: 'auto' | 'manual' | 'scheduled';
|
||||||
|
destinations: string[];
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
schedule?: string; // Cron expression for scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublishingRulesProps {
|
||||||
|
rules: PublishingRule[];
|
||||||
|
onChange: (rules: PublishingRule[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_TYPES = [
|
||||||
|
{ value: 'blog_post', label: 'Blog Post' },
|
||||||
|
{ value: 'page', label: 'Page' },
|
||||||
|
{ value: 'product', label: 'Product' },
|
||||||
|
{ value: 'all', label: 'All Content' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TRIGGERS = [
|
||||||
|
{ value: 'auto', label: 'Auto-Publish' },
|
||||||
|
{ value: 'manual', label: 'Manual' },
|
||||||
|
{ value: 'scheduled', label: 'Scheduled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DESTINATIONS = [
|
||||||
|
{ value: 'sites', label: 'IGNY8 Sites' },
|
||||||
|
{ value: 'wordpress', label: 'WordPress' },
|
||||||
|
{ value: 'shopify', label: 'Shopify' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PublishingRules({ rules, onChange }: PublishingRulesProps) {
|
||||||
|
const [localRules, setLocalRules] = useState<PublishingRule[]>(rules);
|
||||||
|
|
||||||
|
const handleAddRule = () => {
|
||||||
|
const newRule: PublishingRule = {
|
||||||
|
id: `rule_${Date.now()}`,
|
||||||
|
content_type: 'all',
|
||||||
|
trigger: 'manual',
|
||||||
|
destinations: ['sites'],
|
||||||
|
priority: localRules.length + 1,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
const updated = [...localRules, newRule];
|
||||||
|
setLocalRules(updated);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRule = (id: string) => {
|
||||||
|
const updated = localRules.filter((r) => r.id !== id);
|
||||||
|
setLocalRules(updated);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateRule = (id: string, field: keyof PublishingRule, value: any) => {
|
||||||
|
const updated = localRules.map((rule) =>
|
||||||
|
rule.id === id ? { ...rule, [field]: value } : rule
|
||||||
|
);
|
||||||
|
setLocalRules(updated);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveRule = (id: string, direction: 'up' | 'down') => {
|
||||||
|
const index = localRules.findIndex((r) => r.id === id);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (newIndex < 0 || newIndex >= localRules.length) return;
|
||||||
|
|
||||||
|
const updated = [...localRules];
|
||||||
|
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
|
||||||
|
|
||||||
|
// Update priorities
|
||||||
|
updated.forEach((rule, i) => {
|
||||||
|
rule.priority = i + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalRules(updated);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleDestinations = (ruleId: string, destination: string) => {
|
||||||
|
const rule = localRules.find((r) => r.id === ruleId);
|
||||||
|
if (!rule) return;
|
||||||
|
|
||||||
|
const destinations = rule.destinations.includes(destination)
|
||||||
|
? rule.destinations.filter((d) => d !== destination)
|
||||||
|
: [...rule.destinations, destination];
|
||||||
|
|
||||||
|
handleUpdateRule(ruleId, 'destinations', destinations);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Publishing Rules
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Configure how and where content is published
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAddRule} variant="primary" size="sm">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localRules.length === 0 ? (
|
||||||
|
<div className="text-center py-8 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">No publishing rules configured</p>
|
||||||
|
<Button onClick={handleAddRule} variant="outline">
|
||||||
|
Add Your First Rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{localRules.map((rule, index) => (
|
||||||
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Content Type</Label>
|
||||||
|
<SelectDropdown
|
||||||
|
options={CONTENT_TYPES}
|
||||||
|
value={rule.content_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateRule(rule.id, 'content_type', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Trigger</Label>
|
||||||
|
<SelectDropdown
|
||||||
|
options={TRIGGERS}
|
||||||
|
value={rule.trigger}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateRule(rule.id, 'trigger', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Priority</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleMoveRule(rule.id, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">{rule.priority}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleMoveRule(rule.id, 'down')}
|
||||||
|
disabled={index === localRules.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={rule.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateRule(rule.id, 'enabled', e.target.checked)
|
||||||
|
}
|
||||||
|
label="Enabled"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteRule(rule.id)}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Destinations</Label>
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2">
|
||||||
|
{DESTINATIONS.map((dest) => (
|
||||||
|
<Checkbox
|
||||||
|
key={dest.value}
|
||||||
|
checked={rule.destinations.includes(dest.value)}
|
||||||
|
onChange={() => handleToggleDestinations(rule.id, dest.value)}
|
||||||
|
label={dest.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rule.trigger === 'scheduled' && (
|
||||||
|
<div>
|
||||||
|
<Label>Schedule (Cron Expression)</Label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.schedule || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateRule(rule.id, 'schedule', e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="0 0 * * *"
|
||||||
|
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Cron format: minute hour day month weekday
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -89,6 +89,8 @@ export const createTasksPageConfig = (
|
|||||||
setStructureFilter: (value: string) => void;
|
setStructureFilter: (value: string) => void;
|
||||||
typeFilter: string;
|
typeFilter: string;
|
||||||
setTypeFilter: (value: string) => void;
|
setTypeFilter: (value: string) => void;
|
||||||
|
sourceFilter: string;
|
||||||
|
setSourceFilter: (value: string) => void;
|
||||||
setCurrentPage: (page: number) => void;
|
setCurrentPage: (page: number) => void;
|
||||||
}
|
}
|
||||||
): TasksPageConfig => {
|
): TasksPageConfig => {
|
||||||
@@ -103,6 +105,23 @@ export const createTasksPageConfig = (
|
|||||||
toggleable: true,
|
toggleable: true,
|
||||||
toggleContentKey: 'description',
|
toggleContentKey: 'description',
|
||||||
toggleContentLabel: 'Idea & Content Outline',
|
toggleContentLabel: 'Idea & Content Outline',
|
||||||
|
render: (value: string, row: Task) => {
|
||||||
|
const isSiteBuilder = value?.startsWith('[Site Builder]');
|
||||||
|
const displayTitle = isSiteBuilder ? value.replace('[Site Builder] ', '') : value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{displayTitle}
|
||||||
|
</span>
|
||||||
|
{isSiteBuilder && (
|
||||||
|
<Badge color="purple" size="sm" variant="light">
|
||||||
|
Site Builder
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Sector column - only show when viewing all sectors
|
// Sector column - only show when viewing all sectors
|
||||||
...(showSectorColumn ? [{
|
...(showSectorColumn ? [{
|
||||||
@@ -297,6 +316,17 @@ export const createTasksPageConfig = (
|
|||||||
{ value: 'tutorial', label: 'Tutorial' },
|
{ value: 'tutorial', label: 'Tutorial' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'source',
|
||||||
|
label: 'Source',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: '', label: 'All Sources' },
|
||||||
|
{ value: 'site_builder', label: 'Site Builder' },
|
||||||
|
{ value: 'ideas', label: 'Ideas' },
|
||||||
|
{ value: 'manual', label: 'Manual' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'cluster_id',
|
key: 'cluster_id',
|
||||||
label: 'Cluster',
|
label: 'Cluster',
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
{ name: "General", path: "/settings" },
|
{ name: "General", path: "/settings" },
|
||||||
{ name: "Plans", path: "/settings/plans" },
|
{ name: "Plans", path: "/settings/plans" },
|
||||||
{ name: "Integration", path: "/settings/integration" },
|
{ name: "Integration", path: "/settings/integration" },
|
||||||
|
{ name: "Publishing", path: "/settings/publishing" },
|
||||||
{ name: "Import / Export", path: "/settings/import-export" },
|
{ name: "Import / Export", path: "/settings/import-export" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ValidationCard from '../../components/common/ValidationCard';
|
|||||||
import ImageGenerationCard from '../../components/common/ImageGenerationCard';
|
import ImageGenerationCard from '../../components/common/ImageGenerationCard';
|
||||||
import ImageResultCard from '../../components/common/ImageResultCard';
|
import ImageResultCard from '../../components/common/ImageResultCard';
|
||||||
import ImageServiceCard from '../../components/common/ImageServiceCard';
|
import ImageServiceCard from '../../components/common/ImageServiceCard';
|
||||||
|
import SiteIntegrationsSection from '../../components/integration/SiteIntegrationsSection';
|
||||||
import { Modal } from '../../components/ui/modal';
|
import { Modal } from '../../components/ui/modal';
|
||||||
import FormModal, { FormField } from '../../components/common/FormModal';
|
import FormModal, { FormField } from '../../components/common/FormModal';
|
||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
@@ -1082,6 +1083,9 @@ export default function Integration() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Site Integrations Section */}
|
||||||
|
<SiteIntegrationsSection />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details Modal */}
|
{/* Details Modal */}
|
||||||
|
|||||||
165
frontend/src/pages/Settings/Publishing.tsx
Normal file
165
frontend/src/pages/Settings/Publishing.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Publishing Settings Page
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import Button from '../../components/ui/button/Button';
|
||||||
|
import Checkbox from '../../components/form/input/Checkbox';
|
||||||
|
import Label from '../../components/form/Label';
|
||||||
|
import PublishingRules, { PublishingRule } from '../../components/publishing/PublishingRules';
|
||||||
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
|
import { fetchAPI } from '../../services/api';
|
||||||
|
|
||||||
|
export default function Publishing() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [defaultDestinations, setDefaultDestinations] = useState<string[]>(['sites']);
|
||||||
|
const [autoPublishEnabled, setAutoPublishEnabled] = useState(false);
|
||||||
|
const [publishingRules, setPublishingRules] = useState<PublishingRule[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// TODO: Load from backend API when endpoint is available
|
||||||
|
// For now, use defaults
|
||||||
|
setDefaultDestinations(['sites']);
|
||||||
|
setAutoPublishEnabled(false);
|
||||||
|
setPublishingRules([]);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load settings: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
// TODO: Save to backend API when endpoint is available
|
||||||
|
toast.success('Publishing settings saved successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to save settings: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleDestination = (destination: string) => {
|
||||||
|
setDefaultDestinations((prev) =>
|
||||||
|
prev.includes(destination)
|
||||||
|
? prev.filter((d) => d !== destination)
|
||||||
|
: [...prev, destination]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DESTINATIONS = [
|
||||||
|
{ value: 'sites', label: 'IGNY8 Sites' },
|
||||||
|
{ value: 'wordpress', label: 'WordPress' },
|
||||||
|
{ value: 'shopify', label: 'Shopify' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Publishing Settings" />
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Publishing Settings - IGNY8" />
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Publishing Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Configure default publishing destinations and rules
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Default Destinations */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
Default Publishing Destinations
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Select default platforms where content will be published
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{DESTINATIONS.map((dest) => (
|
||||||
|
<div key={dest.value} className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={defaultDestinations.includes(dest.value)}
|
||||||
|
onChange={() => handleToggleDestination(dest.value)}
|
||||||
|
label={dest.label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Auto-Publish Settings */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
Auto-Publish Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Automatically publish content when it's ready
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={autoPublishEnabled}
|
||||||
|
onChange={(e) => setAutoPublishEnabled(e.target.checked)}
|
||||||
|
label="Enable auto-publish"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{autoPublishEnabled && (
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
When enabled, content will be automatically published to selected destinations
|
||||||
|
when generation is complete.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Publishing Rules */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<PublishingRules rules={publishingRules} onChange={setPublishingRules} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
83
frontend/src/pages/Sites/Editor.tsx
Normal file
83
frontend/src/pages/Sites/Editor.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Site Content Editor
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import Button from '../../components/ui/button/Button';
|
||||||
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
|
import { fetchAPI } from '../../services/api';
|
||||||
|
|
||||||
|
interface Page {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
blocks: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteContentEditor() {
|
||||||
|
const { siteId } = useParams<{ siteId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
|
const [pages, setPages] = useState<Page[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedPage, setSelectedPage] = useState<Page | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (siteId) {
|
||||||
|
loadPages();
|
||||||
|
}
|
||||||
|
}, [siteId]);
|
||||||
|
|
||||||
|
const loadPages = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// TODO: Load pages from SiteBlueprint API
|
||||||
|
// For now, placeholder
|
||||||
|
setPages([]);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load pages: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Site Content Editor" />
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500">Loading pages...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Site Content Editor - IGNY8" />
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Site Content Editor
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Edit content for site pages
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Content editor will be implemented in Phase 7
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
202
frontend/src/pages/Sites/Manage.tsx
Normal file
202
frontend/src/pages/Sites/Manage.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Site Management Dashboard
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PlusIcon, EditIcon, SettingsIcon, EyeIcon, TrashIcon } from 'lucide-react';
|
||||||
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import Button from '../../components/ui/button/Button';
|
||||||
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
|
import { fetchAPI } from '../../services/api';
|
||||||
|
|
||||||
|
interface Site {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
site_type: string;
|
||||||
|
hosting_type: string;
|
||||||
|
status: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
page_count?: number;
|
||||||
|
integration_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteManagement() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
|
const [sites, setSites] = useState<Site[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSites();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchAPI('/v1/auth/sites/');
|
||||||
|
if (data && Array.isArray(data)) {
|
||||||
|
setSites(data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load sites: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSite = () => {
|
||||||
|
navigate('/site-builder');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (siteId: number) => {
|
||||||
|
navigate(`/sites/${siteId}/edit`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettings = (siteId: number) => {
|
||||||
|
navigate(`/sites/${siteId}/settings`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (siteId: number) => {
|
||||||
|
navigate(`/sites/${siteId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (siteId: number) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this site?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchAPI(`/v1/auth/sites/${siteId}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
toast.success('Site deleted successfully');
|
||||||
|
loadSites();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to delete site: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Site Management" />
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500">Loading sites...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Site Management - IGNY8" />
|
||||||
|
|
||||||
|
<div className="mb-6 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Site Management
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Manage your sites, pages, and content
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreateSite} variant="primary">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
|
Create New Site
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sites.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
No sites created yet
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleCreateSite} variant="primary">
|
||||||
|
Create Your First Site
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{sites.map((site) => (
|
||||||
|
<Card key={site.id} className="p-4 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{site.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{site.slug}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
site.is_active
|
||||||
|
? '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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{site.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded capitalize">
|
||||||
|
{site.site_type}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-1 bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded capitalize">
|
||||||
|
{site.hosting_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{site.page_count || 0} pages
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleView(site.id)}
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<EyeIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(site.id)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<EditIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSettings(site.id)}
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(site.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
170
frontend/src/pages/Sites/PageManager.tsx
Normal file
170
frontend/src/pages/Sites/PageManager.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Page Manager
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { PlusIcon, EditIcon, TrashIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react';
|
||||||
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import Button from '../../components/ui/button/Button';
|
||||||
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
|
import { fetchAPI } from '../../services/api';
|
||||||
|
|
||||||
|
interface Page {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
order: number;
|
||||||
|
blocks: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageManager() {
|
||||||
|
const { siteId } = useParams<{ siteId: string }>();
|
||||||
|
const toast = useToast();
|
||||||
|
const [pages, setPages] = useState<Page[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (siteId) {
|
||||||
|
loadPages();
|
||||||
|
}
|
||||||
|
}, [siteId]);
|
||||||
|
|
||||||
|
const loadPages = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// TODO: Load pages from SiteBlueprint API
|
||||||
|
// For now, placeholder
|
||||||
|
setPages([]);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load pages: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddPage = () => {
|
||||||
|
// TODO: Navigate to page creation
|
||||||
|
toast.info('Page creation will be implemented in Phase 7');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPage = (pageId: number) => {
|
||||||
|
// TODO: Navigate to page editor
|
||||||
|
toast.info('Page editor will be implemented in Phase 7');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePage = async (pageId: number) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this page?')) return;
|
||||||
|
// TODO: Delete page
|
||||||
|
toast.info('Page deletion will be implemented in Phase 7');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMovePage = async (pageId: number, direction: 'up' | 'down') => {
|
||||||
|
// TODO: Update page order
|
||||||
|
toast.info('Page reordering will be implemented in Phase 7');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Page Manager" />
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500">Loading pages...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Page Manager - IGNY8" />
|
||||||
|
|
||||||
|
<div className="mb-6 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Page Manager
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Manage pages for your site
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAddPage} variant="primary">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
|
Add Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pages.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
No pages created yet
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleAddPage} variant="primary">
|
||||||
|
Add Your First Page
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pages.map((page, index) => (
|
||||||
|
<div
|
||||||
|
key={page.id}
|
||||||
|
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleMovePage(page.id, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleMovePage(page.id, 'down')}
|
||||||
|
disabled={index === pages.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{page.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
/{page.slug} • {page.type} • {page.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditPage(page.id)}
|
||||||
|
>
|
||||||
|
<EditIcon className="w-4 h-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeletePage(page.id)}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
174
frontend/src/pages/Sites/Settings.tsx
Normal file
174
frontend/src/pages/Sites/Settings.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Site Settings
|
||||||
|
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import Button from '../../components/ui/button/Button';
|
||||||
|
import Label from '../../components/form/Label';
|
||||||
|
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||||
|
import Checkbox from '../../components/form/input/Checkbox';
|
||||||
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
|
import { fetchAPI } from '../../services/api';
|
||||||
|
|
||||||
|
export default function SiteSettings() {
|
||||||
|
const { siteId } = useParams<{ siteId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [site, setSite] = useState<any>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
site_type: 'marketing',
|
||||||
|
hosting_type: 'igny8_sites',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (siteId) {
|
||||||
|
loadSite();
|
||||||
|
}
|
||||||
|
}, [siteId]);
|
||||||
|
|
||||||
|
const loadSite = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchAPI(`/v1/auth/sites/${siteId}/`);
|
||||||
|
if (data) {
|
||||||
|
setSite(data);
|
||||||
|
setFormData({
|
||||||
|
name: data.name || '',
|
||||||
|
slug: data.slug || '',
|
||||||
|
site_type: data.site_type || 'marketing',
|
||||||
|
hosting_type: data.hosting_type || 'igny8_sites',
|
||||||
|
is_active: data.is_active !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to load site: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await fetchAPI(`/v1/auth/sites/${siteId}/`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
toast.success('Site settings saved successfully');
|
||||||
|
loadSite();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to save settings: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SITE_TYPES = [
|
||||||
|
{ value: 'marketing', label: 'Marketing Site' },
|
||||||
|
{ value: 'ecommerce', label: 'Ecommerce Site' },
|
||||||
|
{ value: 'blog', label: 'Blog' },
|
||||||
|
{ value: 'portfolio', label: 'Portfolio' },
|
||||||
|
{ value: 'corporate', label: 'Corporate' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOSTING_TYPES = [
|
||||||
|
{ value: 'igny8_sites', label: 'IGNY8 Sites' },
|
||||||
|
{ value: 'wordpress', label: 'WordPress' },
|
||||||
|
{ value: 'shopify', label: 'Shopify' },
|
||||||
|
{ value: 'multi', label: 'Multi-Destination' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Site Settings" />
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500">Loading site settings...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageMeta title="Site Settings - IGNY8" />
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Site Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Configure site type, hosting, and other settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Site Name</Label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Slug</Label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.slug}
|
||||||
|
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||||
|
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Site Type</Label>
|
||||||
|
<SelectDropdown
|
||||||
|
options={SITE_TYPES}
|
||||||
|
value={formData.site_type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, site_type: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Hosting Type</Label>
|
||||||
|
<SelectDropdown
|
||||||
|
options={HOSTING_TYPES}
|
||||||
|
value={formData.hosting_type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, hosting_type: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ export default function Tasks() {
|
|||||||
const [clusterFilter, setClusterFilter] = useState('');
|
const [clusterFilter, setClusterFilter] = useState('');
|
||||||
const [structureFilter, setStructureFilter] = useState('');
|
const [structureFilter, setStructureFilter] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState('');
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
const [sourceFilter, setSourceFilter] = useState('');
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
@@ -129,8 +130,15 @@ export default function Tasks() {
|
|||||||
try {
|
try {
|
||||||
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
|
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
|
||||||
|
|
||||||
|
// Build search term - combine user search with Site Builder filter if needed
|
||||||
|
let finalSearchTerm = searchTerm;
|
||||||
|
if (sourceFilter === 'site_builder') {
|
||||||
|
// If user has a search term, combine it with Site Builder prefix
|
||||||
|
finalSearchTerm = searchTerm ? `[Site Builder] ${searchTerm}` : '[Site Builder]';
|
||||||
|
}
|
||||||
|
|
||||||
const filters: TasksFilters = {
|
const filters: TasksFilters = {
|
||||||
...(searchTerm && { search: searchTerm }),
|
...(finalSearchTerm && { search: finalSearchTerm }),
|
||||||
...(statusFilter && { status: statusFilter }),
|
...(statusFilter && { status: statusFilter }),
|
||||||
...(clusterFilter && { cluster_id: clusterFilter }),
|
...(clusterFilter && { cluster_id: clusterFilter }),
|
||||||
...(structureFilter && { content_structure: structureFilter }),
|
...(structureFilter && { content_structure: structureFilter }),
|
||||||
@@ -495,6 +503,8 @@ export default function Tasks() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
clusterFilter,
|
clusterFilter,
|
||||||
|
sourceFilter,
|
||||||
|
setSourceFilter,
|
||||||
setClusterFilter,
|
setClusterFilter,
|
||||||
structureFilter,
|
structureFilter,
|
||||||
setStructureFilter,
|
setStructureFilter,
|
||||||
@@ -502,7 +512,7 @@ export default function Tasks() {
|
|||||||
setTypeFilter,
|
setTypeFilter,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
});
|
});
|
||||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
|
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, sourceFilter]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// Calculate header metrics
|
||||||
const headerMetrics = useMemo(() => {
|
const headerMetrics = useMemo(() => {
|
||||||
@@ -565,6 +575,7 @@ export default function Tasks() {
|
|||||||
cluster_id: clusterFilter,
|
cluster_id: clusterFilter,
|
||||||
content_structure: structureFilter,
|
content_structure: structureFilter,
|
||||||
content_type: typeFilter,
|
content_type: typeFilter,
|
||||||
|
source: sourceFilter,
|
||||||
}}
|
}}
|
||||||
onFilterChange={(key, value) => {
|
onFilterChange={(key, value) => {
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
@@ -578,6 +589,8 @@ export default function Tasks() {
|
|||||||
setStructureFilter(stringValue);
|
setStructureFilter(stringValue);
|
||||||
} else if (key === 'content_type') {
|
} else if (key === 'content_type') {
|
||||||
setTypeFilter(stringValue);
|
setTypeFilter(stringValue);
|
||||||
|
} else if (key === 'source') {
|
||||||
|
setSourceFilter(stringValue);
|
||||||
}
|
}
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user