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.core.exceptions import PermissionDenied
|
||||||
|
from django.db import models, transaction
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDeleteMixin:
|
||||||
|
"""
|
||||||
|
Mixin that provides a simple working delete action for admin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_actions(self, request):
|
||||||
|
"""Replace default delete_selected with simple working version"""
|
||||||
|
actions = super().get_actions(request)
|
||||||
|
|
||||||
|
# Remove Django's default delete that causes 500 errors
|
||||||
|
if 'delete_selected' in actions:
|
||||||
|
del actions['delete_selected']
|
||||||
|
|
||||||
|
# Add our simple delete action
|
||||||
|
actions['simple_delete'] = (
|
||||||
|
self.__class__.simple_delete,
|
||||||
|
'simple_delete',
|
||||||
|
'Delete selected items'
|
||||||
|
)
|
||||||
|
|
||||||
|
return actions
|
||||||
|
|
||||||
|
def simple_delete(self, request, queryset):
|
||||||
|
"""
|
||||||
|
Simple delete that just works. Deletes items one by one with error handling.
|
||||||
|
"""
|
||||||
|
success = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for obj in queryset:
|
||||||
|
try:
|
||||||
|
# Get object info before delete
|
||||||
|
try:
|
||||||
|
obj_str = str(obj)
|
||||||
|
except Exception:
|
||||||
|
obj_str = f'#{obj.pk}'
|
||||||
|
|
||||||
|
# Just delete it - let the model handle soft vs hard delete
|
||||||
|
obj.delete()
|
||||||
|
success += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'{obj_str}: {str(e)[:50]}')
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.message_user(request, f'Deleted {success} item(s).', messages.SUCCESS)
|
||||||
|
if errors:
|
||||||
|
self.message_user(request, f'Failed to delete {len(errors)}: {"; ".join(errors[:3])}', messages.ERROR)
|
||||||
|
|
||||||
|
|
||||||
class AccountAdminMixin:
|
class AccountAdminMixin:
|
||||||
@@ -110,20 +165,26 @@ class SiteSectorAdminMixin:
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Custom ModelAdmin for Sidebar Fix
|
# Custom ModelAdmin for Sidebar Fix + Delete Fix
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
|
|
||||||
|
|
||||||
class Igny8ModelAdmin(UnfoldModelAdmin):
|
class Igny8ModelAdmin(AdminDeleteMixin, UnfoldModelAdmin):
|
||||||
"""
|
"""
|
||||||
Custom ModelAdmin that ensures sidebar_navigation is set correctly on ALL pages
|
Custom ModelAdmin that:
|
||||||
|
1. Fixes delete actions (no 500 errors, bypasses PROTECT if needed)
|
||||||
|
2. Ensures sidebar_navigation is set correctly on ALL pages
|
||||||
|
3. Uses dropdown filters with Apply button
|
||||||
|
|
||||||
Django's ModelAdmin views don't call AdminSite.each_context(),
|
AdminDeleteMixin provides:
|
||||||
so we override them to inject our custom sidebar.
|
- simple_delete: Safe delete (soft delete if available)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Enable "Apply Filters" button for dropdown filters
|
||||||
|
list_filter_submit = True
|
||||||
|
|
||||||
def _inject_sidebar_context(self, request, extra_context=None):
|
def _inject_sidebar_context(self, request, extra_context=None):
|
||||||
"""Helper to inject custom sidebar into context"""
|
"""Helper to inject custom sidebar into context"""
|
||||||
if extra_context is None:
|
if extra_context is None:
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
Admin interface for auth models
|
Admin interface for auth models
|
||||||
"""
|
"""
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin, messages
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
from unfold.admin import ModelAdmin, TabularInline
|
from unfold.admin import ModelAdmin, TabularInline
|
||||||
|
from unfold.contrib.filters.admin import (
|
||||||
|
RelatedDropdownFilter,
|
||||||
|
ChoicesDropdownFilter,
|
||||||
|
)
|
||||||
from simple_history.admin import SimpleHistoryAdmin
|
from simple_history.admin import SimpleHistoryAdmin
|
||||||
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
||||||
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
|
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
|
||||||
from import_export.admin import ExportMixin, ImportExportMixin
|
from import_export.admin import ExportMixin, ImportExportMixin
|
||||||
from import_export import resources
|
from import_export import resources, fields, widgets
|
||||||
|
|
||||||
|
|
||||||
class AccountAdminForm(forms.ModelForm):
|
class AccountAdminForm(forms.ModelForm):
|
||||||
@@ -128,7 +132,12 @@ class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
|
|||||||
resource_class = PlanResource
|
resource_class = PlanResource
|
||||||
"""Plan admin - Global, no account filtering needed"""
|
"""Plan admin - Global, no account filtering needed"""
|
||||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured']
|
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured']
|
||||||
list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured']
|
list_filter = [
|
||||||
|
('is_active', ChoicesDropdownFilter),
|
||||||
|
('billing_cycle', ChoicesDropdownFilter),
|
||||||
|
('is_internal', ChoicesDropdownFilter),
|
||||||
|
('is_featured', ChoicesDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['name', 'slug']
|
search_fields = ['name', 'slug']
|
||||||
readonly_fields = ['created_at']
|
readonly_fields = ['created_at']
|
||||||
actions = [
|
actions = [
|
||||||
@@ -203,7 +212,10 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
resource_class = AccountResource
|
resource_class = AccountResource
|
||||||
form = AccountAdminForm
|
form = AccountAdminForm
|
||||||
list_display = ['name', 'slug', 'owner', 'plan', 'status', 'health_indicator', 'credits', 'created_at']
|
list_display = ['name', 'slug', 'owner', 'plan', 'status', 'health_indicator', 'credits', 'created_at']
|
||||||
list_filter = ['status', 'plan']
|
list_filter = [
|
||||||
|
('status', ChoicesDropdownFilter),
|
||||||
|
('plan', RelatedDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['name', 'slug']
|
search_fields = ['name', 'slug']
|
||||||
readonly_fields = ['created_at', 'updated_at', 'health_indicator', 'health_details']
|
readonly_fields = ['created_at', 'updated_at', 'health_indicator', 'health_details']
|
||||||
actions = [
|
actions = [
|
||||||
@@ -503,7 +515,9 @@ class SubscriptionResource(resources.ModelResource):
|
|||||||
class SubscriptionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
class SubscriptionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||||
resource_class = SubscriptionResource
|
resource_class = SubscriptionResource
|
||||||
list_display = ['account', 'status', 'current_period_start', 'current_period_end']
|
list_display = ['account', 'status', 'current_period_start', 'current_period_end']
|
||||||
list_filter = ['status']
|
list_filter = [
|
||||||
|
('status', ChoicesDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['account__name', 'stripe_subscription_id']
|
search_fields = ['account__name', 'stripe_subscription_id']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
actions = [
|
actions = [
|
||||||
@@ -621,7 +635,13 @@ class SiteResource(resources.ModelResource):
|
|||||||
class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||||
resource_class = SiteResource
|
resource_class = SiteResource
|
||||||
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_api_key_status', 'get_sectors_count']
|
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_api_key_status', 'get_sectors_count']
|
||||||
list_filter = ['status', 'is_active', 'account', 'industry', 'hosting_type']
|
list_filter = [
|
||||||
|
('status', ChoicesDropdownFilter),
|
||||||
|
('is_active', ChoicesDropdownFilter),
|
||||||
|
('account', RelatedDropdownFilter),
|
||||||
|
('industry', RelatedDropdownFilter),
|
||||||
|
('hosting_type', ChoicesDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['name', 'slug', 'domain', 'industry__name']
|
search_fields = ['name', 'slug', 'domain', 'industry__name']
|
||||||
readonly_fields = ['created_at', 'updated_at', 'get_api_key_display']
|
readonly_fields = ['created_at', 'updated_at', 'get_api_key_display']
|
||||||
inlines = [SectorInline]
|
inlines = [SectorInline]
|
||||||
@@ -676,15 +696,36 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
get_api_key_status.short_description = 'API Key'
|
get_api_key_status.short_description = 'API Key'
|
||||||
|
|
||||||
def generate_api_keys(self, request, queryset):
|
def generate_api_keys(self, request, queryset):
|
||||||
"""Generate API keys for selected sites"""
|
"""Generate API keys for selected sites. API key is stored ONLY in Site.wp_api_key (single source of truth)."""
|
||||||
import secrets
|
import secrets
|
||||||
|
from igny8_core.business.integration.models import SiteIntegration
|
||||||
|
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
for site in queryset:
|
for site in queryset:
|
||||||
if not site.wp_api_key:
|
if not site.wp_api_key:
|
||||||
site.wp_api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}"
|
api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}"
|
||||||
|
|
||||||
|
# SINGLE SOURCE OF TRUTH: Store API key ONLY in Site.wp_api_key
|
||||||
|
site.wp_api_key = api_key
|
||||||
site.save()
|
site.save()
|
||||||
|
|
||||||
|
# Ensure SiteIntegration exists for status tracking (without API key)
|
||||||
|
SiteIntegration.objects.get_or_create(
|
||||||
|
site=site,
|
||||||
|
platform='wordpress',
|
||||||
|
defaults={
|
||||||
|
'account': site.account,
|
||||||
|
'platform': 'wordpress',
|
||||||
|
'platform_type': 'cms',
|
||||||
|
'is_active': True,
|
||||||
|
'sync_enabled': True,
|
||||||
|
'credentials_json': {}, # Empty - API key is on Site model
|
||||||
|
'config_json': {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
self.message_user(request, f'Generated API keys for {updated_count} site(s). Sites with existing keys were skipped.')
|
self.message_user(request, f'Generated API keys for {updated_count} site(s). API keys stored in Site.wp_api_key (single source of truth).')
|
||||||
generate_api_keys.short_description = 'Generate WordPress API Keys'
|
generate_api_keys.short_description = 'Generate WordPress API Keys'
|
||||||
|
|
||||||
def bulk_set_status_active(self, request, queryset):
|
def bulk_set_status_active(self, request, queryset):
|
||||||
@@ -743,7 +784,12 @@ class SectorResource(resources.ModelResource):
|
|||||||
class SectorAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
class SectorAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||||
resource_class = SectorResource
|
resource_class = SectorResource
|
||||||
list_display = ['name', 'slug', 'site', 'industry_sector', 'get_industry', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count']
|
list_display = ['name', 'slug', 'site', 'industry_sector', 'get_industry', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count']
|
||||||
list_filter = ['status', 'is_active', 'site', 'industry_sector__industry']
|
list_filter = [
|
||||||
|
('status', ChoicesDropdownFilter),
|
||||||
|
('is_active', ChoicesDropdownFilter),
|
||||||
|
('site', RelatedDropdownFilter),
|
||||||
|
('industry_sector__industry', RelatedDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['name', 'slug', 'site__name', 'industry_sector__name']
|
search_fields = ['name', 'slug', 'site__name', 'industry_sector__name']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
actions = [
|
actions = [
|
||||||
@@ -877,7 +923,10 @@ class IndustrySectorResource(resources.ModelResource):
|
|||||||
class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
|
class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
|
||||||
resource_class = IndustrySectorResource
|
resource_class = IndustrySectorResource
|
||||||
list_display = ['name', 'slug', 'industry', 'is_active']
|
list_display = ['name', 'slug', 'industry', 'is_active']
|
||||||
list_filter = ['is_active', 'industry']
|
list_filter = [
|
||||||
|
('is_active', ChoicesDropdownFilter),
|
||||||
|
('industry', RelatedDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['name', 'slug', 'description']
|
search_fields = ['name', 'slug', 'description']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
actions = [
|
actions = [
|
||||||
@@ -903,29 +952,74 @@ class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
|
|||||||
|
|
||||||
class SeedKeywordResource(resources.ModelResource):
|
class SeedKeywordResource(resources.ModelResource):
|
||||||
"""Resource class for importing/exporting Seed Keywords"""
|
"""Resource class for importing/exporting Seed Keywords"""
|
||||||
|
industry = fields.Field(
|
||||||
|
column_name='industry',
|
||||||
|
attribute='industry',
|
||||||
|
widget=widgets.ForeignKeyWidget(Industry, 'name')
|
||||||
|
)
|
||||||
|
sector = fields.Field(
|
||||||
|
column_name='sector',
|
||||||
|
attribute='sector',
|
||||||
|
widget=widgets.ForeignKeyWidget(IndustrySector, 'name')
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SeedKeyword
|
model = SeedKeyword
|
||||||
fields = ('id', 'keyword', 'industry__name', 'sector__name', 'volume',
|
fields = ('id', 'keyword', 'industry', 'sector', 'volume',
|
||||||
'difficulty', 'country', 'is_active', 'created_at')
|
'difficulty', 'country', 'is_active', 'created_at')
|
||||||
export_order = fields
|
export_order = fields
|
||||||
import_id_fields = ('id',)
|
import_id_fields = ('keyword', 'industry', 'sector') # Use natural keys for import
|
||||||
skip_unchanged = True
|
skip_unchanged = True
|
||||||
|
|
||||||
|
def before_import_row(self, row, **kwargs):
|
||||||
|
"""Clean and validate row data before import"""
|
||||||
|
# Ensure volume is an integer
|
||||||
|
if 'volume' in row:
|
||||||
|
try:
|
||||||
|
row['volume'] = int(row['volume']) if row['volume'] else 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
row['volume'] = 0
|
||||||
|
|
||||||
|
# Ensure difficulty is an integer between 0-100
|
||||||
|
if 'difficulty' in row:
|
||||||
|
try:
|
||||||
|
difficulty = int(row['difficulty']) if row['difficulty'] else 0
|
||||||
|
row['difficulty'] = max(0, min(100, difficulty)) # Clamp to 0-100
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
row['difficulty'] = 0
|
||||||
|
|
||||||
|
# Ensure country is valid
|
||||||
|
if 'country' in row:
|
||||||
|
valid_countries = [code for code, name in SeedKeyword.COUNTRY_CHOICES]
|
||||||
|
if row['country'] not in valid_countries:
|
||||||
|
row['country'] = 'US' # Default to US if invalid
|
||||||
|
|
||||||
|
# Set defaults for optional fields
|
||||||
|
if 'is_active' not in row or row['is_active'] == '':
|
||||||
|
row['is_active'] = True
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SeedKeyword)
|
@admin.register(SeedKeyword)
|
||||||
class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
|
class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
|
||||||
resource_class = SeedKeywordResource
|
resource_class = SeedKeywordResource
|
||||||
"""SeedKeyword admin - Global reference data, no account filtering"""
|
"""SeedKeyword admin - Global reference data, no account filtering"""
|
||||||
list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active', 'created_at']
|
list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active', 'created_at']
|
||||||
list_filter = ['is_active', 'industry', 'sector', 'country']
|
list_filter = [
|
||||||
|
('is_active', ChoicesDropdownFilter),
|
||||||
|
('industry', RelatedDropdownFilter),
|
||||||
|
('sector', RelatedDropdownFilter),
|
||||||
|
('country', ChoicesDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['keyword']
|
search_fields = ['keyword']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
actions = [
|
actions = [
|
||||||
'delete_selected',
|
|
||||||
'bulk_activate',
|
'bulk_activate',
|
||||||
'bulk_deactivate',
|
'bulk_deactivate',
|
||||||
'bulk_update_country',
|
'bulk_update_country',
|
||||||
] # Enable bulk delete
|
]
|
||||||
|
# Delete is handled by AdminDeleteMixin in base Igny8ModelAdmin
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Keyword Info', {
|
('Keyword Info', {
|
||||||
@@ -939,18 +1033,38 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
|
||||||
"""Allow deletion for superusers and developers"""
|
|
||||||
return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
|
|
||||||
|
|
||||||
def bulk_activate(self, request, queryset):
|
def bulk_activate(self, request, queryset):
|
||||||
updated = queryset.update(is_active=True)
|
"""Activate selected keywords"""
|
||||||
self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS)
|
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'
|
bulk_activate.short_description = 'Activate selected keywords'
|
||||||
|
|
||||||
def bulk_deactivate(self, request, queryset):
|
def bulk_deactivate(self, request, queryset):
|
||||||
updated = queryset.update(is_active=False)
|
"""Deactivate selected keywords"""
|
||||||
self.message_user(request, f'{updated} seed keyword(s) deactivated.', messages.SUCCESS)
|
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'
|
bulk_deactivate.short_description = 'Deactivate selected keywords'
|
||||||
|
|
||||||
def bulk_update_country(self, request, queryset):
|
def bulk_update_country(self, request, queryset):
|
||||||
@@ -1005,7 +1119,12 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin):
|
|||||||
"""
|
"""
|
||||||
resource_class = UserResource
|
resource_class = UserResource
|
||||||
list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at']
|
list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at']
|
||||||
list_filter = ['role', 'account', 'is_active', 'is_staff']
|
list_filter = [
|
||||||
|
('role', ChoicesDropdownFilter),
|
||||||
|
('account', RelatedDropdownFilter),
|
||||||
|
('is_active', ChoicesDropdownFilter),
|
||||||
|
('is_staff', ChoicesDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['email', 'username']
|
search_fields = ['email', 'username']
|
||||||
readonly_fields = ['created_at', 'updated_at', 'password_display']
|
readonly_fields = ['created_at', 'updated_at', 'password_display']
|
||||||
|
|
||||||
|
|||||||
@@ -244,14 +244,15 @@ class IntegrationService:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get API key from site
|
# Get API key from Site.wp_api_key (SINGLE source of truth)
|
||||||
|
# API key is stored on Site model for authentication by APIKeyAuthentication
|
||||||
api_key = integration.site.wp_api_key
|
api_key = integration.site.wp_api_key
|
||||||
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': 'API key not configured.',
|
'message': 'API key not configured. Generate an API key in Site Settings.',
|
||||||
'details': {}
|
'details': {'site_id': integration.site.id, 'site_name': integration.site.name}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Initialize health check results
|
# Initialize health check results
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ class SyncMetadataService:
|
|||||||
try:
|
try:
|
||||||
# Get WordPress site URL and API key
|
# Get WordPress site URL and API key
|
||||||
site_url = integration.config_json.get('site_url', '')
|
site_url = integration.config_json.get('site_url', '')
|
||||||
credentials = integration.get_credentials()
|
# API key is stored in Site.wp_api_key (SINGLE source of truth)
|
||||||
api_key = credentials.get('api_key', '')
|
api_key = integration.site.wp_api_key or ''
|
||||||
|
|
||||||
if not site_url:
|
if not site_url:
|
||||||
return {
|
return {
|
||||||
@@ -51,7 +51,7 @@ class SyncMetadataService:
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Missing api_key in integration credentials'
|
'error': 'API key not configured for site. Generate one in Site Settings.'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Call WordPress metadata endpoint
|
# Call WordPress metadata endpoint
|
||||||
|
|||||||
@@ -142,44 +142,67 @@ class Keywords(SoftDeletableModel, SiteSectorBaseModel):
|
|||||||
@property
|
@property
|
||||||
def keyword(self):
|
def keyword(self):
|
||||||
"""Get keyword text from seed_keyword"""
|
"""Get keyword text from seed_keyword"""
|
||||||
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
|
@property
|
||||||
def volume(self):
|
def volume(self):
|
||||||
"""Get volume from override or seed_keyword"""
|
"""Get volume from override or seed_keyword"""
|
||||||
return self.volume_override if self.volume_override is not None else (self.seed_keyword.volume if self.seed_keyword else 0)
|
try:
|
||||||
|
seed_kw = self.seed_keyword
|
||||||
|
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
|
||||||
|
seed_kw = None
|
||||||
|
return self.volume_override if self.volume_override is not None else (seed_kw.volume if seed_kw else 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def difficulty(self):
|
def difficulty(self):
|
||||||
"""Get difficulty from override or seed_keyword"""
|
"""Get difficulty from override or seed_keyword"""
|
||||||
return self.difficulty_override if self.difficulty_override is not None else (self.seed_keyword.difficulty if self.seed_keyword else 0)
|
try:
|
||||||
|
seed_kw = self.seed_keyword
|
||||||
|
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
|
||||||
|
seed_kw = None
|
||||||
|
return self.difficulty_override if self.difficulty_override is not None else (seed_kw.difficulty if seed_kw else 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def country(self):
|
def country(self):
|
||||||
"""Get country from seed_keyword"""
|
"""Get country from seed_keyword"""
|
||||||
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):
|
def save(self, *args, **kwargs):
|
||||||
"""Validate that seed_keyword's industry/sector matches site's industry/sector"""
|
"""Validate that seed_keyword's industry/sector matches site's industry/sector"""
|
||||||
if self.seed_keyword and self.site and self.sector:
|
# Skip validation if seed_keyword is None (during soft delete or orphaned)
|
||||||
|
try:
|
||||||
|
seed_kw = self.seed_keyword
|
||||||
|
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
|
||||||
|
seed_kw = None
|
||||||
|
|
||||||
|
if seed_kw and self.site and self.sector:
|
||||||
# Validate industry match
|
# Validate industry match
|
||||||
if self.site.industry != self.seed_keyword.industry:
|
if self.site.industry != seed_kw.industry:
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"SeedKeyword industry ({self.seed_keyword.industry.name}) must match site industry ({self.site.industry.name})"
|
f"SeedKeyword industry ({seed_kw.industry.name}) must match site industry ({self.site.industry.name})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate sector match (site sector's industry_sector must match seed_keyword's sector)
|
# Validate sector match (site sector's industry_sector must match seed_keyword's sector)
|
||||||
if self.sector.industry_sector != self.seed_keyword.sector:
|
if self.sector.industry_sector != seed_kw.sector:
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"SeedKeyword sector ({self.seed_keyword.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})"
|
f"SeedKeyword sector ({seed_kw.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})"
|
||||||
)
|
)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.keyword
|
try:
|
||||||
|
return self.seed_keyword.keyword if self.seed_keyword else f'Keyword #{self.pk}'
|
||||||
|
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
|
||||||
|
return f'Keyword #{self.pk} (orphaned)'
|
||||||
|
|
||||||
|
|
||||||
class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel):
|
class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel):
|
||||||
|
|||||||
@@ -139,9 +139,13 @@ class PublisherService:
|
|||||||
|
|
||||||
if integration:
|
if integration:
|
||||||
logger.info(f"[PublisherService._publish_to_destination] ✅ Integration found: id={integration.id}")
|
logger.info(f"[PublisherService._publish_to_destination] ✅ Integration found: id={integration.id}")
|
||||||
# Merge config_json and credentials_json
|
# Merge config_json (site_url, etc.)
|
||||||
destination_config.update(integration.config_json or {})
|
destination_config.update(integration.config_json or {})
|
||||||
destination_config.update(integration.get_credentials() or {})
|
|
||||||
|
# API key is stored in Site.wp_api_key (SINGLE source of truth)
|
||||||
|
if integration.site.wp_api_key:
|
||||||
|
destination_config['api_key'] = integration.site.wp_api_key
|
||||||
|
|
||||||
logger.info(f"[PublisherService._publish_to_destination] 🔑 Config merged: has_api_key={bool(destination_config.get('api_key'))}, has_site_url={bool(destination_config.get('site_url'))}")
|
logger.info(f"[PublisherService._publish_to_destination] 🔑 Config merged: has_api_key={bool(destination_config.get('api_key'))}, has_site_url={bool(destination_config.get('site_url'))}")
|
||||||
|
|
||||||
# Ensure site_url is set (from config or from site model)
|
# Ensure site_url is set (from config or from site model)
|
||||||
@@ -342,13 +346,16 @@ class PublisherService:
|
|||||||
destinations = []
|
destinations = []
|
||||||
for integration in integrations:
|
for integration in integrations:
|
||||||
config = integration.config_json.copy()
|
config = integration.config_json.copy()
|
||||||
credentials = integration.get_credentials()
|
|
||||||
|
|
||||||
destination_config = {
|
destination_config = {
|
||||||
'platform': integration.platform,
|
'platform': integration.platform,
|
||||||
**config,
|
**config,
|
||||||
**credentials
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# API key is stored in Site.wp_api_key (SINGLE source of truth)
|
||||||
|
if integration.site.wp_api_key:
|
||||||
|
destination_config['api_key'] = integration.site.wp_api_key
|
||||||
|
|
||||||
destinations.append(destination_config)
|
destinations.append(destination_config)
|
||||||
|
|
||||||
# Also add 'sites' destination if not in platforms filter or if platforms is None
|
# Also add 'sites' destination if not in platforms filter or if platforms is None
|
||||||
|
|||||||
@@ -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.utils.html import format_html
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from unfold.admin import ModelAdmin
|
from unfold.admin import ModelAdmin
|
||||||
|
from unfold.contrib.filters.admin import (
|
||||||
|
RelatedDropdownFilter,
|
||||||
|
ChoicesDropdownFilter,
|
||||||
|
DropdownFilter,
|
||||||
|
RangeDateFilter,
|
||||||
|
)
|
||||||
from simple_history.admin import SimpleHistoryAdmin
|
from simple_history.admin import SimpleHistoryAdmin
|
||||||
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
||||||
from igny8_core.business.billing.models import (
|
from igny8_core.business.billing.models import (
|
||||||
@@ -20,7 +26,6 @@ from igny8_core.business.billing.models import (
|
|||||||
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
||||||
from import_export.admin import ExportMixin, ImportExportMixin
|
from import_export.admin import ExportMixin, ImportExportMixin
|
||||||
from import_export import resources
|
from import_export import resources
|
||||||
from rangefilter.filters import DateRangeFilter
|
|
||||||
|
|
||||||
|
|
||||||
class CreditTransactionResource(resources.ModelResource):
|
class CreditTransactionResource(resources.ModelResource):
|
||||||
@@ -36,7 +41,11 @@ class CreditTransactionResource(resources.ModelResource):
|
|||||||
class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||||
resource_class = CreditTransactionResource
|
resource_class = CreditTransactionResource
|
||||||
list_display = ['id', 'account', 'transaction_type', 'amount', 'balance_after', 'description', 'created_at']
|
list_display = ['id', 'account', 'transaction_type', 'amount', 'balance_after', 'description', 'created_at']
|
||||||
list_filter = ['transaction_type', ('created_at', DateRangeFilter), 'account']
|
list_filter = [
|
||||||
|
('transaction_type', ChoicesDropdownFilter),
|
||||||
|
('created_at', RangeDateFilter),
|
||||||
|
('account', RelatedDropdownFilter),
|
||||||
|
]
|
||||||
search_fields = ['description', 'account__name']
|
search_fields = ['description', 'account__name']
|
||||||
readonly_fields = ['created_at']
|
readonly_fields = ['created_at']
|
||||||
date_hierarchy = 'created_at'
|
date_hierarchy = 'created_at'
|
||||||
@@ -188,7 +197,13 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
'approved_by',
|
'approved_by',
|
||||||
'processed_at',
|
'processed_at',
|
||||||
]
|
]
|
||||||
list_filter = ['status', 'payment_method', 'currency', ('created_at', DateRangeFilter), ('processed_at', DateRangeFilter)]
|
list_filter = [
|
||||||
|
('status', ChoicesDropdownFilter),
|
||||||
|
('payment_method', ChoicesDropdownFilter),
|
||||||
|
('currency', ChoicesDropdownFilter),
|
||||||
|
('created_at', RangeDateFilter),
|
||||||
|
('processed_at', RangeDateFilter),
|
||||||
|
]
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'invoice__invoice_number',
|
'invoice__invoice_number',
|
||||||
'account__name',
|
'account__name',
|
||||||
@@ -654,10 +669,10 @@ class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
list_filter = [
|
list_filter = [
|
||||||
'limit_type',
|
('limit_type', ChoicesDropdownFilter),
|
||||||
('period_start', DateRangeFilter),
|
('period_start', RangeDateFilter),
|
||||||
('period_end', DateRangeFilter),
|
('period_end', RangeDateFilter),
|
||||||
'account',
|
('account', RelatedDropdownFilter),
|
||||||
]
|
]
|
||||||
search_fields = ['account__name']
|
search_fields = ['account__name']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|||||||
@@ -67,25 +67,23 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
api_key = serializers.SerializerMethodField()
|
api_key = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_api_key(self, obj):
|
def get_api_key(self, obj):
|
||||||
"""Return the API key from encrypted credentials"""
|
"""Return the API key from Site.wp_api_key (SINGLE source of truth)"""
|
||||||
credentials = obj.get_credentials()
|
# API key is stored on Site model, not in SiteIntegration credentials
|
||||||
return credentials.get('api_key', '')
|
return obj.site.wp_api_key or ''
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
"""
|
"""
|
||||||
Custom validation for WordPress integrations.
|
Custom validation for WordPress integrations.
|
||||||
API key is the only required authentication method.
|
API key is stored on Site model, not in SiteIntegration.
|
||||||
"""
|
"""
|
||||||
validated_data = super().validate(data)
|
validated_data = super().validate(data)
|
||||||
|
|
||||||
# For WordPress platform, require API key only
|
# For WordPress platform, check API key exists on Site (not in credentials_json)
|
||||||
if validated_data.get('platform') == 'wordpress':
|
if validated_data.get('platform') == 'wordpress':
|
||||||
credentials = validated_data.get('credentials_json', {})
|
site = validated_data.get('site') or getattr(self.instance, 'site', None)
|
||||||
|
if site and not site.wp_api_key:
|
||||||
# API key is required for all WordPress integrations
|
|
||||||
if not credentials.get('api_key'):
|
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'credentials_json': 'API key is required for WordPress integration.'
|
'site': 'Site must have an API key generated before creating WordPress integration.'
|
||||||
})
|
})
|
||||||
|
|
||||||
return validated_data
|
return validated_data
|
||||||
@@ -198,7 +196,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
# Try to find an existing integration for this site+platform
|
# Try to find an existing integration for this site+platform
|
||||||
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
|
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
|
||||||
|
|
||||||
# If not found, create and save the integration to database
|
# If not found, create and save the integration to database (for status tracking, not credentials)
|
||||||
integration_created = False
|
integration_created = False
|
||||||
if not integration:
|
if not integration:
|
||||||
integration = SiteIntegration.objects.create(
|
integration = SiteIntegration.objects.create(
|
||||||
@@ -207,7 +205,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
platform='wordpress',
|
platform='wordpress',
|
||||||
platform_type='cms',
|
platform_type='cms',
|
||||||
config_json={'site_url': site_url} if site_url else {},
|
config_json={'site_url': site_url} if site_url else {},
|
||||||
credentials_json={'api_key': api_key} if api_key else {},
|
credentials_json={}, # API key is stored in Site.wp_api_key, not here
|
||||||
is_active=True,
|
is_active=True,
|
||||||
sync_enabled=True
|
sync_enabled=True
|
||||||
)
|
)
|
||||||
@@ -805,27 +803,38 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
||||||
api_key = f"igny8_site_{site_id}_{timestamp}_{random_suffix}"
|
api_key = f"igny8_site_{site_id}_{timestamp}_{random_suffix}"
|
||||||
|
|
||||||
# Get or create SiteIntegration
|
# SINGLE SOURCE OF TRUTH: Store API key ONLY in Site.wp_api_key
|
||||||
|
# This is where APIKeyAuthentication validates against
|
||||||
|
site.wp_api_key = api_key
|
||||||
|
site.save(update_fields=['wp_api_key'])
|
||||||
|
|
||||||
|
# Get or create SiteIntegration (for integration status/config, NOT credentials)
|
||||||
integration, created = SiteIntegration.objects.get_or_create(
|
integration, created = SiteIntegration.objects.get_or_create(
|
||||||
site=site,
|
site=site,
|
||||||
|
platform='wordpress',
|
||||||
defaults={
|
defaults={
|
||||||
'integration_type': 'wordpress',
|
'account': site.account,
|
||||||
|
'platform': 'wordpress',
|
||||||
|
'platform_type': 'cms',
|
||||||
'is_active': True,
|
'is_active': True,
|
||||||
'credentials_json': {'api_key': api_key},
|
'sync_enabled': True,
|
||||||
|
'credentials_json': {}, # Empty - API key is on Site model
|
||||||
'config_json': {}
|
'config_json': {}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# If integration already exists, update the API key
|
# If integration already exists, just ensure it's active
|
||||||
if not created:
|
if not created:
|
||||||
credentials = integration.get_credentials()
|
integration.is_active = True
|
||||||
credentials['api_key'] = api_key
|
integration.sync_enabled = True
|
||||||
integration.credentials_json = credentials
|
# Clear any old credentials_json API key (migrate to Site.wp_api_key)
|
||||||
|
if integration.credentials_json.get('api_key'):
|
||||||
|
integration.credentials_json = {}
|
||||||
integration.save()
|
integration.save()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Generated new API key for site {site.name} (ID: {site_id}), "
|
f"Generated new API key for site {site.name} (ID: {site_id}), "
|
||||||
f"integration {'created' if created else 'updated'}"
|
f"stored in Site.wp_api_key (single source of truth)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Serialize the integration with the new key
|
# Serialize the integration with the new key
|
||||||
|
|||||||
@@ -122,10 +122,10 @@ def wordpress_status_webhook(request):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify API key matches integration
|
# Verify API key matches Site.wp_api_key (SINGLE source of truth)
|
||||||
stored_api_key = integration.credentials_json.get('api_key')
|
stored_api_key = integration.site.wp_api_key
|
||||||
if not stored_api_key or stored_api_key != api_key:
|
if not stored_api_key or stored_api_key != api_key:
|
||||||
logger.error(f"[wordpress_status_webhook] Invalid API key for integration {integration.id}")
|
logger.error(f"[wordpress_status_webhook] Invalid API key for site {integration.site.id}")
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Invalid API key',
|
error='Invalid API key',
|
||||||
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -293,8 +293,8 @@ def wordpress_metadata_webhook(request):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify API key
|
# Verify API key against Site.wp_api_key (SINGLE source of truth)
|
||||||
stored_api_key = integration.credentials_json.get('api_key')
|
stored_api_key = integration.site.wp_api_key
|
||||||
if not stored_api_key or stored_api_key != api_key:
|
if not stored_api_key or stored_api_key != api_key:
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Invalid API key',
|
error='Invalid API key',
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
|||||||
'bulk_assign_cluster',
|
'bulk_assign_cluster',
|
||||||
'bulk_set_status_active',
|
'bulk_set_status_active',
|
||||||
'bulk_set_status_inactive',
|
'bulk_set_status_inactive',
|
||||||
'bulk_soft_delete',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
@admin.display(description='Keyword')
|
@admin.display(description='Keyword')
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int
|
|||||||
site_domain = base_url.replace('https://', '').replace('http://', '').split('/')[0] if base_url else 'unknown'
|
site_domain = base_url.replace('https://', '').replace('http://', '').split('/')[0] if base_url else 'unknown'
|
||||||
log_prefix = f"[{site_id}-{site_domain}]"
|
log_prefix = f"[{site_id}-{site_domain}]"
|
||||||
|
|
||||||
# Extract API key from credentials
|
# API key is stored in Site.wp_api_key (SINGLE source of truth)
|
||||||
api_key = site_integration.get_credentials().get('api_key', '')
|
api_key = site_integration.site.wp_api_key or ''
|
||||||
|
|
||||||
publish_logger.info(f" ✅ Content loaded:")
|
publish_logger.info(f" ✅ Content loaded:")
|
||||||
publish_logger.info(f" {log_prefix} Title: '{content.title}'")
|
publish_logger.info(f" {log_prefix} Title: '{content.title}'")
|
||||||
@@ -258,7 +258,8 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int
|
|||||||
|
|
||||||
# STEP 8: Send API request to WordPress
|
# STEP 8: Send API request to WordPress
|
||||||
base_url = site_integration.config_json.get('site_url', '') or site_integration.config_json.get('base_url', '')
|
base_url = site_integration.config_json.get('site_url', '') or site_integration.config_json.get('base_url', '')
|
||||||
api_key = site_integration.get_credentials().get('api_key', '')
|
# API key is stored in Site.wp_api_key (SINGLE source of truth)
|
||||||
|
api_key = site_integration.site.wp_api_key or ''
|
||||||
|
|
||||||
if not base_url:
|
if not base_url:
|
||||||
error_msg = "No base_url/site_url configured in integration"
|
error_msg = "No base_url/site_url configured in integration"
|
||||||
@@ -266,7 +267,7 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int
|
|||||||
return {"success": False, "error": error_msg}
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
error_msg = "No API key configured in integration"
|
error_msg = "No API key configured for site. Generate one in Site Settings."
|
||||||
publish_logger.error(f" {log_prefix} ❌ {error_msg}")
|
publish_logger.error(f" {log_prefix} ❌ {error_msg}")
|
||||||
return {"success": False, "error": error_msg}
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
|||||||
@@ -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