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.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:

View File

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

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

View File

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

View File

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

View File

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

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.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']

View File

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

View File

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

View File

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

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'
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}

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