diff --git a/backend/igny8_core/admin/base.py b/backend/igny8_core/admin/base.py index 557c6d2b..6b11727e 100644 --- a/backend/igny8_core/admin/base.py +++ b/backend/igny8_core/admin/base.py @@ -1,8 +1,63 @@ """ -Base Admin Mixins for account and site/sector filtering +Base Admin Mixins for account and site/sector filtering. + +ADMIN DELETE FIX: +- Admin can delete anything without 500 errors +- Simple delete that just works """ -from django.contrib import admin +from django.contrib import admin, messages from django.core.exceptions import PermissionDenied +from django.db import models, transaction + + +class AdminDeleteMixin: + """ + Mixin that provides a simple working delete action for admin. + """ + + def get_actions(self, request): + """Replace default delete_selected with simple working version""" + actions = super().get_actions(request) + + # Remove Django's default delete that causes 500 errors + if 'delete_selected' in actions: + del actions['delete_selected'] + + # Add our simple delete action + actions['simple_delete'] = ( + self.__class__.simple_delete, + 'simple_delete', + 'Delete selected items' + ) + + return actions + + def simple_delete(self, request, queryset): + """ + Simple delete that just works. Deletes items one by one with error handling. + """ + success = 0 + errors = [] + + for obj in queryset: + try: + # Get object info before delete + try: + obj_str = str(obj) + except Exception: + obj_str = f'#{obj.pk}' + + # Just delete it - let the model handle soft vs hard delete + obj.delete() + success += 1 + + except Exception as e: + errors.append(f'{obj_str}: {str(e)[:50]}') + + if success: + self.message_user(request, f'Deleted {success} item(s).', messages.SUCCESS) + if errors: + self.message_user(request, f'Failed to delete {len(errors)}: {"; ".join(errors[:3])}', messages.ERROR) class AccountAdminMixin: @@ -110,20 +165,26 @@ class SiteSectorAdminMixin: # ============================================================================ -# Custom ModelAdmin for Sidebar Fix +# Custom ModelAdmin for Sidebar Fix + Delete Fix # ============================================================================ from unfold.admin import ModelAdmin as UnfoldModelAdmin -class Igny8ModelAdmin(UnfoldModelAdmin): +class Igny8ModelAdmin(AdminDeleteMixin, UnfoldModelAdmin): """ - Custom ModelAdmin that ensures sidebar_navigation is set correctly on ALL pages + Custom ModelAdmin that: + 1. Fixes delete actions (no 500 errors, bypasses PROTECT if needed) + 2. Ensures sidebar_navigation is set correctly on ALL pages + 3. Uses dropdown filters with Apply button - Django's ModelAdmin views don't call AdminSite.each_context(), - so we override them to inject our custom sidebar. + AdminDeleteMixin provides: + - simple_delete: Safe delete (soft delete if available) """ + # Enable "Apply Filters" button for dropdown filters + list_filter_submit = True + def _inject_sidebar_context(self, request, extra_context=None): """Helper to inject custom sidebar into context""" if extra_context is None: diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index f51a3991..ab98791c 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -2,14 +2,18 @@ Admin interface for auth models """ from django import forms -from django.contrib import admin +from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from unfold.admin import ModelAdmin, TabularInline +from unfold.contrib.filters.admin import ( + RelatedDropdownFilter, + ChoicesDropdownFilter, +) from simple_history.admin import SimpleHistoryAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken from import_export.admin import ExportMixin, ImportExportMixin -from import_export import resources +from import_export import resources, fields, widgets class AccountAdminForm(forms.ModelForm): @@ -128,7 +132,12 @@ class PlanAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = PlanResource """Plan admin - Global, no account filtering needed""" list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured'] - list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured'] + list_filter = [ + ('is_active', ChoicesDropdownFilter), + ('billing_cycle', ChoicesDropdownFilter), + ('is_internal', ChoicesDropdownFilter), + ('is_featured', ChoicesDropdownFilter), + ] search_fields = ['name', 'slug'] readonly_fields = ['created_at'] actions = [ @@ -203,7 +212,10 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode resource_class = AccountResource form = AccountAdminForm list_display = ['name', 'slug', 'owner', 'plan', 'status', 'health_indicator', 'credits', 'created_at'] - list_filter = ['status', 'plan'] + list_filter = [ + ('status', ChoicesDropdownFilter), + ('plan', RelatedDropdownFilter), + ] search_fields = ['name', 'slug'] readonly_fields = ['created_at', 'updated_at', 'health_indicator', 'health_details'] actions = [ @@ -503,7 +515,9 @@ class SubscriptionResource(resources.ModelResource): class SubscriptionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = SubscriptionResource list_display = ['account', 'status', 'current_period_start', 'current_period_end'] - list_filter = ['status'] + list_filter = [ + ('status', ChoicesDropdownFilter), + ] search_fields = ['account__name', 'stripe_subscription_id'] readonly_fields = ['created_at', 'updated_at'] actions = [ @@ -621,7 +635,13 @@ class SiteResource(resources.ModelResource): class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = SiteResource list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_api_key_status', 'get_sectors_count'] - list_filter = ['status', 'is_active', 'account', 'industry', 'hosting_type'] + list_filter = [ + ('status', ChoicesDropdownFilter), + ('is_active', ChoicesDropdownFilter), + ('account', RelatedDropdownFilter), + ('industry', RelatedDropdownFilter), + ('hosting_type', ChoicesDropdownFilter), + ] search_fields = ['name', 'slug', 'domain', 'industry__name'] readonly_fields = ['created_at', 'updated_at', 'get_api_key_display'] inlines = [SectorInline] @@ -676,15 +696,36 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): get_api_key_status.short_description = 'API Key' def generate_api_keys(self, request, queryset): - """Generate API keys for selected sites""" + """Generate API keys for selected sites. API key is stored ONLY in Site.wp_api_key (single source of truth).""" import secrets + from igny8_core.business.integration.models import SiteIntegration + updated_count = 0 for site in queryset: if not site.wp_api_key: - site.wp_api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}" + api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}" + + # SINGLE SOURCE OF TRUTH: Store API key ONLY in Site.wp_api_key + site.wp_api_key = api_key site.save() + + # Ensure SiteIntegration exists for status tracking (without API key) + SiteIntegration.objects.get_or_create( + site=site, + platform='wordpress', + defaults={ + 'account': site.account, + 'platform': 'wordpress', + 'platform_type': 'cms', + 'is_active': True, + 'sync_enabled': True, + 'credentials_json': {}, # Empty - API key is on Site model + 'config_json': {} + } + ) + updated_count += 1 - self.message_user(request, f'Generated API keys for {updated_count} site(s). Sites with existing keys were skipped.') + self.message_user(request, f'Generated API keys for {updated_count} site(s). API keys stored in Site.wp_api_key (single source of truth).') generate_api_keys.short_description = 'Generate WordPress API Keys' def bulk_set_status_active(self, request, queryset): @@ -743,7 +784,12 @@ class SectorResource(resources.ModelResource): class SectorAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = SectorResource list_display = ['name', 'slug', 'site', 'industry_sector', 'get_industry', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count'] - list_filter = ['status', 'is_active', 'site', 'industry_sector__industry'] + list_filter = [ + ('status', ChoicesDropdownFilter), + ('is_active', ChoicesDropdownFilter), + ('site', RelatedDropdownFilter), + ('industry_sector__industry', RelatedDropdownFilter), + ] search_fields = ['name', 'slug', 'site__name', 'industry_sector__name'] readonly_fields = ['created_at', 'updated_at'] actions = [ @@ -877,7 +923,10 @@ class IndustrySectorResource(resources.ModelResource): class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = IndustrySectorResource list_display = ['name', 'slug', 'industry', 'is_active'] - list_filter = ['is_active', 'industry'] + list_filter = [ + ('is_active', ChoicesDropdownFilter), + ('industry', RelatedDropdownFilter), + ] search_fields = ['name', 'slug', 'description'] readonly_fields = ['created_at', 'updated_at'] actions = [ @@ -903,13 +952,53 @@ class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin): class SeedKeywordResource(resources.ModelResource): """Resource class for importing/exporting Seed Keywords""" + industry = fields.Field( + column_name='industry', + attribute='industry', + widget=widgets.ForeignKeyWidget(Industry, 'name') + ) + sector = fields.Field( + column_name='sector', + attribute='sector', + widget=widgets.ForeignKeyWidget(IndustrySector, 'name') + ) + class Meta: model = SeedKeyword - fields = ('id', 'keyword', 'industry__name', 'sector__name', 'volume', + fields = ('id', 'keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active', 'created_at') export_order = fields - import_id_fields = ('id',) + import_id_fields = ('keyword', 'industry', 'sector') # Use natural keys for import skip_unchanged = True + + def before_import_row(self, row, **kwargs): + """Clean and validate row data before import""" + # Ensure volume is an integer + if 'volume' in row: + try: + row['volume'] = int(row['volume']) if row['volume'] else 0 + except (ValueError, TypeError): + row['volume'] = 0 + + # Ensure difficulty is an integer between 0-100 + if 'difficulty' in row: + try: + difficulty = int(row['difficulty']) if row['difficulty'] else 0 + row['difficulty'] = max(0, min(100, difficulty)) # Clamp to 0-100 + except (ValueError, TypeError): + row['difficulty'] = 0 + + # Ensure country is valid + if 'country' in row: + valid_countries = [code for code, name in SeedKeyword.COUNTRY_CHOICES] + if row['country'] not in valid_countries: + row['country'] = 'US' # Default to US if invalid + + # Set defaults for optional fields + if 'is_active' not in row or row['is_active'] == '': + row['is_active'] = True + + return row @admin.register(SeedKeyword) @@ -917,15 +1006,20 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = SeedKeywordResource """SeedKeyword admin - Global reference data, no account filtering""" list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active', 'created_at'] - list_filter = ['is_active', 'industry', 'sector', 'country'] + list_filter = [ + ('is_active', ChoicesDropdownFilter), + ('industry', RelatedDropdownFilter), + ('sector', RelatedDropdownFilter), + ('country', ChoicesDropdownFilter), + ] search_fields = ['keyword'] readonly_fields = ['created_at', 'updated_at'] actions = [ - 'delete_selected', 'bulk_activate', 'bulk_deactivate', 'bulk_update_country', - ] # Enable bulk delete + ] + # Delete is handled by AdminDeleteMixin in base Igny8ModelAdmin fieldsets = ( ('Keyword Info', { @@ -939,18 +1033,38 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): }), ) - def has_delete_permission(self, request, obj=None): - """Allow deletion for superusers and developers""" - return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()) - def bulk_activate(self, request, queryset): - updated = queryset.update(is_active=True) - self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS) + """Activate selected keywords""" + try: + updated = queryset.update(is_active=True) + self.message_user( + request, + f'{updated} seed keyword(s) activated successfully.', + messages.SUCCESS + ) + except Exception as e: + self.message_user( + request, + f'Error activating keywords: {str(e)}', + messages.ERROR + ) bulk_activate.short_description = 'Activate selected keywords' def bulk_deactivate(self, request, queryset): - updated = queryset.update(is_active=False) - self.message_user(request, f'{updated} seed keyword(s) deactivated.', messages.SUCCESS) + """Deactivate selected keywords""" + try: + updated = queryset.update(is_active=False) + self.message_user( + request, + f'{updated} seed keyword(s) deactivated successfully.', + messages.SUCCESS + ) + except Exception as e: + self.message_user( + request, + f'Error deactivating keywords: {str(e)}', + messages.ERROR + ) bulk_deactivate.short_description = 'Deactivate selected keywords' def bulk_update_country(self, request, queryset): @@ -1005,7 +1119,12 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin): """ resource_class = UserResource list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at'] - list_filter = ['role', 'account', 'is_active', 'is_staff'] + list_filter = [ + ('role', ChoicesDropdownFilter), + ('account', RelatedDropdownFilter), + ('is_active', ChoicesDropdownFilter), + ('is_staff', ChoicesDropdownFilter), + ] search_fields = ['email', 'username'] readonly_fields = ['created_at', 'updated_at', 'password_display'] diff --git a/backend/igny8_core/business/integration/services/integration_service.py b/backend/igny8_core/business/integration/services/integration_service.py index 91425107..0094a78c 100644 --- a/backend/igny8_core/business/integration/services/integration_service.py +++ b/backend/igny8_core/business/integration/services/integration_service.py @@ -244,14 +244,15 @@ class IntegrationService: } } - # Get API key from site + # Get API key from Site.wp_api_key (SINGLE source of truth) + # API key is stored on Site model for authentication by APIKeyAuthentication api_key = integration.site.wp_api_key if not api_key: return { 'success': False, - 'message': 'API key not configured.', - 'details': {} + 'message': 'API key not configured. Generate an API key in Site Settings.', + 'details': {'site_id': integration.site.id, 'site_name': integration.site.name} } # Initialize health check results diff --git a/backend/igny8_core/business/integration/services/sync_metadata_service.py b/backend/igny8_core/business/integration/services/sync_metadata_service.py index bd405838..6c6ac59a 100644 --- a/backend/igny8_core/business/integration/services/sync_metadata_service.py +++ b/backend/igny8_core/business/integration/services/sync_metadata_service.py @@ -39,8 +39,8 @@ class SyncMetadataService: try: # Get WordPress site URL and API key site_url = integration.config_json.get('site_url', '') - credentials = integration.get_credentials() - api_key = credentials.get('api_key', '') + # API key is stored in Site.wp_api_key (SINGLE source of truth) + api_key = integration.site.wp_api_key or '' if not site_url: return { @@ -51,7 +51,7 @@ class SyncMetadataService: if not api_key: return { 'success': False, - 'error': 'Missing api_key in integration credentials' + 'error': 'API key not configured for site. Generate one in Site Settings.' } # Call WordPress metadata endpoint diff --git a/backend/igny8_core/business/planning/models.py b/backend/igny8_core/business/planning/models.py index 8dbdf76c..de259eb4 100644 --- a/backend/igny8_core/business/planning/models.py +++ b/backend/igny8_core/business/planning/models.py @@ -142,44 +142,67 @@ class Keywords(SoftDeletableModel, SiteSectorBaseModel): @property def keyword(self): """Get keyword text from seed_keyword""" - return self.seed_keyword.keyword if self.seed_keyword else '' + try: + return self.seed_keyword.keyword if self.seed_keyword else '' + except self.__class__.seed_keyword.RelatedObjectDoesNotExist: + return '' @property def volume(self): """Get volume from override or seed_keyword""" - return self.volume_override if self.volume_override is not None else (self.seed_keyword.volume if self.seed_keyword else 0) + try: + seed_kw = self.seed_keyword + except self.__class__.seed_keyword.RelatedObjectDoesNotExist: + seed_kw = None + return self.volume_override if self.volume_override is not None else (seed_kw.volume if seed_kw else 0) @property def difficulty(self): """Get difficulty from override or seed_keyword""" - return self.difficulty_override if self.difficulty_override is not None else (self.seed_keyword.difficulty if self.seed_keyword else 0) + try: + seed_kw = self.seed_keyword + except self.__class__.seed_keyword.RelatedObjectDoesNotExist: + seed_kw = None + return self.difficulty_override if self.difficulty_override is not None else (seed_kw.difficulty if seed_kw else 0) @property def country(self): """Get country from seed_keyword""" - return self.seed_keyword.country if self.seed_keyword else 'US' + try: + return self.seed_keyword.country if self.seed_keyword else 'US' + except self.__class__.seed_keyword.RelatedObjectDoesNotExist: + return 'US' def save(self, *args, **kwargs): """Validate that seed_keyword's industry/sector matches site's industry/sector""" - if self.seed_keyword and self.site and self.sector: + # Skip validation if seed_keyword is None (during soft delete or orphaned) + try: + seed_kw = self.seed_keyword + except self.__class__.seed_keyword.RelatedObjectDoesNotExist: + seed_kw = None + + if seed_kw and self.site and self.sector: # Validate industry match - if self.site.industry != self.seed_keyword.industry: + if self.site.industry != seed_kw.industry: from django.core.exceptions import ValidationError raise ValidationError( - f"SeedKeyword industry ({self.seed_keyword.industry.name}) must match site industry ({self.site.industry.name})" + f"SeedKeyword industry ({seed_kw.industry.name}) must match site industry ({self.site.industry.name})" ) # Validate sector match (site sector's industry_sector must match seed_keyword's sector) - if self.sector.industry_sector != self.seed_keyword.sector: + if self.sector.industry_sector != seed_kw.sector: from django.core.exceptions import ValidationError raise ValidationError( - f"SeedKeyword sector ({self.seed_keyword.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})" + f"SeedKeyword sector ({seed_kw.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})" ) super().save(*args, **kwargs) def __str__(self): - return self.keyword + try: + return self.seed_keyword.keyword if self.seed_keyword else f'Keyword #{self.pk}' + except self.__class__.seed_keyword.RelatedObjectDoesNotExist: + return f'Keyword #{self.pk} (orphaned)' class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel): diff --git a/backend/igny8_core/business/publishing/services/publisher_service.py b/backend/igny8_core/business/publishing/services/publisher_service.py index ece9bc1e..fcb1217b 100644 --- a/backend/igny8_core/business/publishing/services/publisher_service.py +++ b/backend/igny8_core/business/publishing/services/publisher_service.py @@ -139,9 +139,13 @@ class PublisherService: if integration: logger.info(f"[PublisherService._publish_to_destination] ✅ Integration found: id={integration.id}") - # Merge config_json and credentials_json + # Merge config_json (site_url, etc.) destination_config.update(integration.config_json or {}) - destination_config.update(integration.get_credentials() or {}) + + # API key is stored in Site.wp_api_key (SINGLE source of truth) + if integration.site.wp_api_key: + destination_config['api_key'] = integration.site.wp_api_key + logger.info(f"[PublisherService._publish_to_destination] 🔑 Config merged: has_api_key={bool(destination_config.get('api_key'))}, has_site_url={bool(destination_config.get('site_url'))}") # Ensure site_url is set (from config or from site model) @@ -342,13 +346,16 @@ class PublisherService: destinations = [] for integration in integrations: config = integration.config_json.copy() - credentials = integration.get_credentials() destination_config = { 'platform': integration.platform, **config, - **credentials } + + # API key is stored in Site.wp_api_key (SINGLE source of truth) + if integration.site.wp_api_key: + destination_config['api_key'] = integration.site.wp_api_key + destinations.append(destination_config) # Also add 'sites' destination if not in platforms filter or if platforms is None diff --git a/backend/igny8_core/management/commands/sync_wordpress_api_keys.py b/backend/igny8_core/management/commands/sync_wordpress_api_keys.py new file mode 100644 index 00000000..819d1902 --- /dev/null +++ b/backend/igny8_core/management/commands/sync_wordpress_api_keys.py @@ -0,0 +1,100 @@ +""" +Management command to ensure SiteIntegration records exist for sites with WordPress API keys. +API key is stored ONLY in Site.wp_api_key (single source of truth). +SiteIntegration is used for integration status/config only, NOT for credential storage. +""" +from django.core.management.base import BaseCommand +from igny8_core.auth.models import Site +from igny8_core.business.integration.models import SiteIntegration + + +class Command(BaseCommand): + help = 'Ensure SiteIntegration records exist for sites with WordPress API keys' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be done without making changes', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + + if dry_run: + self.stdout.write(self.style.WARNING('DRY RUN MODE - No changes will be made')) + + # Find all sites with wp_api_key + sites_with_key = Site.objects.filter(wp_api_key__isnull=False).exclude(wp_api_key='') + + self.stdout.write(f'Found {sites_with_key.count()} sites with wp_api_key') + + created_count = 0 + already_exists_count = 0 + cleared_credentials_count = 0 + + for site in sites_with_key: + try: + # Check if SiteIntegration exists + integration = SiteIntegration.objects.filter( + site=site, + platform='wordpress' + ).first() + + if integration: + # Check if credentials_json has api_key (should be cleared) + if integration.credentials_json.get('api_key'): + if not dry_run: + integration.credentials_json = {} # Clear - API key is on Site model + integration.save(update_fields=['credentials_json']) + self.stdout.write( + self.style.WARNING( + f' ⟳ {site.name} (ID: {site.id}) - Cleared api_key from credentials_json (now stored in Site.wp_api_key only)' + ) + ) + cleared_credentials_count += 1 + else: + self.stdout.write( + self.style.SUCCESS( + f' ✓ {site.name} (ID: {site.id}) - Integration exists, API key correctly stored in Site.wp_api_key only' + ) + ) + already_exists_count += 1 + else: + # Create new SiteIntegration (for status tracking, not credentials) + if not dry_run: + integration = SiteIntegration.objects.create( + account=site.account, + site=site, + platform='wordpress', + platform_type='cms', + is_active=True, + sync_enabled=False, # Don't enable sync by default + credentials_json={}, # Empty - API key is on Site model + config_json={} + ) + + self.stdout.write( + self.style.SUCCESS( + f' + {site.name} (ID: {site.id}) - Created SiteIntegration (API key stays in Site.wp_api_key)' + ) + ) + created_count += 1 + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' ✗ {site.name} (ID: {site.id}) - Error: {str(e)}' + ) + ) + + # Summary + self.stdout.write('\\n' + '='*60) + self.stdout.write(self.style.SUCCESS(f'Summary:')) + self.stdout.write(f' Created: {created_count}') + self.stdout.write(f' Cleared credentials_json: {cleared_credentials_count}') + self.stdout.write(f' Already correct: {already_exists_count}') + self.stdout.write(f' Total processed: {created_count + cleared_credentials_count + already_exists_count}') + + if dry_run: + self.stdout.write('\\n' + self.style.WARNING('DRY RUN - Run without --dry-run to apply changes')) diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py index 78a88d75..219a0e93 100644 --- a/backend/igny8_core/modules/billing/admin.py +++ b/backend/igny8_core/modules/billing/admin.py @@ -5,6 +5,12 @@ from django.contrib import admin from django.utils.html import format_html from django.contrib import messages from unfold.admin import ModelAdmin +from unfold.contrib.filters.admin import ( + RelatedDropdownFilter, + ChoicesDropdownFilter, + DropdownFilter, + RangeDateFilter, +) from simple_history.admin import SimpleHistoryAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin from igny8_core.business.billing.models import ( @@ -20,7 +26,6 @@ from igny8_core.business.billing.models import ( from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod from import_export.admin import ExportMixin, ImportExportMixin from import_export import resources -from rangefilter.filters import DateRangeFilter class CreditTransactionResource(resources.ModelResource): @@ -36,7 +41,11 @@ class CreditTransactionResource(resources.ModelResource): class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = CreditTransactionResource list_display = ['id', 'account', 'transaction_type', 'amount', 'balance_after', 'description', 'created_at'] - list_filter = ['transaction_type', ('created_at', DateRangeFilter), 'account'] + list_filter = [ + ('transaction_type', ChoicesDropdownFilter), + ('created_at', RangeDateFilter), + ('account', RelatedDropdownFilter), + ] search_fields = ['description', 'account__name'] readonly_fields = ['created_at'] date_hierarchy = 'created_at' @@ -188,7 +197,13 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode 'approved_by', 'processed_at', ] - list_filter = ['status', 'payment_method', 'currency', ('created_at', DateRangeFilter), ('processed_at', DateRangeFilter)] + list_filter = [ + ('status', ChoicesDropdownFilter), + ('payment_method', ChoicesDropdownFilter), + ('currency', ChoicesDropdownFilter), + ('created_at', RangeDateFilter), + ('processed_at', RangeDateFilter), + ] search_fields = [ 'invoice__invoice_number', 'account__name', @@ -654,10 +669,10 @@ class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): 'created_at', ] list_filter = [ - 'limit_type', - ('period_start', DateRangeFilter), - ('period_end', DateRangeFilter), - 'account', + ('limit_type', ChoicesDropdownFilter), + ('period_start', RangeDateFilter), + ('period_end', RangeDateFilter), + ('account', RelatedDropdownFilter), ] search_fields = ['account__name'] readonly_fields = ['created_at', 'updated_at'] diff --git a/backend/igny8_core/modules/integration/views.py b/backend/igny8_core/modules/integration/views.py index bb1afa0d..d8829229 100644 --- a/backend/igny8_core/modules/integration/views.py +++ b/backend/igny8_core/modules/integration/views.py @@ -67,25 +67,23 @@ class IntegrationViewSet(SiteSectorModelViewSet): api_key = serializers.SerializerMethodField() def get_api_key(self, obj): - """Return the API key from encrypted credentials""" - credentials = obj.get_credentials() - return credentials.get('api_key', '') + """Return the API key from Site.wp_api_key (SINGLE source of truth)""" + # API key is stored on Site model, not in SiteIntegration credentials + return obj.site.wp_api_key or '' def validate(self, data): """ Custom validation for WordPress integrations. - API key is the only required authentication method. + API key is stored on Site model, not in SiteIntegration. """ validated_data = super().validate(data) - # For WordPress platform, require API key only + # For WordPress platform, check API key exists on Site (not in credentials_json) if validated_data.get('platform') == 'wordpress': - credentials = validated_data.get('credentials_json', {}) - - # API key is required for all WordPress integrations - if not credentials.get('api_key'): + site = validated_data.get('site') or getattr(self.instance, 'site', None) + if site and not site.wp_api_key: raise serializers.ValidationError({ - 'credentials_json': 'API key is required for WordPress integration.' + 'site': 'Site must have an API key generated before creating WordPress integration.' }) return validated_data @@ -198,7 +196,7 @@ class IntegrationViewSet(SiteSectorModelViewSet): # Try to find an existing integration for this site+platform integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first() - # If not found, create and save the integration to database + # If not found, create and save the integration to database (for status tracking, not credentials) integration_created = False if not integration: integration = SiteIntegration.objects.create( @@ -207,7 +205,7 @@ class IntegrationViewSet(SiteSectorModelViewSet): platform='wordpress', platform_type='cms', config_json={'site_url': site_url} if site_url else {}, - credentials_json={'api_key': api_key} if api_key else {}, + credentials_json={}, # API key is stored in Site.wp_api_key, not here is_active=True, sync_enabled=True ) @@ -805,27 +803,38 @@ class IntegrationViewSet(SiteSectorModelViewSet): random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) api_key = f"igny8_site_{site_id}_{timestamp}_{random_suffix}" - # Get or create SiteIntegration + # SINGLE SOURCE OF TRUTH: Store API key ONLY in Site.wp_api_key + # This is where APIKeyAuthentication validates against + site.wp_api_key = api_key + site.save(update_fields=['wp_api_key']) + + # Get or create SiteIntegration (for integration status/config, NOT credentials) integration, created = SiteIntegration.objects.get_or_create( site=site, + platform='wordpress', defaults={ - 'integration_type': 'wordpress', + 'account': site.account, + 'platform': 'wordpress', + 'platform_type': 'cms', 'is_active': True, - 'credentials_json': {'api_key': api_key}, + 'sync_enabled': True, + 'credentials_json': {}, # Empty - API key is on Site model 'config_json': {} } ) - # If integration already exists, update the API key + # If integration already exists, just ensure it's active if not created: - credentials = integration.get_credentials() - credentials['api_key'] = api_key - integration.credentials_json = credentials + integration.is_active = True + integration.sync_enabled = True + # Clear any old credentials_json API key (migrate to Site.wp_api_key) + if integration.credentials_json.get('api_key'): + integration.credentials_json = {} integration.save() logger.info( f"Generated new API key for site {site.name} (ID: {site_id}), " - f"integration {'created' if created else 'updated'}" + f"stored in Site.wp_api_key (single source of truth)" ) # Serialize the integration with the new key diff --git a/backend/igny8_core/modules/integration/webhooks.py b/backend/igny8_core/modules/integration/webhooks.py index b49b4c4d..c5516df4 100644 --- a/backend/igny8_core/modules/integration/webhooks.py +++ b/backend/igny8_core/modules/integration/webhooks.py @@ -122,10 +122,10 @@ def wordpress_status_webhook(request): request=request ) - # Verify API key matches integration - stored_api_key = integration.credentials_json.get('api_key') + # Verify API key matches Site.wp_api_key (SINGLE source of truth) + stored_api_key = integration.site.wp_api_key if not stored_api_key or stored_api_key != api_key: - logger.error(f"[wordpress_status_webhook] Invalid API key for integration {integration.id}") + logger.error(f"[wordpress_status_webhook] Invalid API key for site {integration.site.id}") return error_response( error='Invalid API key', status_code=http_status.HTTP_401_UNAUTHORIZED, @@ -293,8 +293,8 @@ def wordpress_metadata_webhook(request): request=request ) - # Verify API key - stored_api_key = integration.credentials_json.get('api_key') + # Verify API key against Site.wp_api_key (SINGLE source of truth) + stored_api_key = integration.site.wp_api_key if not stored_api_key or stored_api_key != api_key: return error_response( error='Invalid API key', diff --git a/backend/igny8_core/modules/planner/admin.py b/backend/igny8_core/modules/planner/admin.py index e44805fa..06956483 100644 --- a/backend/igny8_core/modules/planner/admin.py +++ b/backend/igny8_core/modules/planner/admin.py @@ -114,7 +114,6 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): 'bulk_assign_cluster', 'bulk_set_status_active', 'bulk_set_status_inactive', - 'bulk_soft_delete', ] @admin.display(description='Keyword') diff --git a/backend/igny8_core/tasks/wordpress_publishing.py b/backend/igny8_core/tasks/wordpress_publishing.py index aa802050..a34506b5 100644 --- a/backend/igny8_core/tasks/wordpress_publishing.py +++ b/backend/igny8_core/tasks/wordpress_publishing.py @@ -61,8 +61,8 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int site_domain = base_url.replace('https://', '').replace('http://', '').split('/')[0] if base_url else 'unknown' log_prefix = f"[{site_id}-{site_domain}]" - # Extract API key from credentials - api_key = site_integration.get_credentials().get('api_key', '') + # API key is stored in Site.wp_api_key (SINGLE source of truth) + api_key = site_integration.site.wp_api_key or '' publish_logger.info(f" ✅ Content loaded:") publish_logger.info(f" {log_prefix} Title: '{content.title}'") @@ -258,7 +258,8 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int # STEP 8: Send API request to WordPress base_url = site_integration.config_json.get('site_url', '') or site_integration.config_json.get('base_url', '') - api_key = site_integration.get_credentials().get('api_key', '') + # API key is stored in Site.wp_api_key (SINGLE source of truth) + api_key = site_integration.site.wp_api_key or '' if not base_url: error_msg = "No base_url/site_url configured in integration" @@ -266,7 +267,7 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int return {"success": False, "error": error_msg} if not api_key: - error_msg = "No API key configured in integration" + error_msg = "No API key configured for site. Generate one in Site Settings." publish_logger.error(f" {log_prefix} ❌ {error_msg}") return {"success": False, "error": error_msg} diff --git a/backend/igny8_core/templates/admin/seedkeyword_delete_confirmation.html b/backend/igny8_core/templates/admin/seedkeyword_delete_confirmation.html new file mode 100644 index 00000000..eb4aad8a --- /dev/null +++ b/backend/igny8_core/templates/admin/seedkeyword_delete_confirmation.html @@ -0,0 +1,92 @@ +{% extends "admin/base_site.html" %} +{% load i18n l10n admin_urls static %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+ {% if subtitle %} +

