mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 13:51:09 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
0
backend/apps/accounts/tests/__init__.py
Normal file
0
backend/apps/accounts/tests/__init__.py
Normal file
207
backend/apps/accounts/tests/test_admin.py
Normal file
207
backend/apps/accounts/tests/test_admin.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user