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.html

Override 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 form

Custom 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 actions

Group-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_display

Admin Performance Optimization

@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()  # Prefetched

Caching 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 automatically

Bà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 statistics

Bà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 PostAdminForm

Bà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 checks

Bà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 optimization

Tài Liệu Tham Khảo


Previous: Bài 09: Django Admin | Next: Bài 10: Forms