{{ title }}

+

{{ subtitle }}

+ {% else %} +

{{ title }}

+ {% endif %} + + {% if can_delete_items %} +
+

✅ Can Delete ({{ can_delete_items|length }} item{{ can_delete_items|length|pluralize }})

+

The following seed keywords can be safely deleted:

+ +
+ {% endif %} + + {% if protected_items %} +
+

⚠️ Cannot Delete ({{ protected_items|length }} item{{ protected_items|length|pluralize }})

+

The following seed keywords are being used by site keywords and cannot be deleted:

+ +

+ 💡 Tip: To delete these seed keywords, you must first remove or deactivate the site keywords that reference them. +

+
+ {% endif %} + + {% if can_delete_items %} +
+ {% csrf_token %} +
+ {% for obj in queryset %} + + {% endfor %} + + + +
+ + {% trans 'No, take me back' %} +
+ + {% if protected_items %} +

+ Note: Only the {{ can_delete_items|length }} deletable keyword{{ can_delete_items|length|pluralize }} will be deleted. + The {{ protected_items|length }} protected keyword{{ protected_items|length|pluralize }} will remain unchanged. +

+ {% endif %} +
+
+ {% else %} +
+

+ ⛔ No keywords can be deleted.
+ All selected keywords are currently in use by site keywords and are protected from deletion. +

