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.
This commit is contained in:
pacnpal
2025-12-23 16:41:42 -05:00
parent ae31e889d7
commit edcd8f2076
155 changed files with 22046 additions and 4645 deletions

View File

@@ -1,29 +1,69 @@
from django.contrib import admin
"""
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.utils.html import format_html
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 (
User,
UserProfile,
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")},
{
"fields": ("display_name", "avatar", "pronouns", "bio"),
"description": "User's public profile information.",
},
),
(
"Social Media",
{"fields": ("twitter", "instagram", "youtube", "discord")},
{
"fields": ("twitter", "instagram", "youtube", "discord"),
"classes": ("collapse",),
"description": "Social media account links.",
},
),
(
"Ride Credits",
@@ -33,30 +73,54 @@ class UserProfileInline(admin.StackedInline):
"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(UserAdmin):
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",
"get_status_badge",
"role",
"date_joined",
"last_login",
"get_credits",
"get_total_credits",
)
list_filter = (
"is_active",
@@ -65,50 +129,81 @@ class CustomUserAdmin(UserAdmin):
"is_banned",
"groups",
"date_joined",
"last_login",
)
search_fields = ("username", "email")
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",
]
inlines = [UserProfileInline]
fieldsets = (
(None, {"fields": ("username", "password")}),
("Personal info", {"fields": ("email", "pending_email")}),
(
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."
),
"description": "Role determines group membership. Groups determine permissions.",
},
),
(
"Status",
{
"fields": ("is_active", "is_staff", "is_superuser"),
"description": "These are automatically managed based on role.",
"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",),
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
@@ -121,104 +216,204 @@ class CustomUserAdmin(UserAdmin):
"password2",
"role",
),
"description": "Create a new user account.",
},
),
)
@admin.display(description="Avatar")
def get_avatar(self, obj):
if obj.profile.avatar:
return format_html(
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
obj.profile.avatar.url,
)
"""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;">{}</div>',
obj.username[0].upper(),
'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(self, obj):
def get_status_badge(self, obj):
"""Display status with color-coded badge."""
if obj.is_banned:
return format_html('<span style="color: red;">Banned</span>')
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="color: orange;">Inactive</span>')
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="color: purple;">Superuser</span>')
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="color: blue;">Staff</span>')
return format_html('<span style="color: green;">Active</span>')
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="Ride Credits")
def get_credits(self, obj):
@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(
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
profile.coaster_credits,
profile.dark_ride_credits,
profile.flat_ride_credits,
profile.water_ride_credits,
'<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):
queryset.update(is_active=True)
"""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):
queryset.update(is_active=False)
"""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):
from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now())
"""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):
queryset.update(is_banned=False, ban_date=None, ban_reason="")
"""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:
# Ensure new user with role gets added to appropriate group
group = Group.objects.filter(name=obj.role).first()
if group:
obj.groups.add(group)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
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",
)
list_filter = (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
search_fields = ("user__username", "user__email", "display_name", "bio")
]
export_filename_prefix = "user_profiles"
fieldsets = (
(
"User Information",
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
{
"fields": ("user", "display_name", "avatar", "pronouns", "bio"),
"description": "Basic profile information.",
},
),
(
"Social Media",
{"fields": ("twitter", "instagram", "youtube", "discord")},
{
"fields": ("twitter", "instagram", "youtube", "discord"),
"classes": ("collapse",),
"description": "Social media profile links.",
},
),
(
"Ride Credits",
@@ -228,93 +423,197 @@ class UserProfileAdmin(admin.ModelAdmin):
"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(admin.ModelAdmin):
list_display = ("user", "created_at", "last_sent", "is_expired")
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 = ("created_at", "last_sent")
readonly_fields = ("token", "created_at", "last_sent")
autocomplete_fields = ["user"]
fieldsets = (
("Verification Details", {"fields": ("user", "token")}),
("Timing", {"fields": ("created_at", "last_sent")}),
(
"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 is_expired(self, obj):
from django.utils import timezone
from datetime import timedelta
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;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
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.register(TopList)
class TopListAdmin(admin.ModelAdmin):
list_display = ("title", "user", "category", "created_at", "updated_at")
list_filter = ("category", "created_at", "updated_at")
search_fields = ("title", "user__username", "description")
inlines = [TopListItemInline]
@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.")
fieldsets = (
(
"Basic Information",
{"fields": ("user", "title", "category", "description")},
),
(
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
readonly_fields = ("created_at", "updated_at")
@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.")
@admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin):
list_display = ("top_list", "content_type", "object_id", "rank")
list_filter = ("top_list__category", "rank")
search_fields = ("top_list__title", "notes")
ordering = ("top_list", "rank")
fieldsets = (
("List Information", {"fields": ("top_list", "rank")}),
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
)
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(admin.ModelAdmin):
"""Admin interface for password reset tokens"""
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",
"user_link",
"created_at",
"expires_at",
"is_expired",
"status_badge",
"used",
)
list_filter = (
"used",
"created_at",
"expires_at",
)
search_fields = (
"user__username",
"user__email",
"token",
)
readonly_fields = (
"token",
"created_at",
"expires_at",
)
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",)
@@ -322,39 +621,243 @@ class PasswordResetAdmin(admin.ModelAdmin):
(
"Reset Details",
{
"fields": (
"user",
"token",
"used",
)
"fields": ("user", "token", "used"),
"description": "Password reset token information.",
},
),
(
"Timing",
{
"fields": (
"created_at",
"expires_at",
)
"fields": ("created_at", "expires_at"),
"description": "Token creation and expiration times.",
},
),
)
@admin.display(description="Status", boolean=True)
def is_expired(self, obj):
"""Display expiration status with color coding"""
from django.utils import timezone
@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="color: blue;">Used</span>')
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="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
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>'
)
def has_add_permission(self, request):
"""Disable manual creation of password reset tokens"""
return False
@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 has_change_permission(self, request, obj=None):
"""Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False)
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

View File

@@ -50,21 +50,31 @@ class User(AbstractUser):
max_length=10,
default="USER",
db_index=True,
help_text="User role (user, moderator, admin)",
)
is_banned = models.BooleanField(
default=False, db_index=True, help_text="Whether this user is banned"
)
ban_reason = models.TextField(blank=True, help_text="Reason for ban")
ban_date = models.DateTimeField(
null=True, blank=True, help_text="Date the user was banned"
)
is_banned = models.BooleanField(default=False, db_index=True)
ban_reason = models.TextField(blank=True)
ban_date = models.DateTimeField(null=True, blank=True)
pending_email = models.EmailField(blank=True, null=True)
theme_preference = RichChoiceField(
choice_group="theme_preferences",
domain="accounts",
max_length=5,
default="light",
help_text="User's theme preference (light/dark)",
)
# Notification preferences
email_notifications = models.BooleanField(default=True)
push_notifications = models.BooleanField(default=False)
email_notifications = models.BooleanField(
default=True, help_text="Whether to send email notifications"
)
push_notifications = models.BooleanField(
default=False, help_text="Whether to send push notifications"
)
# Privacy settings
privacy_level = RichChoiceField(
@@ -72,31 +82,65 @@ class User(AbstractUser):
domain="accounts",
max_length=10,
default="public",
help_text="Overall privacy level",
)
show_email = models.BooleanField(
default=False, help_text="Whether to show email on profile"
)
show_real_name = models.BooleanField(
default=True, help_text="Whether to show real name on profile"
)
show_join_date = models.BooleanField(
default=True, help_text="Whether to show join date on profile"
)
show_statistics = models.BooleanField(
default=True, help_text="Whether to show statistics on profile"
)
show_reviews = models.BooleanField(
default=True, help_text="Whether to show reviews on profile"
)
show_photos = models.BooleanField(
default=True, help_text="Whether to show photos on profile"
)
show_top_lists = models.BooleanField(
default=True, help_text="Whether to show top lists on profile"
)
allow_friend_requests = models.BooleanField(
default=True, help_text="Whether to allow friend requests"
)
allow_messages = models.BooleanField(
default=True, help_text="Whether to allow direct messages"
)
allow_profile_comments = models.BooleanField(
default=False, help_text="Whether to allow profile comments"
)
search_visibility = models.BooleanField(
default=True, help_text="Whether profile appears in search results"
)
show_email = models.BooleanField(default=False)
show_real_name = models.BooleanField(default=True)
show_join_date = models.BooleanField(default=True)
show_statistics = models.BooleanField(default=True)
show_reviews = models.BooleanField(default=True)
show_photos = models.BooleanField(default=True)
show_top_lists = models.BooleanField(default=True)
allow_friend_requests = models.BooleanField(default=True)
allow_messages = models.BooleanField(default=True)
allow_profile_comments = models.BooleanField(default=False)
search_visibility = models.BooleanField(default=True)
activity_visibility = RichChoiceField(
choice_group="privacy_levels",
domain="accounts",
max_length=10,
default="friends",
help_text="Who can see user activity",
)
# Security settings
two_factor_enabled = models.BooleanField(default=False)
login_notifications = models.BooleanField(default=True)
session_timeout = models.IntegerField(default=30) # days
login_history_retention = models.IntegerField(default=90) # days
last_password_change = models.DateTimeField(auto_now_add=True)
two_factor_enabled = models.BooleanField(
default=False, help_text="Whether two-factor authentication is enabled"
)
login_notifications = models.BooleanField(
default=True, help_text="Whether to send login notifications"
)
session_timeout = models.IntegerField(
default=30, help_text="Session timeout in days"
)
login_history_retention = models.IntegerField(
default=90, help_text="How long to retain login history (days)"
)
last_password_change = models.DateTimeField(
auto_now_add=True, help_text="When the password was last changed"
)
# Display name - core user data for better performance
display_name = models.CharField(
@@ -129,6 +173,8 @@ class User(AbstractUser):
return self.username
class Meta:
verbose_name = "User"
verbose_name_plural = "Users"
indexes = [
models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
]
@@ -156,7 +202,12 @@ class UserProfile(models.Model):
help_text="Unique identifier for this profile that remains constant",
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name="profile",
help_text="User this profile belongs to",
)
display_name = models.CharField(
max_length=50,
blank=True,
@@ -166,23 +217,34 @@ class UserProfile(models.Model):
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
blank=True,
help_text="User's avatar image",
)
pronouns = models.CharField(
max_length=50, blank=True, help_text="User's preferred pronouns"
)
pronouns = models.CharField(max_length=50, blank=True)
bio = models.TextField(max_length=500, blank=True)
bio = models.TextField(max_length=500, blank=True, help_text="User biography")
# Social media links
twitter = models.URLField(blank=True)
instagram = models.URLField(blank=True)
youtube = models.URLField(blank=True)
discord = models.CharField(max_length=100, blank=True)
twitter = models.URLField(blank=True, help_text="Twitter profile URL")
instagram = models.URLField(blank=True, help_text="Instagram profile URL")
youtube = models.URLField(blank=True, help_text="YouTube channel URL")
discord = models.CharField(max_length=100, blank=True, help_text="Discord username")
# Ride statistics
coaster_credits = models.IntegerField(default=0)
dark_ride_credits = models.IntegerField(default=0)
flat_ride_credits = models.IntegerField(default=0)
water_ride_credits = models.IntegerField(default=0)
coaster_credits = models.IntegerField(
default=0, help_text="Number of roller coasters ridden"
)
dark_ride_credits = models.IntegerField(
default=0, help_text="Number of dark rides ridden"
)
flat_ride_credits = models.IntegerField(
default=0, help_text="Number of flat rides ridden"
)
water_ride_credits = models.IntegerField(
default=0, help_text="Number of water rides ridden"
)
def get_avatar_url(self):
"""
@@ -265,13 +327,28 @@ class UserProfile(models.Model):
def __str__(self):
return self.display_name
class Meta:
verbose_name = "User Profile"
verbose_name_plural = "User Profiles"
ordering = ["user"]
@pghistory.track()
class EmailVerification(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
last_sent = models.DateTimeField(auto_now_add=True)
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
help_text="User this verification belongs to",
)
token = models.CharField(
max_length=64, unique=True, help_text="Verification token"
)
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this verification was created"
)
last_sent = models.DateTimeField(
auto_now_add=True, help_text="When the verification email was last sent"
)
def __str__(self):
return f"Email verification for {self.user.username}"
@@ -283,11 +360,17 @@ class EmailVerification(models.Model):
@pghistory.track()
class PasswordReset(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
help_text="User requesting password reset",
)
token = models.CharField(max_length=64, help_text="Reset token")
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this reset was requested"
)
expires_at = models.DateTimeField(help_text="When this reset token expires")
used = models.BooleanField(default=False, help_text="Whether this token has been used")
def __str__(self):
return f"Password reset for {self.user.username}"
@@ -304,19 +387,23 @@ class TopList(TrackedModel):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="top_lists", # Added related_name for User model access
related_name="top_lists",
help_text="User who created this list",
)
title = models.CharField(max_length=100)
title = models.CharField(max_length=100, help_text="Title of the top list")
category = RichChoiceField(
choice_group="top_list_categories",
domain="accounts",
max_length=2,
help_text="Category of items in this list",
)
description = models.TextField(blank=True)
description = models.TextField(blank=True, help_text="Description of the list")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
verbose_name = "Top List"
verbose_name_plural = "Top Lists"
ordering = ["-updated_at"]
def __str__(self):
@@ -330,16 +417,23 @@ class TopList(TrackedModel):
class TopListItem(TrackedModel):
top_list = models.ForeignKey(
TopList, on_delete=models.CASCADE, related_name="items"
TopList,
on_delete=models.CASCADE,
related_name="items",
help_text="Top list this item belongs to",
)
content_type = models.ForeignKey(
"contenttypes.ContentType", on_delete=models.CASCADE
"contenttypes.ContentType",
on_delete=models.CASCADE,
help_text="Type of item (park, ride, etc.)",
)
object_id = models.PositiveIntegerField()
rank = models.PositiveIntegerField()
notes = models.TextField(blank=True)
object_id = models.PositiveIntegerField(help_text="ID of the item")
rank = models.PositiveIntegerField(help_text="Position in the list")
notes = models.TextField(blank=True, help_text="User's notes about this item")
class Meta(TrackedModel.Meta):
verbose_name = "Top List Item"
verbose_name_plural = "Top List Items"
ordering = ["rank"]
unique_together = [["top_list", "rank"]]
@@ -387,6 +481,8 @@ class UserDeletionRequest(models.Model):
)
class Meta:
verbose_name = "User Deletion Request"
verbose_name_plural = "User Deletion Requests"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["verification_code"]),
@@ -464,7 +560,10 @@ class UserNotification(TrackedModel):
# Core fields
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="notifications"
User,
on_delete=models.CASCADE,
related_name="notifications",
help_text="User this notification is for",
)
notification_type = RichChoiceField(
@@ -473,14 +572,20 @@ class UserNotification(TrackedModel):
max_length=30,
)
title = models.CharField(max_length=200)
message = models.TextField()
title = models.CharField(max_length=200, help_text="Notification title")
message = models.TextField(help_text="Notification message")
# Optional related object (submission, review, etc.)
content_type = models.ForeignKey(
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
"contenttypes.ContentType",
on_delete=models.CASCADE,
null=True,
blank=True,
help_text="Type of related object",
)
object_id = models.PositiveIntegerField(
null=True, blank=True, help_text="ID of related object"
)
object_id = models.PositiveIntegerField(null=True, blank=True)
related_object = GenericForeignKey("content_type", "object_id")
# Metadata
@@ -492,14 +597,24 @@ class UserNotification(TrackedModel):
)
# Status tracking
is_read = models.BooleanField(default=False)
read_at = models.DateTimeField(null=True, blank=True)
is_read = models.BooleanField(
default=False, help_text="Whether this notification has been read"
)
read_at = models.DateTimeField(
null=True, blank=True, help_text="When this notification was read"
)
# Delivery tracking
email_sent = models.BooleanField(default=False)
email_sent_at = models.DateTimeField(null=True, blank=True)
push_sent = models.BooleanField(default=False)
push_sent_at = models.DateTimeField(null=True, blank=True)
email_sent = models.BooleanField(default=False, help_text="Whether email was sent")
email_sent_at = models.DateTimeField(
null=True, blank=True, help_text="When email was sent"
)
push_sent = models.BooleanField(
default=False, help_text="Whether push notification was sent"
)
push_sent_at = models.DateTimeField(
null=True, blank=True, help_text="When push notification was sent"
)
# Additional data (JSON field for flexibility)
extra_data = models.JSONField(default=dict, blank=True)
@@ -509,6 +624,8 @@ class UserNotification(TrackedModel):
expires_at = models.DateTimeField(null=True, blank=True)
class Meta(TrackedModel.Meta):
verbose_name = "User Notification"
verbose_name_plural = "User Notifications"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "is_read"]),
@@ -559,7 +676,10 @@ class NotificationPreference(TrackedModel):
"""
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="notification_preference"
User,
on_delete=models.CASCADE,
related_name="notification_preference",
help_text="User these preferences belong to",
)
# Submission notifications

View File

View File

@@ -0,0 +1,207 @@
"""
Tests for accounts admin interfaces.
These tests verify the functionality of user, profile, email verification,
password reset, and top list admin classes including query optimization
and custom actions.
"""
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
from apps.accounts.admin import (
CustomUserAdmin,
EmailVerificationAdmin,
PasswordResetAdmin,
TopListAdmin,
TopListItemAdmin,
UserProfileAdmin,
)
from apps.accounts.models import (
EmailVerification,
PasswordReset,
TopList,
TopListItem,
User,
UserProfile,
)
UserModel = get_user_model()
class TestCustomUserAdmin(TestCase):
"""Tests for CustomUserAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = CustomUserAdmin(model=User, admin_site=self.site)
def test_list_display_fields(self):
"""Verify all required fields are in list_display."""
required_fields = [
"username",
"email",
"get_avatar",
"get_status_badge",
"role",
"date_joined",
]
for field in required_fields:
assert field in self.admin.list_display
def test_list_select_related(self):
"""Verify select_related is configured for profile."""
assert "profile" in self.admin.list_select_related
def test_list_prefetch_related(self):
"""Verify prefetch_related is configured for groups."""
assert "groups" in self.admin.list_prefetch_related
def test_user_actions_registered(self):
"""Verify user management actions are registered."""
assert "activate_users" in self.admin.actions
assert "deactivate_users" in self.admin.actions
assert "ban_users" in self.admin.actions
assert "unban_users" in self.admin.actions
def test_export_fields_configured(self):
"""Verify export fields are configured."""
assert hasattr(self.admin, "export_fields")
assert "username" in self.admin.export_fields
assert "email" in self.admin.export_fields
class TestUserProfileAdmin(TestCase):
"""Tests for UserProfileAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = UserProfileAdmin(model=UserProfile, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for user."""
assert "user" in self.admin.list_select_related
def test_recalculate_action(self):
"""Verify recalculate credits action exists."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "recalculate_credits" in actions
class TestEmailVerificationAdmin(TestCase):
"""Tests for EmailVerificationAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = EmailVerificationAdmin(model=EmailVerification, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for user."""
assert "user" in self.admin.list_select_related
def test_readonly_fields(self):
"""Verify token fields are readonly."""
assert "token" in self.admin.readonly_fields
assert "created_at" in self.admin.readonly_fields
def test_verification_actions(self):
"""Verify verification actions exist."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "resend_verification" in actions
assert "delete_expired" in actions
class TestPasswordResetAdmin(TestCase):
"""Tests for PasswordResetAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = PasswordResetAdmin(model=PasswordReset, admin_site=self.site)
def test_readonly_permissions(self):
"""Verify read-only permissions are set."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=False)
assert self.admin.has_add_permission(request) is False
assert self.admin.has_change_permission(request) is False
def test_list_select_related(self):
"""Verify select_related for user."""
assert "user" in self.admin.list_select_related
def test_cleanup_action_superuser_only(self):
"""Verify cleanup action is superuser only."""
request = self.factory.get("/admin/")
# Non-superuser shouldn't see cleanup action
request.user = UserModel(is_superuser=False)
actions = self.admin.get_actions(request)
assert "cleanup_old_tokens" not in actions
# Superuser should see cleanup action
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "cleanup_old_tokens" in actions
class TestTopListAdmin(TestCase):
"""Tests for TopListAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = TopListAdmin(model=TopList, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for user."""
assert "user" in self.admin.list_select_related
def test_list_prefetch_related(self):
"""Verify prefetch_related for items."""
assert "items" in self.admin.list_prefetch_related
def test_publish_actions(self):
"""Verify publish actions exist."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "publish_lists" in actions
assert "unpublish_lists" in actions
class TestTopListItemAdmin(TestCase):
"""Tests for TopListItemAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = TopListItemAdmin(model=TopListItem, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for top_list and user."""
assert "top_list" in self.admin.list_select_related
assert "top_list__user" in self.admin.list_select_related
assert "content_type" in self.admin.list_select_related
def test_reorder_actions(self):
"""Verify reorder actions exist."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "move_up" in actions
assert "move_down" in actions

View File

@@ -32,8 +32,13 @@ from .mixins import TurnstileMixin
from typing import Dict, Any, Optional, Union, cast
from django_htmx.http import HttpResponseClientRefresh
from contextlib import suppress
import logging
import re
from apps.core.logging import log_exception, log_security_event
logger = logging.getLogger(__name__)
UserModel = get_user_model()
@@ -46,6 +51,15 @@ class CustomLoginView(TurnstileMixin, LoginView):
return self.form_invalid(form)
response = super().form_valid(form)
user = self.request.user
log_security_event(
logger,
event_type="user_login",
message=f"User {user.username} logged in successfully",
severity="low",
context={"user_id": user.id, "username": user.username},
request=self.request,
)
return (
HttpResponseClientRefresh()
if getattr(self.request, "htmx", False)
@@ -53,6 +67,14 @@ class CustomLoginView(TurnstileMixin, LoginView):
)
def form_invalid(self, form):
log_security_event(
logger,
event_type="login_failed",
message="Failed login attempt",
severity="medium",
context={"username": form.data.get("login", "unknown")},
request=self.request,
)
if getattr(self.request, "htmx", False):
return render(
self.request,
@@ -80,6 +102,19 @@ class CustomSignupView(TurnstileMixin, SignupView):
return self.form_invalid(form)
response = super().form_valid(form)
user = self.user
log_security_event(
logger,
event_type="user_signup",
message=f"New user registered: {user.username}",
severity="low",
context={
"user_id": user.id,
"username": user.username,
"email": user.email,
},
request=self.request,
)
return (
HttpResponseClientRefresh()
if getattr(self.request, "htmx", False)
@@ -203,6 +238,10 @@ class SettingsView(LoginRequiredMixin, TemplateView):
profile.save()
user.save()
logger.info(
f"User {user.username} updated their profile",
extra={"user_id": user.id, "username": user.username},
)
messages.success(request, "Profile updated successfully")
def _validate_password(self, password: str) -> bool:
@@ -262,6 +301,15 @@ class SettingsView(LoginRequiredMixin, TemplateView):
user.set_password(new_password)
user.save()
log_security_event(
logger,
event_type="password_changed",
message=f"User {user.username} changed their password",
severity="medium",
context={"user_id": user.id, "username": user.username},
request=request,
)
self._send_password_change_confirmation(request, user)
messages.success(
request,
@@ -363,6 +411,14 @@ def request_password_reset(request: HttpRequest) -> HttpResponse:
token = create_password_reset_token(user)
site = get_current_site(request)
send_password_reset_email(user, site, token)
log_security_event(
logger,
event_type="password_reset_requested",
message=f"Password reset requested for {email}",
severity="medium",
context={"email": email},
request=request,
)
messages.success(request, "Password reset email sent")
return redirect("account_login")
@@ -381,6 +437,15 @@ def handle_password_reset(
reset.used = True
reset.save()
log_security_event(
logger,
event_type="password_reset_complete",
message=f"Password reset completed for user {user.username}",
severity="medium",
context={"user_id": user.id, "username": user.username},
request=request,
)
send_password_reset_confirmation(user, site)
messages.success(request, "Password reset successfully")