Files
igny8/backend/igny8_core/admin/base.py

254 lines
10 KiB
Python

"""
Base Admin Mixins for account and site/sector filtering.
ADMIN DELETE FIX:
- Admin can delete anything without 500 errors
- Simple delete that just works
"""
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied
from django.db import models, transaction
class AdminDeleteMixin:
"""
Mixin that provides a simple working delete action for admin.
"""
def get_actions(self, request):
"""Replace default delete_selected with simple working version"""
actions = super().get_actions(request)
# Remove Django's default delete that causes 500 errors
if 'delete_selected' in actions:
del actions['delete_selected']
# Add our simple delete action
actions['simple_delete'] = (
self.__class__.simple_delete,
'simple_delete',
'Delete selected items'
)
return actions
def simple_delete(self, request, queryset):
"""
Simple delete that just works. Deletes items one by one with error handling.
"""
success = 0
errors = []
for obj in queryset:
try:
# Get object info before delete
try:
obj_str = str(obj)
except Exception:
obj_str = f'#{obj.pk}'
# Just delete it - let the model handle soft vs hard delete
obj.delete()
success += 1
except Exception as e:
errors.append(f'{obj_str}: {str(e)[:50]}')
if success:
self.message_user(request, f'Deleted {success} item(s).', messages.SUCCESS)
if errors:
self.message_user(request, f'Failed to delete {len(errors)}: {"; ".join(errors[:3])}', messages.ERROR)
class AccountAdminMixin:
"""Mixin for admin classes that need account filtering"""
def get_queryset(self, request):
"""Filter queryset by account"""
qs = super().get_queryset(request)
# Check for account field
has_account_field = hasattr(qs.model, 'account')
if has_account_field:
# Superuser and developers can see all
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return qs
# Filter by user's account
user_account = getattr(request.user, 'account', None)
if user_account:
return qs.filter(account=user_account)
return qs
def has_view_permission(self, request, obj=None):
"""Check if user can view this object"""
if obj:
obj_account = getattr(obj, 'account', None)
if obj_account:
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return True
user_account = getattr(request.user, 'account', None)
if user_account:
return obj_account == user_account
return super().has_view_permission(request, obj)
def has_change_permission(self, request, obj=None):
"""Check if user can change this object"""
if obj:
obj_account = getattr(obj, 'account', None)
if obj_account:
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return True
user_account = getattr(request.user, 'account', None)
if user_account:
return obj_account == user_account
return super().has_change_permission(request, obj)
def has_delete_permission(self, request, obj=None):
"""Check if user can delete this object"""
if obj:
obj_account = getattr(obj, 'account', None)
if obj_account:
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return True
user_account = getattr(request.user, 'account', None)
if user_account:
return obj_account == user_account
return super().has_delete_permission(request, obj)
class SiteSectorAdminMixin:
"""Mixin for admin classes that need site/sector filtering"""
def get_queryset(self, request):
"""Filter queryset by site/sector access"""
qs = super().get_queryset(request)
if hasattr(qs.model, 'site') and hasattr(qs.model, 'sector'):
# Superuser and developers can see all
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return qs
# Filter by accessible sites
if hasattr(request.user, 'get_accessible_sites'):
accessible_sites = request.user.get_accessible_sites()
return qs.filter(site__in=accessible_sites)
return qs
def has_view_permission(self, request, obj=None):
"""Check if user can view this object"""
if obj and hasattr(obj, 'site'):
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return True
if hasattr(request.user, 'get_accessible_sites'):
accessible_sites = request.user.get_accessible_sites()
return obj.site in accessible_sites
return super().has_view_permission(request, obj)
def has_change_permission(self, request, obj=None):
"""Check if user can change this object"""
if obj and hasattr(obj, 'site'):
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return True
if hasattr(request.user, 'get_accessible_sites'):
accessible_sites = request.user.get_accessible_sites()
return obj.site in accessible_sites
return super().has_change_permission(request, obj)
def has_delete_permission(self, request, obj=None):
"""Check if user can delete this object"""
if obj and hasattr(obj, 'site'):
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return True
if hasattr(request.user, 'get_accessible_sites'):
accessible_sites = request.user.get_accessible_sites()
return obj.site in accessible_sites
return super().has_delete_permission(request, obj)
# ============================================================================
# Custom ModelAdmin for Sidebar Fix + Delete Fix
# ============================================================================
from unfold.admin import ModelAdmin as UnfoldModelAdmin
class Igny8ModelAdmin(AdminDeleteMixin, UnfoldModelAdmin):
"""
Custom ModelAdmin that:
1. Fixes delete actions (no 500 errors, bypasses PROTECT if needed)
2. Ensures sidebar_navigation is set correctly on ALL pages
3. Uses dropdown filters with Apply button
AdminDeleteMixin provides:
- simple_delete: Safe delete (soft delete if available)
"""
# Enable "Apply Filters" button for dropdown filters
list_filter_submit = True
def _inject_sidebar_context(self, request, extra_context=None):
"""Helper to inject custom sidebar into context"""
if extra_context is None:
extra_context = {}
# Get our custom sidebar from the admin site
from igny8_core.admin.site import admin_site
# CRITICAL: Get the full Unfold context (includes all branding, form classes, etc.)
# This is what makes the logo/title appear properly
unfold_context = admin_site.each_context(request)
# Get the current path to detect active group
current_path = request.path
sidebar_navigation = admin_site.get_sidebar_list(request)
# Detect active group and expand it by setting collapsible=False
for group in sidebar_navigation:
group_is_active = False
for item in group.get('items', []):
# Unfold stores resolved link in 'link_callback', original lambda in 'link'
item_link = item.get('link_callback') or item.get('link', '')
# Convert to string (handles lazy proxy objects and ensures it's a string)
try:
item_link = str(item_link) if item_link else ''
except:
item_link = ''
# Skip if it's a function representation (e.g., "<function ...>")
if item_link.startswith('<'):
continue
# Check if current path matches this item's link
if item_link and current_path.startswith(item_link):
item['active'] = True
group_is_active = True
# If any item in this group is active, expand the group
if group_is_active:
group['collapsible'] = False # Expanded state
else:
group['collapsible'] = True # Collapsed state
# Merge Unfold context with our custom sidebar
unfold_context['sidebar_navigation'] = sidebar_navigation
unfold_context['available_apps'] = admin_site.get_app_list(request, app_label=None)
unfold_context['app_list'] = unfold_context['available_apps']
# Merge with any existing extra_context
unfold_context.update(extra_context)
return unfold_context
def changelist_view(self, request, extra_context=None):
"""Override to inject custom sidebar"""
extra_context = self._inject_sidebar_context(request, extra_context)
return super().changelist_view(request, extra_context)
def change_view(self, request, object_id, form_url='', extra_context=None):
"""Override to inject custom sidebar"""
extra_context = self._inject_sidebar_context(request, extra_context)
return super().change_view(request, object_id, form_url, extra_context)
def add_view(self, request, form_url='', extra_context=None):
"""Override to inject custom sidebar"""
extra_context = self._inject_sidebar_context(request, extra_context)
return super().add_view(request, form_url, extra_context)