mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-25 07:51:08 -05:00
- 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.
268 lines
6.2 KiB
Markdown
268 lines
6.2 KiB
Markdown
# Admin Best Practices
|
|
|
|
This guide outlines best practices for developing and maintaining Django admin interfaces in ThrillWiki.
|
|
|
|
## Query Optimization
|
|
|
|
### Always Use select_related for ForeignKeys
|
|
|
|
```python
|
|
class RideAdmin(BaseModelAdmin):
|
|
list_display = ['name', 'park', 'manufacturer']
|
|
list_select_related = ['park', 'manufacturer'] # Prevents N+1 queries
|
|
```
|
|
|
|
### Use prefetch_related for Reverse Relations
|
|
|
|
```python
|
|
class ParkAdmin(BaseModelAdmin):
|
|
list_display = ['name', 'ride_count']
|
|
list_prefetch_related = ['rides'] # For efficient count queries
|
|
```
|
|
|
|
### Use Annotations for Calculated Fields
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
@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
|
|
|
|
```python
|
|
@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
|
|
|
|
```python
|
|
fieldsets = (
|
|
(
|
|
"Basic Information",
|
|
{
|
|
"fields": ("name", "slug", "description"),
|
|
"description": "Core identification for this record.",
|
|
},
|
|
),
|
|
)
|
|
```
|
|
|
|
## Custom Actions
|
|
|
|
### Use Proper Action Decorator
|
|
|
|
```python
|
|
@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
|
|
|
|
```python
|
|
@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
|
|
|
|
```python
|
|
@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
|
|
|
|
```python
|
|
class RankingAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
|
|
"""Rankings are calculated automatically - no manual editing."""
|
|
pass
|
|
```
|
|
|
|
### Field-Level Permissions
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
class MyAdmin(BaseModelAdmin):
|
|
show_full_result_count = False # Faster for large datasets
|
|
```
|
|
|
|
### Use Pagination
|
|
|
|
```python
|
|
class MyAdmin(BaseModelAdmin):
|
|
list_per_page = 50 # Reasonable page size
|
|
```
|
|
|
|
### Limit Inline Items
|
|
|
|
```python
|
|
class ItemInline(admin.TabularInline):
|
|
model = Item
|
|
extra = 1 # Only show 1 empty form
|
|
max_num = 20 # Limit total items
|
|
```
|
|
|
|
## Testing
|
|
|
|
### Test Query Counts
|
|
|
|
```python
|
|
def test_list_view_query_count(self):
|
|
with self.assertNumQueries(10): # Set target
|
|
response = self.client.get('/admin/app/model/')
|
|
```
|
|
|
|
### Test Permissions
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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']
|
|
```
|