diff --git a/backend/igny8_core/admin/alerts.py b/backend/igny8_core/admin/alerts.py
new file mode 100644
index 00000000..f497040c
--- /dev/null
+++ b/backend/igny8_core/admin/alerts.py
@@ -0,0 +1,122 @@
+"""
+Admin Alert System
+"""
+from django.utils import timezone
+from datetime import timedelta
+
+
+class AdminAlerts:
+ """System for admin alerts and notifications"""
+
+ @staticmethod
+ def get_alerts():
+ """Get all active alerts"""
+ alerts = []
+ today = timezone.now().date()
+
+ # Check for pending payments
+ from igny8_core.business.billing.models import Payment
+ pending_payments = Payment.objects.filter(status='pending_approval').count()
+ if pending_payments > 0:
+ alerts.append({
+ 'level': 'warning',
+ 'icon': 'â ī¸',
+ 'message': f'{pending_payments} payment(s) awaiting approval',
+ 'url': '/admin/billing/payment/?status=pending_approval',
+ 'action': 'Review Payments'
+ })
+
+ # Check for low credit accounts
+ from igny8_core.auth.models import Account
+ low_credit_accounts = Account.objects.filter(
+ status='active',
+ credits__lt=100
+ ).count()
+ if low_credit_accounts > 0:
+ alerts.append({
+ 'level': 'info',
+ 'icon': 'âšī¸',
+ 'message': f'{low_credit_accounts} account(s) with low credits',
+ 'url': '/admin/igny8_core_auth/account/?credits__lt=100',
+ 'action': 'View Accounts'
+ })
+
+ # Check for very low credits (critical)
+ critical_credit_accounts = Account.objects.filter(
+ status='active',
+ credits__lt=10
+ ).count()
+ if critical_credit_accounts > 0:
+ alerts.append({
+ 'level': 'error',
+ 'icon': 'đ´',
+ 'message': f'{critical_credit_accounts} account(s) with critical low credits (< 10)',
+ 'url': '/admin/igny8_core_auth/account/?credits__lt=10',
+ 'action': 'Urgent Review'
+ })
+
+ # Check for failed automations
+ from igny8_core.business.automation.models import AutomationRun
+ failed_today = AutomationRun.objects.filter(
+ status='failed',
+ started_at__date=today
+ ).count()
+ if failed_today > 0:
+ alerts.append({
+ 'level': 'error',
+ 'icon': 'đ´',
+ 'message': f'{failed_today} automation(s) failed today',
+ 'url': '/admin/automation/automationrun/?status=failed',
+ 'action': 'Review Failures'
+ })
+
+ # Check for failed syncs
+ from igny8_core.business.integration.models import SyncEvent
+ failed_syncs = SyncEvent.objects.filter(
+ success=False,
+ created_at__date=today
+ ).count()
+ if failed_syncs > 5: # Only alert if more than 5
+ alerts.append({
+ 'level': 'warning',
+ 'icon': 'â ī¸',
+ 'message': f'{failed_syncs} WordPress sync failures today',
+ 'url': '/admin/integration/syncevent/?success=False',
+ 'action': 'Review Syncs'
+ })
+
+ # Check for failed Celery tasks
+ try:
+ from django_celery_results.models import TaskResult
+ celery_failed = TaskResult.objects.filter(
+ status='FAILURE',
+ date_created__date=today
+ ).count()
+ if celery_failed > 0:
+ alerts.append({
+ 'level': 'error',
+ 'icon': 'đ´',
+ 'message': f'{celery_failed} Celery task(s) failed today',
+ 'url': '/admin/django_celery_results/taskresult/?status=FAILURE',
+ 'action': 'Review Tasks'
+ })
+ except:
+ pass
+
+ # Check for stale pending tasks (older than 24 hours)
+ from igny8_core.modules.writer.models import Tasks
+ yesterday = today - timedelta(days=1)
+ stale_tasks = Tasks.objects.filter(
+ status='pending',
+ created_at__date__lte=yesterday
+ ).count()
+ if stale_tasks > 10:
+ alerts.append({
+ 'level': 'info',
+ 'icon': 'âšī¸',
+ 'message': f'{stale_tasks} tasks pending for more than 24 hours',
+ 'url': '/admin/writer/tasks/?status=pending',
+ 'action': 'Review Tasks'
+ })
+
+ return alerts
diff --git a/backend/igny8_core/admin/apps.py b/backend/igny8_core/admin/apps.py
index aa4b19d3..0840715b 100644
--- a/backend/igny8_core/admin/apps.py
+++ b/backend/igny8_core/admin/apps.py
@@ -39,5 +39,28 @@ class Igny8AdminConfig(AdminConfig):
_safe_register(Group, admin.ModelAdmin)
_safe_register(ContentType, ReadOnlyAdmin)
_safe_register(Session, ReadOnlyAdmin)
+
+ # Import and setup enhanced Celery task monitoring
+ self._setup_celery_admin()
+
+ def _setup_celery_admin(self):
+ """Setup enhanced Celery admin with proper unregister/register"""
+ try:
+ from django_celery_results.models import TaskResult
+ from igny8_core.admin.celery_admin import CeleryTaskResultAdmin
+
+ # Unregister the default TaskResult admin
+ try:
+ admin.site.unregister(TaskResult)
+ except admin.sites.NotRegistered:
+ pass
+
+ # Register our enhanced version
+ admin.site.register(TaskResult, CeleryTaskResultAdmin)
+ except Exception as e:
+ # Log the error but don't crash the app
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.warning(f"Could not setup enhanced Celery admin: {e}")
diff --git a/backend/igny8_core/admin/celery_admin.py b/backend/igny8_core/admin/celery_admin.py
new file mode 100644
index 00000000..3bcb3e56
--- /dev/null
+++ b/backend/igny8_core/admin/celery_admin.py
@@ -0,0 +1,154 @@
+"""
+Celery Task Monitoring Admin
+"""
+from django.contrib import admin
+from django.utils.html import format_html
+from django.contrib import messages
+from django_celery_results.models import TaskResult
+from rangefilter.filters import DateRangeFilter
+
+
+class CeleryTaskResultAdmin(admin.ModelAdmin):
+ """Admin interface for monitoring Celery tasks"""
+
+ list_display = [
+ 'task_id',
+ 'task_name',
+ 'colored_status',
+ 'date_created',
+ 'date_done',
+ 'execution_time',
+ ]
+ list_filter = [
+ 'status',
+ 'task_name',
+ ('date_created', DateRangeFilter),
+ ('date_done', DateRangeFilter),
+ ]
+ search_fields = ['task_id', 'task_name', 'task_args']
+ readonly_fields = [
+ 'task_id', 'task_name', 'task_args', 'task_kwargs',
+ 'result', 'traceback', 'date_created', 'date_done',
+ 'colored_status', 'execution_time'
+ ]
+ date_hierarchy = 'date_created'
+ ordering = ['-date_created']
+
+ actions = ['retry_failed_tasks', 'clear_old_tasks']
+
+ fieldsets = (
+ ('Task Information', {
+ 'fields': ('task_id', 'task_name', 'colored_status')
+ }),
+ ('Execution Details', {
+ 'fields': ('date_created', 'date_done', 'execution_time')
+ }),
+ ('Task Arguments', {
+ 'fields': ('task_args', 'task_kwargs'),
+ 'classes': ('collapse',)
+ }),
+ ('Result & Errors', {
+ 'fields': ('result', 'traceback'),
+ 'classes': ('collapse',)
+ }),
+ )
+
+ def colored_status(self, obj):
+ """Display status with color coding"""
+ colors = {
+ 'SUCCESS': '#0bbf87', # IGNY8 success green
+ 'FAILURE': '#ef4444', # IGNY8 danger red
+ 'PENDING': '#ff7a00', # IGNY8 warning orange
+ 'STARTED': '#0693e3', # IGNY8 primary blue
+ 'RETRY': '#5d4ae3', # IGNY8 purple
+ }
+ color = colors.get(obj.status, '#64748b') # Default gray
+
+ return format_html(
+ '{}',
+ color,
+ obj.status
+ )
+ colored_status.short_description = 'Status'
+
+ def execution_time(self, obj):
+ """Calculate and display execution time"""
+ if obj.date_done and obj.date_created:
+ duration = obj.date_done - obj.date_created
+ seconds = duration.total_seconds()
+
+ if seconds < 1:
+ return format_html('{:.2f}ms', seconds * 1000)
+ elif seconds < 60:
+ return format_html('{:.2f}s', seconds)
+ else:
+ minutes = seconds / 60
+ return format_html('{:.1f}m', minutes)
+ return '-'
+ execution_time.short_description = 'Duration'
+
+ def retry_failed_tasks(self, request, queryset):
+ """Retry failed celery tasks"""
+ from celery import current_app
+
+ failed_tasks = queryset.filter(status='FAILURE')
+ count = 0
+ errors = []
+
+ for task in failed_tasks:
+ try:
+ # Get task function
+ task_func = current_app.tasks.get(task.task_name)
+ if task_func:
+ # Parse task args and kwargs
+ import ast
+ try:
+ args = ast.literal_eval(task.task_args) if task.task_args else []
+ kwargs = ast.literal_eval(task.task_kwargs) if task.task_kwargs else {}
+ except:
+ args = []
+ kwargs = {}
+
+ # Retry the task
+ task_func.apply_async(args=args, kwargs=kwargs)
+ count += 1
+ else:
+ errors.append(f'Task function not found: {task.task_name}')
+ except Exception as e:
+ errors.append(f'Error retrying {task.task_id}: {str(e)}')
+
+ if count > 0:
+ self.message_user(request, f'â
Retried {count} failed task(s)', messages.SUCCESS)
+
+ if errors:
+ for error in errors[:5]: # Show max 5 errors
+ self.message_user(request, f'â ī¸ {error}', messages.WARNING)
+
+ retry_failed_tasks.short_description = 'đ Retry Failed Tasks'
+
+ def clear_old_tasks(self, request, queryset):
+ """Clear old completed tasks"""
+ from datetime import timedelta
+ from django.utils import timezone
+
+ # Delete tasks older than 30 days
+ cutoff_date = timezone.now() - timedelta(days=30)
+ old_tasks = queryset.filter(
+ date_created__lt=cutoff_date,
+ status__in=['SUCCESS', 'FAILURE']
+ )
+
+ count = old_tasks.count()
+ old_tasks.delete()
+
+ self.message_user(request, f'đī¸ Cleared {count} old task(s)', messages.SUCCESS)
+
+ clear_old_tasks.short_description = 'đī¸ Clear Old Tasks (30+ days)'
+
+ def has_add_permission(self, request):
+ """Disable manual task creation"""
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ """Make read-only"""
+ return False
diff --git a/backend/igny8_core/admin/dashboard.py b/backend/igny8_core/admin/dashboard.py
new file mode 100644
index 00000000..9f9aff30
--- /dev/null
+++ b/backend/igny8_core/admin/dashboard.py
@@ -0,0 +1,112 @@
+"""
+Custom Admin Dashboard with Key Metrics
+"""
+from django.contrib.admin.views.decorators import staff_member_required
+from django.shortcuts import render
+from django.db.models import Count, Sum, Q
+from django.utils import timezone
+from datetime import timedelta
+
+
+@staff_member_required
+def admin_dashboard(request):
+ """Custom admin dashboard with operational metrics"""
+
+ # Date ranges
+ today = timezone.now().date()
+ week_ago = today - timedelta(days=7)
+ month_ago = today - timedelta(days=30)
+
+ # Account metrics
+ from igny8_core.auth.models import Account
+ total_accounts = Account.objects.count()
+ active_accounts = Account.objects.filter(status='active').count()
+ low_credit_accounts = Account.objects.filter(
+ status='active',
+ credits__lt=100
+ ).count()
+
+ # Content metrics
+ from igny8_core.modules.writer.models import Content, Tasks
+ content_this_week = Content.objects.filter(created_at__gte=week_ago).count()
+ content_this_month = Content.objects.filter(created_at__gte=month_ago).count()
+ tasks_pending = Tasks.objects.filter(status='pending').count()
+ tasks_in_progress = Tasks.objects.filter(status='in_progress').count()
+
+ # Billing metrics
+ from igny8_core.business.billing.models import Payment, CreditTransaction
+ pending_payments = Payment.objects.filter(status='pending_approval').count()
+ payments_this_month = Payment.objects.filter(
+ created_at__gte=month_ago,
+ status='succeeded'
+ ).aggregate(total=Sum('amount'))['total'] or 0
+
+ credit_usage_this_month = CreditTransaction.objects.filter(
+ created_at__gte=month_ago,
+ transaction_type='deduction'
+ ).aggregate(total=Sum('amount'))['total'] or 0
+
+ # Automation metrics
+ from igny8_core.business.automation.models import AutomationRun
+ automation_running = AutomationRun.objects.filter(status='running').count()
+ automation_failed = AutomationRun.objects.filter(
+ status='failed',
+ started_at__gte=week_ago
+ ).count()
+
+ # WordPress sync metrics
+ from igny8_core.business.integration.models import SyncEvent
+ sync_failed_today = SyncEvent.objects.filter(
+ success=False,
+ created_at__date=today
+ ).count()
+
+ # Celery task metrics
+ try:
+ from django_celery_results.models import TaskResult
+ celery_failed = TaskResult.objects.filter(
+ status='FAILURE',
+ date_created__date=today
+ ).count()
+ celery_pending = TaskResult.objects.filter(status='PENDING').count()
+ except:
+ celery_failed = 0
+ celery_pending = 0
+
+ # Get alerts
+ from .alerts import AdminAlerts
+ alerts = AdminAlerts.get_alerts()
+
+ context = {
+ 'title': 'IGNY8 Dashboard',
+ 'accounts': {
+ 'total': total_accounts,
+ 'active': active_accounts,
+ 'low_credit': low_credit_accounts,
+ },
+ 'content': {
+ 'this_week': content_this_week,
+ 'this_month': content_this_month,
+ 'tasks_pending': tasks_pending,
+ 'tasks_in_progress': tasks_in_progress,
+ },
+ 'billing': {
+ 'pending_payments': pending_payments,
+ 'payments_this_month': float(payments_this_month),
+ 'credit_usage_this_month': abs(credit_usage_this_month),
+ },
+ 'automation': {
+ 'running': automation_running,
+ 'failed_this_week': automation_failed,
+ },
+ 'integration': {
+ 'sync_failed_today': sync_failed_today,
+ },
+ 'celery': {
+ 'failed_today': celery_failed,
+ 'pending': celery_pending,
+ },
+ 'alerts': alerts,
+ }
+
+ return render(request, 'admin/dashboard.html', context)
diff --git a/backend/igny8_core/admin/site.py b/backend/igny8_core/admin/site.py
index 7bd162c5..4985acd6 100644
--- a/backend/igny8_core/admin/site.py
+++ b/backend/igny8_core/admin/site.py
@@ -4,6 +4,8 @@ Custom AdminSite for IGNY8 to organize models into proper groups
from django.contrib import admin
from django.contrib.admin.apps import AdminConfig
from django.apps import apps
+from django.urls import path
+from django.shortcuts import redirect
class Igny8AdminSite(admin.AdminSite):
@@ -20,6 +22,19 @@ class Igny8AdminSite(admin.AdminSite):
site_header = 'IGNY8 Administration'
site_title = 'IGNY8 Admin'
index_title = 'IGNY8 Administration'
+
+ def get_urls(self):
+ """Add dashboard URL"""
+ urls = super().get_urls()
+ custom_urls = [
+ path('dashboard/', self.dashboard_view, name='dashboard'),
+ ]
+ return custom_urls + urls
+
+ def dashboard_view(self, request):
+ """Dashboard view wrapper"""
+ from igny8_core.admin.dashboard import admin_dashboard
+ return admin_dashboard(request)
def get_app_list(self, request):
"""
@@ -113,7 +128,13 @@ class Igny8AdminSite(admin.AdminSite):
('system', 'SystemStatus'),
],
},
- 'đ§ Django System': {
+ 'īŋŊ Monitoring & Tasks': {
+ 'models': [
+ ('django_celery_results', 'TaskResult'),
+ ('django_celery_results', 'GroupResult'),
+ ],
+ },
+ 'īŋŊđ§ Django System': {
'models': [
('admin', 'LogEntry'),
('auth', 'Group'),
diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py
index f8082a97..197bf488 100644
--- a/backend/igny8_core/auth/admin.py
+++ b/backend/igny8_core/auth/admin.py
@@ -145,10 +145,10 @@ class PlanAdmin(admin.ModelAdmin):
@admin.register(Account)
class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
form = AccountAdminForm
- list_display = ['name', 'slug', 'owner', 'plan', 'status', 'credits', 'created_at']
+ list_display = ['name', 'slug', 'owner', 'plan', 'status', 'health_indicator', 'credits', 'created_at']
list_filter = ['status', 'plan']
search_fields = ['name', 'slug']
- readonly_fields = ['created_at', 'updated_at']
+ readonly_fields = ['created_at', 'updated_at', 'health_indicator', 'health_details']
def get_queryset(self, request):
"""Override to filter by account for non-superusers"""
@@ -167,6 +167,137 @@ class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
# If account access fails (e.g., column mismatch), return empty
pass
return qs.none()
+
+ def health_indicator(self, obj):
+ """Display health status with visual indicator"""
+ from django.utils.html import format_html
+ from django.utils import timezone
+ from datetime import timedelta
+
+ # Check credits
+ if obj.credits < 10:
+ status = 'critical'
+ icon = 'đ´'
+ message = 'Critical: Very low credits'
+ elif obj.credits < 100:
+ status = 'warning'
+ icon = 'â ī¸'
+ message = 'Warning: Low credits'
+ else:
+ status = 'good'
+ icon = 'â
'
+ message = 'Good'
+
+ # Check for recent failed automations
+ try:
+ from igny8_core.business.automation.models import AutomationRun
+ week_ago = timezone.now() - timedelta(days=7)
+ failed_runs = AutomationRun.objects.filter(
+ account=obj,
+ status='failed',
+ created_at__gte=week_ago
+ ).count()
+
+ if failed_runs > 5:
+ status = 'critical'
+ icon = 'đ´'
+ message = f'Critical: {failed_runs} automation failures'
+ elif failed_runs > 0:
+ if status == 'good':
+ status = 'warning'
+ icon = 'â ī¸'
+ message = f'Warning: {failed_runs} automation failures'
+ except:
+ pass
+
+ # Check account status
+ if obj.status != 'active':
+ status = 'critical'
+ icon = 'đ´'
+ message = f'Critical: Account {obj.status}'
+
+ colors = {
+ 'good': '#0bbf87',
+ 'warning': '#ff7a00',
+ 'critical': '#ef4444'
+ }
+
+ return format_html(
+ '{} {}',
+ icon, colors[status], message
+ )
+ health_indicator.short_description = 'Health'
+
+ def health_details(self, obj):
+ """Detailed health information"""
+ from django.utils.html import format_html
+ from django.utils import timezone
+ from datetime import timedelta
+
+ details = []
+
+ # Credits status
+ if obj.credits < 10:
+ details.append(f'đ´ Critical: Only {obj.credits} credits remaining')
+ elif obj.credits < 100:
+ details.append(f'â ī¸ Warning: Only {obj.credits} credits remaining')
+ else:
+ details.append(f'â
Credits: {obj.credits} available')
+
+ # Recent activity
+ try:
+ from igny8_core.modules.writer.models import Content
+ week_ago = timezone.now() - timedelta(days=7)
+ recent_content = Content.objects.filter(
+ site__account=obj,
+ created_at__gte=week_ago
+ ).count()
+ details.append(f'đ Activity: {recent_content} content pieces created this week')
+ except:
+ pass
+
+ # Failed automations
+ try:
+ from igny8_core.business.automation.models import AutomationRun
+ week_ago = timezone.now() - timedelta(days=7)
+ failed_runs = AutomationRun.objects.filter(
+ account=obj,
+ status='failed',
+ created_at__gte=week_ago
+ ).count()
+
+ if failed_runs > 0:
+ details.append(f'đ´ Automations: {failed_runs} failures this week')
+ else:
+ details.append(f'â
Automations: No failures this week')
+ except:
+ pass
+
+ # Failed syncs
+ try:
+ from igny8_core.business.integration.models import SyncEvent
+ today = timezone.now().date()
+ failed_syncs = SyncEvent.objects.filter(
+ site__account=obj,
+ success=False,
+ created_at__date=today
+ ).count()
+
+ if failed_syncs > 0:
+ details.append(f'â ī¸ Syncs: {failed_syncs} failures today')
+ else:
+ details.append(f'â
Syncs: No failures today')
+ except:
+ pass
+
+ # Account status
+ if obj.status == 'active':
+ details.append(f'â
Status: Active')
+ else:
+ details.append(f'đ´ Status: {obj.status.title()}')
+
+ return format_html('
'.join(details))
+ health_details.short_description = 'Health Details'
def has_delete_permission(self, request, obj=None):
if obj and getattr(obj, 'slug', '') == 'aws-admin':
diff --git a/backend/igny8_core/modules/planner/admin.py b/backend/igny8_core/modules/planner/admin.py
index 6ee79aa3..b4b05857 100644
--- a/backend/igny8_core/modules/planner/admin.py
+++ b/backend/igny8_core/modules/planner/admin.py
@@ -1,6 +1,18 @@
from django.contrib import admin
+from django.contrib import messages
from igny8_core.admin.base import SiteSectorAdminMixin
from .models import Keywords, Clusters, ContentIdeas
+from import_export.admin import ExportMixin
+from import_export import resources
+
+
+class KeywordsResource(resources.ModelResource):
+ """Resource class for exporting Keywords"""
+ class Meta:
+ model = Keywords
+ fields = ('id', 'keyword', 'seed_keyword__keyword', 'site__name', 'sector__name',
+ 'cluster__name', 'volume', 'difficulty', 'intent', 'status', 'created_at')
+ export_order = fields
@admin.register(Clusters)
@@ -9,6 +21,7 @@ class ClustersAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
list_filter = ['status', 'site', 'sector']
search_fields = ['name']
ordering = ['name']
+ autocomplete_fields = ['site', 'sector']
def get_site_display(self, obj):
"""Safely get site name"""
@@ -27,11 +40,18 @@ class ClustersAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
@admin.register(Keywords)
-class KeywordsAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
+class KeywordsAdmin(ExportMixin, SiteSectorAdminMixin, admin.ModelAdmin):
+ resource_class = KeywordsResource
list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'intent', 'status', 'created_at']
list_filter = ['status', 'seed_keyword__intent', 'site', 'sector', 'seed_keyword__industry', 'seed_keyword__sector']
search_fields = ['seed_keyword__keyword']
ordering = ['-created_at']
+ autocomplete_fields = ['cluster', 'site', 'sector', 'seed_keyword']
+ actions = [
+ 'bulk_assign_cluster',
+ 'bulk_set_status_active',
+ 'bulk_set_status_inactive',
+ ]
def get_site_display(self, obj):
"""Safely get site name"""
@@ -55,6 +75,58 @@ class KeywordsAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
except:
return '-'
get_cluster_display.short_description = 'Cluster'
+
+ def bulk_assign_cluster(self, request, queryset):
+ """Assign selected keywords to a cluster"""
+ from django import forms
+
+ # If this is the POST request with cluster selection
+ if 'apply' in request.POST:
+ cluster_id = request.POST.get('cluster')
+ if cluster_id:
+ cluster = Clusters.objects.get(pk=cluster_id)
+ updated = queryset.update(cluster=cluster)
+ self.message_user(request, f'{updated} keyword(s) assigned to cluster: {cluster.name}', messages.SUCCESS)
+ return
+
+ # Get first keyword's site/sector for filtering clusters
+ first_keyword = queryset.first()
+ if first_keyword:
+ clusters = Clusters.objects.filter(site=first_keyword.site, sector=first_keyword.sector)
+ else:
+ clusters = Clusters.objects.all()
+
+ # Create form for cluster selection
+ class ClusterForm(forms.Form):
+ cluster = forms.ModelChoiceField(
+ queryset=clusters,
+ label="Select Cluster",
+ help_text=f"Assign {queryset.count()} selected keyword(s) to:"
+ )
+
+ if clusters.exists():
+ from django.shortcuts import render
+ return render(request, 'admin/bulk_action_form.html', {
+ 'title': 'Assign Keywords to Cluster',
+ 'queryset': queryset,
+ 'form': ClusterForm(),
+ 'action': 'bulk_assign_cluster',
+ })
+ else:
+ self.message_user(request, 'No clusters available for the selected keywords.', messages.WARNING)
+ bulk_assign_cluster.short_description = 'Assign to Cluster'
+
+ def bulk_set_status_active(self, request, queryset):
+ """Set selected keywords to active status"""
+ updated = queryset.update(status='active')
+ self.message_user(request, f'{updated} keyword(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 keywords to inactive status"""
+ 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'
@admin.register(ContentIdeas)
diff --git a/backend/igny8_core/modules/writer/admin.py b/backend/igny8_core/modules/writer/admin.py
index d1228b86..d168e074 100644
--- a/backend/igny8_core/modules/writer/admin.py
+++ b/backend/igny8_core/modules/writer/admin.py
@@ -33,7 +33,13 @@ class TasksAdmin(ExportMixin, SiteSectorAdminMixin, admin.ModelAdmin):
search_fields = ['title', 'description']
ordering = ['-created_at']
readonly_fields = ['created_at', 'updated_at']
- actions = ['bulk_set_status_draft', 'bulk_set_status_in_progress', 'bulk_set_status_completed']
+ autocomplete_fields = ['cluster', 'site', 'sector']
+ actions = [
+ 'bulk_set_status_draft',
+ 'bulk_set_status_in_progress',
+ 'bulk_set_status_completed',
+ 'bulk_assign_cluster',
+ ]
fieldsets = (
('Basic Info', {
@@ -69,6 +75,47 @@ class TasksAdmin(ExportMixin, SiteSectorAdminMixin, admin.ModelAdmin):
self.message_user(request, f'{updated} task(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 tasks to a cluster - requires form input"""
+ from django import forms
+ from igny8_core.modules.planner.models import Clusters
+
+ # If this is the POST request with cluster selection
+ if 'apply' in request.POST:
+ cluster_id = request.POST.get('cluster')
+ if cluster_id:
+ cluster = Clusters.objects.get(pk=cluster_id)
+ updated = queryset.update(cluster=cluster)
+ self.message_user(request, f'{updated} task(s) assigned to cluster: {cluster.name}', messages.SUCCESS)
+ return
+
+ # Get first task's site/sector for filtering clusters
+ first_task = queryset.first()
+ if first_task:
+ clusters = Clusters.objects.filter(site=first_task.site, sector=first_task.sector)
+ else:
+ clusters = Clusters.objects.all()
+
+ # Create form for cluster selection
+ class ClusterForm(forms.Form):
+ cluster = forms.ModelChoiceField(
+ queryset=clusters,
+ label="Select Cluster",
+ help_text=f"Assign {queryset.count()} selected task(s) to:"
+ )
+
+ if clusters.exists():
+ from django.shortcuts import render
+ return render(request, 'admin/bulk_action_form.html', {
+ 'title': 'Assign Tasks to Cluster',
+ 'queryset': queryset,
+ 'form': ClusterForm(),
+ 'action': 'bulk_assign_cluster',
+ })
+ else:
+ self.message_user(request, 'No clusters available for the selected tasks.', messages.WARNING)
+ bulk_assign_cluster.short_description = 'Assign to Cluster'
+
def get_site_display(self, obj):
"""Safely get site name"""
try:
@@ -143,8 +190,13 @@ class ContentAdmin(ExportMixin, SiteSectorAdminMixin, admin.ModelAdmin):
search_fields = ['title', 'content_html', 'external_url']
ordering = ['-created_at']
readonly_fields = ['created_at', 'updated_at', 'word_count', 'get_tags_display', 'get_categories_display']
+ autocomplete_fields = ['cluster', 'site', 'sector']
inlines = [ContentTaxonomyInline]
- actions = ['bulk_set_status_published', 'bulk_set_status_draft']
+ actions = [
+ 'bulk_set_status_published',
+ 'bulk_set_status_draft',
+ 'bulk_add_taxonomy',
+ ]
fieldsets = (
('Basic Info', {
@@ -208,6 +260,55 @@ class ContentAdmin(ExportMixin, SiteSectorAdminMixin, admin.ModelAdmin):
self.message_user(request, f'{updated} content item(s) set to draft.', messages.SUCCESS)
bulk_set_status_draft.short_description = 'Set status to Draft'
+ def bulk_add_taxonomy(self, request, queryset):
+ """Add taxonomy terms to selected content"""
+ from django import forms
+ from igny8_core.business.content.models import ContentTaxonomy, ContentTaxonomyRelation
+
+ # If this is the POST request with taxonomy selection
+ if 'apply' in request.POST:
+ taxonomy_ids = request.POST.getlist('taxonomies')
+ if taxonomy_ids:
+ count = 0
+ for content in queryset:
+ for tax_id in taxonomy_ids:
+ taxonomy = ContentTaxonomy.objects.get(pk=tax_id)
+ ContentTaxonomyRelation.objects.get_or_create(
+ content=content,
+ taxonomy=taxonomy
+ )
+ count += 1
+ self.message_user(request, f'Added {count} taxonomy relation(s) to {queryset.count()} content item(s).', messages.SUCCESS)
+ return
+
+ # Get first content's site/sector for filtering taxonomies
+ first_content = queryset.first()
+ if first_content:
+ taxonomies = ContentTaxonomy.objects.filter(site=first_content.site, sector=first_content.sector)
+ else:
+ taxonomies = ContentTaxonomy.objects.all()
+
+ # Create form for taxonomy selection
+ class TaxonomyForm(forms.Form):
+ taxonomies = forms.ModelMultipleChoiceField(
+ queryset=taxonomies,
+ label="Select Taxonomies",
+ help_text=f"Add taxonomy terms to {queryset.count()} selected content item(s)",
+ widget=forms.CheckboxSelectMultiple
+ )
+
+ if taxonomies.exists():
+ from django.shortcuts import render
+ return render(request, 'admin/bulk_action_form.html', {
+ 'title': 'Add Taxonomies to Content',
+ 'queryset': queryset,
+ 'form': TaxonomyForm(),
+ 'action': 'bulk_add_taxonomy',
+ })
+ else:
+ self.message_user(request, 'No taxonomies available for the selected content.', messages.WARNING)
+ bulk_add_taxonomy.short_description = 'Add Taxonomy Terms'
+
def get_site_display(self, obj):
"""Safely get site name"""
try:
diff --git a/backend/igny8_core/static/admin/css/igny8_admin.css b/backend/igny8_core/static/admin/css/igny8_admin.css
index 98c10a42..00b55e29 100644
--- a/backend/igny8_core/static/admin/css/igny8_admin.css
+++ b/backend/igny8_core/static/admin/css/igny8_admin.css
@@ -1,13 +1,17 @@
/* ===================================================================
- IGNY8 CUSTOM ADMIN STYLES
+ IGNY8 CUSTOM ADMIN STYLES - COMPLETE REDESIGN
===================================================================
- Using IGNY8 brand colors from frontend design system
+ Using exact IGNY8 brand colors from frontend design system
=================================================================== */
-/* IGNY8 Brand Color Variables */
+/* IGNY8 Brand Color Variables - Matching Frontend App */
:root {
+ /* Primary Colors */
--igny8-primary: #0693e3; /* Primary brand blue */
--igny8-primary-dark: #0472b8; /* Primary dark */
+ --igny8-primary-light: #3da9e8; /* Primary light */
+
+ /* Accent Colors */
--igny8-success: #0bbf87; /* Success teal-green */
--igny8-success-dark: #08966b; /* Success dark */
--igny8-warning: #ff7a00; /* Warning orange */
@@ -16,34 +20,560 @@
--igny8-danger-dark: #d13333; /* Danger dark */
--igny8-purple: #5d4ae3; /* Purple accent */
--igny8-purple-dark: #3a2f94; /* Purple dark */
+
+ /* Neutral Colors */
--igny8-navy: #0d1b2a; /* Dark navy background */
- --igny8-navy-light: #142b3f; /* Navy light */
+ --igny8-navy-light: #1a2e44; /* Navy light */
--igny8-surface: #f8fafc; /* Page background */
--igny8-panel: #ffffff; /* Panel background */
- --igny8-text: #555a68; /* Main text */
- --igny8-text-dim: #64748b; /* Dimmed text */
+ --igny8-text: #1e293b; /* Main text */
+ --igny8-text-light: #64748b; /* Light text */
+ --igny8-text-dim: #94a3b8; /* Dimmed text */
--igny8-stroke: #e2e8f0; /* Borders */
+ --igny8-stroke-dark: #cbd5e1; /* Dark borders */
}
/* ===================================================================
- HEADER & BRANDING - IGNY8 Primary Blue with Gradient
+ GLOBAL RESETS
+ =================================================================== */
+body {
+ background: var(--igny8-surface) !important;
+ color: var(--igny8-text) !important;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
+}
+
+/* ===================================================================
+ HEADER - Clean Professional Design
=================================================================== */
#header {
background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
color: white !important;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important;
+ padding: 0 !important;
}
-#header a:link, #header a:visited {
+#branding {
+ padding: 16px 30px !important;
+}
+
+#branding h1 {
+ margin: 0 !important;
+ font-size: 20px !important;
+ font-weight: 600 !important;
+}
+
+#branding h1 a:link,
+#branding h1 a:visited {
+ color: white !important;
+ text-decoration: none !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 10px !important;
+}
+
+#header a:link,
+#header a:visited {
color: white !important;
}
-#branding h1, #branding h1 a:link, #branding h1 a:visited {
- color: white !important;
+/* ===================================================================
+ ADD RECORD BUTTON - Modern Accent Color
+ =================================================================== */
+.object-tools {
+ margin-bottom: 24px !important;
+ float: right !important;
}
-.header-user-tools a {
+.object-tools li {
+ margin: 0 !important;
+ list-style: none !important;
+}
+
+.object-tools a.addlink {
+ background: linear-gradient(135deg, var(--igny8-success) 0%, var(--igny8-success-dark) 100%) !important;
color: white !important;
+ padding: 12px 28px !important;
+ border-radius: 8px !important;
+ font-weight: 600 !important;
+ font-size: 14px !important;
+ text-decoration: none !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ gap: 10px !important;
+ box-shadow: 0 2px 6px rgba(11, 191, 135, 0.3) !important;
+ transition: all 0.2s ease !important;
+ border: none !important;
+ text-transform: none !important;
+}
+
+.object-tools a.addlink:before {
+ content: "\f067" !important;
+ font-family: "Font Awesome 6 Free" !important;
+ font-weight: 900 !important;
+ font-size: 14px !important;
+ margin-right: 0 !important;
+ background: none !important;
+ border: none !important;
+ width: auto !important;
+ height: auto !important;
+}
+
+.object-tools a.addlink:hover {
+ background: linear-gradient(135deg, var(--igny8-success-dark) 0%, #067354 100%) !important;
+ box-shadow: 0 4px 12px rgba(11, 191, 135, 0.4) !important;
+ transform: translateY(-2px) !important;
+}
+
+/* ===================================================================
+ EXPORT BUTTON - Purple Accent
+ =================================================================== */
+a[href*="export"],
+.export-button,
+input[name="_export"] {
+ background: linear-gradient(135deg, var(--igny8-purple) 0%, var(--igny8-purple-dark) 100%) !important;
+ color: white !important;
+ padding: 10px 24px !important;
+ border-radius: 8px !important;
+ font-weight: 600 !important;
+ font-size: 13px !important;
+ text-decoration: none !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ gap: 8px !important;
+ box-shadow: 0 2px 6px rgba(93, 74, 227, 0.3) !important;
+ transition: all 0.2s ease !important;
+ border: none !important;
+ cursor: pointer !important;
+}
+
+a[href*="export"]:hover,
+.export-button:hover,
+input[name="_export"]:hover {
+ background: linear-gradient(135deg, var(--igny8-purple-dark) 0%, #2a1f6b 100%) !important;
+ box-shadow: 0 4px 12px rgba(93, 74, 227, 0.4) !important;
+ transform: translateY(-2px) !important;
+}
+
+/* ===================================================================
+ SEARCH BAR & TOOLBAR - Professional Layout
+ =================================================================== */
+#toolbar {
+ padding: 20px 24px !important;
+ background: var(--igny8-panel) !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 12px !important;
+ margin-bottom: 24px !important;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05) !important;
+}
+
+#toolbar form {
+ display: flex !important;
+ gap: 12px !important;
+ align-items: center !important;
+}
+
+#toolbar input[type="text"],
+#searchbar {
+ flex: 1 !important;
+ min-width: 320px !important;
+ max-width: 500px !important;
+ padding: 12px 18px !important;
+ border: 1.5px solid var(--igny8-stroke) !important;
+ border-radius: 8px !important;
+ font-size: 14px !important;
+ transition: all 0.2s ease !important;
+ background: var(--igny8-panel) !important;
+ color: var(--igny8-text) !important;
+}
+
+#toolbar input[type="text"]:focus,
+#searchbar:focus {
+ border-color: var(--igny8-primary) !important;
+ outline: none !important;
+ box-shadow: 0 0 0 4px rgba(6, 147, 227, 0.1) !important;
+}
+
+#toolbar input[type="submit"],
+#toolbar button[type="submit"] {
+ background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
+ color: white !important;
+ padding: 12px 28px !important;
+ border: none !important;
+ border-radius: 8px !important;
+ font-weight: 600 !important;
+ font-size: 14px !important;
+ cursor: pointer !important;
+ transition: all 0.2s ease !important;
+ box-shadow: 0 2px 6px rgba(6, 147, 227, 0.3) !important;
+}
+
+#toolbar input[type="submit"]:hover,
+#toolbar button[type="submit"]:hover {
+ background: linear-gradient(135deg, var(--igny8-primary-dark) 0%, #035a8f 100%) !important;
+ box-shadow: 0 4px 12px rgba(6, 147, 227, 0.4) !important;
+ transform: translateY(-2px) !important;
+}
+
+/* ===================================================================
+ ACTIONS BAR - Better Styling
+ =================================================================== */
+#changelist-form .actions {
+ padding: 20px 24px !important;
+ background: var(--igny8-panel) !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 12px !important;
+ margin-bottom: 24px !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 16px !important;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05) !important;
+}
+
+#changelist-form .actions label {
+ font-weight: 600 !important;
+ color: var(--igny8-text) !important;
+ font-size: 14px !important;
+ margin-right: 0 !important;
+}
+
+#changelist-form .actions select {
+ min-width: 240px !important;
+ padding: 12px 18px !important;
+ border: 1.5px solid var(--igny8-stroke) !important;
+ border-radius: 8px !important;
+ font-size: 14px !important;
+ transition: all 0.2s ease !important;
+ background: var(--igny8-panel) !important;
+ color: var(--igny8-text) !important;
+}
+
+#changelist-form .actions select:focus {
+ border-color: var(--igny8-primary) !important;
+ outline: none !important;
+ box-shadow: 0 0 0 4px rgba(6, 147, 227, 0.1) !important;
+}
+
+#changelist-form .actions button,
+#changelist-form .actions input[type="submit"] {
+ background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
+ color: white !important;
+ padding: 12px 28px !important;
+ border: none !important;
+ border-radius: 8px !important;
+ font-weight: 600 !important;
+ font-size: 14px !important;
+ cursor: pointer !important;
+ transition: all 0.2s ease !important;
+ box-shadow: 0 2px 6px rgba(6, 147, 227, 0.3) !important;
+}
+
+#changelist-form .actions button:hover,
+#changelist-form .actions input[type="submit"]:hover {
+ background: linear-gradient(135deg, var(--igny8-primary-dark) 0%, #035a8f 100%) !important;
+ box-shadow: 0 4px 12px rgba(6, 147, 227, 0.4) !important;
+ transform: translateY(-2px) !important;
+}
+
+/* ===================================================================
+ FILTERS PANEL - Clear Organization
+ =================================================================== */
+#changelist-filter {
+ background: var(--igny8-panel) !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 12px !important;
+ padding: 0 !important;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05) !important;
+}
+
+#changelist-filter h2 {
+ background: linear-gradient(135deg, var(--igny8-navy) 0%, var(--igny8-navy-light) 100%) !important;
+ color: white !important;
+ padding: 18px 24px !important;
+ margin: 0 !important;
+ font-size: 16px !important;
+ font-weight: 600 !important;
+ border-radius: 12px 12px 0 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 10px !important;
+}
+
+#changelist-filter h2:before {
+ content: "\f0b0" !important;
+ font-family: "Font Awesome 6 Free" !important;
+ font-weight: 900 !important;
+}
+
+#changelist-filter h3 {
+ background: var(--igny8-surface) !important;
+ color: var(--igny8-text) !important;
+ padding: 14px 24px !important;
+ margin: 0 !important;
+ font-size: 14px !important;
+ font-weight: 600 !important;
+ border-top: 1px solid var(--igny8-stroke) !important;
+ border-bottom: 1px solid var(--igny8-stroke) !important;
+}
+
+#changelist-filter ul {
+ padding: 16px 24px !important;
+ margin: 0 !important;
+}
+
+#changelist-filter li {
+ padding: 0 !important;
+ margin: 0 0 8px 0 !important;
+ list-style: none !important;
+}
+
+#changelist-filter a {
+ color: var(--igny8-text) !important;
+ text-decoration: none !important;
+ display: block !important;
+ padding: 10px 16px !important;
+ border-radius: 6px !important;
+ transition: all 0.2s ease !important;
+ font-size: 14px !important;
+}
+
+#changelist-filter a:hover {
+ background: var(--igny8-surface) !important;
+ color: var(--igny8-primary) !important;
+}
+
+#changelist-filter a.selected {
+ background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
+ color: white !important;
+ font-weight: 600 !important;
+ box-shadow: 0 2px 4px rgba(6, 147, 227, 0.2) !important;
+}
+
+#changelist-filter .quiet {
+ color: var(--igny8-text-dim) !important;
+ font-size: 13px !important;
+}
+
+/* ===================================================================
+ ADD RECORD BUTTON - Clean Professional Style
+ =================================================================== */
+.object-tools {
+ margin-bottom: 20px !important;
+}
+
+.object-tools li {
+ margin: 0 !important;
+}
+
+.object-tools a.addlink {
+ background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
+ color: white !important;
+ padding: 12px 24px !important;
+ border-radius: 8px !important;
+ font-weight: 600 !important;
+ font-size: 14px !important;
+ text-decoration: none !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ gap: 8px !important;
+ box-shadow: 0 2px 4px rgba(6, 147, 227, 0.3) !important;
+ transition: all 0.2s ease !important;
+ border: none !important;
+}
+
+.object-tools a.addlink:before {
+ content: "+" !important;
+ font-size: 18px !important;
+ font-weight: bold !important;
+ margin-right: 0 !important;
+ background: none !important;
+ border: none !important;
+ width: auto !important;
+ height: auto !important;
+}
+
+.object-tools a.addlink:hover {
+ background: linear-gradient(135deg, var(--igny8-primary-dark) 0%, #035a8f 100%) !important;
+ box-shadow: 0 4px 8px rgba(6, 147, 227, 0.4) !important;
+ transform: translateY(-1px) !important;
+}
+
+/* Remove the background from the + icon */
+.object-tools a.addlink:before {
+ background: none !important;
+}
+
+/* ===================================================================
+ SEARCH & ACTION BAR - Better Sizing and Layout
+ =================================================================== */
+#toolbar {
+ padding: 16px 20px !important;
+ background: var(--igny8-panel) !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 8px !important;
+ margin-bottom: 20px !important;
+ display: flex !important;
+ gap: 16px !important;
+ align-items: center !important;
+ flex-wrap: wrap !important;
+}
+
+#toolbar form {
+ display: flex !important;
+ gap: 12px !important;
+ align-items: center !important;
+ flex: 1 !important;
+ max-width: 600px !important;
+}
+
+#toolbar input[type="text"] {
+ flex: 1 !important;
+ min-width: 250px !important;
+ padding: 10px 16px !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 6px !important;
+ font-size: 14px !important;
+ transition: all 0.2s ease !important;
+}
+
+#toolbar input[type="text"]:focus {
+ border-color: var(--igny8-primary) !important;
+ outline: none !important;
+ box-shadow: 0 0 0 3px rgba(6, 147, 227, 0.1) !important;
+}
+
+#toolbar input[type="submit"],
+#toolbar button[type="submit"] {
+ background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
+ color: white !important;
+ padding: 10px 24px !important;
+ border: none !important;
+ border-radius: 6px !important;
+ font-weight: 600 !important;
+ font-size: 14px !important;
+ cursor: pointer !important;
+ transition: all 0.2s ease !important;
+ box-shadow: 0 2px 4px rgba(6, 147, 227, 0.2) !important;
+}
+
+#toolbar input[type="submit"]:hover,
+#toolbar button[type="submit"]:hover {
+ background: linear-gradient(135deg, var(--igny8-primary-dark) 0%, #035a8f 100%) !important;
+ box-shadow: 0 4px 8px rgba(6, 147, 227, 0.3) !important;
+ transform: translateY(-1px) !important;
+}
+
+/* Action dropdown */
+#changelist-form .actions {
+ padding: 16px 20px !important;
+ background: var(--igny8-panel) !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 8px !important;
+ margin-bottom: 20px !important;
+}
+
+#changelist-form .actions select {
+ min-width: 220px !important;
+ padding: 10px 16px !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 6px !important;
+ font-size: 14px !important;
+ margin-right: 12px !important;
+ transition: all 0.2s ease !important;
+}
+
+#changelist-form .actions select:focus {
+ border-color: var(--igny8-primary) !important;
+ outline: none !important;
+ box-shadow: 0 0 0 3px rgba(6, 147, 227, 0.1) !important;
+}
+
+#changelist-form .actions button,
+#changelist-form .actions input[type="submit"] {
+ background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
+ color: white !important;
+ padding: 10px 24px !important;
+ border: none !important;
+ border-radius: 6px !important;
+ font-weight: 600 !important;
+ font-size: 14px !important;
+ cursor: pointer !important;
+ transition: all 0.2s ease !important;
+ box-shadow: 0 2px 4px rgba(6, 147, 227, 0.2) !important;
+}
+
+#changelist-form .actions button:hover,
+#changelist-form .actions input[type="submit"]:hover {
+ background: linear-gradient(135deg, var(--igny8-primary-dark) 0%, #035a8f 100%) !important;
+ box-shadow: 0 4px 8px rgba(6, 147, 227, 0.3) !important;
+ transform: translateY(-1px) !important;
+}
+
+/* ===================================================================
+ FILTERS - Clear Labels and Better Organization
+ =================================================================== */
+#changelist-filter {
+ background: var(--igny8-panel) !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 8px !important;
+ padding: 0 !important;
+}
+
+#changelist-filter h2 {
+ background: linear-gradient(135deg, var(--igny8-navy) 0%, var(--igny8-navy-light) 100%) !important;
+ color: white !important;
+ padding: 16px 20px !important;
+ margin: 0 !important;
+ font-size: 16px !important;
+ font-weight: 600 !important;
+ border-radius: 8px 8px 0 0 !important;
+}
+
+#changelist-filter h3 {
+ background: var(--igny8-surface) !important;
+ color: var(--igny8-text) !important;
+ padding: 12px 20px !important;
+ margin: 0 !important;
+ font-size: 14px !important;
+ font-weight: 600 !important;
+ border-top: 1px solid var(--igny8-stroke) !important;
+ border-bottom: 1px solid var(--igny8-stroke) !important;
+}
+
+#changelist-filter ul {
+ padding: 12px 20px !important;
+ margin: 0 0 16px 0 !important;
+}
+
+#changelist-filter li {
+ padding: 8px 0 !important;
+ margin: 0 !important;
+ list-style: none !important;
+}
+
+#changelist-filter a {
+ color: var(--igny8-text) !important;
+ text-decoration: none !important;
+ display: block !important;
+ padding: 6px 12px !important;
+ border-radius: 4px !important;
+ transition: all 0.2s ease !important;
+ font-size: 14px !important;
+}
+
+#changelist-filter a:hover {
+ background: var(--igny8-surface) !important;
+ color: var(--igny8-primary) !important;
+}
+
+#changelist-filter a.selected {
+ background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
+ color: white !important;
+ font-weight: 600 !important;
+}
+
+#changelist-filter .quiet {
+ color: var(--igny8-text-dim) !important;
+ font-size: 13px !important;
}
/* ===================================================================
@@ -271,6 +801,217 @@
background: var(--igny8-danger-dark) !important;
}
+/* ===================================================================
+ SIDEBAR MODULE "ADD" LINKS - Icon Only with Theme Colors
+ =================================================================== */
+.module .addlink,
+.module a.addlink,
+#content-main .module .addlink {
+ color: var(--igny8-text) !important;
+ text-decoration: none !important;
+ font-size: 0 !important;
+ padding: 0 !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ transition: all 0.2s ease !important;
+}
+
+.module .addlink:before,
+.module a.addlink:before {
+ content: "\f067" !important;
+ font-family: "Font Awesome 6 Free" !important;
+ font-weight: 900 !important;
+ font-size: 12px !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ width: 24px !important;
+ height: 24px !important;
+ border-radius: 6px !important;
+ background: linear-gradient(135deg, var(--igny8-success) 0%, var(--igny8-success-dark) 100%) !important;
+ color: white !important;
+ margin: 0 !important;
+ transition: all 0.2s ease !important;
+ box-shadow: 0 2px 4px rgba(11, 191, 135, 0.2) !important;
+}
+
+.module .addlink:hover:before,
+.module a.addlink:hover:before {
+ background: linear-gradient(135deg, var(--igny8-success-dark) 0%, #067354 100%) !important;
+ box-shadow: 0 4px 8px rgba(11, 191, 135, 0.3) !important;
+ transform: translateY(-1px) scale(1.05) !important;
+}
+
+/* Module navigation styling */
+.module {
+ margin-bottom: 20px !important;
+ background: var(--igny8-panel) !important;
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 8px !important;
+ overflow: hidden !important;
+}
+
+.module h2 {
+ background: linear-gradient(135deg, var(--igny8-navy) 0%, var(--igny8-navy-light) 100%) !important;
+ color: white !important;
+ padding: 14px 16px !important;
+ font-size: 14px !important;
+ font-weight: 600 !important;
+ margin: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 10px !important;
+}
+
+.module table {
+ width: 100% !important;
+}
+
+.module tr {
+ border-bottom: 1px solid var(--igny8-stroke) !important;
+}
+
+.module tr:last-child {
+ border-bottom: none !important;
+}
+
+.module th,
+.module td {
+ padding: 12px 16px !important;
+ text-align: left !important;
+}
+
+.module th {
+ background: var(--igny8-surface) !important;
+ color: var(--igny8-text) !important;
+ font-weight: 600 !important;
+ font-size: 13px !important;
+}
+
+.module a {
+ color: var(--igny8-primary) !important;
+ text-decoration: none !important;
+ transition: color 0.2s ease !important;
+}
+
+.module a:hover {
+ color: var(--igny8-primary-dark) !important;
+}
+
+/* ===================================================================
+ ALL BUTTONS - Consistent Theme Colors
+ =================================================================== */
+.button,
+input[type=submit],
+input[type=button],
+.submit-row input,
+button:not(.close) {
+ background: linear-gradient(135deg, var(--igny8-primary) 0%, var(--igny8-primary-dark) 100%) !important;
+ color: white !important;
+ border: none !important;
+ padding: 12px 24px !important;
+ border-radius: 8px !important;
+ cursor: pointer !important;
+ font-weight: 600 !important;
+ font-size: 14px !important;
+ transition: all 0.2s ease !important;
+ box-shadow: 0 2px 6px rgba(6, 147, 227, 0.3) !important;
+}
+
+.button:hover,
+input[type=submit]:hover,
+input[type=button]:hover,
+button:not(.close):hover {
+ background: linear-gradient(135deg, var(--igny8-primary-dark) 0%, #035a8f 100%) !important;
+ box-shadow: 0 4px 12px rgba(6, 147, 227, 0.4) !important;
+ transform: translateY(-2px) !important;
+}
+
+/* Save buttons - Success color */
+input[name="_save"],
+input[name="_continue"],
+.submit-row input[type="submit"]:first-child {
+ background: linear-gradient(135deg, var(--igny8-success) 0%, var(--igny8-success-dark) 100%) !important;
+ box-shadow: 0 2px 6px rgba(11, 191, 135, 0.3) !important;
+}
+
+input[name="_save"]:hover,
+input[name="_continue"]:hover {
+ background: linear-gradient(135deg, var(--igny8-success-dark) 0%, #067354 100%) !important;
+ box-shadow: 0 4px 12px rgba(11, 191, 135, 0.4) !important;
+}
+
+/* Delete buttons - Danger color */
+.deletelink,
+.deletelink-box a,
+a.deletelink:link,
+a.deletelink:visited,
+input[name="_delete"],
+.delete-confirmation input[type="submit"] {
+ background: linear-gradient(135deg, var(--igny8-danger) 0%, var(--igny8-danger-dark) 100%) !important;
+ color: white !important;
+ box-shadow: 0 2px 6px rgba(239, 68, 68, 0.3) !important;
+}
+
+.deletelink:hover,
+.deletelink-box a:hover,
+input[name="_delete"]:hover {
+ background: linear-gradient(135deg, var(--igny8-danger-dark) 0%, #b82222 100%) !important;
+ box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4) !important;
+}
+
+/* ===================================================================
+ TABLE IMPROVEMENTS
+ =================================================================== */
+#result_list {
+ border: 1px solid var(--igny8-stroke) !important;
+ border-radius: 12px !important;
+ overflow: hidden !important;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05) !important;
+}
+
+#result_list thead th {
+ background: linear-gradient(135deg, var(--igny8-surface) 0%, #f1f5f9 100%) !important;
+ color: var(--igny8-text) !important;
+ padding: 16px 12px !important;
+ font-weight: 600 !important;
+ font-size: 13px !important;
+ text-transform: uppercase !important;
+ letter-spacing: 0.5px !important;
+ border-bottom: 2px solid var(--igny8-stroke-dark) !important;
+}
+
+#result_list thead th a {
+ color: var(--igny8-text) !important;
+ text-decoration: none !important;
+}
+
+#result_list tbody tr {
+ transition: background-color 0.2s ease !important;
+}
+
+#result_list tbody tr:nth-child(odd) {
+ background-color: white !important;
+}
+
+#result_list tbody tr:nth-child(even) {
+ background-color: var(--igny8-surface) !important;
+}
+
+#result_list tbody tr:hover {
+ background-color: rgba(6, 147, 227, 0.05) !important;
+}
+
+#result_list td {
+ padding: 14px 12px !important;
+ color: var(--igny8-text) !important;
+ font-size: 14px !important;
+ border-bottom: 1px solid var(--igny8-stroke) !important;
+}
+ background: var(--igny8-danger-dark) !important;
+}
+
/* ===================================================================
LINKS - IGNY8 Primary Blue
=================================================================== */
@@ -517,3 +1258,46 @@ fieldset.module h2 {
border-left-color: var(--igny8-success);
color: var(--igny8-success-dark);
}
+
+/* ===================================================================
+ SIDEBAR MODULE ADD LINKS - Clean and Professional
+ =================================================================== */
+.module .addlink,
+.module a.addlink,
+#content-main .module .addlink {
+ color: var(--igny8-primary) !important;
+ text-decoration: none !important;
+ font-size: 13px !important;
+ font-weight: 500 !important;
+ padding: 6px 10px !important;
+ display: inline-block !important;
+ border-radius: 4px !important;
+ transition: all 0.2s ease !important;
+}
+
+.module .addlink:before,
+.module a.addlink:before {
+ content: "+" !important;
+ display: inline-block !important;
+ width: 18px !important;
+ height: 18px !important;
+ line-height: 18px !important;
+ text-align: center !important;
+ border-radius: 3px !important;
+ background: var(--igny8-primary) !important;
+ color: white !important;
+ margin-right: 6px !important;
+ font-size: 14px !important;
+ font-weight: bold !important;
+}
+
+.module .addlink:hover,
+.module a.addlink:hover {
+ background: rgba(6, 147, 227, 0.1) !important;
+ color: var(--igny8-primary-dark) !important;
+}
+
+.module .addlink:hover:before,
+.module a.addlink:hover:before {
+ background: var(--igny8-primary-dark) !important;
+}
diff --git a/backend/igny8_core/templates/admin/base_site.html b/backend/igny8_core/templates/admin/base_site.html
index 1446add4..c46fa9ef 100644
--- a/backend/igny8_core/templates/admin/base_site.html
+++ b/backend/igny8_core/templates/admin/base_site.html
@@ -3,17 +3,68 @@
{% block title %}{{ title }} | IGNY8 Admin{% endblock %}
+{% block extrahead %}
+{{ block.super }}
+
+
+{% endblock %}
+
{% block branding %}
Real-time operational metrics and system health monitoring
+No active alerts or issues detected
+