django bacekdn opeartioanl fixes and site wp integration api fixes

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-11 21:54:08 +00:00
parent 00ef985a5f
commit 3925ddf894
16 changed files with 2045 additions and 89 deletions

View File

@@ -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.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: 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 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(), AdminDeleteMixin provides:
so we override them to inject our custom sidebar. - 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): def _inject_sidebar_context(self, request, extra_context=None):
"""Helper to inject custom sidebar into context""" """Helper to inject custom sidebar into context"""
if extra_context is None: if extra_context is None:

View File

@@ -2,14 +2,18 @@
Admin interface for auth models Admin interface for auth models
""" """
from django import forms 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 django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from unfold.admin import ModelAdmin, TabularInline from unfold.admin import ModelAdmin, TabularInline
from unfold.contrib.filters.admin import (
RelatedDropdownFilter,
ChoicesDropdownFilter,
)
from simple_history.admin import SimpleHistoryAdmin from simple_history.admin import SimpleHistoryAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
from import_export.admin import ExportMixin, ImportExportMixin from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources from import_export import resources, fields, widgets
class AccountAdminForm(forms.ModelForm): class AccountAdminForm(forms.ModelForm):
@@ -128,7 +132,12 @@ class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = PlanResource resource_class = PlanResource
"""Plan admin - Global, no account filtering needed""" """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_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'] search_fields = ['name', 'slug']
readonly_fields = ['created_at'] readonly_fields = ['created_at']
actions = [ actions = [
@@ -203,7 +212,10 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
resource_class = AccountResource resource_class = AccountResource
form = AccountAdminForm form = AccountAdminForm
list_display = ['name', 'slug', 'owner', 'plan', 'status', 'health_indicator', 'credits', 'created_at'] 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'] search_fields = ['name', 'slug']
readonly_fields = ['created_at', 'updated_at', 'health_indicator', 'health_details'] readonly_fields = ['created_at', 'updated_at', 'health_indicator', 'health_details']
actions = [ actions = [
@@ -503,7 +515,9 @@ class SubscriptionResource(resources.ModelResource):
class SubscriptionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): class SubscriptionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = SubscriptionResource resource_class = SubscriptionResource
list_display = ['account', 'status', 'current_period_start', 'current_period_end'] list_display = ['account', 'status', 'current_period_start', 'current_period_end']
list_filter = ['status'] list_filter = [
('status', ChoicesDropdownFilter),
]
search_fields = ['account__name', 'stripe_subscription_id'] search_fields = ['account__name', 'stripe_subscription_id']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
actions = [ actions = [
@@ -621,7 +635,13 @@ class SiteResource(resources.ModelResource):
class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = SiteResource resource_class = SiteResource
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_api_key_status', 'get_sectors_count'] 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'] search_fields = ['name', 'slug', 'domain', 'industry__name']
readonly_fields = ['created_at', 'updated_at', 'get_api_key_display'] readonly_fields = ['created_at', 'updated_at', 'get_api_key_display']
inlines = [SectorInline] inlines = [SectorInline]
@@ -676,15 +696,36 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
get_api_key_status.short_description = 'API Key' get_api_key_status.short_description = 'API Key'
def generate_api_keys(self, request, queryset): 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 import secrets
from igny8_core.business.integration.models import SiteIntegration
updated_count = 0 updated_count = 0
for site in queryset: for site in queryset:
if not site.wp_api_key: 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() 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 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' generate_api_keys.short_description = 'Generate WordPress API Keys'
def bulk_set_status_active(self, request, queryset): def bulk_set_status_active(self, request, queryset):
@@ -743,7 +784,12 @@ class SectorResource(resources.ModelResource):
class SectorAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): class SectorAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = SectorResource resource_class = SectorResource
list_display = ['name', 'slug', 'site', 'industry_sector', 'get_industry', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count'] 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'] search_fields = ['name', 'slug', 'site__name', 'industry_sector__name']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
actions = [ actions = [
@@ -877,7 +923,10 @@ class IndustrySectorResource(resources.ModelResource):
class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin): class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = IndustrySectorResource resource_class = IndustrySectorResource
list_display = ['name', 'slug', 'industry', 'is_active'] list_display = ['name', 'slug', 'industry', 'is_active']
list_filter = ['is_active', 'industry'] list_filter = [
('is_active', ChoicesDropdownFilter),
('industry', RelatedDropdownFilter),
]
search_fields = ['name', 'slug', 'description'] search_fields = ['name', 'slug', 'description']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
actions = [ actions = [
@@ -903,29 +952,74 @@ class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
class SeedKeywordResource(resources.ModelResource): class SeedKeywordResource(resources.ModelResource):
"""Resource class for importing/exporting Seed Keywords""" """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: class Meta:
model = SeedKeyword model = SeedKeyword
fields = ('id', 'keyword', 'industry__name', 'sector__name', 'volume', fields = ('id', 'keyword', 'industry', 'sector', 'volume',
'difficulty', 'country', 'is_active', 'created_at') 'difficulty', 'country', 'is_active', 'created_at')
export_order = fields export_order = fields
import_id_fields = ('id',) import_id_fields = ('keyword', 'industry', 'sector') # Use natural keys for import
skip_unchanged = True 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) @admin.register(SeedKeyword)
class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = SeedKeywordResource resource_class = SeedKeywordResource
"""SeedKeyword admin - Global reference data, no account filtering""" """SeedKeyword admin - Global reference data, no account filtering"""
list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active', 'created_at'] 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'] search_fields = ['keyword']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
actions = [ actions = [
'delete_selected',
'bulk_activate', 'bulk_activate',
'bulk_deactivate', 'bulk_deactivate',
'bulk_update_country', 'bulk_update_country',
] # Enable bulk delete ]
# Delete is handled by AdminDeleteMixin in base Igny8ModelAdmin
fieldsets = ( fieldsets = (
('Keyword Info', { ('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): def bulk_activate(self, request, queryset):
"""Activate selected keywords"""
try:
updated = queryset.update(is_active=True) updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS) 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' bulk_activate.short_description = 'Activate selected keywords'
def bulk_deactivate(self, request, queryset): def bulk_deactivate(self, request, queryset):
"""Deactivate selected keywords"""
try:
updated = queryset.update(is_active=False) updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} seed keyword(s) deactivated.', messages.SUCCESS) 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' bulk_deactivate.short_description = 'Deactivate selected keywords'
def bulk_update_country(self, request, queryset): def bulk_update_country(self, request, queryset):
@@ -1005,7 +1119,12 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin):
""" """
resource_class = UserResource resource_class = UserResource
list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at'] 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'] search_fields = ['email', 'username']
readonly_fields = ['created_at', 'updated_at', 'password_display'] readonly_fields = ['created_at', 'updated_at', 'password_display']

View File

@@ -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 api_key = integration.site.wp_api_key
if not api_key: if not api_key:
return { return {
'success': False, 'success': False,
'message': 'API key not configured.', 'message': 'API key not configured. Generate an API key in Site Settings.',
'details': {} 'details': {'site_id': integration.site.id, 'site_name': integration.site.name}
} }
# Initialize health check results # Initialize health check results

View File

@@ -39,8 +39,8 @@ class SyncMetadataService:
try: try:
# Get WordPress site URL and API key # Get WordPress site URL and API key
site_url = integration.config_json.get('site_url', '') site_url = integration.config_json.get('site_url', '')
credentials = integration.get_credentials() # API key is stored in Site.wp_api_key (SINGLE source of truth)
api_key = credentials.get('api_key', '') api_key = integration.site.wp_api_key or ''
if not site_url: if not site_url:
return { return {
@@ -51,7 +51,7 @@ class SyncMetadataService:
if not api_key: if not api_key:
return { return {
'success': False, '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 # Call WordPress metadata endpoint

View File

@@ -142,44 +142,67 @@ class Keywords(SoftDeletableModel, SiteSectorBaseModel):
@property @property
def keyword(self): def keyword(self):
"""Get keyword text from seed_keyword""" """Get keyword text from seed_keyword"""
try:
return self.seed_keyword.keyword if self.seed_keyword else '' return self.seed_keyword.keyword if self.seed_keyword else ''
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
return ''
@property @property
def volume(self): def volume(self):
"""Get volume from override or seed_keyword""" """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 @property
def difficulty(self): def difficulty(self):
"""Get difficulty from override or seed_keyword""" """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 @property
def country(self): def country(self):
"""Get country from seed_keyword""" """Get country from seed_keyword"""
try:
return self.seed_keyword.country if self.seed_keyword else 'US' return self.seed_keyword.country if self.seed_keyword else 'US'
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
return 'US'
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Validate that seed_keyword's industry/sector matches site's industry/sector""" """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 # Validate industry match
if self.site.industry != self.seed_keyword.industry: if self.site.industry != seed_kw.industry:
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
raise 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) # 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 from django.core.exceptions import ValidationError
raise 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) super().save(*args, **kwargs)
def __str__(self): 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): class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel):

View File

@@ -139,9 +139,13 @@ class PublisherService:
if integration: if integration:
logger.info(f"[PublisherService._publish_to_destination] ✅ Integration found: id={integration.id}") 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.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'))}") 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) # Ensure site_url is set (from config or from site model)
@@ -342,13 +346,16 @@ class PublisherService:
destinations = [] destinations = []
for integration in integrations: for integration in integrations:
config = integration.config_json.copy() config = integration.config_json.copy()
credentials = integration.get_credentials()
destination_config = { destination_config = {
'platform': integration.platform, 'platform': integration.platform,
**config, **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) destinations.append(destination_config)
# Also add 'sites' destination if not in platforms filter or if platforms is None # Also add 'sites' destination if not in platforms filter or if platforms is None

View File

@@ -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'))

View File

@@ -5,6 +5,12 @@ from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django.contrib import messages from django.contrib import messages
from unfold.admin import ModelAdmin from unfold.admin import ModelAdmin
from unfold.contrib.filters.admin import (
RelatedDropdownFilter,
ChoicesDropdownFilter,
DropdownFilter,
RangeDateFilter,
)
from simple_history.admin import SimpleHistoryAdmin from simple_history.admin import SimpleHistoryAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from igny8_core.business.billing.models import ( 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 .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
from import_export.admin import ExportMixin, ImportExportMixin from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources from import_export import resources
from rangefilter.filters import DateRangeFilter
class CreditTransactionResource(resources.ModelResource): class CreditTransactionResource(resources.ModelResource):
@@ -36,7 +41,11 @@ class CreditTransactionResource(resources.ModelResource):
class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = CreditTransactionResource resource_class = CreditTransactionResource
list_display = ['id', 'account', 'transaction_type', 'amount', 'balance_after', 'description', 'created_at'] 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'] search_fields = ['description', 'account__name']
readonly_fields = ['created_at'] readonly_fields = ['created_at']
date_hierarchy = 'created_at' date_hierarchy = 'created_at'
@@ -188,7 +197,13 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
'approved_by', 'approved_by',
'processed_at', '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 = [ search_fields = [
'invoice__invoice_number', 'invoice__invoice_number',
'account__name', 'account__name',
@@ -654,10 +669,10 @@ class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
'created_at', 'created_at',
] ]
list_filter = [ list_filter = [
'limit_type', ('limit_type', ChoicesDropdownFilter),
('period_start', DateRangeFilter), ('period_start', RangeDateFilter),
('period_end', DateRangeFilter), ('period_end', RangeDateFilter),
'account', ('account', RelatedDropdownFilter),
] ]
search_fields = ['account__name'] search_fields = ['account__name']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']

View File

@@ -67,25 +67,23 @@ class IntegrationViewSet(SiteSectorModelViewSet):
api_key = serializers.SerializerMethodField() api_key = serializers.SerializerMethodField()
def get_api_key(self, obj): def get_api_key(self, obj):
"""Return the API key from encrypted credentials""" """Return the API key from Site.wp_api_key (SINGLE source of truth)"""
credentials = obj.get_credentials() # API key is stored on Site model, not in SiteIntegration credentials
return credentials.get('api_key', '') return obj.site.wp_api_key or ''
def validate(self, data): def validate(self, data):
""" """
Custom validation for WordPress integrations. 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) 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': if validated_data.get('platform') == 'wordpress':
credentials = validated_data.get('credentials_json', {}) site = validated_data.get('site') or getattr(self.instance, 'site', None)
if site and not site.wp_api_key:
# API key is required for all WordPress integrations
if not credentials.get('api_key'):
raise serializers.ValidationError({ 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 return validated_data
@@ -198,7 +196,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
# Try to find an existing integration for this site+platform # Try to find an existing integration for this site+platform
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first() integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
# 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 integration_created = False
if not integration: if not integration:
integration = SiteIntegration.objects.create( integration = SiteIntegration.objects.create(
@@ -207,7 +205,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
platform='wordpress', platform='wordpress',
platform_type='cms', platform_type='cms',
config_json={'site_url': site_url} if site_url else {}, 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, is_active=True,
sync_enabled=True sync_enabled=True
) )
@@ -805,27 +803,38 @@ class IntegrationViewSet(SiteSectorModelViewSet):
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
api_key = f"igny8_site_{site_id}_{timestamp}_{random_suffix}" 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( integration, created = SiteIntegration.objects.get_or_create(
site=site, site=site,
platform='wordpress',
defaults={ defaults={
'integration_type': 'wordpress', 'account': site.account,
'platform': 'wordpress',
'platform_type': 'cms',
'is_active': True, 'is_active': True,
'credentials_json': {'api_key': api_key}, 'sync_enabled': True,
'credentials_json': {}, # Empty - API key is on Site model
'config_json': {} 'config_json': {}
} }
) )
# If integration already exists, update the API key # If integration already exists, just ensure it's active
if not created: if not created:
credentials = integration.get_credentials() integration.is_active = True
credentials['api_key'] = api_key integration.sync_enabled = True
integration.credentials_json = credentials # 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() integration.save()
logger.info( logger.info(
f"Generated new API key for site {site.name} (ID: {site_id}), " 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 # Serialize the integration with the new key

View File

@@ -122,10 +122,10 @@ def wordpress_status_webhook(request):
request=request request=request
) )
# Verify API key matches integration # Verify API key matches Site.wp_api_key (SINGLE source of truth)
stored_api_key = integration.credentials_json.get('api_key') stored_api_key = integration.site.wp_api_key
if not stored_api_key or stored_api_key != 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( return error_response(
error='Invalid API key', error='Invalid API key',
status_code=http_status.HTTP_401_UNAUTHORIZED, status_code=http_status.HTTP_401_UNAUTHORIZED,
@@ -293,8 +293,8 @@ def wordpress_metadata_webhook(request):
request=request request=request
) )
# Verify API key # Verify API key against Site.wp_api_key (SINGLE source of truth)
stored_api_key = integration.credentials_json.get('api_key') stored_api_key = integration.site.wp_api_key
if not stored_api_key or stored_api_key != api_key: if not stored_api_key or stored_api_key != api_key:
return error_response( return error_response(
error='Invalid API key', error='Invalid API key',

View File

@@ -114,7 +114,6 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
'bulk_assign_cluster', 'bulk_assign_cluster',
'bulk_set_status_active', 'bulk_set_status_active',
'bulk_set_status_inactive', 'bulk_set_status_inactive',
'bulk_soft_delete',
] ]
@admin.display(description='Keyword') @admin.display(description='Keyword')

View File

@@ -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' site_domain = base_url.replace('https://', '').replace('http://', '').split('/')[0] if base_url else 'unknown'
log_prefix = f"[{site_id}-{site_domain}]" log_prefix = f"[{site_id}-{site_domain}]"
# Extract API key from credentials # API key is stored in Site.wp_api_key (SINGLE source of truth)
api_key = site_integration.get_credentials().get('api_key', '') api_key = site_integration.site.wp_api_key or ''
publish_logger.info(f" ✅ Content loaded:") publish_logger.info(f" ✅ Content loaded:")
publish_logger.info(f" {log_prefix} Title: '{content.title}'") 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 # STEP 8: Send API request to WordPress
base_url = site_integration.config_json.get('site_url', '') or site_integration.config_json.get('base_url', '') 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: if not base_url:
error_msg = "No base_url/site_url configured in integration" 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} return {"success": False, "error": error_msg}
if not api_key: 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}") publish_logger.error(f" {log_prefix}{error_msg}")
return {"success": False, "error": error_msg} return {"success": False, "error": error_msg}

View File

@@ -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 %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% trans 'Delete multiple objects' %}
</div>
{% endblock %}
{% block content %}
<div class="delete-confirmation">
{% if subtitle %}
<h1>{{ title }}</h1>
<p class="subtitle">{{ subtitle }}</p>
{% else %}
<h1>{{ title }}</h1>
{% endif %}
{% if can_delete_items %}
<div class="can-delete-section" style="margin: 20px 0; padding: 15px; background: #e8f5e9; border-left: 4px solid #4caf50;">
<h3 style="margin-top: 0; color: #2e7d32;">✅ Can Delete ({{ can_delete_items|length }} item{{ can_delete_items|length|pluralize }})</h3>
<p>The following seed keywords can be safely deleted:</p>
<ul>
{% for obj in can_delete_items %}
<li><strong>{{ obj.keyword }}</strong> ({{ obj.industry.name }} - {{ obj.sector.name }})</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if protected_items %}
<div class="protected-section" style="margin: 20px 0; padding: 15px; background: #fff3e0; border-left: 4px solid #ff9800;">
<h3 style="margin-top: 0; color: #e65100;">⚠️ Cannot Delete ({{ protected_items|length }} item{{ protected_items|length|pluralize }})</h3>
<p>The following seed keywords are being used by site keywords and <strong>cannot be deleted</strong>:</p>
<ul>
{% for item in protected_items %}
<li>
<strong>{{ item.object.keyword }}</strong> ({{ item.object.industry.name }} - {{ item.object.sector.name }})
<br>
<span style="color: #666; font-size: 0.9em;">
→ Used by <strong>{{ item.related_count }}</strong> keyword{{ item.related_count|pluralize }} on sites:
{% for site in item.sites %}{{ site }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% if item.related_count > 5 %}(+{{ item.related_count|add:"-5" }} more){% endif %}
</span>
</li>
{% endfor %}
</ul>
<p style="margin-top: 15px; padding: 10px; background: #fff; border: 1px solid #ff9800;">
<strong>💡 Tip:</strong> To delete these seed keywords, you must first remove or deactivate the site keywords that reference them.
</p>
</div>
{% endif %}
{% if can_delete_items %}
<form method="post">
{% csrf_token %}
<div>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}">
{% endfor %}
<input type="hidden" name="action" value="{{ action }}">
<input type="hidden" name="post" value="yes">
<div style="margin: 20px 0;">
<input type="submit" value="{% trans 'Yes, delete selected items' %}" class="button" style="background: #dc3545; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
<a href="{% url opts|admin_urlname:'changelist' %}" class="button cancel-link" style="margin-left: 10px; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; display: inline-block;">{% trans 'No, take me back' %}</a>
</div>
{% if protected_items %}
<p style="color: #e65100;">
<strong>Note:</strong> 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.
</p>
{% endif %}
</div>
</form>
{% else %}
<div style="margin: 20px 0;">
<p style="background: #ffebee; padding: 15px; border-left: 4px solid #f44336;">
<strong>⛔ No keywords can be deleted.</strong><br>
All selected keywords are currently in use by site keywords and are protected from deletion.
</p>
<a href="{% url opts|admin_urlname:'changelist' %}" class="button" style="padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; display: inline-block;">{% trans 'Back to list' %}</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -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
1 keyword industry sector volume difficulty country is_active
2 best massage chairs Health & Wellness Massage Products 5400 45 US True
3 deep tissue massage chair Health & Wellness Massage Products 720 52 US True
4 shiatsu massage chair Health & Wellness Massage Products 1200 48 US True
5 zero gravity massage chair Health & Wellness Massage Products 890 50 US True
6 affordable massage chair Health & Wellness Massage Products 320 35 US True
7 professional massage chair Health & Wellness Massage Products 280 42 US True
8 massage chair benefits Health & Wellness Massage Products 450 25 US True
9 full body massage chair Health & Wellness Massage Products 650 40 US True
10 portable massage chair Health & Wellness Massage Products 390 38 US True
11 electric massage chair Health & Wellness Massage Products 510 43 US True

File diff suppressed because it is too large Load Diff

View File

@@ -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