Bài 09 Part 2: Django Admin Advanced Features
Custom Admin Site
Creating Custom Admin Site
# myapp/admin.pyfrom django.contrib import adminfrom django.contrib.admin import AdminSite class MyAdminSite(AdminSite): """Custom admin site.""" site_header = 'My Blog Administration' site_title = 'My Blog Admin' index_title = 'Welcome to My Blog Admin Portal' # Custom URL site_url = '/blog/' # Enable/disable features enable_nav_sidebar = True # Create custom admin site instancemy_admin_site = MyAdminSite(name='myadmin') # Register models to custom sitefrom .models import Post, Category my_admin_site.register(Post)my_admin_site.register(Category) # In urls.pyfrom myapp.admin import my_admin_site urlpatterns = [ path('admin/', admin.site.urls), # Default admin path('myadmin/', my_admin_site.urls), # Custom admin]Multiple Admin Sites
# admin.pyfrom django.contrib.admin import AdminSite # Admin for staffclass StaffAdminSite(AdminSite): site_header = 'Staff Administration' def has_permission(self, request): """Only staff can access.""" return request.user.is_active and request.user.is_staff staff_admin_site = StaffAdminSite(name='staffadmin') # Admin for managersclass ManagerAdminSite(AdminSite): site_header = 'Manager Administration' def has_permission(self, request): """Only managers can access.""" return ( request.user.is_active and request.user.groups.filter(name='Managers').exists() ) manager_admin_site = ManagerAdminSite(name='manageradmin') # Register different models to different sitesfrom .models import Post, Comment, User staff_admin_site.register(Post)staff_admin_site.register(Comment) manager_admin_site.register(User) # urls.pyurlpatterns = [ path('admin/', admin.site.urls), path('staff/', staff_admin_site.urls), path('manager/', manager_admin_site.urls),]Customizing Admin Index
class MyAdminSite(AdminSite): def index(self, request, extra_context=None): """Custom index page with statistics.""" from django.db.models import Count from .models import Post, Comment extra_context = extra_context or {} # Add custom data extra_context['total_posts'] = Post.objects.count() extra_context['published_posts'] = Post.objects.filter( published=True ).count() extra_context['total_comments'] = Comment.objects.count() extra_context['pending_comments'] = Comment.objects.filter( approved=False ).count() # Recent posts extra_context['recent_posts'] = Post.objects.order_by( '-created' )[:5] return super().index(request, extra_context) my_admin_site = MyAdminSite(name='myadmin')Admin Templates Customization
Template Override Structure
# Template hierarchy for admin:# your_app/templates/admin/# ├── base_site.html # Override site branding# ├── index.html # Override index page# ├── app_label/# │ ├── change_list.html # Override list view for app# │ └── model_name/# │ ├── change_form.html # Override edit form# │ ├── change_list.html # Override list view# │ └── delete_confirmation.html# └── widgets/# └── custom_widget.htmlOverride base_site.html
{# templates/admin/base_site.html #}{% extends "admin/base.html" %}{% load static %} {% block title %} {{ title }} | My Blog Admin{% endblock %} {% block branding %}<div id="site-name"> <a href="{% url 'admin:index' %}"> <img src="{% static 'admin/img/logo.png' %}" alt="Logo" style="height: 40px;"> My Blog Administration </a></div>{% endblock %} {% block nav-global %}<div class="nav-global"> <a href="/">View Site</a> <a href="/docs/">Documentation</a> <a href="/stats/">Statistics</a></div>{% endblock %} {% block extrastyle %}<style> #site-name a { color: #2e7d32; font-weight: bold; } .nav-global { float: right; margin-top: 10px; } .nav-global a { margin-left: 20px; color: #fff; }</style>{% endblock %}Override change_list.html
{# templates/admin/blog/post/change_list.html #}{% extends "admin/change_list.html" %}{% load static %} {% block content_title %}<h1>Manage Blog Posts</h1><div class="stats"> <span>Total: {{ cl.result_count }}</span> <span>Published: {{ published_count }}</span> <span>Draft: {{ draft_count }}</span></div>{% endblock %} {% block result_list %} {# Add custom content before list #} <div class="alert alert-info"> Quick tip: Use filters to find posts quickly! </div> {{ block.super }}{% endblock %} {% block extrastyle %}{{ block.super }}<style> .stats { margin: 10px 0; padding: 10px; background: #f5f5f5; border-radius: 4px; } .stats span { margin-right: 20px; font-weight: bold; }</style>{% endblock %}Override change_form.html
{# templates/admin/blog/post/change_form.html #}{% extends "admin/change_form.html" %}{% load static %} {% block field_sets %} {# Add help text before form #} <div class="help-panel"> <h3>Writing Tips</h3> <ul> <li>Keep titles under 60 characters for better SEO</li> <li>Add at least one image</li> <li>Use proper formatting in content</li> </ul> </div> {{ block.super }}{% endblock %} {% block after_field_sets %} {# Add preview button #} <div class="submit-row"> <button type="button" id="preview-btn" class="button"> Preview Post </button> </div>{% endblock %} {% block extrahead %}{{ block.super }}<script>document.addEventListener('DOMContentLoaded', function() { document.getElementById('preview-btn').addEventListener('click', function() { // Get form data const title = document.getElementById('id_title').value; const content = document.getElementById('id_content').value; // Open preview window const preview = window.open('', 'preview', 'width=800,height=600'); preview.document.write(` <html> <head><title>Preview: ${title}</title></head> <body> <h1>${title}</h1> <div>${content}</div> </body> </html> `); });});</script>{% endblock %} {% block extrastyle %}{{ block.super }}<style> .help-panel { background: #e7f3ff; padding: 15px; margin-bottom: 20px; border-left: 4px solid #2196F3; }</style>{% endblock %}Custom Admin Widgets
Custom Widget Class
# blog/widgets.pyfrom django import formsfrom django.contrib.admin.widgets import AdminTextareaWidgetfrom django.utils.safestring import mark_safe class MarkdownEditorWidget(AdminTextareaWidget): """Custom markdown editor widget.""" def render(self, name, value, attrs=None, renderer=None): """Render widget with markdown editor.""" output = super().render(name, value, attrs, renderer) # Add markdown editor toolbar toolbar = ''' <div class="markdown-toolbar"> <button type="button" onclick="insertMarkdown('**', '**')">Bold</button> <button type="button" onclick="insertMarkdown('*', '*')">Italic</button> <button type="button" onclick="insertMarkdown('# ', '')">Heading</button> <button type="button" onclick="insertMarkdown('[', '](url)')">Link</button> <button type="button" onclick="insertMarkdown('```\\n', '\\n```')">Code</button> </div> ''' # JavaScript for markdown insertion script = ''' <script> function insertMarkdown(before, after) { const textarea = document.getElementById('id_{name}'); const start = textarea.selectionStart; const end = textarea.selectionEnd; const text = textarea.value; const selectedText = text.substring(start, end); textarea.value = text.substring(0, start) + before + selectedText + after + text.substring(end); textarea.focus(); textarea.selectionStart = start + before.length; textarea.selectionEnd = start + before.length + selectedText.length; } </script> '''.format(name=name) style = ''' <style> .markdown-toolbar { margin-bottom: 5px; } .markdown-toolbar button { padding: 5px 10px; margin-right: 5px; background: #2196F3; color: white; border: none; cursor: pointer; } </style> ''' return mark_safe(toolbar + output + script + style) class Media: css = { 'all': ('admin/css/markdown-editor.css',) } js = ('admin/js/markdown-editor.js',)Using Custom Widget
# blog/admin.pyfrom django import formsfrom django.contrib import adminfrom .models import Postfrom .widgets import MarkdownEditorWidget class PostAdminForm(forms.ModelForm): """Custom form with markdown editor.""" class Meta: model = Post fields = '__all__' widgets = { 'content': MarkdownEditorWidget(attrs={ 'rows': 20, 'cols': 80 }) } @admin.register(Post)class PostAdmin(admin.ModelAdmin): form = PostAdminForm list_display = ['title', 'author', 'created']Image Preview Widget
# widgets.pyfrom django.contrib.admin.widgets import AdminFileWidgetfrom django.utils.html import format_html class ImagePreviewWidget(AdminFileWidget): """Widget with image preview.""" def render(self, name, value, attrs=None, renderer=None): output = super().render(name, value, attrs, renderer) if value and hasattr(value, 'url'): preview = format_html( '<div class="image-preview">' '<img src="{}" style="max-width: 200px; max-height: 200px; margin-top: 10px;">' '</div>', value.url ) return format_html('{}{}', output, preview) return output # Usageclass PostAdminForm(forms.ModelForm): class Meta: model = Post fields = '__all__' widgets = { 'featured_image': ImagePreviewWidget() }Advanced Permissions
Object-Level Permissions
@admin.register(Post)class PostAdmin(admin.ModelAdmin): list_display = ['title', 'author', 'published'] def get_queryset(self, request): """Filter queryset based on user.""" qs = super().get_queryset(request) if request.user.is_superuser: return qs # Regular users see only their posts return qs.filter(author=request.user) def has_change_permission(self, request, obj=None): """Check if user can edit this object.""" if obj is None: return True if request.user.is_superuser: return True # Check if user is the author return obj.author == request.user def has_delete_permission(self, request, obj=None): """Check if user can delete this object.""" if obj is None: return True if request.user.is_superuser: return True # Authors can delete, but only unpublished posts return obj.author == request.user and not obj.published def save_model(self, request, obj, form, change): """Set author automatically.""" if not change: # Creating new obj.author = request.user super().save_model(request, obj, form, change) def get_form(self, request, obj=None, **kwargs): """Customize form based on user.""" form = super().get_form(request, obj, **kwargs) # Non-superusers can't change author if not request.user.is_superuser: if 'author' in form.base_fields: form.base_fields['author'].disabled = True return formCustom Permissions
# models.pyclass Post(models.Model): title = models.CharField(max_length=200) content = models.TextField() published = models.BooleanField(default=False) class Meta: permissions = [ ('can_publish', 'Can publish posts'), ('can_feature', 'Can feature posts'), ('can_moderate', 'Can moderate comments'), ] # admin.py@admin.register(Post)class PostAdmin(admin.ModelAdmin): actions = ['make_published', 'make_featured'] @admin.action(description='Publish selected posts') def make_published(self, request, queryset): """Publish posts (requires permission).""" if not request.user.has_perm('blog.can_publish'): self.message_user( request, 'You do not have permission to publish posts.', level='error' ) return updated = queryset.update(published=True) self.message_user(request, f'{updated} posts published.') @admin.action(description='Feature selected posts') def make_featured(self, request, queryset): """Feature posts (requires permission).""" if not request.user.has_perm('blog.can_feature'): self.message_user( request, 'You do not have permission to feature posts.', level='error' ) return updated = queryset.update(featured=True) self.message_user(request, f'{updated} posts featured.') def get_actions(self, request): """Show actions based on permissions.""" actions = super().get_actions(request) # Remove publish action if no permission if not request.user.has_perm('blog.can_publish'): if 'make_published' in actions: del actions['make_published'] # Remove feature action if no permission if not request.user.has_perm('blog.can_feature'): if 'make_featured' in actions: del actions['make_featured'] return actionsGroup-Based Permissions
@admin.register(Post)class PostAdmin(admin.ModelAdmin): def get_readonly_fields(self, request, obj=None): """Set readonly fields based on user group.""" readonly = list(super().get_readonly_fields(request, obj)) # Editors can't change author if request.user.groups.filter(name='Editors').exists(): readonly.append('author') # Writers can't publish if request.user.groups.filter(name='Writers').exists(): readonly.extend(['published', 'featured']) return readonly def get_list_display(self, request): """Customize columns based on user group.""" list_display = ['title', 'author', 'created'] # Only managers see views if request.user.groups.filter(name='Managers').exists(): list_display.append('views') return list_displayAdmin Performance Optimization
select_related and prefetch_related
@admin.register(Post)class PostAdmin(admin.ModelAdmin): list_display = ['title', 'author_name', 'category_name', 'tag_count'] list_select_related = ['author', 'category'] # Optimize ForeignKey def get_queryset(self, request): """Optimize queryset with prefetch.""" qs = super().get_queryset(request) return qs.select_related('author', 'category').prefetch_related('tags') @admin.display(description='Author') def author_name(self, obj): return obj.author.username # No N+1 query @admin.display(description='Category') def category_name(self, obj): return obj.category.name if obj.category else '-' @admin.display(description='Tags') def tag_count(self, obj): return obj.tags.count() # PrefetchedCaching Counts
from django.core.cache import cachefrom django.db.models import Count @admin.register(Post)class PostAdmin(admin.ModelAdmin): def changelist_view(self, request, extra_context=None): """Add cached statistics to context.""" extra_context = extra_context or {} # Cache statistics for 5 minutes cache_key = 'admin_post_stats' stats = cache.get(cache_key) if stats is None: stats = { 'total': Post.objects.count(), 'published': Post.objects.filter(published=True).count(), 'draft': Post.objects.filter(published=False).count(), 'by_category': Post.objects.values('category__name').annotate( count=Count('id') ).order_by('-count')[:5] } cache.set(cache_key, stats, 300) # 5 minutes extra_context['stats'] = stats return super().changelist_view(request, extra_context)Limit Choices
@admin.register(Comment)class CommentAdmin(admin.ModelAdmin): def formfield_for_foreignkey(self, db_field, request, **kwargs): """Limit choices for ForeignKey.""" if db_field.name == 'post': # Only show published posts kwargs['queryset'] = Post.objects.filter(published=True) if db_field.name == 'author': # Only show active users kwargs['queryset'] = User.objects.filter(is_active=True) return super().formfield_for_foreignkey(db_field, request, **kwargs) def formfield_for_manytomany(self, db_field, request, **kwargs): """Limit choices for ManyToMany.""" if db_field.name == 'tags': # Only show tags with posts kwargs['queryset'] = Tag.objects.annotate( post_count=Count('post') ).filter(post_count__gt=0) return super().formfield_for_manytomany(db_field, request, **kwargs)Advanced Features
Admin Log Entries
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETIONfrom django.contrib.contenttypes.models import ContentType # View log entries@admin.register(LogEntry)class LogEntryAdmin(admin.ModelAdmin): list_display = [ 'action_time', 'user', 'content_type', 'object_repr', 'action_flag', 'change_message' ] list_filter = ['action_time', 'user', 'content_type'] search_fields = ['object_repr', 'change_message'] date_hierarchy = 'action_time' def has_add_permission(self, request): return False def has_change_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): return False # Create custom log entryfrom django.contrib.admin.models import LogEntry def custom_action(request, post): """Create custom log entry.""" LogEntry.objects.log_action( user_id=request.user.id, content_type_id=ContentType.objects.get_for_model(post).pk, object_id=post.pk, object_repr=str(post), action_flag=CHANGE, change_message='Custom action performed' )Custom Admin Views
# admin.pyfrom django.urls import pathfrom django.shortcuts import renderfrom django.contrib.admin.views.decorators import staff_member_required @admin.register(Post)class PostAdmin(admin.ModelAdmin): def get_urls(self): """Add custom URLs.""" urls = super().get_urls() custom_urls = [ path( 'statistics/', self.admin_site.admin_view(self.statistics_view), name='post_statistics' ), path( '<int:post_id>/preview/', self.admin_site.admin_view(self.preview_view), name='post_preview' ), ] return custom_urls + urls def statistics_view(self, request): """Custom statistics view.""" from django.db.models import Count, Avg stats = { 'total_posts': Post.objects.count(), 'published': Post.objects.filter(published=True).count(), 'avg_views': Post.objects.aggregate(Avg('views'))['views__avg'], 'by_author': Post.objects.values('author__username').annotate( count=Count('id') ).order_by('-count')[:10], 'by_category': Post.objects.values('category__name').annotate( count=Count('id') ).order_by('-count')[:10], } context = dict( self.admin_site.each_context(request), stats=stats, title='Post Statistics' ) return render(request, 'admin/post_statistics.html', context) def preview_view(self, request, post_id): """Preview post.""" post = Post.objects.get(pk=post_id) context = dict( self.admin_site.each_context(request), post=post, title=f'Preview: {post.title}' ) return render(request, 'admin/post_preview.html', context) def changelist_view(self, request, extra_context=None): """Add link to statistics in changelist.""" extra_context = extra_context or {} extra_context['statistics_url'] = 'statistics/' return super().changelist_view(request, extra_context)Export/Import with django-import-export
# Install: pip install django-import-export from import_export import resourcesfrom import_export.admin import ImportExportModelAdmin class PostResource(resources.ModelResource): """Define import/export format.""" class Meta: model = Post fields = ['id', 'title', 'slug', 'content', 'published', 'created'] export_order = ['id', 'title', 'slug', 'content', 'published', 'created'] def dehydrate_published(self, post): """Custom export format.""" return 'Yes' if post.published else 'No' @admin.register(Post)class PostAdmin(ImportExportModelAdmin): resource_class = PostResource list_display = ['title', 'published', 'created'] # Import/Export buttons appear automaticallyBài Tập
Bài 1: Custom Admin Site
Task: Create custom admin site:
# 1. Create custom admin siteclass MyAdminSite(AdminSite): site_header = 'My Custom Admin' site_title = 'My Admin Portal' index_title = 'Dashboard' # 2. Register models to custom site# 3. Add custom URL# 4. Customize index page with statisticsBài 2: Custom Widget
Task: Create rich text editor widget:
# 1. Create RichTextEditorWidget# 2. Add toolbar with formatting buttons# 3. Add JavaScript for text manipulation# 4. Use widget in PostAdminFormBài 3: Advanced Permissions
Task: Implement role-based permissions:
# Roles:# - Writer: Can create/edit own posts# - Editor: Can edit all posts# - Publisher: Can publish posts# - Admin: Full access # Implement:# 1. get_queryset based on role# 2. has_change_permission based on role# 3. get_readonly_fields based on role# 4. Custom actions with permission checksBài 4: Admin Optimization
Task: Optimize Post admin:
# 1. Add select_related and prefetch_related# 2. Implement caching for statistics# 3. Limit choices for ForeignKey/ManyToMany# 4. Add custom view for analytics# 5. Measure query count before/after optimizationTài Liệu Tham Khảo
- Customizing the Django admin interface
- Admin site class
- Overriding admin templates
- Admin widgets
- django-import-export
Previous: Bài 09: Django Admin | Next: Bài 10: Forms