Files
thrillwiki_django_no_react/docs/admin/best_practices.md
pacnpal edcd8f2076 Add secret management guide, client-side performance monitoring, and search accessibility enhancements
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols.
- Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage.
- Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
2025-12-23 16:41:42 -05:00

6.2 KiB

Admin Best Practices

This guide outlines best practices for developing and maintaining Django admin interfaces in ThrillWiki.

Query Optimization

class RideAdmin(BaseModelAdmin):
    list_display = ['name', 'park', 'manufacturer']
    list_select_related = ['park', 'manufacturer']  # Prevents N+1 queries
class ParkAdmin(BaseModelAdmin):
    list_display = ['name', 'ride_count']
    list_prefetch_related = ['rides']  # For efficient count queries

Use Annotations for Calculated Fields

def get_queryset(self, request):
    qs = super().get_queryset(request)
    return qs.annotate(
        _ride_count=Count('rides', distinct=True),
        _avg_rating=Avg('reviews__rating'),
    )

Display Methods

Always Use format_html for Safety

@admin.display(description="Status")
def status_badge(self, obj):
    return format_html(
        '<span style="color: {};">{}</span>',
        'green' if obj.is_active else 'red',
        obj.get_status_display(),
    )

Handle Missing Data Gracefully

@admin.display(description="Park")
def park_link(self, obj):
    if obj.park:
        url = reverse("admin:parks_park_change", args=[obj.park.pk])
        return format_html('<a href="{}">{}</a>', url, obj.park.name)
    return "-"  # Or format_html for styled "N/A"

Fieldsets Organization

Standard Fieldset Structure

  1. Basic Information - Name, slug, description
  2. Relationships - ForeignKeys, ManyToMany
  3. Status & Dates - Status fields, timestamps
  4. Specifications - Technical details (collapsed)
  5. Media - Images, photos (collapsed)
  6. Metadata - created_at, updated_at (collapsed)

Include Descriptions

fieldsets = (
    (
        "Basic Information",
        {
            "fields": ("name", "slug", "description"),
            "description": "Core identification for this record.",
        },
    ),
)

Custom Actions

Use Proper Action Decorator

@admin.action(description="Approve selected items")
def bulk_approve(self, request, queryset):
    count = queryset.update(status='approved')
    self.message_user(request, f"Approved {count} items.")

Handle Errors Gracefully

@admin.action(description="Process selected items")
def process_items(self, request, queryset):
    processed = 0
    errors = 0
    for item in queryset:
        try:
            item.process()
            processed += 1
        except Exception as e:
            errors += 1
            self.message_user(
                request,
                f"Error processing {item}: {e}",
                level=messages.ERROR,
            )
    if processed:
        self.message_user(request, f"Processed {processed} items.")

Protect Against Dangerous Operations

@admin.action(description="Ban selected users")
def ban_users(self, request, queryset):
    # Prevent banning self
    queryset = queryset.exclude(pk=request.user.pk)
    # Prevent banning superusers
    queryset = queryset.exclude(is_superuser=True)
    updated = queryset.update(is_banned=True)
    self.message_user(request, f"Banned {updated} users.")

Permissions

Read-Only for Auto-Generated Data

class RankingAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
    """Rankings are calculated automatically - no manual editing."""
    pass

Field-Level Permissions

def get_readonly_fields(self, request, obj=None):
    readonly = list(super().get_readonly_fields(request, obj))
    if not request.user.is_superuser:
        readonly.extend(['sensitive_field', 'admin_notes'])
    return readonly

Object-Level Permissions

def has_change_permission(self, request, obj=None):
    if obj is None:
        return super().has_change_permission(request)
    # Only allow editing own content or moderator access
    if obj.user == request.user:
        return True
    return request.user.has_perm('app.moderate_content')

Performance Tips

Disable Full Result Count

class MyAdmin(BaseModelAdmin):
    show_full_result_count = False  # Faster for large datasets

Use Pagination

class MyAdmin(BaseModelAdmin):
    list_per_page = 50  # Reasonable page size

Limit Inline Items

class ItemInline(admin.TabularInline):
    model = Item
    extra = 1  # Only show 1 empty form
    max_num = 20  # Limit total items

Testing

Test Query Counts

def test_list_view_query_count(self):
    with self.assertNumQueries(10):  # Set target
        response = self.client.get('/admin/app/model/')

Test Permissions

def test_readonly_permissions(self):
    request = self.factory.get('/admin/')
    request.user = User(is_superuser=False)

    assert self.admin.has_add_permission(request) is False
    assert self.admin.has_change_permission(request) is False

Test Custom Actions

def test_bulk_approve_action(self):
    items = [create_item(status='pending') for _ in range(5)]
    queryset = Item.objects.filter(pk__in=[i.pk for i in items])

    self.admin.bulk_approve(self.request, queryset)

    for item in items:
        item.refresh_from_db()
        assert item.status == 'approved'

Common Pitfalls

Avoid N+1 Queries

# BAD: Creates N+1 queries
@admin.display()
def related_count(self, obj):
    return obj.related_set.count()

# GOOD: Use annotation
def get_queryset(self, request):
    return super().get_queryset(request).annotate(
        _related_count=Count('related_set')
    )

@admin.display()
def related_count(self, obj):
    return obj._related_count

Don't Forget Error Handling

# BAD: May raise AttributeError
@admin.display()
def user_name(self, obj):
    return obj.user.username

# GOOD: Handle missing relations
@admin.display()
def user_name(self, obj):
    return obj.user.username if obj.user else "-"

Use Autocomplete for Large Relations

# BAD: Loads all options
class MyAdmin(BaseModelAdmin):
    raw_id_fields = ['park']  # Works but poor UX

# GOOD: Search with autocomplete
class MyAdmin(BaseModelAdmin):
    autocomplete_fields = ['park']