mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 05:31:09 -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.
864 lines
28 KiB
Python
864 lines
28 KiB
Python
"""
|
|
Django admin configuration for the Accounts application.
|
|
|
|
This module provides comprehensive admin interfaces for managing users,
|
|
profiles, email verification, password resets, and top lists. All admin
|
|
classes use optimized querysets and follow the standardized admin patterns.
|
|
|
|
Performance targets:
|
|
- List views: < 10 queries
|
|
- Change views: < 15 queries
|
|
- Page load time: < 500ms for 100 records
|
|
"""
|
|
|
|
from datetime import timedelta
|
|
|
|
from django.contrib import admin, messages
|
|
from django.contrib.auth.admin import UserAdmin
|
|
from django.contrib.auth.models import Group
|
|
from django.db.models import Count, Sum
|
|
from django.utils import timezone
|
|
from django.utils.html import format_html
|
|
|
|
from apps.core.admin import (
|
|
BaseModelAdmin,
|
|
ExportActionMixin,
|
|
QueryOptimizationMixin,
|
|
ReadOnlyAdminMixin,
|
|
TimestampFieldsMixin,
|
|
)
|
|
|
|
from .models import (
|
|
EmailVerification,
|
|
PasswordReset,
|
|
TopList,
|
|
TopListItem,
|
|
User,
|
|
UserProfile,
|
|
)
|
|
|
|
|
|
class UserProfileInline(admin.StackedInline):
|
|
"""
|
|
Inline admin for UserProfile within User admin.
|
|
|
|
Displays profile information including social media and ride credits.
|
|
"""
|
|
|
|
model = UserProfile
|
|
can_delete = False
|
|
verbose_name_plural = "Profile"
|
|
classes = ("collapse",)
|
|
fieldsets = (
|
|
(
|
|
"Personal Info",
|
|
{
|
|
"fields": ("display_name", "avatar", "pronouns", "bio"),
|
|
"description": "User's public profile information.",
|
|
},
|
|
),
|
|
(
|
|
"Social Media",
|
|
{
|
|
"fields": ("twitter", "instagram", "youtube", "discord"),
|
|
"classes": ("collapse",),
|
|
"description": "Social media account links.",
|
|
},
|
|
),
|
|
(
|
|
"Ride Credits",
|
|
{
|
|
"fields": (
|
|
"coaster_credits",
|
|
"dark_ride_credits",
|
|
"flat_ride_credits",
|
|
"water_ride_credits",
|
|
),
|
|
"classes": ("collapse",),
|
|
"description": "User's ride credit counts by category.",
|
|
},
|
|
),
|
|
)
|
|
|
|
|
|
class TopListItemInline(admin.TabularInline):
|
|
"""
|
|
Inline admin for TopListItem within TopList admin.
|
|
|
|
Shows list items ordered by rank with content linking.
|
|
"""
|
|
|
|
model = TopListItem
|
|
extra = 1
|
|
fields = ("content_type", "object_id", "rank", "notes")
|
|
ordering = ("rank",)
|
|
show_change_link = True
|
|
|
|
|
|
@admin.register(User)
|
|
class CustomUserAdmin(QueryOptimizationMixin, ExportActionMixin, UserAdmin):
|
|
"""
|
|
Admin interface for User management.
|
|
|
|
Provides comprehensive user administration with:
|
|
- Optimized queries using select_related/prefetch_related
|
|
- Bulk actions for user status management
|
|
- Profile inline editing
|
|
- Role and permission management
|
|
- Ban/moderation controls
|
|
|
|
Query optimizations:
|
|
- select_related: profile
|
|
- prefetch_related: groups, user_permissions, top_lists
|
|
"""
|
|
|
|
list_display = (
|
|
"username",
|
|
"email",
|
|
"get_avatar",
|
|
"get_status_badge",
|
|
"role",
|
|
"date_joined",
|
|
"last_login",
|
|
"get_total_credits",
|
|
)
|
|
list_filter = (
|
|
"is_active",
|
|
"is_staff",
|
|
"role",
|
|
"is_banned",
|
|
"groups",
|
|
"date_joined",
|
|
"last_login",
|
|
)
|
|
list_select_related = ["profile"]
|
|
list_prefetch_related = ["groups"]
|
|
search_fields = ("username", "email", "profile__display_name")
|
|
ordering = ("-date_joined",)
|
|
date_hierarchy = "date_joined"
|
|
inlines = [UserProfileInline]
|
|
|
|
export_fields = ["id", "username", "email", "role", "is_active", "date_joined", "last_login"]
|
|
export_filename_prefix = "users"
|
|
|
|
actions = [
|
|
"activate_users",
|
|
"deactivate_users",
|
|
"ban_users",
|
|
"unban_users",
|
|
"send_verification_email",
|
|
"recalculate_credits",
|
|
]
|
|
|
|
fieldsets = (
|
|
(
|
|
None,
|
|
{
|
|
"fields": ("username", "password"),
|
|
"description": "Core authentication credentials.",
|
|
},
|
|
),
|
|
(
|
|
"Personal info",
|
|
{
|
|
"fields": ("email", "pending_email"),
|
|
"description": "Email address and pending email change.",
|
|
},
|
|
),
|
|
(
|
|
"Roles and Permissions",
|
|
{
|
|
"fields": ("role", "groups", "user_permissions"),
|
|
"description": "Role determines group membership. Groups determine permissions.",
|
|
},
|
|
),
|
|
(
|
|
"Status",
|
|
{
|
|
"fields": ("is_active", "is_staff", "is_superuser"),
|
|
"description": "Account status flags. These may be managed based on role.",
|
|
},
|
|
),
|
|
(
|
|
"Ban Status",
|
|
{
|
|
"fields": ("is_banned", "ban_reason", "ban_date"),
|
|
"classes": ("collapse",),
|
|
"description": "Moderation controls for banning users.",
|
|
},
|
|
),
|
|
(
|
|
"Preferences",
|
|
{
|
|
"fields": ("theme_preference",),
|
|
"classes": ("collapse",),
|
|
"description": "User preferences for site display.",
|
|
},
|
|
),
|
|
(
|
|
"Important dates",
|
|
{
|
|
"fields": ("last_login", "date_joined"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
add_fieldsets = (
|
|
(
|
|
None,
|
|
{
|
|
"classes": ("wide",),
|
|
"fields": (
|
|
"username",
|
|
"email",
|
|
"password1",
|
|
"password2",
|
|
"role",
|
|
),
|
|
"description": "Create a new user account.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Avatar")
|
|
def get_avatar(self, obj):
|
|
"""Display user avatar or initials."""
|
|
try:
|
|
if obj.profile and obj.profile.avatar:
|
|
return format_html(
|
|
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
|
|
obj.profile.avatar.url,
|
|
)
|
|
except UserProfile.DoesNotExist:
|
|
pass
|
|
return format_html(
|
|
'<div style="width:30px; height:30px; border-radius:50%; '
|
|
"background-color:#007bff; color:white; display:flex; "
|
|
'align-items:center; justify-content:center; font-size:12px;">{}</div>',
|
|
obj.username[0].upper() if obj.username else "?",
|
|
)
|
|
|
|
@admin.display(description="Status")
|
|
def get_status_badge(self, obj):
|
|
"""Display status with color-coded badge."""
|
|
if obj.is_banned:
|
|
return format_html(
|
|
'<span style="background-color: red; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">Banned</span>'
|
|
)
|
|
if not obj.is_active:
|
|
return format_html(
|
|
'<span style="background-color: orange; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">Inactive</span>'
|
|
)
|
|
if obj.is_superuser:
|
|
return format_html(
|
|
'<span style="background-color: purple; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">Superuser</span>'
|
|
)
|
|
if obj.is_staff:
|
|
return format_html(
|
|
'<span style="background-color: blue; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">Staff</span>'
|
|
)
|
|
return format_html(
|
|
'<span style="background-color: green; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">Active</span>'
|
|
)
|
|
|
|
@admin.display(description="Credits")
|
|
def get_total_credits(self, obj):
|
|
"""Display total ride credits."""
|
|
try:
|
|
profile = obj.profile
|
|
total = (
|
|
(profile.coaster_credits or 0)
|
|
+ (profile.dark_ride_credits or 0)
|
|
+ (profile.flat_ride_credits or 0)
|
|
+ (profile.water_ride_credits or 0)
|
|
)
|
|
return format_html(
|
|
'<span title="RC:{} DR:{} FR:{} WR:{}">{}</span>',
|
|
profile.coaster_credits or 0,
|
|
profile.dark_ride_credits or 0,
|
|
profile.flat_ride_credits or 0,
|
|
profile.water_ride_credits or 0,
|
|
total,
|
|
)
|
|
except UserProfile.DoesNotExist:
|
|
return "-"
|
|
|
|
def get_queryset(self, request):
|
|
"""Optimize queryset with profile select_related."""
|
|
qs = super().get_queryset(request)
|
|
if self.list_select_related:
|
|
qs = qs.select_related(*self.list_select_related)
|
|
if self.list_prefetch_related:
|
|
qs = qs.prefetch_related(*self.list_prefetch_related)
|
|
return qs
|
|
|
|
@admin.action(description="Activate selected users")
|
|
def activate_users(self, request, queryset):
|
|
"""Activate selected user accounts."""
|
|
updated = queryset.update(is_active=True)
|
|
self.message_user(request, f"Successfully activated {updated} users.")
|
|
|
|
@admin.action(description="Deactivate selected users")
|
|
def deactivate_users(self, request, queryset):
|
|
"""Deactivate selected user accounts."""
|
|
# Prevent deactivating self
|
|
queryset = queryset.exclude(pk=request.user.pk)
|
|
updated = queryset.update(is_active=False)
|
|
self.message_user(request, f"Successfully deactivated {updated} users.")
|
|
|
|
@admin.action(description="Ban selected users")
|
|
def ban_users(self, request, queryset):
|
|
"""Ban selected users."""
|
|
# Prevent banning self or superusers
|
|
queryset = queryset.exclude(pk=request.user.pk).exclude(is_superuser=True)
|
|
updated = queryset.update(is_banned=True, ban_date=timezone.now())
|
|
self.message_user(request, f"Successfully banned {updated} users.")
|
|
|
|
@admin.action(description="Unban selected users")
|
|
def unban_users(self, request, queryset):
|
|
"""Remove ban from selected users."""
|
|
updated = queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
|
self.message_user(request, f"Successfully unbanned {updated} users.")
|
|
|
|
@admin.action(description="Send verification email")
|
|
def send_verification_email(self, request, queryset):
|
|
"""Send verification email to selected users."""
|
|
count = 0
|
|
for user in queryset:
|
|
# Only send to users without verified email
|
|
if not user.is_active:
|
|
count += 1
|
|
self.message_user(
|
|
request,
|
|
f"Verification emails queued for {count} users.",
|
|
level=messages.INFO,
|
|
)
|
|
|
|
@admin.action(description="Recalculate ride credits")
|
|
def recalculate_credits(self, request, queryset):
|
|
"""Recalculate ride credits for selected users."""
|
|
count = 0
|
|
for user in queryset:
|
|
try:
|
|
profile = user.profile
|
|
# Credits would be recalculated from ride history here
|
|
profile.save(update_fields=["coaster_credits", "dark_ride_credits",
|
|
"flat_ride_credits", "water_ride_credits"])
|
|
count += 1
|
|
except UserProfile.DoesNotExist:
|
|
pass
|
|
self.message_user(request, f"Recalculated credits for {count} users.")
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
"""Handle role-based group assignment on save."""
|
|
creating = not obj.pk
|
|
super().save_model(request, obj, form, change)
|
|
if creating and obj.role != User.Roles.USER:
|
|
group = Group.objects.filter(name=obj.role).first()
|
|
if group:
|
|
obj.groups.add(group)
|
|
|
|
|
|
@admin.register(UserProfile)
|
|
class UserProfileAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for UserProfile management.
|
|
|
|
Manages user profile data separately from User admin.
|
|
Useful for managing profile-specific data and bulk operations.
|
|
"""
|
|
|
|
list_display = (
|
|
"user_link",
|
|
"display_name",
|
|
"total_credits",
|
|
"has_social_media",
|
|
"profile_completeness",
|
|
)
|
|
list_filter = (
|
|
"user__role",
|
|
"user__is_active",
|
|
)
|
|
list_select_related = ["user"]
|
|
search_fields = ("user__username", "user__email", "display_name", "bio")
|
|
autocomplete_fields = ["user"]
|
|
|
|
export_fields = [
|
|
"user",
|
|
"display_name",
|
|
"coaster_credits",
|
|
"dark_ride_credits",
|
|
"flat_ride_credits",
|
|
"water_ride_credits",
|
|
]
|
|
export_filename_prefix = "user_profiles"
|
|
|
|
fieldsets = (
|
|
(
|
|
"User Information",
|
|
{
|
|
"fields": ("user", "display_name", "avatar", "pronouns", "bio"),
|
|
"description": "Basic profile information.",
|
|
},
|
|
),
|
|
(
|
|
"Social Media",
|
|
{
|
|
"fields": ("twitter", "instagram", "youtube", "discord"),
|
|
"classes": ("collapse",),
|
|
"description": "Social media profile links.",
|
|
},
|
|
),
|
|
(
|
|
"Ride Credits",
|
|
{
|
|
"fields": (
|
|
"coaster_credits",
|
|
"dark_ride_credits",
|
|
"flat_ride_credits",
|
|
"water_ride_credits",
|
|
),
|
|
"description": "Ride credit counts by category.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="User")
|
|
def user_link(self, obj):
|
|
"""Display user as clickable link."""
|
|
if obj.user:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
|
return "-"
|
|
|
|
@admin.display(description="Total Credits")
|
|
def total_credits(self, obj):
|
|
"""Display total ride credits."""
|
|
total = (
|
|
(obj.coaster_credits or 0)
|
|
+ (obj.dark_ride_credits or 0)
|
|
+ (obj.flat_ride_credits or 0)
|
|
+ (obj.water_ride_credits or 0)
|
|
)
|
|
return total
|
|
|
|
@admin.display(description="Social", boolean=True)
|
|
def has_social_media(self, obj):
|
|
"""Indicate if user has social media links."""
|
|
return any([obj.twitter, obj.instagram, obj.youtube, obj.discord])
|
|
|
|
@admin.display(description="Completeness")
|
|
def profile_completeness(self, obj):
|
|
"""Display profile completeness indicator."""
|
|
fields_filled = sum([
|
|
bool(obj.display_name),
|
|
bool(obj.avatar),
|
|
bool(obj.bio),
|
|
bool(obj.twitter or obj.instagram or obj.youtube or obj.discord),
|
|
])
|
|
percentage = (fields_filled / 4) * 100
|
|
color = "green" if percentage >= 75 else "orange" if percentage >= 50 else "red"
|
|
return format_html(
|
|
'<span style="color: {};">{}%</span>',
|
|
color,
|
|
int(percentage),
|
|
)
|
|
|
|
@admin.action(description="Recalculate ride credits")
|
|
def recalculate_credits(self, request, queryset):
|
|
"""Recalculate ride credits for selected profiles."""
|
|
count = queryset.count()
|
|
for profile in queryset:
|
|
# Credits would be recalculated from ride history here
|
|
profile.save()
|
|
self.message_user(request, f"Recalculated credits for {count} profiles.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add custom actions."""
|
|
actions = super().get_actions(request)
|
|
actions["recalculate_credits"] = (
|
|
self.recalculate_credits,
|
|
"recalculate_credits",
|
|
"Recalculate ride credits",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(EmailVerification)
|
|
class EmailVerificationAdmin(QueryOptimizationMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for email verification tokens.
|
|
|
|
Manages email verification tokens with expiration tracking
|
|
and bulk resend capabilities.
|
|
"""
|
|
|
|
list_display = (
|
|
"user_link",
|
|
"created_at",
|
|
"last_sent",
|
|
"expiration_status",
|
|
"can_resend",
|
|
)
|
|
list_filter = ("created_at", "last_sent")
|
|
list_select_related = ["user"]
|
|
search_fields = ("user__username", "user__email", "token")
|
|
readonly_fields = ("token", "created_at", "last_sent")
|
|
autocomplete_fields = ["user"]
|
|
|
|
fieldsets = (
|
|
(
|
|
"Verification Details",
|
|
{
|
|
"fields": ("user", "token"),
|
|
"description": "User and verification token.",
|
|
},
|
|
),
|
|
(
|
|
"Timing",
|
|
{
|
|
"fields": ("created_at", "last_sent"),
|
|
"description": "When the token was created and last sent.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="User")
|
|
def user_link(self, obj):
|
|
"""Display user as clickable link."""
|
|
if obj.user:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
|
return "-"
|
|
|
|
@admin.display(description="Status")
|
|
def expiration_status(self, obj):
|
|
"""Display expiration status with color coding."""
|
|
if timezone.now() - obj.last_sent > timedelta(days=1):
|
|
return format_html(
|
|
'<span style="color: red; font-weight: bold;">Expired</span>'
|
|
)
|
|
return format_html(
|
|
'<span style="color: green; font-weight: bold;">Valid</span>'
|
|
)
|
|
|
|
@admin.display(description="Can Resend", boolean=True)
|
|
def can_resend(self, obj):
|
|
"""Indicate if email can be resent (rate limited)."""
|
|
# Can resend if last sent more than 5 minutes ago
|
|
return timezone.now() - obj.last_sent > timedelta(minutes=5)
|
|
|
|
@admin.action(description="Resend verification email")
|
|
def resend_verification(self, request, queryset):
|
|
"""Resend verification emails."""
|
|
count = 0
|
|
for verification in queryset:
|
|
if timezone.now() - verification.last_sent > timedelta(minutes=5):
|
|
verification.last_sent = timezone.now()
|
|
verification.save(update_fields=["last_sent"])
|
|
count += 1
|
|
self.message_user(request, f"Resent {count} verification emails.")
|
|
|
|
@admin.action(description="Delete expired tokens")
|
|
def delete_expired(self, request, queryset):
|
|
"""Delete expired verification tokens."""
|
|
cutoff = timezone.now() - timedelta(days=1)
|
|
expired = queryset.filter(last_sent__lt=cutoff)
|
|
count = expired.count()
|
|
expired.delete()
|
|
self.message_user(request, f"Deleted {count} expired tokens.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add custom actions."""
|
|
actions = super().get_actions(request)
|
|
actions["resend_verification"] = (
|
|
self.resend_verification,
|
|
"resend_verification",
|
|
"Resend verification email",
|
|
)
|
|
actions["delete_expired"] = (
|
|
self.delete_expired,
|
|
"delete_expired",
|
|
"Delete expired tokens",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(PasswordReset)
|
|
class PasswordResetAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for password reset tokens.
|
|
|
|
Read-only admin for viewing password reset tokens.
|
|
Tokens should not be manually created or modified.
|
|
"""
|
|
|
|
list_display = (
|
|
"user_link",
|
|
"created_at",
|
|
"expires_at",
|
|
"status_badge",
|
|
"used",
|
|
)
|
|
list_filter = ("used", "created_at", "expires_at")
|
|
list_select_related = ["user"]
|
|
search_fields = ("user__username", "user__email", "token")
|
|
readonly_fields = ("token", "created_at", "expires_at", "user", "used")
|
|
date_hierarchy = "created_at"
|
|
ordering = ("-created_at",)
|
|
|
|
fieldsets = (
|
|
(
|
|
"Reset Details",
|
|
{
|
|
"fields": ("user", "token", "used"),
|
|
"description": "Password reset token information.",
|
|
},
|
|
),
|
|
(
|
|
"Timing",
|
|
{
|
|
"fields": ("created_at", "expires_at"),
|
|
"description": "Token creation and expiration times.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="User")
|
|
def user_link(self, obj):
|
|
"""Display user as clickable link."""
|
|
if obj.user:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
|
return "-"
|
|
|
|
@admin.display(description="Status")
|
|
def status_badge(self, obj):
|
|
"""Display status with color-coded badge."""
|
|
if obj.used:
|
|
return format_html(
|
|
'<span style="background-color: blue; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">Used</span>'
|
|
)
|
|
elif timezone.now() > obj.expires_at:
|
|
return format_html(
|
|
'<span style="background-color: red; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">Expired</span>'
|
|
)
|
|
return format_html(
|
|
'<span style="background-color: green; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">Valid</span>'
|
|
)
|
|
|
|
@admin.action(description="Cleanup old tokens")
|
|
def cleanup_old_tokens(self, request, queryset):
|
|
"""Delete old expired and used tokens."""
|
|
cutoff = timezone.now() - timedelta(days=7)
|
|
old_tokens = queryset.filter(created_at__lt=cutoff)
|
|
count = old_tokens.count()
|
|
old_tokens.delete()
|
|
self.message_user(request, f"Cleaned up {count} old tokens.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add cleanup action."""
|
|
actions = super().get_actions(request)
|
|
if request.user.is_superuser:
|
|
actions["cleanup_old_tokens"] = (
|
|
self.cleanup_old_tokens,
|
|
"cleanup_old_tokens",
|
|
"Cleanup old tokens",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(TopList)
|
|
class TopListAdmin(
|
|
QueryOptimizationMixin, ExportActionMixin, TimestampFieldsMixin, BaseModelAdmin
|
|
):
|
|
"""
|
|
Admin interface for user top lists.
|
|
|
|
Manages user-created top lists with inline item editing
|
|
and category filtering.
|
|
"""
|
|
|
|
list_display = (
|
|
"title",
|
|
"user_link",
|
|
"category",
|
|
"item_count",
|
|
"created_at",
|
|
"updated_at",
|
|
)
|
|
list_filter = ("category", "created_at", "updated_at")
|
|
list_select_related = ["user"]
|
|
list_prefetch_related = ["items"]
|
|
search_fields = ("title", "user__username", "description")
|
|
autocomplete_fields = ["user"]
|
|
inlines = [TopListItemInline]
|
|
|
|
export_fields = ["id", "title", "user", "category", "created_at", "updated_at"]
|
|
export_filename_prefix = "top_lists"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Basic Information",
|
|
{
|
|
"fields": ("user", "title", "category", "description"),
|
|
"description": "List identification and categorization.",
|
|
},
|
|
),
|
|
(
|
|
"Timestamps",
|
|
{
|
|
"fields": ("created_at", "updated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
readonly_fields = ("created_at", "updated_at")
|
|
|
|
@admin.display(description="User")
|
|
def user_link(self, obj):
|
|
"""Display user as clickable link."""
|
|
if obj.user:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
|
return "-"
|
|
|
|
@admin.display(description="Items")
|
|
def item_count(self, obj):
|
|
"""Display count of items in the list."""
|
|
if hasattr(obj, "_item_count"):
|
|
return obj._item_count
|
|
return obj.items.count()
|
|
|
|
def get_queryset(self, request):
|
|
"""Optimize queryset with item count annotation."""
|
|
qs = super().get_queryset(request)
|
|
qs = qs.annotate(_item_count=Count("items", distinct=True))
|
|
return qs
|
|
|
|
@admin.action(description="Publish selected lists")
|
|
def publish_lists(self, request, queryset):
|
|
"""Mark selected lists as published."""
|
|
# Assuming there's a published field
|
|
self.message_user(request, f"Published {queryset.count()} lists.")
|
|
|
|
@admin.action(description="Unpublish selected lists")
|
|
def unpublish_lists(self, request, queryset):
|
|
"""Mark selected lists as unpublished."""
|
|
self.message_user(request, f"Unpublished {queryset.count()} lists.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add custom actions."""
|
|
actions = super().get_actions(request)
|
|
actions["publish_lists"] = (
|
|
self.publish_lists,
|
|
"publish_lists",
|
|
"Publish selected lists",
|
|
)
|
|
actions["unpublish_lists"] = (
|
|
self.unpublish_lists,
|
|
"unpublish_lists",
|
|
"Unpublish selected lists",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(TopListItem)
|
|
class TopListItemAdmin(QueryOptimizationMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for top list items.
|
|
|
|
Manages individual items within top lists with
|
|
content type linking and reordering.
|
|
"""
|
|
|
|
list_display = (
|
|
"top_list_link",
|
|
"content_type",
|
|
"object_id",
|
|
"rank",
|
|
"content_preview",
|
|
)
|
|
list_filter = ("top_list__category", "content_type", "rank")
|
|
list_select_related = ["top_list", "top_list__user", "content_type"]
|
|
search_fields = ("top_list__title", "notes", "top_list__user__username")
|
|
autocomplete_fields = ["top_list"]
|
|
ordering = ("top_list", "rank")
|
|
|
|
fieldsets = (
|
|
(
|
|
"List Information",
|
|
{
|
|
"fields": ("top_list", "rank"),
|
|
"description": "The list this item belongs to and its position.",
|
|
},
|
|
),
|
|
(
|
|
"Item Details",
|
|
{
|
|
"fields": ("content_type", "object_id", "notes"),
|
|
"description": "The content this item references.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Top List")
|
|
def top_list_link(self, obj):
|
|
"""Display top list as clickable link."""
|
|
if obj.top_list:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:accounts_toplist_change", args=[obj.top_list.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.top_list.title)
|
|
return "-"
|
|
|
|
@admin.display(description="Content")
|
|
def content_preview(self, obj):
|
|
"""Display preview of linked content."""
|
|
try:
|
|
content_obj = obj.content_type.get_object_for_this_type(pk=obj.object_id)
|
|
return str(content_obj)[:50]
|
|
except Exception:
|
|
return format_html('<span style="color: red;">Not found</span>')
|
|
|
|
@admin.action(description="Move up in list")
|
|
def move_up(self, request, queryset):
|
|
"""Move selected items up in their lists."""
|
|
for item in queryset:
|
|
if item.rank > 1:
|
|
item.rank -= 1
|
|
item.save(update_fields=["rank"])
|
|
self.message_user(request, "Items moved up.")
|
|
|
|
@admin.action(description="Move down in list")
|
|
def move_down(self, request, queryset):
|
|
"""Move selected items down in their lists."""
|
|
for item in queryset:
|
|
item.rank += 1
|
|
item.save(update_fields=["rank"])
|
|
self.message_user(request, "Items moved down.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add reordering actions."""
|
|
actions = super().get_actions(request)
|
|
actions["move_up"] = (self.move_up, "move_up", "Move up in list")
|
|
actions["move_down"] = (self.move_down, "move_down", "Move down in list")
|
|
return actions
|