bulk actions & some next audits docs

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-20 02:46:00 +00:00
parent c17b22e927
commit ab0d6469d4
16 changed files with 2987 additions and 305 deletions

View File

@@ -17,7 +17,7 @@ from igny8_core.business.billing.models import (
PlanLimitUsage,
)
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
from import_export.admin import ExportMixin
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
from rangefilter.filters import DateRangeFilter
@@ -50,8 +50,18 @@ class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
get_account_display.short_description = 'Account'
class CreditUsageLogResource(resources.ModelResource):
"""Resource class for exporting Credit Usage Logs"""
class Meta:
model = CreditUsageLog
fields = ('id', 'account__name', 'operation_type', 'credits_used', 'cost_usd',
'model_used', 'created_at')
export_order = fields
@admin.register(CreditUsageLog)
class CreditUsageLogAdmin(AccountAdminMixin, Igny8ModelAdmin):
class CreditUsageLogAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = CreditUsageLogResource
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_used', 'created_at']
list_filter = ['operation_type', 'created_at', 'account', 'model_used']
search_fields = ['account__name', 'model_used']
@@ -68,8 +78,18 @@ class CreditUsageLogAdmin(AccountAdminMixin, Igny8ModelAdmin):
get_account_display.short_description = 'Account'
class InvoiceResource(resources.ModelResource):
"""Resource class for exporting Invoices"""
class Meta:
model = Invoice
fields = ('id', 'invoice_number', 'account__name', 'status', 'total', 'currency',
'invoice_date', 'due_date', 'created_at', 'updated_at')
export_order = fields
@admin.register(Invoice)
class InvoiceAdmin(AccountAdminMixin, Igny8ModelAdmin):
class InvoiceAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = InvoiceResource
list_display = [
'invoice_number',
'account',
@@ -82,6 +102,56 @@ class InvoiceAdmin(AccountAdminMixin, Igny8ModelAdmin):
list_filter = ['status', 'currency', 'invoice_date', 'account']
search_fields = ['invoice_number', 'account__name']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_set_status_draft',
'bulk_set_status_sent',
'bulk_set_status_paid',
'bulk_set_status_overdue',
'bulk_set_status_cancelled',
'bulk_send_reminders',
]
def bulk_set_status_draft(self, request, queryset):
"""Set selected invoices to draft status"""
updated = queryset.update(status='draft')
self.message_user(request, f'{updated} invoice(s) set to draft.', messages.SUCCESS)
bulk_set_status_draft.short_description = 'Set status to Draft'
def bulk_set_status_sent(self, request, queryset):
"""Set selected invoices to sent status"""
updated = queryset.update(status='sent')
self.message_user(request, f'{updated} invoice(s) set to sent.', messages.SUCCESS)
bulk_set_status_sent.short_description = 'Set status to Sent'
def bulk_set_status_paid(self, request, queryset):
"""Set selected invoices to paid status"""
updated = queryset.update(status='paid')
self.message_user(request, f'{updated} invoice(s) set to paid.', messages.SUCCESS)
bulk_set_status_paid.short_description = 'Set status to Paid'
def bulk_set_status_overdue(self, request, queryset):
"""Set selected invoices to overdue status"""
updated = queryset.update(status='overdue')
self.message_user(request, f'{updated} invoice(s) set to overdue.', messages.SUCCESS)
bulk_set_status_overdue.short_description = 'Set status to Overdue'
def bulk_set_status_cancelled(self, request, queryset):
"""Set selected invoices to cancelled status"""
updated = queryset.update(status='cancelled')
self.message_user(request, f'{updated} invoice(s) set to cancelled.', messages.SUCCESS)
bulk_set_status_cancelled.short_description = 'Set status to Cancelled'
def bulk_send_reminders(self, request, queryset):
"""Send reminder emails for selected invoices"""
# TODO: Implement email sending logic when email service is configured
unpaid = queryset.filter(status__in=['sent', 'overdue'])
count = unpaid.count()
self.message_user(
request,
f'{count} invoice reminder(s) queued for sending. (Email integration required)',
messages.INFO
)
bulk_send_reminders.short_description = 'Send payment reminders'
class PaymentResource(resources.ModelResource):
@@ -128,7 +198,7 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
'manual_notes'
]
readonly_fields = ['created_at', 'updated_at', 'approved_at', 'processed_at', 'failed_at', 'refunded_at']
actions = ['approve_payments', 'reject_payments']
actions = ['approve_payments', 'reject_payments', 'bulk_refund']
fieldsets = (
('Payment Info', {
@@ -374,14 +444,71 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
self.message_user(request, f'Rejected {count} payment(s)')
reject_payments.short_description = 'Reject selected manual payments'
def bulk_refund(self, request, queryset):
"""Refund selected payments"""
from django.utils import timezone
# Only refund succeeded payments
succeeded_payments = queryset.filter(status='succeeded')
count = 0
for payment in succeeded_payments:
# Mark as refunded
payment.status = 'refunded'
payment.refunded_at = timezone.now()
payment.admin_notes = f'{payment.admin_notes or ""}\nBulk refunded by {request.user.email} on {timezone.now()}'
payment.save()
# TODO: Process actual refund through payment gateway (Stripe/PayPal)
# For now, just marking as refunded in database
count += 1
self.message_user(
request,
f'{count} payment(s) marked as refunded. Note: Actual gateway refunds need to be processed separately.',
messages.WARNING
)
bulk_refund.short_description = 'Refund selected payments'
class CreditPackageResource(resources.ModelResource):
"""Resource class for importing/exporting Credit Packages"""
class Meta:
model = CreditPackage
fields = ('id', 'name', 'slug', 'credits', 'price', 'discount_percentage',
'is_active', 'is_featured', 'sort_order', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(CreditPackage)
class CreditPackageAdmin(Igny8ModelAdmin):
class CreditPackageAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = CreditPackageResource
list_display = ['name', 'slug', 'credits', 'price', 'discount_percentage', 'is_active', 'is_featured', 'sort_order']
list_filter = ['is_active', 'is_featured']
search_fields = ['name', 'slug']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_activate',
'bulk_deactivate',
]
actions = [
'bulk_activate',
'bulk_deactivate',
]
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} credit package(s) activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected packages'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} credit package(s) deactivated.', messages.SUCCESS)
bulk_deactivate.short_description = 'Deactivate selected packages'
@admin.register(PaymentMethodConfig)
@@ -499,8 +626,18 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
super().save_model(request, obj, form, change)
class PlanLimitUsageResource(resources.ModelResource):
"""Resource class for exporting Plan Limit Usage"""
class Meta:
model = PlanLimitUsage
fields = ('id', 'account__name', 'limit_type', 'amount_used',
'period_start', 'period_end', 'created_at')
export_order = fields
@admin.register(PlanLimitUsage)
class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = PlanLimitUsageResource
"""Admin for tracking plan limit usage across billing periods"""
list_display = [
'account',
@@ -518,6 +655,10 @@ class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
search_fields = ['account__name']
readonly_fields = ['created_at', 'updated_at']
date_hierarchy = 'period_start'
actions = [
'bulk_reset_usage',
'bulk_delete_old_records',
]
fieldsets = (
('Usage Info', {
@@ -540,6 +681,24 @@ class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
"""Display billing period range"""
return f"{obj.period_start} to {obj.period_end}"
period_display.short_description = 'Billing Period'
def bulk_reset_usage(self, request, queryset):
"""Reset usage counters to zero"""
updated = queryset.update(amount_used=0)
self.message_user(request, f'{updated} usage counter(s) reset to zero.', messages.SUCCESS)
bulk_reset_usage.short_description = 'Reset usage counters'
def bulk_delete_old_records(self, request, queryset):
"""Delete usage records older than 1 year"""
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=365)
old_records = queryset.filter(period_end__lt=cutoff_date)
count = old_records.count()
old_records.delete()
self.message_user(request, f'{count} old usage record(s) deleted (older than 1 year).', messages.SUCCESS)
bulk_delete_old_records.short_description = 'Delete old records (>1 year)'
@admin.register(BillingConfiguration)

View File

@@ -9,21 +9,35 @@ from unfold.contrib.filters.admin import (
)
from igny8_core.admin.base import SiteSectorAdminMixin, Igny8ModelAdmin
from .models import Keywords, Clusters, ContentIdeas
from import_export.admin import ExportMixin
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
class KeywordsResource(resources.ModelResource):
"""Resource class for exporting Keywords"""
"""Resource class for importing/exporting Keywords"""
class Meta:
model = Keywords
fields = ('id', 'keyword', 'seed_keyword__keyword', 'site__name', 'sector__name',
'cluster__name', 'volume', 'difficulty', 'country', 'status', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
class ClustersResource(resources.ModelResource):
"""Resource class for importing/exporting Clusters"""
class Meta:
model = Clusters
fields = ('id', 'name', 'site__name', 'sector__name', 'keywords_count', 'volume',
'status', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(Clusters)
class ClustersAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
class ClustersAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ClustersResource
list_display = ['name', 'site', 'sector', 'keywords_count', 'volume', 'status', 'created_at']
list_filter = [
('status', ChoicesDropdownFilter),
@@ -35,6 +49,11 @@ class ClustersAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
search_fields = ['name']
ordering = ['name']
autocomplete_fields = ['site', 'sector']
actions = [
'bulk_set_status_active',
'bulk_set_status_inactive',
'bulk_soft_delete',
]
def get_site_display(self, obj):
"""Safely get site name"""
@@ -50,10 +69,31 @@ class ClustersAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
return obj.sector.name if obj.sector else '-'
except:
return '-'
def bulk_set_status_active(self, request, queryset):
"""Set selected clusters to active status"""
updated = queryset.update(status='active')
self.message_user(request, f'{updated} cluster(s) set to active.', messages.SUCCESS)
bulk_set_status_active.short_description = 'Set status to Active'
def bulk_set_status_inactive(self, request, queryset):
"""Set selected clusters to inactive status"""
updated = queryset.update(status='inactive')
self.message_user(request, f'{updated} cluster(s) set to inactive.', messages.SUCCESS)
bulk_set_status_inactive.short_description = 'Set status to Inactive'
def bulk_soft_delete(self, request, queryset):
"""Soft delete selected clusters"""
count = 0
for cluster in queryset:
cluster.delete() # Soft delete via SoftDeletableModel
count += 1
self.message_user(request, f'{count} cluster(s) soft deleted.', messages.SUCCESS)
bulk_soft_delete.short_description = 'Soft delete selected clusters'
@admin.register(Keywords)
class KeywordsAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = KeywordsResource
list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'country', 'status', 'created_at']
list_editable = ['status'] # Enable inline editing for status
@@ -74,6 +114,7 @@ class KeywordsAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
'bulk_assign_cluster',
'bulk_set_status_active',
'bulk_set_status_inactive',
'bulk_soft_delete',
]
def get_site_display(self, obj):
@@ -150,10 +191,32 @@ class KeywordsAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
updated = queryset.update(status='inactive')
self.message_user(request, f'{updated} keyword(s) set to inactive.', messages.SUCCESS)
bulk_set_status_inactive.short_description = 'Set status to Inactive'
def bulk_soft_delete(self, request, queryset):
"""Soft delete selected keywords"""
count = 0
for keyword in queryset:
keyword.delete() # Soft delete via SoftDeletableModel
count += 1
self.message_user(request, f'{count} keyword(s) soft deleted.', messages.SUCCESS)
bulk_soft_delete.short_description = 'Soft delete selected keywords'
class ContentIdeasResource(resources.ModelResource):
"""Resource class for importing/exporting Content Ideas"""
class Meta:
model = ContentIdeas
fields = ('id', 'idea_title', 'description', 'site__name', 'sector__name',
'content_type', 'content_structure', 'status', 'keyword_cluster__name',
'target_keywords', 'estimated_word_count', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(ContentIdeas)
class ContentIdeasAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
class ContentIdeasAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentIdeasResource
list_display = ['idea_title', 'site', 'sector', 'description_preview', 'content_type', 'content_structure', 'status', 'keyword_cluster', 'estimated_word_count', 'created_at']
list_filter = [
('status', ChoicesDropdownFilter),
@@ -168,6 +231,15 @@ class ContentIdeasAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
search_fields = ['idea_title', 'target_keywords', 'description']
ordering = ['-created_at']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_set_status_draft',
'bulk_set_status_approved',
'bulk_set_status_rejected',
'bulk_set_status_completed',
'bulk_assign_cluster',
'bulk_update_content_type',
'bulk_soft_delete',
]
fieldsets = (
('Basic Info', {
@@ -218,4 +290,109 @@ class ContentIdeasAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
except:
return '-'
get_keyword_cluster_display.short_description = 'Cluster'
def bulk_set_status_draft(self, request, queryset):
"""Set selected content ideas to draft status"""
updated = queryset.update(status='draft')
self.message_user(request, f'{updated} content idea(s) set to draft.', messages.SUCCESS)
bulk_set_status_draft.short_description = 'Set status to Draft'
def bulk_set_status_approved(self, request, queryset):
"""Set selected content ideas to approved status"""
updated = queryset.update(status='approved')
self.message_user(request, f'{updated} content idea(s) set to approved.', messages.SUCCESS)
bulk_set_status_approved.short_description = 'Set status to Approved'
def bulk_set_status_rejected(self, request, queryset):
"""Set selected content ideas to rejected status"""
updated = queryset.update(status='rejected')
self.message_user(request, f'{updated} content idea(s) set to rejected.', messages.SUCCESS)
bulk_set_status_rejected.short_description = 'Set status to Rejected'
def bulk_set_status_completed(self, request, queryset):
"""Set selected content ideas to completed status"""
updated = queryset.update(status='completed')
self.message_user(request, f'{updated} content idea(s) set to completed.', messages.SUCCESS)
bulk_set_status_completed.short_description = 'Set status to Completed'
def bulk_assign_cluster(self, request, queryset):
"""Assign selected content ideas to a cluster"""
from django import forms
if 'apply' in request.POST:
cluster_id = request.POST.get('cluster')
if cluster_id:
cluster = Clusters.objects.get(pk=cluster_id)
updated = queryset.update(keyword_cluster=cluster)
self.message_user(request, f'{updated} content idea(s) assigned to cluster: {cluster.name}', messages.SUCCESS)
return
first_idea = queryset.first()
if first_idea:
clusters = Clusters.objects.filter(site=first_idea.site, sector=first_idea.sector)
else:
clusters = Clusters.objects.all()
class ClusterForm(forms.Form):
cluster = forms.ModelChoiceField(
queryset=clusters,
label="Select Cluster",
help_text=f"Assign {queryset.count()} selected content idea(s) to:"
)
if clusters.exists():
from django.shortcuts import render
return render(request, 'admin/bulk_action_form.html', {
'title': 'Assign Content Ideas to Cluster',
'queryset': queryset,
'form': ClusterForm(),
'action': 'bulk_assign_cluster',
})
else:
self.message_user(request, 'No clusters available for the selected content ideas.', messages.WARNING)
bulk_assign_cluster.short_description = 'Assign to Cluster'
def bulk_update_content_type(self, request, queryset):
"""Update content type for selected content ideas"""
from django import forms
if 'apply' in request.POST:
content_type = request.POST.get('content_type')
if content_type:
updated = queryset.update(content_type=content_type)
self.message_user(request, f'{updated} content idea(s) updated to content type: {content_type}', messages.SUCCESS)
return
CONTENT_TYPE_CHOICES = [
('blog_post', 'Blog Post'),
('article', 'Article'),
('product', 'Product'),
('service', 'Service'),
('page', 'Page'),
('landing_page', 'Landing Page'),
]
class ContentTypeForm(forms.Form):
content_type = forms.ChoiceField(
choices=CONTENT_TYPE_CHOICES,
label="Select Content Type",
help_text=f"Update content type for {queryset.count()} selected content idea(s)"
)
from django.shortcuts import render
return render(request, 'admin/bulk_action_form.html', {
'title': 'Update Content Type',
'queryset': queryset,
'form': ContentTypeForm(),
'action': 'bulk_update_content_type',
})
bulk_update_content_type.short_description = 'Update content type'
def bulk_soft_delete(self, request, queryset):
"""Soft delete selected content ideas"""
count = 0
for idea in queryset:
idea.delete() # Soft delete via SoftDeletableModel
count += 1
self.message_user(request, f'{count} content idea(s) soft deleted.', messages.SUCCESS)
bulk_soft_delete.short_description = 'Soft delete selected content ideas'

View File

@@ -6,6 +6,21 @@ from unfold.admin import ModelAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from .models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
from django.contrib import messages
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
class AIPromptResource(resources.ModelResource):
"""Resource class for importing/exporting AI Prompts"""
class Meta:
model = AIPrompt
fields = ('id', 'account__name', 'prompt_type', 'prompt_value', 'is_active', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
# Import settings admin
from .settings_admin import (
SystemSettingsAdmin, AccountSettingsAdmin, UserSettingsAdmin,
@@ -35,11 +50,17 @@ except ImportError:
@admin.register(AIPrompt)
class AIPromptAdmin(AccountAdminMixin, Igny8ModelAdmin):
class AIPromptAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = AIPromptResource
list_display = ['id', 'prompt_type', 'account', 'is_active', 'updated_at']
list_filter = ['prompt_type', 'is_active', 'account']
search_fields = ['prompt_type']
readonly_fields = ['created_at', 'updated_at', 'default_prompt']
actions = [
'bulk_activate',
'bulk_deactivate',
'bulk_reset_to_default',
]
fieldsets = (
('Basic Info', {
@@ -61,14 +82,48 @@ class AIPromptAdmin(AccountAdminMixin, Igny8ModelAdmin):
except:
return '-'
get_account_display.short_description = 'Account'
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} AI prompt(s) activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected prompts'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} AI prompt(s) deactivated.', messages.SUCCESS)
bulk_deactivate.short_description = 'Deactivate selected prompts'
def bulk_reset_to_default(self, request, queryset):
count = 0
for prompt in queryset:
if prompt.default_prompt:
prompt.prompt_value = prompt.default_prompt
prompt.save()
count += 1
self.message_user(request, f'{count} AI prompt(s) reset to default values.', messages.SUCCESS)
bulk_reset_to_default.short_description = 'Reset to default values'
class IntegrationSettingsResource(resources.ModelResource):
"""Resource class for exporting Integration Settings (config masked)"""
class Meta:
model = IntegrationSettings
fields = ('id', 'account__name', 'integration_type', 'is_active', 'created_at')
export_order = fields
@admin.register(IntegrationSettings)
class IntegrationSettingsAdmin(AccountAdminMixin, Igny8ModelAdmin):
class IntegrationSettingsAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = IntegrationSettingsResource
list_display = ['id', 'integration_type', 'account', 'is_active', 'updated_at']
list_filter = ['integration_type', 'is_active', 'account']
search_fields = ['integration_type']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_activate',
'bulk_deactivate',
'bulk_test_connection',
]
fieldsets = (
('Basic Info', {
@@ -97,14 +152,50 @@ class IntegrationSettingsAdmin(AccountAdminMixin, Igny8ModelAdmin):
except:
return '-'
get_account_display.short_description = 'Account'
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} integration setting(s) activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected integrations'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} integration setting(s) deactivated.', messages.SUCCESS)
bulk_deactivate.short_description = 'Deactivate selected integrations'
def bulk_test_connection(self, request, queryset):
"""Test connection for selected integration settings"""
count = queryset.filter(is_active=True).count()
self.message_user(
request,
f'{count} integration(s) queued for connection test. (Test logic to be implemented)',
messages.INFO
)
bulk_test_connection.short_description = 'Test connections'
class AuthorProfileResource(resources.ModelResource):
"""Resource class for importing/exporting Author Profiles"""
class Meta:
model = AuthorProfile
fields = ('id', 'name', 'account__name', 'tone', 'language', 'is_active', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(AuthorProfile)
class AuthorProfileAdmin(AccountAdminMixin, Igny8ModelAdmin):
class AuthorProfileAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = AuthorProfileResource
list_display = ['name', 'account', 'tone', 'language', 'is_active', 'created_at']
list_filter = ['is_active', 'tone', 'language', 'account']
search_fields = ['name', 'description', 'tone']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_activate',
'bulk_deactivate',
'bulk_clone',
]
fieldsets = (
('Basic Info', {
@@ -126,14 +217,52 @@ class AuthorProfileAdmin(AccountAdminMixin, Igny8ModelAdmin):
except:
return '-'
get_account_display.short_description = 'Account'
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} author profile(s) activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected profiles'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} author profile(s) deactivated.', messages.SUCCESS)
bulk_deactivate.short_description = 'Deactivate selected profiles'
def bulk_clone(self, request, queryset):
count = 0
for profile in queryset:
profile_copy = AuthorProfile.objects.get(pk=profile.pk)
profile_copy.pk = None
profile_copy.name = f"{profile.name} (Copy)"
profile_copy.is_active = False
profile_copy.save()
count += 1
self.message_user(request, f'{count} author profile(s) cloned.', messages.SUCCESS)
bulk_clone.short_description = 'Clone selected profiles'
class StrategyResource(resources.ModelResource):
"""Resource class for importing/exporting Strategies"""
class Meta:
model = Strategy
fields = ('id', 'name', 'account__name', 'sector__name', 'is_active', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(Strategy)
class StrategyAdmin(AccountAdminMixin, Igny8ModelAdmin):
class StrategyAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = StrategyResource
list_display = ['name', 'account', 'sector', 'is_active', 'created_at']
list_filter = ['is_active', 'account']
search_fields = ['name', 'description']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_activate',
'bulk_deactivate',
'bulk_clone',
]
fieldsets = (
('Basic Info', {
@@ -162,4 +291,25 @@ class StrategyAdmin(AccountAdminMixin, Igny8ModelAdmin):
return obj.sector.name if obj.sector else 'Global'
except:
return 'Global'
get_sector_display.short_description = 'Sector'
get_sector_display.short_description = 'Sector'
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} strategy/strategies activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected strategies'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} strategy/strategies deactivated.', messages.SUCCESS)
bulk_deactivate.short_description = 'Deactivate selected strategies'
def bulk_clone(self, request, queryset):
count = 0
for strategy in queryset:
strategy_copy = Strategy.objects.get(pk=strategy.pk)
strategy_copy.pk = None
strategy_copy.name = f"{strategy.name} (Copy)"
strategy_copy.is_active = False
strategy_copy.save()
count += 1
self.message_user(request, f'{count} strategy/strategies cloned.', messages.SUCCESS)
bulk_clone.short_description = 'Clone selected strategies'

View File

@@ -10,7 +10,7 @@ from unfold.contrib.filters.admin import (
from igny8_core.admin.base import SiteSectorAdminMixin, Igny8ModelAdmin
from .models import Tasks, Images, Content
from igny8_core.business.content.models import ContentTaxonomy, ContentAttribute, ContentTaxonomyRelation, ContentClusterMap
from import_export.admin import ExportMixin
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
@@ -24,16 +24,18 @@ class ContentTaxonomyInline(TabularInline):
class TaskResource(resources.ModelResource):
"""Resource class for exporting Tasks"""
"""Resource class for importing/exporting Tasks"""
class Meta:
model = Tasks
fields = ('id', 'title', 'description', 'status', 'content_type', 'content_structure',
'site__name', 'sector__name', 'cluster__name', 'created_at', 'updated_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(Tasks)
class TasksAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
class TasksAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = TaskResource
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'status', 'cluster', 'created_at']
list_editable = ['status'] # Enable inline editing for status
@@ -55,6 +57,8 @@ class TasksAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
'bulk_set_status_in_progress',
'bulk_set_status_completed',
'bulk_assign_cluster',
'bulk_soft_delete',
'bulk_update_content_type',
]
fieldsets = (
@@ -132,6 +136,52 @@ class TasksAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
self.message_user(request, 'No clusters available for the selected tasks.', messages.WARNING)
bulk_assign_cluster.short_description = 'Assign to Cluster'
def bulk_soft_delete(self, request, queryset):
"""Soft delete selected tasks"""
count = 0
for task in queryset:
task.delete() # Soft delete via SoftDeletableModel
count += 1
self.message_user(request, f'{count} task(s) soft deleted.', messages.SUCCESS)
bulk_soft_delete.short_description = 'Soft delete selected tasks'
def bulk_update_content_type(self, request, queryset):
"""Update content type for selected tasks"""
from django import forms
if 'apply' in request.POST:
content_type = request.POST.get('content_type')
if content_type:
updated = queryset.update(content_type=content_type)
self.message_user(request, f'{updated} task(s) updated to content type: {content_type}', messages.SUCCESS)
return
# Get content type choices from model
CONTENT_TYPE_CHOICES = [
('blog_post', 'Blog Post'),
('article', 'Article'),
('product', 'Product'),
('service', 'Service'),
('page', 'Page'),
('landing_page', 'Landing Page'),
]
class ContentTypeForm(forms.Form):
content_type = forms.ChoiceField(
choices=CONTENT_TYPE_CHOICES,
label="Select Content Type",
help_text=f"Update content type for {queryset.count()} selected task(s)"
)
from django.shortcuts import render
return render(request, 'admin/bulk_action_form.html', {
'title': 'Update Content Type',
'queryset': queryset,
'form': ContentTypeForm(),
'action': 'bulk_update_content_type',
})
bulk_update_content_type.short_description = 'Update content type'
def get_site_display(self, obj):
"""Safely get site name"""
try:
@@ -156,12 +206,29 @@ class TasksAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
get_cluster_display.short_description = 'Cluster'
class ImagesResource(resources.ModelResource):
"""Resource class for importing/exporting Images"""
class Meta:
model = Images
fields = ('id', 'content__title', 'site__name', 'sector__name', 'image_type', 'status', 'position', 'created_at')
export_order = fields
@admin.register(Images)
class ImagesAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
class ImagesAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ImagesResource
list_display = ['get_content_title', 'site', 'sector', 'image_type', 'status', 'position', 'created_at']
list_filter = ['image_type', 'status', 'site', 'sector']
search_fields = ['content__title']
ordering = ['-id'] # Sort by ID descending (newest first)
actions = [
'bulk_set_status_published',
'bulk_set_status_draft',
'bulk_set_type_featured',
'bulk_set_type_inline',
'bulk_set_type_thumbnail',
'bulk_soft_delete',
]
def get_content_title(self, obj):
"""Get content title, fallback to task title if no content"""
@@ -186,20 +253,62 @@ class ImagesAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
return obj.sector.name if obj.sector else '-'
except:
return '-'
def bulk_set_status_published(self, request, queryset):
"""Set selected images to published status"""
updated = queryset.update(status='published')
self.message_user(request, f'{updated} image(s) set to published.', messages.SUCCESS)
bulk_set_status_published.short_description = 'Set status to Published'
def bulk_set_status_draft(self, request, queryset):
"""Set selected images to draft status"""
updated = queryset.update(status='draft')
self.message_user(request, f'{updated} image(s) set to draft.', messages.SUCCESS)
bulk_set_status_draft.short_description = 'Set status to Draft'
def bulk_set_type_featured(self, request, queryset):
"""Set selected images to featured type"""
updated = queryset.update(image_type='featured')
self.message_user(request, f'{updated} image(s) set to featured.', messages.SUCCESS)
bulk_set_type_featured.short_description = 'Set type to Featured'
def bulk_set_type_inline(self, request, queryset):
"""Set selected images to inline type"""
updated = queryset.update(image_type='inline')
self.message_user(request, f'{updated} image(s) set to inline.', messages.SUCCESS)
bulk_set_type_inline.short_description = 'Set type to Inline'
def bulk_set_type_thumbnail(self, request, queryset):
"""Set selected images to thumbnail type"""
updated = queryset.update(image_type='thumbnail')
self.message_user(request, f'{updated} image(s) set to thumbnail.', messages.SUCCESS)
bulk_set_type_thumbnail.short_description = 'Set type to Thumbnail'
def bulk_soft_delete(self, request, queryset):
"""Soft delete selected images"""
count = 0
for image in queryset:
image.delete() # Soft delete via SoftDeletableModel
count += 1
self.message_user(request, f'{count} image(s) soft deleted.', messages.SUCCESS)
bulk_soft_delete.short_description = 'Soft delete selected images'
class ContentResource(resources.ModelResource):
"""Resource class for exporting Content"""
"""Resource class for importing/exporting Content"""
class Meta:
model = Content
fields = ('id', 'title', 'content_type', 'content_structure', 'status', 'source',
'site__name', 'sector__name', 'cluster__name', 'word_count',
'meta_title', 'meta_description', 'primary_keyword', 'external_url', 'created_at')
'meta_title', 'meta_description', 'primary_keyword', 'secondary_keywords',
'content_html', 'external_url', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(Content)
class ContentAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentResource
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'word_count', 'get_taxonomy_count', 'created_at']
list_filter = [
@@ -222,6 +331,9 @@ class ContentAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
'bulk_set_status_published',
'bulk_set_status_draft',
'bulk_add_taxonomy',
'bulk_soft_delete',
'bulk_publish_to_wordpress',
'bulk_unpublish_from_wordpress',
]
fieldsets = (
@@ -335,6 +447,57 @@ class ContentAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
self.message_user(request, 'No taxonomies available for the selected content.', messages.WARNING)
bulk_add_taxonomy.short_description = 'Add Taxonomy Terms'
def bulk_soft_delete(self, request, queryset):
"""Soft delete selected content"""
count = 0
for content in queryset:
content.delete() # Soft delete via SoftDeletableModel
count += 1
self.message_user(request, f'{count} content item(s) soft deleted.', messages.SUCCESS)
bulk_soft_delete.short_description = 'Soft delete selected content'
def bulk_publish_to_wordpress(self, request, queryset):
"""Publish selected content to WordPress"""
from igny8_core.business.publishing.models import PublishingRecord
count = 0
for content in queryset:
if content.site:
# Create publishing record for WordPress
PublishingRecord.objects.get_or_create(
content=content,
site=content.site,
sector=content.sector,
account=content.account,
destination='wordpress',
defaults={
'status': 'pending',
'metadata': {}
}
)
count += 1
self.message_user(request, f'{count} content item(s) queued for WordPress publishing.', messages.SUCCESS)
bulk_publish_to_wordpress.short_description = 'Publish to WordPress'
def bulk_unpublish_from_wordpress(self, request, queryset):
"""Unpublish/remove selected content from WordPress"""
from igny8_core.business.publishing.models import PublishingRecord
count = 0
for content in queryset:
# Update existing publishing records to mark for removal
records = PublishingRecord.objects.filter(
content=content,
destination='wordpress',
status__in=['published', 'pending', 'publishing']
)
records.update(status='failed', error_message='Unpublish requested from admin')
count += records.count()
self.message_user(request, f'{count} publishing record(s) marked for unpublish.', messages.SUCCESS)
bulk_unpublish_from_wordpress.short_description = 'Unpublish from WordPress'
def get_site_display(self, obj):
"""Safely get site name"""
try:
@@ -351,13 +514,29 @@ class ContentAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
return '-'
class ContentTaxonomyResource(resources.ModelResource):
"""Resource class for importing/exporting Content Taxonomies"""
class Meta:
model = ContentTaxonomy
fields = ('id', 'name', 'slug', 'taxonomy_type', 'description', 'site__name', 'sector__name',
'count', 'external_id', 'external_taxonomy', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(ContentTaxonomy)
class ContentTaxonomyAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
class ContentTaxonomyAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentTaxonomyResource
list_display = ['name', 'taxonomy_type', 'slug', 'count', 'external_id', 'external_taxonomy', 'site', 'sector']
list_filter = ['taxonomy_type', 'site', 'sector']
search_fields = ['name', 'slug', 'external_taxonomy']
ordering = ['taxonomy_type', 'name']
readonly_fields = ['count', 'created_at', 'updated_at']
actions = [
'bulk_soft_delete',
'bulk_merge_taxonomies',
]
fieldsets = (
('Basic Info', {
@@ -380,14 +559,77 @@ class ContentTaxonomyAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('site', 'sector')
def bulk_soft_delete(self, request, queryset):
"""Delete selected taxonomies"""
count = queryset.count()
queryset.delete()
self.message_user(request, f'{count} taxonomy/taxonomies deleted.', messages.SUCCESS)
bulk_soft_delete.short_description = 'Delete selected taxonomies'
def bulk_merge_taxonomies(self, request, queryset):
"""Merge selected taxonomies into one"""
from django import forms
from igny8_core.business.content.models import ContentTaxonomyRelation
if 'apply' in request.POST:
target_id = request.POST.get('target_taxonomy')
if target_id:
target = ContentTaxonomy.objects.get(pk=target_id)
merged_count = 0
for taxonomy in queryset.exclude(pk=target.pk):
# Move all relations to target
ContentTaxonomyRelation.objects.filter(taxonomy=taxonomy).update(taxonomy=target)
taxonomy.delete()
merged_count += 1
# Update target count
target.count = ContentTaxonomyRelation.objects.filter(taxonomy=target).count()
target.save()
self.message_user(request, f'{merged_count} taxonomies merged into: {target.name}', messages.SUCCESS)
return
class MergeForm(forms.Form):
target_taxonomy = forms.ModelChoiceField(
queryset=queryset,
label="Merge into",
help_text="Select the taxonomy to keep (others will be merged into this one)"
)
from django.shortcuts import render
return render(request, 'admin/bulk_action_form.html', {
'title': 'Merge Taxonomies',
'queryset': queryset,
'form': MergeForm(),
'action': 'bulk_merge_taxonomies',
})
bulk_merge_taxonomies.short_description = 'Merge selected taxonomies'
class ContentAttributeResource(resources.ModelResource):
"""Resource class for importing/exporting Content Attributes"""
class Meta:
model = ContentAttribute
fields = ('id', 'name', 'value', 'attribute_type', 'content__title', 'cluster__name',
'external_id', 'source', 'site__name', 'sector__name', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(ContentAttribute)
class ContentAttributeAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
class ContentAttributeAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentAttributeResource
list_display = ['name', 'value', 'attribute_type', 'content', 'cluster', 'external_id', 'source', 'site', 'sector']
list_filter = ['attribute_type', 'source', 'site', 'sector']
search_fields = ['name', 'value', 'external_attribute_name', 'content__title']
ordering = ['attribute_type', 'name']
actions = [
'bulk_soft_delete',
'bulk_update_attribute_type',
]
fieldsets = (
('Basic Info', {
@@ -405,19 +647,222 @@ class ContentAttributeAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('content', 'cluster', 'site', 'sector')
def bulk_soft_delete(self, request, queryset):
"""Delete selected attributes"""
count = queryset.count()
queryset.delete()
self.message_user(request, f'{count} attribute(s) deleted.', messages.SUCCESS)
bulk_soft_delete.short_description = 'Delete selected attributes'
def bulk_update_attribute_type(self, request, queryset):
"""Update attribute type for selected attributes"""
from django import forms
if 'apply' in request.POST:
attr_type = request.POST.get('attribute_type')
if attr_type:
updated = queryset.update(attribute_type=attr_type)
self.message_user(request, f'{updated} attribute(s) updated to type: {attr_type}', messages.SUCCESS)
return
ATTR_TYPE_CHOICES = [
('product', 'Product Attribute'),
('service', 'Service Attribute'),
('semantic', 'Semantic Attribute'),
('technical', 'Technical Attribute'),
]
class AttributeTypeForm(forms.Form):
attribute_type = forms.ChoiceField(
choices=ATTR_TYPE_CHOICES,
label="Select Attribute Type",
help_text=f"Update attribute type for {queryset.count()} selected attribute(s)"
)
from django.shortcuts import render
return render(request, 'admin/bulk_action_form.html', {
'title': 'Update Attribute Type',
'queryset': queryset,
'form': AttributeTypeForm(),
'action': 'bulk_update_attribute_type',
})
bulk_update_attribute_type.short_description = 'Update attribute type'
class ContentTaxonomyRelationResource(resources.ModelResource):
"""Resource class for exporting Content Taxonomy Relations"""
class Meta:
model = ContentTaxonomyRelation
fields = ('id', 'content__title', 'taxonomy__name', 'taxonomy__taxonomy_type', 'created_at')
export_order = fields
@admin.register(ContentTaxonomyRelation)
class ContentTaxonomyRelationAdmin(Igny8ModelAdmin):
class ContentTaxonomyRelationAdmin(ExportMixin, Igny8ModelAdmin):
resource_class = ContentTaxonomyRelationResource
list_display = ['content', 'taxonomy', 'created_at']
search_fields = ['content__title', 'taxonomy__name']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_delete_relations',
'bulk_reassign_taxonomy',
]
def bulk_delete_relations(self, request, queryset):
count = queryset.count()
queryset.delete()
self.message_user(request, f'{count} content taxonomy relation(s) deleted.', messages.SUCCESS)
bulk_delete_relations.short_description = 'Delete selected relations'
def bulk_reassign_taxonomy(self, request, queryset):
"""Admin action to bulk reassign taxonomy - requires intermediate page"""
if 'apply' in request.POST:
from django import forms
from .models import ContentTaxonomy
class TaxonomyForm(forms.Form):
taxonomy = forms.ModelChoiceField(
queryset=ContentTaxonomy.objects.filter(is_active=True),
required=True,
label='New Taxonomy'
)
form = TaxonomyForm(request.POST)
if form.is_valid():
new_taxonomy = form.cleaned_data['taxonomy']
count = queryset.update(taxonomy=new_taxonomy)
self.message_user(request, f'{count} relation(s) reassigned to {new_taxonomy.name}.', messages.SUCCESS)
return
from django import forms
from .models import ContentTaxonomy
class TaxonomyForm(forms.Form):
taxonomy = forms.ModelChoiceField(
queryset=ContentTaxonomy.objects.filter(is_active=True),
required=True,
label='New Taxonomy',
help_text='Select the taxonomy to reassign these relations to'
)
context = {
'title': 'Bulk Reassign Taxonomy',
'queryset': queryset,
'form': TaxonomyForm(),
'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
}
return render(request, 'admin/bulk_action_form.html', context)
bulk_reassign_taxonomy.short_description = 'Reassign taxonomy'
class ContentClusterMapResource(resources.ModelResource):
"""Resource class for exporting Content Cluster Maps"""
class Meta:
model = ContentClusterMap
fields = ('id', 'content__title', 'task__title', 'cluster__name',
'role', 'source', 'site__name', 'sector__name', 'created_at')
export_order = fields
@admin.register(ContentClusterMap)
class ContentClusterMapAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
class ContentClusterMapAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentClusterMapResource
list_display = ['content', 'task', 'cluster', 'role', 'source', 'site', 'sector', 'created_at']
list_filter = ['role', 'source', 'site', 'sector']
search_fields = ['content__title', 'task__title', 'cluster__name']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_delete_maps',
'bulk_update_role',
'bulk_reassign_cluster',
]
def bulk_delete_maps(self, request, queryset):
count = queryset.count()
queryset.delete()
self.message_user(request, f'{count} content cluster map(s) deleted.', messages.SUCCESS)
bulk_delete_maps.short_description = 'Delete selected maps'
def bulk_update_role(self, request, queryset):
"""Admin action to bulk update role"""
if 'apply' in request.POST:
from django import forms
class RoleForm(forms.Form):
ROLE_CHOICES = [
('pillar', 'Pillar'),
('supporting', 'Supporting'),
('related', 'Related'),
]
role = forms.ChoiceField(choices=ROLE_CHOICES, required=True)
form = RoleForm(request.POST)
if form.is_valid():
new_role = form.cleaned_data['role']
count = queryset.update(role=new_role)
self.message_user(request, f'{count} map(s) updated to role: {new_role}.', messages.SUCCESS)
return
from django import forms
class RoleForm(forms.Form):
ROLE_CHOICES = [
('pillar', 'Pillar'),
('supporting', 'Supporting'),
('related', 'Related'),
]
role = forms.ChoiceField(
choices=ROLE_CHOICES,
required=True,
help_text='Select the new role for these content cluster maps'
)
context = {
'title': 'Bulk Update Role',
'queryset': queryset,
'form': RoleForm(),
'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
}
return render(request, 'admin/bulk_action_form.html', context)
bulk_update_role.short_description = 'Update role'
def bulk_reassign_cluster(self, request, queryset):
"""Admin action to bulk reassign cluster"""
if 'apply' in request.POST:
from django import forms
from igny8_core.business.planner.models import Cluster
class ClusterForm(forms.Form):
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.filter(is_active=True),
required=True,
label='New Cluster'
)
form = ClusterForm(request.POST)
if form.is_valid():
new_cluster = form.cleaned_data['cluster']
count = queryset.update(cluster=new_cluster)
self.message_user(request, f'{count} map(s) reassigned to cluster: {new_cluster.name}.', messages.SUCCESS)
return
from django import forms
from igny8_core.business.planner.models import Cluster
class ClusterForm(forms.Form):
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.filter(is_active=True),
required=True,
label='New Cluster',
help_text='Select the cluster to reassign these maps to'
)
context = {
'title': 'Bulk Reassign Cluster',
'queryset': queryset,
'form': ClusterForm(),
'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
}
return render(request, 'admin/bulk_action_form.html', context)
bulk_reassign_cluster.short_description = 'Reassign cluster'