django bacekdn opeartioanl fixes and site wp integration api fixes
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,29 +952,74 @@ 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)
|
||||
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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› {% 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 %}
|
||||
11
backend/seed_keywords_import_template.csv
Normal file
11
backend/seed_keywords_import_template.csv
Normal 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
|
||||
|
File diff suppressed because it is too large
Load Diff
195
docs/90-REFERENCE/SEED-KEYWORDS-IMPORT-GUIDE.md
Normal file
195
docs/90-REFERENCE/SEED-KEYWORDS-IMPORT-GUIDE.md
Normal 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
|
||||
Reference in New Issue
Block a user