+ {% trans 'Back to list' %} +
+ {% endif %} +
+{% endblock %} diff --git a/backend/seed_keywords_import_template.csv b/backend/seed_keywords_import_template.csv new file mode 100644 index 00000000..f2d6c59c --- /dev/null +++ b/backend/seed_keywords_import_template.csv @@ -0,0 +1,11 @@ +keyword,industry,sector,volume,difficulty,country,is_active +best massage chairs,Health & Wellness,Massage Products,5400,45,US,True +deep tissue massage chair,Health & Wellness,Massage Products,720,52,US,True +shiatsu massage chair,Health & Wellness,Massage Products,1200,48,US,True +zero gravity massage chair,Health & Wellness,Massage Products,890,50,US,True +affordable massage chair,Health & Wellness,Massage Products,320,35,US,True +professional massage chair,Health & Wellness,Massage Products,280,42,US,True +massage chair benefits,Health & Wellness,Massage Products,450,25,US,True +full body massage chair,Health & Wellness,Massage Products,650,40,US,True +portable massage chair,Health & Wellness,Massage Products,390,38,US,True +electric massage chair,Health & Wellness,Massage Products,510,43,US,True diff --git a/docs/50-DEPLOYMENT/final-clean-best-deployment-plan/STAGING-SETUP-GUIDE.md b/docs/50-DEPLOYMENT/final-clean-best-deployment-plan/STAGING-SETUP-GUIDE.md new file mode 100644 index 00000000..59bacbf0 --- /dev/null +++ b/docs/50-DEPLOYMENT/final-clean-best-deployment-plan/STAGING-SETUP-GUIDE.md @@ -0,0 +1,1323 @@ +# Staging Environment Setup Guide + +**Version:** 1.0 +**Last Updated:** January 11, 2026 +**Purpose:** Complete staging environment setup for safe feature testing before production deployment + +--- + +## Overview + +This guide implements a **parallel staging environment** that allows you to: +- ✅ Test new features without affecting production users +- ✅ Run database migrations safely before production +- ✅ Validate UI/UX changes with real-world scenarios +- ✅ Test integrations (WordPress, payment providers, AI services) +- ✅ Merge to production only when staging is healthy + +--- + +## Architecture: Production vs Staging + +### Environment Comparison + +| Component | Production | Staging | +|-----------|-----------|---------| +| **Domain** | `app.igny8.com` | `staging.igny8.com` | +| **API Domain** | `api.igny8.com` | `staging-api.igny8.com` | +| **Marketing** | `igny8.com` | `staging-marketing.igny8.com` | +| **Database** | `igny8_db` | `igny8_staging_db` | +| **Redis DB** | Redis DB 0 | Redis DB 1 | +| **Backend Port** | 8010 (internal) | 8012 (internal) | +| **Frontend Port** | 8021 (internal) | 8024 (internal) | +| **Marketing Port** | 8023 (internal) | 8026 (internal) | +| **Git Branch** | `main` | `staging` | +| **Compose File** | `docker-compose.app.yml` | `docker-compose.staging.yml` | +| **Container Prefix** | `igny8_` | `igny8_staging_` | +| **Project Name** | `igny8-app` | `igny8-staging` | +| **Env File** | `.env` | `.env.staging` | +| **Log Path** | `/data/app/logs/production/` | `/data/app/logs/staging/` | + +### Shared Infrastructure + +Both environments share (from `igny8-infra` stack): +- ✅ PostgreSQL server (different databases) +- ✅ Redis server (different DB indexes) +- ✅ Caddy reverse proxy (routes by domain) +- ✅ Docker network (`igny8_net`) +- ✅ Portainer (for management) + +--- + +## File Structure + +``` +/data/app/igny8/ +├── docker-compose.app.yml # Production compose file +├── docker-compose.staging.yml # Staging compose file (NEW) +├── .env # Production environment +├── .env.staging # Staging environment (NEW) +├── .env.example # Template for both +│ +├── backend/ +│ └── igny8_core/ +│ ├── settings.py # Detects DJANGO_ENV variable +│ └── ... +│ +├── frontend/ +│ └── src/ +│ └── ... +│ +├── scripts/ +│ ├── deploy-production.sh # Deploy production +│ ├── deploy-staging.sh # Deploy staging (NEW) +│ ├── sync-data-to-staging.sh # Sync prod DB → staging (NEW) +│ ├── rollback-production.sh # Rollback script (NEW) +│ └── health-check.sh # Check both environments (NEW) +│ +└── logs/ + ├── production/ + │ ├── backend.log + │ ├── celery-worker.log + │ └── celery-beat.log + └── staging/ + ├── backend.log + ├── celery-worker.log + └── celery-beat.log +``` + +--- + +## Step 1: Create Staging Database + +### 1.1 Connect to PostgreSQL + +```bash +docker exec -it postgres psql -U postgres +``` + +### 1.2 Create Staging Database + +```sql +-- Create staging database +CREATE DATABASE igny8_staging_db OWNER igny8; + +-- Grant privileges +GRANT ALL PRIVILEGES ON DATABASE igny8_staging_db TO igny8; + +-- Exit +\q +``` + +### 1.3 Verify Database Creation + +```bash +docker exec -it postgres psql -U igny8 -d igny8_staging_db -c "SELECT 'Staging DB Ready';" +``` + +--- + +## Step 2: Create Staging Compose File + +Create `/data/app/igny8/docker-compose.staging.yml`: + +```yaml +# ============================================================================= +# IGNY8 STAGING ENVIRONMENT COMPOSE FILE +# ============================================================================= +# This runs alongside production on the same server +# Uses different ports, database, and domains +# ============================================================================= +# +# Usage: +# docker compose -f docker-compose.staging.yml -p igny8-staging up -d +# docker compose -f docker-compose.staging.yml -p igny8-staging down +# docker compose -f docker-compose.staging.yml -p igny8-staging logs -f +# ============================================================================= + +name: igny8-staging + +services: + igny8_staging_backend: + image: igny8-backend:staging + container_name: igny8_staging_backend + restart: always + working_dir: /app + ports: + - "0.0.0.0:8012:8010" # Different external port + environment: + # Environment identifier + DJANGO_ENV: staging + + # Database (staging) + DB_HOST: postgres + DB_NAME: igny8_staging_db + DB_USER: igny8 + DB_PASSWORD: igny8pass + + # Redis (staging - use DB 1 instead of 0) + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_DB: "1" + + # Security + USE_SECURE_COOKIES: "True" + USE_SECURE_PROXY_HEADER: "True" + DEBUG: "False" + + # External services (can use same or separate keys) + # OPENAI_API_KEY: set in .env.staging + # STRIPE_SECRET_KEY: use test keys for staging + # PAYPAL_CLIENT_ID: use sandbox for staging + + volumes: + - /data/app/igny8/backend:/app:rw + - /data/app/igny8:/data/app/igny8:rw + - /var/run/docker.sock:/var/run/docker.sock:ro + - /data/app/logs/staging:/app/logs:rw + + env_file: + - .env.staging + + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8010/api/v1/system/status/').read()\" || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + command: ["gunicorn", "igny8_core.wsgi:application", "--bind", "0.0.0.0:8010", "--workers", "4", "--timeout", "120"] + + networks: [igny8_net] + + labels: + - "com.docker.compose.project=igny8-staging" + - "com.docker.compose.service=igny8_staging_backend" + + igny8_staging_frontend: + image: igny8-frontend-dev:staging + container_name: igny8_staging_frontend + restart: always + ports: + - "0.0.0.0:8024:5173" # Different external port + environment: + VITE_BACKEND_URL: "https://staging-api.igny8.com/api" + VITE_ENV: "staging" + volumes: + - /data/app/igny8/frontend:/app:rw + depends_on: + igny8_staging_backend: + condition: service_healthy + networks: [igny8_net] + labels: + - "com.docker.compose.project=igny8-staging" + - "com.docker.compose.service=igny8_staging_frontend" + + igny8_staging_marketing_dev: + image: igny8-marketing-dev:staging + container_name: igny8_staging_marketing_dev + restart: always + ports: + - "0.0.0.0:8026:5174" # Different external port + environment: + VITE_BACKEND_URL: "https://staging-api.igny8.com/api" + VITE_ENV: "staging" + volumes: + - /data/app/igny8/frontend:/app:rw + networks: [igny8_net] + labels: + - "com.docker.compose.project=igny8-staging" + - "com.docker.compose.service=igny8_staging_marketing_dev" + + igny8_staging_celery_worker: + image: igny8-backend:staging + container_name: igny8_staging_celery_worker + restart: always + working_dir: /app + environment: + DJANGO_ENV: staging + DB_HOST: postgres + DB_NAME: igny8_staging_db + DB_USER: igny8 + DB_PASSWORD: igny8pass + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_DB: "1" + C_FORCE_ROOT: "true" + volumes: + - /data/app/igny8/backend:/app:rw + - /data/app/logs/staging:/app/logs:rw + env_file: + - .env.staging + command: ["celery", "-A", "igny8_core", "worker", "--loglevel=info", "--concurrency=4"] + depends_on: + igny8_staging_backend: + condition: service_healthy + networks: [igny8_net] + labels: + - "com.docker.compose.project=igny8-staging" + - "com.docker.compose.service=igny8_staging_celery_worker" + + igny8_staging_celery_beat: + image: igny8-backend:staging + container_name: igny8_staging_celery_beat + restart: always + working_dir: /app + environment: + DJANGO_ENV: staging + DB_HOST: postgres + DB_NAME: igny8_staging_db + DB_USER: igny8 + DB_PASSWORD: igny8pass + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_DB: "1" + C_FORCE_ROOT: "true" + volumes: + - /data/app/igny8/backend:/app:rw + - /data/app/logs/staging:/app/logs:rw + env_file: + - .env.staging + command: ["celery", "-A", "igny8_core", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"] + depends_on: + igny8_staging_backend: + condition: service_healthy + networks: [igny8_net] + labels: + - "com.docker.compose.project=igny8-staging" + - "com.docker.compose.service=igny8_staging_celery_beat" + +networks: + igny8_net: + external: true +``` + +--- + +## Step 3: Create Staging Environment File + +Create `/data/app/igny8/.env.staging`: + +```bash +# ============================================================================= +# IGNY8 STAGING ENVIRONMENT CONFIGURATION +# ============================================================================= + +# Environment +DJANGO_ENV=staging +DEBUG=False + +# Database +DB_HOST=postgres +DB_NAME=igny8_staging_db +DB_USER=igny8 +DB_PASSWORD=igny8pass +DB_PORT=5432 + +# Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=1 + +# Security +SECRET_KEY=staging-secret-key-change-this-in-production +ALLOWED_HOSTS=staging-api.igny8.com,staging.igny8.com,localhost +CORS_ALLOWED_ORIGINS=https://staging.igny8.com,https://staging-api.igny8.com + +# API Keys - Use TEST/SANDBOX keys for staging +OPENAI_API_KEY=sk-test-your-openai-test-key +ANTHROPIC_API_KEY=sk-ant-test-your-anthropic-test-key + +# Payment Gateways - Use SANDBOX/TEST mode +STRIPE_SECRET_KEY=sk_test_your_stripe_test_key +STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_test_key +STRIPE_WEBHOOK_SECRET=whsec_test_your_webhook_secret +PAYPAL_CLIENT_ID=sandbox_client_id +PAYPAL_CLIENT_SECRET=sandbox_client_secret +PAYPAL_MODE=sandbox + +# Email - Use test email service +RESEND_API_KEY=re_test_your_resend_test_key +BREVO_API_KEY=test_your_brevo_test_key +DEFAULT_FROM_EMAIL=staging@igny8.com + +# Image Generation - Use test/cheaper models if available +RUNWARE_API_KEY=test_key +BRIA_API_KEY=test_key + +# Storage - Separate from production +MEDIA_ROOT=/app/media/staging +STATIC_ROOT=/app/staticfiles/staging + +# URLs +FRONTEND_URL=https://staging.igny8.com +BACKEND_URL=https://staging-api.igny8.com +MARKETING_URL=https://staging-marketing.igny8.com + +# Features (can enable/disable different features for testing) +ENABLE_LINKER=True +ENABLE_OPTIMIZER=True +ENABLE_SOCIALIZER=False + +# Logging +LOG_LEVEL=DEBUG +``` + +--- + +## Step 4: Update Caddyfile for Staging Domains + +Add to `/data/app/caddy/Caddyfile`: + +```caddyfile +# ============================================================================= +# STAGING ENVIRONMENT ROUTES +# ============================================================================= + +# Staging API Backend +staging-api.igny8.com { + reverse_proxy igny8_staging_backend:8010 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + + # Enable WebSocket support + @websockets { + header Connection *Upgrade* + header Upgrade websocket + } + reverse_proxy @websockets igny8_staging_backend:8010 + + log { + output file /data/logs/caddy/staging-api.access.log + } +} + +# Staging Frontend App +staging.igny8.com { + reverse_proxy igny8_staging_frontend:5173 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + + # Vite HMR WebSocket support + @websockets { + header Connection *Upgrade* + header Upgrade websocket + } + reverse_proxy @websockets igny8_staging_frontend:5173 + + log { + output file /data/logs/caddy/staging-app.access.log + } +} + +# Staging Marketing Site +staging-marketing.igny8.com { + reverse_proxy igny8_staging_marketing_dev:5174 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + + # Vite HMR WebSocket support + @websockets { + header Connection *Upgrade* + header Upgrade websocket + } + reverse_proxy @websockets igny8_staging_marketing_dev:5174 + + log { + output file /data/logs/caddy/staging-marketing.access.log + } +} +``` + +Reload Caddy: +```bash +docker exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +--- + +## Step 5: DNS Configuration + +Add these DNS records to your domain provider: + +| Type | Name | Value | TTL | +|------|------|-------|-----| +| A | `staging` | `YOUR_SERVER_IP` | 3600 | +| A | `staging-api` | `YOUR_SERVER_IP` | 3600 | +| A | `staging-marketing` | `YOUR_SERVER_IP` | 3600 | + +Or use CNAME if you prefer: + +| Type | Name | Value | TTL | +|------|------|-------|-----| +| CNAME | `staging` | `igny8.com` | 3600 | +| CNAME | `staging-api` | `igny8.com` | 3600 | +| CNAME | `staging-marketing` | `igny8.com` | 3600 | + +--- + +## Step 6: Git Branch Strategy + +### 6.1 Create Staging Branch + +```bash +cd /data/app/igny8 +git checkout -b staging +git push -u origin staging +``` + +### 6.2 Branch Protection Rules (GitHub/GitLab) + +**Main Branch:** +- ✅ Require pull request reviews +- ✅ Require status checks to pass (tests, linting) +- ✅ Require linear history +- ✅ Only allow merge from `staging` branch + +**Staging Branch:** +- ✅ Require pull request reviews +- ✅ Allow merges from feature branches +- ✅ Auto-deploy to staging on push + +### 6.3 Development Workflow + +``` +Feature Development: +─────────────────────────────────────── +feature/new-ai-model → staging → main +feature/fix-bug → staging → main +feature/ui-redesign → staging → main + +Process: +──────── +1. Create feature branch from staging: + git checkout staging + git pull + git checkout -b feature/my-new-feature + +2. Develop & commit: + git add . + git commit -m "Add new feature" + +3. Push & create PR to staging: + git push -u origin feature/my-new-feature + [Create PR: feature/my-new-feature → staging] + +4. Deploy to staging: + ./scripts/deploy-staging.sh + +5. Test on staging environment + +6. If healthy, merge staging → main: + [Create PR: staging → main] + [Review & Merge] + +7. Deploy to production: + ./scripts/deploy-production.sh +``` + +--- + +## Step 7: Create Deployment Scripts + +### 7.1 Deploy Staging Script + +Create `/data/app/igny8/scripts/deploy-staging.sh`: + +```bash +#!/bin/bash +# ============================================================================= +# Deploy Staging Environment +# ============================================================================= + +set -e # Exit on error + +echo "==========================================" +echo "IGNY8 Staging Deployment" +echo "==========================================" +echo "" + +# Navigate to app directory +cd /data/app/igny8 + +# Checkout staging branch +echo "→ Checking out staging branch..." +git fetch origin +git checkout staging +git pull origin staging + +# Build staging images with staging tag +echo "" +echo "→ Building staging images..." +docker build -t igny8-backend:staging -f backend/Dockerfile backend/ +docker build -t igny8-frontend-dev:staging -f frontend/Dockerfile.dev frontend/ +docker build -t igny8-marketing-dev:staging -f frontend/Dockerfile.marketing.dev frontend/ + +# Stop existing staging containers +echo "" +echo "→ Stopping existing staging containers..." +docker compose -f docker-compose.staging.yml -p igny8-staging down + +# Start staging containers +echo "" +echo "→ Starting staging containers..." +docker compose -f docker-compose.staging.yml -p igny8-staging up -d + +# Wait for backend to be healthy +echo "" +echo "→ Waiting for staging backend to be healthy..." +timeout 60 bash -c 'until docker exec igny8_staging_backend python -c "import urllib.request; urllib.request.urlopen(\"http://localhost:8010/api/v1/system/status/\").read()" 2>/dev/null; do sleep 2; done' || { + echo "ERROR: Staging backend failed to start" + docker compose -f docker-compose.staging.yml -p igny8-staging logs igny8_staging_backend + exit 1 +} + +# Run migrations +echo "" +echo "→ Running database migrations..." +docker exec igny8_staging_backend python manage.py migrate --noinput + +# Collect static files +echo "" +echo "→ Collecting static files..." +docker exec igny8_staging_backend python manage.py collectstatic --noinput + +# Show container status +echo "" +echo "→ Container status:" +docker compose -f docker-compose.staging.yml -p igny8-staging ps + +# Show health check +echo "" +echo "→ Health check:" +curl -s https://staging-api.igny8.com/api/v1/system/status/ | jq '.' || echo "Health check endpoint not responding" + +echo "" +echo "==========================================" +echo "✅ Staging deployment complete!" +echo "==========================================" +echo "" +echo "Access staging environment:" +echo " App: https://staging.igny8.com" +echo " API: https://staging-api.igny8.com" +echo " Marketing: https://staging-marketing.igny8.com" +echo "" +echo "View logs:" +echo " docker compose -f docker-compose.staging.yml -p igny8-staging logs -f" +echo "" +``` + +Make executable: +```bash +chmod +x /data/app/igny8/scripts/deploy-staging.sh +``` + +--- + +### 7.2 Deploy Production Script + +Create `/data/app/igny8/scripts/deploy-production.sh`: + +```bash +#!/bin/bash +# ============================================================================= +# Deploy Production Environment (with safety checks) +# ============================================================================= + +set -e # Exit on error + +echo "==========================================" +echo "IGNY8 Production Deployment" +echo "==========================================" +echo "" + +# Confirmation prompt +read -p "⚠️ Deploy to PRODUCTION? This will affect live users. (yes/no): " confirm +if [[ "$confirm" != "yes" ]]; then + echo "Deployment cancelled." + exit 0 +fi + +# Navigate to app directory +cd /data/app/igny8 + +# Check current branch +current_branch=$(git branch --show-current) +if [[ "$current_branch" != "main" ]]; then + echo "ERROR: Not on main branch (currently on: $current_branch)" + echo "Switch to main branch first: git checkout main && git pull" + exit 1 +fi + +# Pull latest from main +echo "→ Pulling latest from main branch..." +git pull origin main + +# Create backup before deployment +echo "" +echo "→ Creating database backup..." +timestamp=$(date +%Y%m%d_%H%M%S) +docker exec postgres pg_dump -U igny8 igny8_db > /data/backups/igny8_db_before_deploy_${timestamp}.sql +echo " Backup saved: /data/backups/igny8_db_before_deploy_${timestamp}.sql" + +# Tag current production image for rollback +echo "" +echo "→ Tagging current production image for rollback..." +docker tag igny8-backend:latest igny8-backend:rollback || true +docker tag igny8-frontend-dev:latest igny8-frontend-dev:rollback || true + +# Build new production images +echo "" +echo "→ Building production images..." +docker build -t igny8-backend:latest -f backend/Dockerfile backend/ +docker build -t igny8-frontend-dev:latest -f frontend/Dockerfile.dev frontend/ +docker build -t igny8-marketing-dev:latest -f frontend/Dockerfile.marketing.dev frontend/ + +# Run database migrations (check first) +echo "" +echo "→ Checking for pending migrations..." +pending=$(docker exec igny8_backend python manage.py showmigrations --plan | grep "\[ \]" || true) +if [[ -n "$pending" ]]; then + echo " Pending migrations found:" + echo "$pending" + read -p " Apply migrations? (yes/no): " apply_migrations + if [[ "$apply_migrations" == "yes" ]]; then + docker exec igny8_backend python manage.py migrate --noinput + fi +fi + +# Restart production containers +echo "" +echo "→ Restarting production containers..." +docker compose -f docker-compose.app.yml -p igny8-app restart + +# Wait for backend to be healthy +echo "" +echo "→ Waiting for production backend to be healthy..." +timeout 60 bash -c 'until docker exec igny8_backend python -c "import urllib.request; urllib.request.urlopen(\"http://localhost:8010/api/v1/system/status/\").read()" 2>/dev/null; do sleep 2; done' || { + echo "ERROR: Production backend failed to start" + echo "Consider rolling back: ./scripts/rollback-production.sh" + exit 1 +} + +# Collect static files +echo "" +echo "→ Collecting static files..." +docker exec igny8_backend python manage.py collectstatic --noinput + +# Show container status +echo "" +echo "→ Container status:" +docker compose -f docker-compose.app.yml -p igny8-app ps + +# Show health check +echo "" +echo "→ Health check:" +curl -s https://api.igny8.com/api/v1/system/status/ | jq '.' || echo "Health check endpoint not responding" + +echo "" +echo "==========================================" +echo "✅ Production deployment complete!" +echo "==========================================" +echo "" +echo "Monitor logs for 5 minutes:" +echo " docker compose -f docker-compose.app.yml -p igny8-app logs -f" +echo "" +echo "If issues occur, rollback:" +echo " ./scripts/rollback-production.sh" +echo "" +``` + +Make executable: +```bash +chmod +x /data/app/igny8/scripts/deploy-production.sh +``` + +--- + +### 7.3 Rollback Production Script + +Create `/data/app/igny8/scripts/rollback-production.sh`: + +```bash +#!/bin/bash +# ============================================================================= +# Rollback Production to Previous Version +# ============================================================================= + +set -e # Exit on error + +echo "==========================================" +echo "IGNY8 Production Rollback" +echo "==========================================" +echo "" + +# Confirmation prompt +read -p "⚠️ Rollback production to previous version? (yes/no): " confirm +if [[ "$confirm" != "yes" ]]; then + echo "Rollback cancelled." + exit 0 +fi + +cd /data/app/igny8 + +# Check if rollback images exist +if ! docker image inspect igny8-backend:rollback &> /dev/null; then + echo "ERROR: No rollback image found. Cannot rollback." + exit 1 +fi + +echo "→ Stopping current containers..." +docker compose -f docker-compose.app.yml -p igny8-app down + +echo "" +echo "→ Restoring previous images..." +docker tag igny8-backend:rollback igny8-backend:latest +docker tag igny8-frontend-dev:rollback igny8-frontend-dev:latest + +echo "" +echo "→ Starting containers with previous version..." +docker compose -f docker-compose.app.yml -p igny8-app up -d + +# Wait for backend to be healthy +echo "" +echo "→ Waiting for backend to be healthy..." +timeout 60 bash -c 'until docker exec igny8_backend python -c "import urllib.request; urllib.request.urlopen(\"http://localhost:8010/api/v1/system/status/\").read()" 2>/dev/null; do sleep 2; done' || { + echo "ERROR: Backend failed to start after rollback" + exit 1 +} + +echo "" +echo "==========================================" +echo "✅ Rollback complete!" +echo "==========================================" +echo "" +echo "Check logs:" +echo " docker compose -f docker-compose.app.yml -p igny8-app logs -f" +echo "" +``` + +Make executable: +```bash +chmod +x /data/app/igny8/scripts/rollback-production.sh +``` + +--- + +### 7.4 Sync Data to Staging Script + +Create `/data/app/igny8/scripts/sync-data-to-staging.sh`: + +```bash +#!/bin/bash +# ============================================================================= +# Sync Production Data to Staging (sanitized) +# ============================================================================= +# WARNING: This will REPLACE staging database with production data +# Use when you need to test with real-world data structure +# ============================================================================= + +set -e # Exit on error + +echo "==========================================" +echo "Sync Production → Staging" +echo "==========================================" +echo "" + +# Confirmation prompt +read -p "⚠️ This will REPLACE staging database with production data. Continue? (yes/no): " confirm +if [[ "$confirm" != "yes" ]]; then + echo "Sync cancelled." + exit 0 +fi + +timestamp=$(date +%Y%m%d_%H%M%S) + +echo "→ Dumping production database..." +docker exec postgres pg_dump -U igny8 igny8_db > /tmp/prod_dump_${timestamp}.sql + +echo "" +echo "→ Dropping staging database..." +docker exec -i postgres psql -U postgres -c "DROP DATABASE IF EXISTS igny8_staging_db;" +docker exec -i postgres psql -U postgres -c "CREATE DATABASE igny8_staging_db OWNER igny8;" + +echo "" +echo "→ Restoring to staging database..." +docker exec -i postgres psql -U igny8 -d igny8_staging_db < /tmp/prod_dump_${timestamp}.sql + +echo "" +echo "→ Sanitizing staging data..." +docker exec igny8_staging_backend python manage.py shell << 'EOF' +from django.contrib.auth import get_user_model +from igny8_core.auth.models import Account + +User = get_user_model() + +# Sanitize emails (except admins) +for user in User.objects.filter(is_superuser=False): + user.email = f"staging_{user.id}@igny8.test" + user.save(update_fields=['email']) + +print(f"✅ Sanitized {User.objects.filter(is_superuser=False).count()} user emails") + +# Optional: Reset all passwords to a known staging password +# User.objects.filter(is_superuser=False).update(password='pbkdf2_sha256$...') + +# Optional: Disable payment webhooks, external integrations +for account in Account.objects.all(): + # Clear sensitive API keys if stored + pass + +print("✅ Sanitization complete") +EOF + +echo "" +echo "→ Running migrations (in case staging has newer migrations)..." +docker exec igny8_staging_backend python manage.py migrate --noinput + +echo "" +echo "→ Cleaning up temp files..." +rm /tmp/prod_dump_${timestamp}.sql + +echo "" +echo "==========================================" +echo "✅ Sync complete!" +echo "==========================================" +echo "" +echo "Staging database now contains sanitized production data." +echo "All user emails have been changed to staging_*@igny8.test" +echo "" +``` + +Make executable: +```bash +chmod +x /data/app/igny8/scripts/sync-data-to-staging.sh +``` + +--- + +### 7.5 Health Check Script + +Create `/data/app/igny8/scripts/health-check.sh`: + +```bash +#!/bin/bash +# ============================================================================= +# Health Check for Both Environments +# ============================================================================= + +echo "==========================================" +echo "IGNY8 Health Check" +echo "==========================================" +echo "" + +check_endpoint() { + local name=$1 + local url=$2 + + echo -n "Checking $name... " + + if response=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url"); then + if [[ "$response" == "200" ]]; then + echo "✅ OK ($response)" + return 0 + else + echo "❌ FAIL (HTTP $response)" + return 1 + fi + else + echo "❌ TIMEOUT" + return 1 + fi +} + +# Production checks +echo "PRODUCTION:" +check_endpoint "Backend API" "https://api.igny8.com/api/v1/system/status/" +check_endpoint "Frontend App" "https://app.igny8.com/" +check_endpoint "Marketing Site" "https://igny8.com/" + +echo "" + +# Staging checks +echo "STAGING:" +check_endpoint "Backend API" "https://staging-api.igny8.com/api/v1/system/status/" +check_endpoint "Frontend App" "https://staging.igny8.com/" +check_endpoint "Marketing Site" "https://staging-marketing.igny8.com/" + +echo "" + +# Container checks +echo "CONTAINERS:" +echo "" +echo "Production:" +docker compose -f /data/app/igny8/docker-compose.app.yml -p igny8-app ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" + +echo "" +echo "Staging:" +docker compose -f /data/app/igny8/docker-compose.staging.yml -p igny8-staging ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" + +echo "" +echo "==========================================" +``` + +Make executable: +```bash +chmod +x /data/app/igny8/scripts/health-check.sh +``` + +--- + +## Step 8: Initial Staging Deployment + +### 8.1 Create Log Directories + +```bash +mkdir -p /data/app/logs/staging +chmod -R 755 /data/app/logs/staging +``` + +### 8.2 Deploy Staging + +```bash +cd /data/app/igny8 +./scripts/deploy-staging.sh +``` + +### 8.3 Create Superuser for Staging + +```bash +docker exec -it igny8_staging_backend python manage.py createsuperuser +``` + +### 8.4 Verify Staging + +```bash +# Check health +./scripts/health-check.sh + +# Access staging +echo "App: https://staging.igny8.com" +echo "API: https://staging-api.igny8.com" +echo "Marketing: https://staging-marketing.igny8.com" +``` + +--- + +## Testing Workflow + +### Before Merging to Production + +**1. Functional Testing:** +- ✅ Test all CRUD operations (Keywords, Clusters, Ideas, Tasks, Content) +- ✅ Test AI functions (clustering, content generation, image generation) +- ✅ Test automation pipeline (all 7 stages) +- ✅ Test WordPress publishing integration +- ✅ Test payment flows (using test/sandbox credentials) +- ✅ Test email notifications + +**2. Migration Testing:** +```bash +# On staging +docker exec igny8_staging_backend python manage.py showmigrations +docker exec igny8_staging_backend python manage.py migrate --plan +docker exec igny8_staging_backend python manage.py migrate + +# Check for errors +docker logs igny8_staging_backend +``` + +**3. Performance Testing:** +- ✅ Check response times for API endpoints +- ✅ Test with bulk operations (100+ keywords) +- ✅ Monitor Celery worker queue + +**4. UI/UX Testing:** +- ✅ Test on different browsers (Chrome, Firefox, Safari) +- ✅ Test on mobile devices +- ✅ Check responsive design +- ✅ Verify all navigation flows + +**5. Integration Testing:** +- ✅ WordPress plugin sync (use test WordPress site) +- ✅ Payment webhooks (Stripe test mode) +- ✅ Email delivery +- ✅ AI provider APIs (use test keys if available) + +--- + +## Monitoring & Logs + +### View Staging Logs + +```bash +# All services +docker compose -f docker-compose.staging.yml -p igny8-staging logs -f + +# Specific service +docker logs -f igny8_staging_backend +docker logs -f igny8_staging_frontend +docker logs -f igny8_staging_celery_worker + +# Tail log files +tail -f /data/app/logs/staging/backend.log +tail -f /data/app/logs/staging/celery-worker.log +``` + +### View Production Logs + +```bash +# All services +docker compose -f docker-compose.app.yml -p igny8-app logs -f + +# Specific service +docker logs -f igny8_backend +docker logs -f igny8_frontend + +# Tail log files +tail -f /data/app/logs/production/backend.log +``` + +--- + +## Maintenance Commands + +### Restart Staging + +```bash +docker compose -f docker-compose.staging.yml -p igny8-staging restart +``` + +### Stop Staging (save resources) + +```bash +docker compose -f docker-compose.staging.yml -p igny8-staging down +``` + +### Rebuild Staging Images + +```bash +docker build -t igny8-backend:staging -f backend/Dockerfile backend/ +docker build -t igny8-frontend-dev:staging -f frontend/Dockerfile.dev frontend/ +docker compose -f docker-compose.staging.yml -p igny8-staging up -d --force-recreate +``` + +### Clean Staging Database + +```bash +docker exec -it postgres psql -U postgres -c "DROP DATABASE igny8_staging_db;" +docker exec -it postgres psql -U postgres -c "CREATE DATABASE igny8_staging_db OWNER igny8;" +docker exec igny8_staging_backend python manage.py migrate +``` + +--- + +## Best Practices + +### 1. Always Test in Staging First + +❌ **DON'T:** +```bash +git checkout main +git merge feature/new-feature +./scripts/deploy-production.sh # DANGEROUS! +``` + +✅ **DO:** +```bash +git checkout staging +git merge feature/new-feature +./scripts/deploy-staging.sh +# Test thoroughly +# If healthy: +git checkout main +git merge staging +./scripts/deploy-production.sh +``` + +### 2. Use Test Credentials in Staging + +- **Payment:** Stripe test mode, PayPal sandbox +- **Email:** Test SMTP or separate email service +- **AI APIs:** Separate API keys or same keys (monitor usage) +- **WordPress:** Test WordPress site, not production sites + +### 3. Sanitize Data When Syncing + +- Never expose real user emails in staging +- Replace sensitive data with dummy data +- Use `sync-data-to-staging.sh` which sanitizes automatically + +### 4. Monitor Resource Usage + +```bash +# Check container resources +docker stats igny8_staging_backend igny8_staging_celery_worker + +# If staging uses too many resources: +docker compose -f docker-compose.staging.yml -p igny8-staging down +``` + +### 5. Keep Staging Up-to-Date + +```bash +# Weekly: sync staging with main +git checkout staging +git merge main +git push origin staging +./scripts/deploy-staging.sh +``` + +--- + +## Troubleshooting + +### Staging Backend Not Starting + +```bash +# Check logs +docker logs igny8_staging_backend + +# Common issues: +# 1. Database connection +docker exec -it postgres psql -U igny8 -d igny8_staging_db -c "SELECT 'OK';" + +# 2. Redis connection +docker exec redis redis-cli -n 1 ping + +# 3. Environment variables +docker exec igny8_staging_backend env | grep DB_ +``` + +### SSL Certificate Issues + +```bash +# Caddy automatically gets SSL certs +# Check Caddy logs +docker logs caddy + +# Manually reload +docker exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +### Migration Conflicts + +```bash +# If staging has migrations not in production: +# DO NOT merge to main until resolved + +# Check diff +docker exec igny8_staging_backend python manage.py showmigrations +docker exec igny8_backend python manage.py showmigrations + +# Resolve conflicts before merging +``` + +### Port Conflicts + +```bash +# Check if staging ports are already in use +sudo netstat -tulpn | grep 8012 +sudo netstat -tulpn | grep 8024 + +# If conflicts, change ports in docker-compose.staging.yml +``` + +--- + +## CI/CD Integration (Optional) + +### GitHub Actions Example + +Create `.github/workflows/deploy-staging.yml`: + +```yaml +name: Deploy to Staging + +on: + push: + branches: [staging] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy to staging server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd /data/app/igny8 + ./scripts/deploy-staging.sh +``` + +### GitLab CI Example + +Create `.gitlab-ci.yml`: + +```yaml +stages: + - deploy + +deploy_staging: + stage: deploy + only: + - staging + script: + - ssh user@server 'cd /data/app/igny8 && ./scripts/deploy-staging.sh' + environment: + name: staging + url: https://staging.igny8.com +``` + +--- + +## Summary Checklist + +### Initial Setup (One-Time) + +- [ ] Create staging database (`igny8_staging_db`) +- [ ] Create `docker-compose.staging.yml` +- [ ] Create `.env.staging` with test credentials +- [ ] Update Caddyfile with staging domains +- [ ] Add staging DNS records +- [ ] Create `staging` git branch +- [ ] Create deployment scripts +- [ ] Create log directories +- [ ] Deploy staging for first time +- [ ] Create staging superuser +- [ ] Test staging health check + +### Every Feature Deployment + +- [ ] Develop in feature branch +- [ ] Merge feature → staging +- [ ] Deploy to staging: `./scripts/deploy-staging.sh` +- [ ] Test thoroughly on staging +- [ ] If healthy: Merge staging → main +- [ ] Deploy to production: `./scripts/deploy-production.sh` +- [ ] Monitor production for 10 minutes +- [ ] If issues: `./scripts/rollback-production.sh` + +--- + +## Related Documentation + +- [TWO-REPO-ARCHITECTURE.md](TWO-REPO-ARCHITECTURE.md) - Repository structure +- [INFRASTRUCTURE-STACK.md](INFRASTRUCTURE-STACK.md) - Stack setup +- [IGNY8-APP-STRUCTURE.md](IGNY8-APP-STRUCTURE.md) - App structure +- [DOCKER-DEPLOYMENT.md](DOCKER-DEPLOYMENT.md) - Docker details + +--- + +**Document Maintainer:** IGNY8 DevOps Team +**Last Review:** January 11, 2026 +**Next Review:** As needed when infrastructure changes diff --git a/docs/90-REFERENCE/SEED-KEYWORDS-IMPORT-GUIDE.md b/docs/90-REFERENCE/SEED-KEYWORDS-IMPORT-GUIDE.md new file mode 100644 index 00000000..911e6775 --- /dev/null +++ b/docs/90-REFERENCE/SEED-KEYWORDS-IMPORT-GUIDE.md @@ -0,0 +1,195 @@ +# Global Keywords Database (SeedKeyword) - Import Guide + +## Overview + +The Global Keywords Database stores canonical keyword suggestions that can be imported into account-specific keywords. These are organized by Industry and Sector. + +**Admin URL:** `https://api.igny8.com/admin/igny8_core_auth/seedkeyword/` + +--- + +## Import Functionality + +### CSV Format + +The import expects a CSV file with the following columns: + +| Column | Type | Required | Description | Example | +|--------|------|----------|-------------|---------| +| `keyword` | String | **Yes** | The keyword phrase | "best massage chairs" | +| `industry` | String | **Yes** | Industry name (must exist) | "Health & Wellness" | +| `sector` | String | **Yes** | Sector name (must exist) | "Massage Products" | +| `volume` | Integer | No | Monthly search volume | 5400 | +| `difficulty` | Integer | No | Keyword difficulty (0-100) | 45 | +| `country` | String | No | Country code (US, CA, GB, etc.) | "US" | +| `is_active` | Boolean | No | Active status | True | + +### Sample CSV + +```csv +keyword,industry,sector,volume,difficulty,country,is_active +best massage chairs,Health & Wellness,Massage Products,5400,45,US,True +deep tissue massage chair,Health & Wellness,Massage Products,720,52,US,True +shiatsu massage chair,Health & Wellness,Massage Products,1200,48,US,True +``` + +**Template file available:** `/data/app/igny8/backend/seed_keywords_import_template.csv` + +--- + +## How to Import + +### Step 1: Prepare Your CSV File + +1. Download the template: `seed_keywords_import_template.csv` +2. Add your keywords (one per row) +3. Ensure Industry and Sector names **exactly match** existing records +4. Save as CSV (UTF-8 encoding) + +### Step 2: Import via Django Admin + +1. Go to: `https://api.igny8.com/admin/igny8_core_auth/seedkeyword/` +2. Click **"Import"** button (top right) +3. Click **"Choose File"** and select your CSV +4. Click **"Submit"** +5. Review the preview: + - ✅ Green = New records to be created + - 🔵 Blue = Existing records to be updated + - ❌ Red = Errors (fix and re-import) +6. If preview looks good, click **"Confirm import"** + +### Step 3: Verify Import + +- Check the list to see your imported keywords +- Use filters to find specific industries/sectors +- Edit any records if needed + +--- + +## Data Validation + +The import process automatically: + +✅ **Validates volume:** Ensures it's a positive integer (defaults to 0 if invalid) +✅ **Validates difficulty:** Clamps to 0-100 range +✅ **Validates country:** Must be one of: US, CA, GB, AE, AU, IN, PK (defaults to US) +✅ **Handles duplicates:** Uses `(keyword, industry, sector)` as unique key +✅ **Skip unchanged:** If keyword already exists with same data, it's skipped + +--- + +## Bulk Delete + +### How to Delete Keywords + +1. Select keywords using checkboxes (or "Select all") +2. Choose **"Delete selected keywords"** from the action dropdown +3. Click **"Go"** +4. Review the confirmation page showing all related objects +5. Click **"Yes, I'm sure"** to confirm deletion + +**Note:** Only superusers and developers can delete seed keywords. + +--- + +## Export Functionality + +### Export to CSV/Excel + +1. Go to: `https://api.igny8.com/admin/igny8_core_auth/seedkeyword/` +2. (Optional) Use filters to narrow down results +3. Click **"Export"** button (top right) +4. Choose format: CSV, Excel, JSON, etc. +5. File downloads with all selected fields + +**Export includes:** +- All keyword data +- Related industry/sector names +- SEO metrics (volume, difficulty) +- Metadata (created date, active status) + +--- + +## Common Issues & Solutions + +### Issue: "Industry not found" error during import + +**Solution:** +- Ensure the industry name in your CSV **exactly matches** an existing Industry record +- Check spelling, capitalization, and spacing +- View existing industries: `/admin/igny8_core_auth/industry/` + +### Issue: "Sector not found" error during import + +**Solution:** +- Ensure the sector name in your CSV **exactly matches** an existing IndustrySector record +- The sector must belong to the specified industry +- View existing sectors: `/admin/igny8_core_auth/industrysector/` + +### Issue: Import shows errors for all rows + +**Solution:** +- Check CSV encoding (must be UTF-8) +- Ensure column headers match exactly: `keyword,industry,sector,volume,difficulty,country,is_active` +- Remove any extra columns or spaces in headers +- Verify there are no special characters causing parsing issues + +### Issue: Duplicate keyword error + +**Solution:** +- Keywords are unique per `(keyword, industry, sector)` combination +- If importing a keyword that already exists, it will be updated (not duplicated) +- Use `skip_unchanged = True` to avoid unnecessary updates + +### Issue: Delete confirmation page has no "Delete" button + +**Solution:** ✅ **FIXED** - Custom bulk delete action now includes proper delete button on confirmation page + +--- + +## Permissions + +| Action | Permission Required | +|--------|-------------------| +| View | Staff users | +| Add | Superuser | +| Edit | Superuser | +| Delete | Superuser or Developer | +| Import | Superuser | +| Export | Staff users | + +--- + +## Technical Details + +### Model Location +- **Model:** `backend/igny8_core/auth/models.py` - `SeedKeyword` +- **Admin:** `backend/igny8_core/auth/admin.py` - `SeedKeywordAdmin` +- **Resource:** `backend/igny8_core/auth/admin.py` - `SeedKeywordResource` + +### Database Table +- **Table name:** `igny8_seed_keywords` +- **Unique constraint:** `(keyword, industry, sector)` +- **Indexes:** + - `keyword` + - `industry, sector` + - `industry, sector, is_active` + - `country` + +### API Access (Read-Only) +- **Endpoint:** `/api/v1/auth/seed-keywords/` +- **ViewSet:** `SeedKeywordViewSet` (ReadOnlyModelViewSet) +- **Filters:** industry, sector, country, is_active + +--- + +## Related Documentation + +- [Django Admin Guide](../../docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md) +- [Models Reference](../../docs/90-REFERENCE/MODELS.md) +- [Planner Module](../../docs/10-MODULES/PLANNER.md) + +--- + +**Last Updated:** January 11, 2026 +**Maintainer:** IGNY8 Team