mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 19:11:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
0
django-backend/apps/users/__init__.py
Normal file
0
django-backend/apps/users/__init__.py
Normal file
584
django-backend/apps/users/admin.py
Normal file
584
django-backend/apps/users/admin.py
Normal file
@@ -0,0 +1,584 @@
|
||||
"""
|
||||
Django admin configuration for User models.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.decorators import display
|
||||
from import_export import resources
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from .models import User, UserRole, UserProfile, UserRideCredit, UserTopList, UserTopListItem
|
||||
|
||||
|
||||
class UserResource(resources.ModelResource):
|
||||
"""Resource for importing/exporting users."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
'id', 'email', 'username', 'first_name', 'last_name',
|
||||
'date_joined', 'last_login', 'is_active', 'is_staff',
|
||||
'banned', 'reputation_score', 'mfa_enabled'
|
||||
)
|
||||
export_order = fields
|
||||
|
||||
|
||||
class UserRoleInline(admin.StackedInline):
|
||||
"""Inline for user role."""
|
||||
model = UserRole
|
||||
can_delete = False
|
||||
verbose_name_plural = 'Role'
|
||||
fk_name = 'user'
|
||||
fields = ('role', 'granted_by', 'granted_at')
|
||||
readonly_fields = ('granted_at',)
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
"""Inline for user profile."""
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = 'Profile & Preferences'
|
||||
fk_name = 'user'
|
||||
fields = (
|
||||
('email_notifications', 'email_on_submission_approved', 'email_on_submission_rejected'),
|
||||
('profile_public', 'show_email'),
|
||||
('total_submissions', 'approved_submissions'),
|
||||
)
|
||||
readonly_fields = ('total_submissions', 'approved_submissions')
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin, ModelAdmin, ImportExportModelAdmin):
|
||||
"""Admin interface for User model."""
|
||||
|
||||
resource_class = UserResource
|
||||
|
||||
list_display = [
|
||||
'email',
|
||||
'username',
|
||||
'display_name_admin',
|
||||
'role_badge',
|
||||
'reputation_badge',
|
||||
'status_badge',
|
||||
'mfa_badge',
|
||||
'date_joined',
|
||||
'last_login',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'banned',
|
||||
'mfa_enabled',
|
||||
'oauth_provider',
|
||||
'date_joined',
|
||||
'last_login',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'email',
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
]
|
||||
|
||||
ordering = ['-date_joined']
|
||||
|
||||
fieldsets = (
|
||||
('Account Information', {
|
||||
'fields': ('email', 'username', 'password')
|
||||
}),
|
||||
('Personal Information', {
|
||||
'fields': ('first_name', 'last_name', 'avatar_url', 'bio')
|
||||
}),
|
||||
('Permissions', {
|
||||
'fields': (
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'groups',
|
||||
'user_permissions',
|
||||
)
|
||||
}),
|
||||
('Moderation', {
|
||||
'fields': (
|
||||
'banned',
|
||||
'ban_reason',
|
||||
'banned_at',
|
||||
'banned_by',
|
||||
)
|
||||
}),
|
||||
('OAuth', {
|
||||
'fields': ('oauth_provider', 'oauth_sub'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Security', {
|
||||
'fields': ('mfa_enabled', 'reputation_score'),
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('date_joined', 'last_login'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
add_fieldsets = (
|
||||
('Create New User', {
|
||||
'classes': ('wide',),
|
||||
'fields': ('email', 'username', 'password1', 'password2'),
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = [
|
||||
'date_joined',
|
||||
'last_login',
|
||||
'banned_at',
|
||||
'oauth_provider',
|
||||
'oauth_sub',
|
||||
]
|
||||
|
||||
inlines = [UserRoleInline, UserProfileInline]
|
||||
|
||||
@display(description="Name", label=True)
|
||||
def display_name_admin(self, obj):
|
||||
"""Display user's display name."""
|
||||
return obj.display_name or '-'
|
||||
|
||||
@display(description="Role", label=True)
|
||||
def role_badge(self, obj):
|
||||
"""Display user role with badge."""
|
||||
try:
|
||||
role = obj.role.role
|
||||
colors = {
|
||||
'admin': 'red',
|
||||
'moderator': 'blue',
|
||||
'user': 'green',
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 8px; border-radius: 3px; font-size: 11px;">{}</span>',
|
||||
colors.get(role, 'gray'),
|
||||
role.upper()
|
||||
)
|
||||
except UserRole.DoesNotExist:
|
||||
return format_html('<span style="color: gray;">No Role</span>')
|
||||
|
||||
@display(description="Reputation", label=True)
|
||||
def reputation_badge(self, obj):
|
||||
"""Display reputation score."""
|
||||
score = obj.reputation_score
|
||||
if score >= 100:
|
||||
color = 'green'
|
||||
elif score >= 50:
|
||||
color = 'blue'
|
||||
elif score >= 0:
|
||||
color = 'gray'
|
||||
else:
|
||||
color = 'red'
|
||||
|
||||
return format_html(
|
||||
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||||
color,
|
||||
score
|
||||
)
|
||||
|
||||
@display(description="Status", label=True)
|
||||
def status_badge(self, obj):
|
||||
"""Display user status."""
|
||||
if obj.banned:
|
||||
return format_html(
|
||||
'<span style="background-color: red; color: white; padding: 3px 8px; border-radius: 3px; font-size: 11px;">BANNED</span>'
|
||||
)
|
||||
elif not obj.is_active:
|
||||
return format_html(
|
||||
'<span style="background-color: orange; color: white; padding: 3px 8px; border-radius: 3px; font-size: 11px;">INACTIVE</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="background-color: green; color: white; padding: 3px 8px; border-radius: 3px; font-size: 11px;">ACTIVE</span>'
|
||||
)
|
||||
|
||||
@display(description="MFA", label=True)
|
||||
def mfa_badge(self, obj):
|
||||
"""Display MFA status."""
|
||||
if obj.mfa_enabled:
|
||||
return format_html(
|
||||
'<span style="color: green;">✓ Enabled</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: gray;">✗ Disabled</span>'
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('role', 'banned_by')
|
||||
|
||||
actions = ['ban_users', 'unban_users', 'make_moderator', 'make_user']
|
||||
|
||||
@admin.action(description="Ban selected users")
|
||||
def ban_users(self, request, queryset):
|
||||
"""Ban selected users."""
|
||||
count = 0
|
||||
for user in queryset:
|
||||
if not user.banned:
|
||||
user.ban(reason="Banned by admin", banned_by=request.user)
|
||||
count += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f"{count} user(s) have been banned."
|
||||
)
|
||||
|
||||
@admin.action(description="Unban selected users")
|
||||
def unban_users(self, request, queryset):
|
||||
"""Unban selected users."""
|
||||
count = 0
|
||||
for user in queryset:
|
||||
if user.banned:
|
||||
user.unban()
|
||||
count += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f"{count} user(s) have been unbanned."
|
||||
)
|
||||
|
||||
@admin.action(description="Set role to Moderator")
|
||||
def make_moderator(self, request, queryset):
|
||||
"""Set users' role to moderator."""
|
||||
from .services import RoleService
|
||||
|
||||
count = 0
|
||||
for user in queryset:
|
||||
RoleService.assign_role(user, 'moderator', request.user)
|
||||
count += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f"{count} user(s) have been set to Moderator role."
|
||||
)
|
||||
|
||||
@admin.action(description="Set role to User")
|
||||
def make_user(self, request, queryset):
|
||||
"""Set users' role to user."""
|
||||
from .services import RoleService
|
||||
|
||||
count = 0
|
||||
for user in queryset:
|
||||
RoleService.assign_role(user, 'user', request.user)
|
||||
count += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f"{count} user(s) have been set to User role."
|
||||
)
|
||||
|
||||
|
||||
@admin.register(UserRole)
|
||||
class UserRoleAdmin(ModelAdmin):
|
||||
"""Admin interface for UserRole model."""
|
||||
|
||||
list_display = ['user', 'role', 'is_moderator', 'is_admin', 'granted_at', 'granted_by']
|
||||
list_filter = ['role', 'granted_at']
|
||||
search_fields = ['user__email', 'user__username']
|
||||
ordering = ['-granted_at']
|
||||
|
||||
readonly_fields = ['granted_at']
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('user', 'granted_by')
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(ModelAdmin):
|
||||
"""Admin interface for UserProfile model."""
|
||||
|
||||
list_display = [
|
||||
'user',
|
||||
'total_submissions',
|
||||
'approved_submissions',
|
||||
'approval_rate',
|
||||
'email_notifications',
|
||||
'profile_public',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'email_notifications',
|
||||
'profile_public',
|
||||
'show_email',
|
||||
]
|
||||
|
||||
search_fields = ['user__email', 'user__username']
|
||||
|
||||
readonly_fields = ['created', 'modified', 'total_submissions', 'approved_submissions']
|
||||
|
||||
fieldsets = (
|
||||
('User', {
|
||||
'fields': ('user',)
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('total_submissions', 'approved_submissions'),
|
||||
}),
|
||||
('Notification Preferences', {
|
||||
'fields': (
|
||||
'email_notifications',
|
||||
'email_on_submission_approved',
|
||||
'email_on_submission_rejected',
|
||||
)
|
||||
}),
|
||||
('Privacy Settings', {
|
||||
'fields': ('profile_public', 'show_email'),
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
@display(description="Approval Rate")
|
||||
def approval_rate(self, obj):
|
||||
"""Display approval rate percentage."""
|
||||
if obj.total_submissions == 0:
|
||||
return '-'
|
||||
|
||||
rate = (obj.approved_submissions / obj.total_submissions) * 100
|
||||
|
||||
if rate >= 80:
|
||||
color = 'green'
|
||||
elif rate >= 60:
|
||||
color = 'blue'
|
||||
elif rate >= 40:
|
||||
color = 'orange'
|
||||
else:
|
||||
color = 'red'
|
||||
|
||||
return format_html(
|
||||
'<span style="color: {}; font-weight: bold;">{:.1f}%</span>',
|
||||
color,
|
||||
rate
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('user')
|
||||
|
||||
|
||||
@admin.register(UserRideCredit)
|
||||
class UserRideCreditAdmin(ModelAdmin):
|
||||
"""Admin interface for UserRideCredit model."""
|
||||
|
||||
list_display = [
|
||||
'user_link',
|
||||
'ride_link',
|
||||
'park_link',
|
||||
'first_ride_date',
|
||||
'ride_count',
|
||||
'created',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'first_ride_date',
|
||||
'created',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'user__email',
|
||||
'user__username',
|
||||
'ride__name',
|
||||
'notes',
|
||||
]
|
||||
|
||||
ordering = ['-first_ride_date', '-created']
|
||||
|
||||
readonly_fields = ['created', 'modified']
|
||||
|
||||
fieldsets = (
|
||||
('Credit Information', {
|
||||
'fields': ('user', 'ride', 'first_ride_date', 'ride_count')
|
||||
}),
|
||||
('Notes', {
|
||||
'fields': ('notes',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
@display(description='User', ordering='user__username')
|
||||
def user_link(self, obj):
|
||||
url = reverse('admin:users_user_change', args=[obj.user.pk])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||
|
||||
@display(description='Ride', ordering='ride__name')
|
||||
def ride_link(self, obj):
|
||||
url = reverse('admin:entities_ride_change', args=[obj.ride.pk])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.ride.name)
|
||||
|
||||
@display(description='Park')
|
||||
def park_link(self, obj):
|
||||
if obj.ride.park:
|
||||
url = reverse('admin:entities_park_change', args=[obj.ride.park.pk])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.ride.park.name)
|
||||
return '-'
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('user', 'ride', 'ride__park')
|
||||
|
||||
|
||||
class UserTopListItemInline(admin.TabularInline):
|
||||
"""Inline for top list items."""
|
||||
model = UserTopListItem
|
||||
extra = 1
|
||||
fields = ('position', 'content_type', 'object_id', 'notes')
|
||||
ordering = ['position']
|
||||
|
||||
|
||||
@admin.register(UserTopList)
|
||||
class UserTopListAdmin(ModelAdmin):
|
||||
"""Admin interface for UserTopList model."""
|
||||
|
||||
list_display = [
|
||||
'title',
|
||||
'user_link',
|
||||
'list_type',
|
||||
'item_count_display',
|
||||
'visibility_badge',
|
||||
'created',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'list_type',
|
||||
'is_public',
|
||||
'created',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'title',
|
||||
'description',
|
||||
'user__email',
|
||||
'user__username',
|
||||
]
|
||||
|
||||
ordering = ['-created']
|
||||
|
||||
readonly_fields = ['created', 'modified', 'item_count']
|
||||
|
||||
fieldsets = (
|
||||
('List Information', {
|
||||
'fields': ('user', 'list_type', 'title', 'description')
|
||||
}),
|
||||
('Privacy', {
|
||||
'fields': ('is_public',)
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('item_count',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
inlines = [UserTopListItemInline]
|
||||
|
||||
@display(description='User', ordering='user__username')
|
||||
def user_link(self, obj):
|
||||
url = reverse('admin:users_user_change', args=[obj.user.pk])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||
|
||||
@display(description='Items', ordering='items__count')
|
||||
def item_count_display(self, obj):
|
||||
count = obj.item_count
|
||||
return format_html('<span style="font-weight: bold;">{}</span>', count)
|
||||
|
||||
@display(description='Visibility', ordering='is_public')
|
||||
def visibility_badge(self, obj):
|
||||
if obj.is_public:
|
||||
return format_html(
|
||||
'<span style="background-color: green; color: white; padding: 3px 8px; '
|
||||
'border-radius: 3px; font-size: 11px;">PUBLIC</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="background-color: gray; color: white; padding: 3px 8px; '
|
||||
'border-radius: 3px; font-size: 11px;">PRIVATE</span>'
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('user').prefetch_related('items')
|
||||
|
||||
|
||||
@admin.register(UserTopListItem)
|
||||
class UserTopListItemAdmin(ModelAdmin):
|
||||
"""Admin interface for UserTopListItem model."""
|
||||
|
||||
list_display = [
|
||||
'position',
|
||||
'list_link',
|
||||
'entity_type',
|
||||
'entity_link',
|
||||
'created',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'content_type',
|
||||
'created',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'top_list__title',
|
||||
'notes',
|
||||
]
|
||||
|
||||
ordering = ['top_list', 'position']
|
||||
|
||||
readonly_fields = ['created', 'modified']
|
||||
|
||||
fieldsets = (
|
||||
('Item Information', {
|
||||
'fields': ('top_list', 'position', 'content_type', 'object_id')
|
||||
}),
|
||||
('Notes', {
|
||||
'fields': ('notes',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
@display(description='List', ordering='top_list__title')
|
||||
def list_link(self, obj):
|
||||
url = reverse('admin:users_usertoplist_change', args=[obj.top_list.pk])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.top_list.title)
|
||||
|
||||
@display(description='Type', ordering='content_type')
|
||||
def entity_type(self, obj):
|
||||
return obj.content_type.model.title()
|
||||
|
||||
@display(description='Entity')
|
||||
def entity_link(self, obj):
|
||||
if obj.content_object:
|
||||
model_name = obj.content_type.model
|
||||
url = reverse(f'admin:entities_{model_name}_change', args=[obj.object_id])
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||
return f"ID: {obj.object_id}"
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('top_list', 'content_type')
|
||||
17
django-backend/apps/users/apps.py
Normal file
17
django-backend/apps/users/apps.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Users app configuration.
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.users'
|
||||
verbose_name = 'Users'
|
||||
|
||||
def ready(self):
|
||||
"""Import signal handlers when app is ready"""
|
||||
# Import signals here to avoid circular imports
|
||||
# import apps.users.signals
|
||||
pass
|
||||
370
django-backend/apps/users/migrations/0001_initial.py
Normal file
370
django-backend/apps/users/migrations/0001_initial.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# Generated by Django 4.2.8 on 2025-11-08 16:35
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import django_lifecycle.mixins
|
||||
import model_utils.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
help_text="Email address for authentication",
|
||||
max_length=254,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"oauth_provider",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "None"),
|
||||
("google", "Google"),
|
||||
("discord", "Discord"),
|
||||
],
|
||||
help_text="OAuth provider used for authentication",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"oauth_sub",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="OAuth subject identifier from provider",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"mfa_enabled",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether two-factor authentication is enabled",
|
||||
),
|
||||
),
|
||||
(
|
||||
"avatar_url",
|
||||
models.URLField(blank=True, help_text="URL to user's avatar image"),
|
||||
),
|
||||
(
|
||||
"bio",
|
||||
models.TextField(
|
||||
blank=True, help_text="User biography", max_length=500
|
||||
),
|
||||
),
|
||||
(
|
||||
"banned",
|
||||
models.BooleanField(
|
||||
db_index=True,
|
||||
default=False,
|
||||
help_text="Whether this user is banned",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ban_reason",
|
||||
models.TextField(blank=True, help_text="Reason for ban"),
|
||||
),
|
||||
(
|
||||
"banned_at",
|
||||
models.DateTimeField(
|
||||
blank=True, help_text="When the user was banned", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"reputation_score",
|
||||
models.IntegerField(
|
||||
default=0,
|
||||
help_text="User reputation score based on contributions",
|
||||
),
|
||||
),
|
||||
(
|
||||
"banned_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Moderator who banned this user",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="users_banned",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "users",
|
||||
"ordering": ["-date_joined"],
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserRole",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("user", "User"),
|
||||
("moderator", "Moderator"),
|
||||
("admin", "Admin"),
|
||||
],
|
||||
db_index=True,
|
||||
default="user",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("granted_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"granted_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="roles_granted",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="role",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_roles",
|
||||
},
|
||||
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserProfile",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_notifications",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Receive email notifications"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_on_submission_approved",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Email when submissions are approved"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_on_submission_rejected",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Email when submissions are rejected"
|
||||
),
|
||||
),
|
||||
(
|
||||
"profile_public",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Make profile publicly visible"
|
||||
),
|
||||
),
|
||||
(
|
||||
"show_email",
|
||||
models.BooleanField(
|
||||
default=False, help_text="Show email on public profile"
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_submissions",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Total number of submissions made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"approved_submissions",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of approved submissions"
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="profile",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_profiles",
|
||||
},
|
||||
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["email"], name="users_email_4b85f2_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["banned"], name="users_banned_ee00ad_idx"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,265 @@
|
||||
# Generated by Django 4.2.8 on 2025-11-08 20:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import django_lifecycle.mixins
|
||||
import model_utils.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("entities", "0003_add_search_vector_gin_indexes"),
|
||||
("users", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserTopList",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"list_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("parks", "Parks"),
|
||||
("rides", "Rides"),
|
||||
("coasters", "Coasters"),
|
||||
],
|
||||
db_index=True,
|
||||
help_text="Type of entities in this list",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.CharField(help_text="Title of the list", max_length=200),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, help_text="Description of the list"),
|
||||
),
|
||||
(
|
||||
"is_public",
|
||||
models.BooleanField(
|
||||
db_index=True,
|
||||
default=True,
|
||||
help_text="Whether this list is publicly visible",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="top_lists",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_top_lists",
|
||||
"ordering": ["-created"],
|
||||
},
|
||||
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserRideCredit",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_ride_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Date of first ride", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_count",
|
||||
models.PositiveIntegerField(
|
||||
default=1, help_text="Number of times user has ridden this ride"
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True, help_text="User notes about this ride"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="user_credits",
|
||||
to="entities.ride",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ride_credits",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_ride_credits",
|
||||
"ordering": ["-first_ride_date", "-created"],
|
||||
},
|
||||
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserTopListItem",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
(
|
||||
"position",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Position in the list (1 = top)"
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="User notes about why this item is ranked here",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
limit_choices_to={"model__in": ("park", "ride")},
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"top_list",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="items",
|
||||
to="users.usertoplist",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "user_top_list_items",
|
||||
"ordering": ["position"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["top_list", "position"],
|
||||
name="user_top_li_top_lis_d31db9_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="user_top_li_content_889eb7_idx",
|
||||
),
|
||||
],
|
||||
"unique_together": {("top_list", "position")},
|
||||
},
|
||||
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usertoplist",
|
||||
index=models.Index(
|
||||
fields=["user", "list_type"], name="user_top_li_user_id_63f56d_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usertoplist",
|
||||
index=models.Index(
|
||||
fields=["is_public", "created"], name="user_top_li_is_publ_983146_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userridecredit",
|
||||
index=models.Index(
|
||||
fields=["user", "first_ride_date"],
|
||||
name="user_ride_c_user_id_56a0e5_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userridecredit",
|
||||
index=models.Index(fields=["ride"], name="user_ride_c_ride_id_f0990b_idx"),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="userridecredit",
|
||||
unique_together={("user", "ride")},
|
||||
),
|
||||
]
|
||||
0
django-backend/apps/users/migrations/__init__.py
Normal file
0
django-backend/apps/users/migrations/__init__.py
Normal file
419
django-backend/apps/users/models.py
Normal file
419
django-backend/apps/users/models.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
User models for ThrillWiki.
|
||||
Custom user model with OAuth and MFA support.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""
|
||||
Custom user model with UUID primary key and additional fields.
|
||||
|
||||
Supports:
|
||||
- Email-based authentication
|
||||
- OAuth (Google, Discord)
|
||||
- Two-factor authentication (TOTP)
|
||||
- User reputation and moderation
|
||||
"""
|
||||
|
||||
# Override id to use UUID
|
||||
id = models.UUIDField(
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False
|
||||
)
|
||||
|
||||
# Email as primary identifier
|
||||
email = models.EmailField(
|
||||
unique=True,
|
||||
help_text="Email address for authentication"
|
||||
)
|
||||
|
||||
# OAuth fields
|
||||
oauth_provider = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
choices=[
|
||||
('', 'None'),
|
||||
('google', 'Google'),
|
||||
('discord', 'Discord'),
|
||||
],
|
||||
help_text="OAuth provider used for authentication"
|
||||
)
|
||||
oauth_sub = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="OAuth subject identifier from provider"
|
||||
)
|
||||
|
||||
# MFA fields
|
||||
mfa_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether two-factor authentication is enabled"
|
||||
)
|
||||
|
||||
# Profile fields
|
||||
avatar_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="URL to user's avatar image"
|
||||
)
|
||||
bio = models.TextField(
|
||||
blank=True,
|
||||
max_length=500,
|
||||
help_text="User biography"
|
||||
)
|
||||
|
||||
# Moderation fields
|
||||
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"
|
||||
)
|
||||
banned_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the user was banned"
|
||||
)
|
||||
banned_by = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='users_banned',
|
||||
help_text="Moderator who banned this user"
|
||||
)
|
||||
|
||||
# Reputation system
|
||||
reputation_score = models.IntegerField(
|
||||
default=0,
|
||||
help_text="User reputation score based on contributions"
|
||||
)
|
||||
|
||||
# Timestamps (inherited from AbstractUser)
|
||||
# date_joined, last_login
|
||||
|
||||
# Use email for authentication
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
ordering = ['-date_joined']
|
||||
indexes = [
|
||||
models.Index(fields=['email']),
|
||||
models.Index(fields=['banned']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
def ban(self, reason, banned_by=None):
|
||||
"""Ban this user"""
|
||||
from django.utils import timezone
|
||||
self.banned = True
|
||||
self.ban_reason = reason
|
||||
self.banned_at = timezone.now()
|
||||
self.banned_by = banned_by
|
||||
self.save(update_fields=['banned', 'ban_reason', 'banned_at', 'banned_by'])
|
||||
|
||||
def unban(self):
|
||||
"""Unban this user"""
|
||||
self.banned = False
|
||||
self.ban_reason = ''
|
||||
self.banned_at = None
|
||||
self.banned_by = None
|
||||
self.save(update_fields=['banned', 'ban_reason', 'banned_at', 'banned_by'])
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Return the user's display name (full name or username)"""
|
||||
if self.first_name or self.last_name:
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
return self.username or self.email.split('@')[0]
|
||||
|
||||
|
||||
class UserRole(BaseModel):
|
||||
"""
|
||||
User role assignments for permission management.
|
||||
|
||||
Roles:
|
||||
- user: Standard user (default)
|
||||
- moderator: Can approve submissions and moderate content
|
||||
- admin: Full access to admin features
|
||||
"""
|
||||
|
||||
ROLE_CHOICES = [
|
||||
('user', 'User'),
|
||||
('moderator', 'Moderator'),
|
||||
('admin', 'Admin'),
|
||||
]
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='role'
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=ROLE_CHOICES,
|
||||
default='user',
|
||||
db_index=True
|
||||
)
|
||||
granted_at = models.DateTimeField(auto_now_add=True)
|
||||
granted_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='roles_granted'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_roles'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.role}"
|
||||
|
||||
@property
|
||||
def is_moderator(self):
|
||||
"""Check if user is a moderator or admin"""
|
||||
return self.role in ['moderator', 'admin']
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
"""Check if user is an admin"""
|
||||
return self.role == 'admin'
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
"""
|
||||
Extended user profile information.
|
||||
|
||||
Stores additional user preferences and settings.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='profile'
|
||||
)
|
||||
|
||||
# Preferences
|
||||
email_notifications = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Receive email notifications"
|
||||
)
|
||||
email_on_submission_approved = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Email when submissions are approved"
|
||||
)
|
||||
email_on_submission_rejected = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Email when submissions are rejected"
|
||||
)
|
||||
|
||||
# Privacy
|
||||
profile_public = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Make profile publicly visible"
|
||||
)
|
||||
show_email = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Show email on public profile"
|
||||
)
|
||||
|
||||
# Statistics
|
||||
total_submissions = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Total number of submissions made"
|
||||
)
|
||||
approved_submissions = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of approved submissions"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_profiles'
|
||||
|
||||
def __str__(self):
|
||||
return f"Profile for {self.user.email}"
|
||||
|
||||
def update_submission_stats(self):
|
||||
"""Update submission statistics"""
|
||||
from apps.moderation.models import ContentSubmission
|
||||
self.total_submissions = ContentSubmission.objects.filter(user=self.user).count()
|
||||
self.approved_submissions = ContentSubmission.objects.filter(
|
||||
user=self.user,
|
||||
status='approved'
|
||||
).count()
|
||||
self.save(update_fields=['total_submissions', 'approved_submissions'])
|
||||
|
||||
|
||||
class UserRideCredit(BaseModel):
|
||||
"""
|
||||
Track which rides users have ridden (ride credits/coaster counting).
|
||||
|
||||
Users can log which rides they've been on and track their first ride date.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='ride_credits'
|
||||
)
|
||||
ride = models.ForeignKey(
|
||||
'entities.Ride',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='user_credits'
|
||||
)
|
||||
|
||||
# First ride date
|
||||
first_ride_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Date of first ride"
|
||||
)
|
||||
|
||||
# Ride count for this specific ride
|
||||
ride_count = models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Number of times user has ridden this ride"
|
||||
)
|
||||
|
||||
# Notes about the ride experience
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="User notes about this ride"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_ride_credits'
|
||||
unique_together = [['user', 'ride']]
|
||||
ordering = ['-first_ride_date', '-created']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'first_ride_date']),
|
||||
models.Index(fields=['ride']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.ride.name}"
|
||||
|
||||
@property
|
||||
def park(self):
|
||||
"""Get the park this ride is at"""
|
||||
return self.ride.park
|
||||
|
||||
|
||||
class UserTopList(BaseModel):
|
||||
"""
|
||||
User-created ranked lists (top parks, top rides, top coasters, etc.).
|
||||
|
||||
Users can create and share their personal rankings of parks, rides, and other entities.
|
||||
"""
|
||||
|
||||
LIST_TYPE_CHOICES = [
|
||||
('parks', 'Parks'),
|
||||
('rides', 'Rides'),
|
||||
('coasters', 'Coasters'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='top_lists'
|
||||
)
|
||||
|
||||
# List metadata
|
||||
list_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=LIST_TYPE_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Type of entities in this list"
|
||||
)
|
||||
title = models.CharField(
|
||||
max_length=200,
|
||||
help_text="Title of the list"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of the list"
|
||||
)
|
||||
|
||||
# Privacy
|
||||
is_public = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Whether this list is publicly visible"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_top_lists'
|
||||
ordering = ['-created']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'list_type']),
|
||||
models.Index(fields=['is_public', 'created']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.title}"
|
||||
|
||||
@property
|
||||
def item_count(self):
|
||||
"""Get the number of items in this list"""
|
||||
return self.items.count()
|
||||
|
||||
|
||||
class UserTopListItem(BaseModel):
|
||||
"""
|
||||
Individual items in a user's top list with position and notes.
|
||||
"""
|
||||
|
||||
top_list = models.ForeignKey(
|
||||
UserTopList,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items'
|
||||
)
|
||||
|
||||
# Generic relation to park or ride
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
limit_choices_to={'model__in': ('park', 'ride')}
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
# Position in list (1 = top)
|
||||
position = models.PositiveIntegerField(
|
||||
help_text="Position in the list (1 = top)"
|
||||
)
|
||||
|
||||
# Optional notes about this specific item
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="User notes about why this item is ranked here"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_top_list_items'
|
||||
ordering = ['position']
|
||||
unique_together = [['top_list', 'position']]
|
||||
indexes = [
|
||||
models.Index(fields=['top_list', 'position']),
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
entity_name = str(self.content_object) if self.content_object else f"ID {self.object_id}"
|
||||
return f"#{self.position}: {entity_name}"
|
||||
310
django-backend/apps/users/permissions.py
Normal file
310
django-backend/apps/users/permissions.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
Permission utilities and decorators for API endpoints.
|
||||
|
||||
Provides:
|
||||
- Permission checking decorators
|
||||
- Role-based access control
|
||||
- Object-level permissions
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Optional, Callable
|
||||
from django.http import HttpRequest
|
||||
from ninja import Router
|
||||
from ninja.security import HttpBearer
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
from rest_framework_simplejwt.exceptions import TokenError
|
||||
from django.core.exceptions import PermissionDenied
|
||||
import logging
|
||||
|
||||
from .models import User, UserRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWTAuth(HttpBearer):
|
||||
"""JWT authentication for django-ninja"""
|
||||
|
||||
def authenticate(self, request: HttpRequest, token: str) -> Optional[User]:
|
||||
"""
|
||||
Authenticate user from JWT token.
|
||||
|
||||
Args:
|
||||
request: HTTP request
|
||||
token: JWT access token
|
||||
|
||||
Returns:
|
||||
User instance if valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Decode token
|
||||
access_token = AccessToken(token)
|
||||
user_id = access_token['user_id']
|
||||
|
||||
# Get user
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
# Check if banned
|
||||
if user.banned:
|
||||
logger.warning(f"Banned user attempted API access: {user.email}")
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
except TokenError as e:
|
||||
logger.debug(f"Invalid token: {e}")
|
||||
return None
|
||||
except User.DoesNotExist:
|
||||
logger.warning(f"Token for non-existent user: {user_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Global JWT auth instance
|
||||
jwt_auth = JWTAuth()
|
||||
|
||||
|
||||
def require_auth(func: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require authentication.
|
||||
|
||||
Usage:
|
||||
@api.get("/protected")
|
||||
@require_auth
|
||||
def protected_endpoint(request):
|
||||
return {"user": request.auth.email}
|
||||
"""
|
||||
@wraps(func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
if not request.auth or not isinstance(request.auth, User):
|
||||
raise PermissionDenied("Authentication required")
|
||||
return func(request, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_role(role: str) -> Callable:
|
||||
"""
|
||||
Decorator to require specific role.
|
||||
|
||||
Args:
|
||||
role: Required role (user, moderator, admin)
|
||||
|
||||
Usage:
|
||||
@api.post("/moderate")
|
||||
@require_role("moderator")
|
||||
def moderate_endpoint(request):
|
||||
return {"message": "Access granted"}
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
if not request.auth or not isinstance(request.auth, User):
|
||||
raise PermissionDenied("Authentication required")
|
||||
|
||||
user = request.auth
|
||||
|
||||
try:
|
||||
user_role = user.role
|
||||
|
||||
# Admin has access to everything
|
||||
if user_role.is_admin:
|
||||
return func(request, *args, **kwargs)
|
||||
|
||||
# Check specific role
|
||||
if role == 'moderator' and user_role.is_moderator:
|
||||
return func(request, *args, **kwargs)
|
||||
elif role == 'user':
|
||||
return func(request, *args, **kwargs)
|
||||
|
||||
raise PermissionDenied(f"Role '{role}' required")
|
||||
|
||||
except UserRole.DoesNotExist:
|
||||
raise PermissionDenied("User role not assigned")
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_moderator(func: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require moderator or admin role.
|
||||
|
||||
Usage:
|
||||
@api.post("/approve")
|
||||
@require_moderator
|
||||
def approve_endpoint(request):
|
||||
return {"message": "Access granted"}
|
||||
"""
|
||||
return require_role("moderator")(func)
|
||||
|
||||
|
||||
def require_admin(func: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require admin role.
|
||||
|
||||
Usage:
|
||||
@api.delete("/delete-user")
|
||||
@require_admin
|
||||
def delete_user_endpoint(request):
|
||||
return {"message": "Access granted"}
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
if not request.auth or not isinstance(request.auth, User):
|
||||
raise PermissionDenied("Authentication required")
|
||||
|
||||
user = request.auth
|
||||
|
||||
try:
|
||||
user_role = user.role
|
||||
|
||||
if not user_role.is_admin:
|
||||
raise PermissionDenied("Admin role required")
|
||||
|
||||
return func(request, *args, **kwargs)
|
||||
|
||||
except UserRole.DoesNotExist:
|
||||
raise PermissionDenied("User role not assigned")
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def is_owner_or_moderator(user: User, obj_user_id) -> bool:
|
||||
"""
|
||||
Check if user is the owner of an object or a moderator.
|
||||
|
||||
Args:
|
||||
user: User to check
|
||||
obj_user_id: User ID of the object owner
|
||||
|
||||
Returns:
|
||||
True if user is owner or moderator
|
||||
"""
|
||||
if str(user.id) == str(obj_user_id):
|
||||
return True
|
||||
|
||||
try:
|
||||
return user.role.is_moderator
|
||||
except UserRole.DoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
def can_moderate(user: User) -> bool:
|
||||
"""
|
||||
Check if user can moderate content.
|
||||
|
||||
Args:
|
||||
user: User to check
|
||||
|
||||
Returns:
|
||||
True if user is moderator or admin
|
||||
"""
|
||||
if user.banned:
|
||||
return False
|
||||
|
||||
try:
|
||||
return user.role.is_moderator
|
||||
except UserRole.DoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
def can_submit(user: User) -> bool:
|
||||
"""
|
||||
Check if user can submit content.
|
||||
|
||||
Args:
|
||||
user: User to check
|
||||
|
||||
Returns:
|
||||
True if user is not banned
|
||||
"""
|
||||
return not user.banned
|
||||
|
||||
|
||||
class PermissionChecker:
|
||||
"""Helper class for checking permissions"""
|
||||
|
||||
def __init__(self, user: User):
|
||||
self.user = user
|
||||
try:
|
||||
self.user_role = user.role
|
||||
except UserRole.DoesNotExist:
|
||||
self.user_role = None
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if user is authenticated"""
|
||||
return self.user is not None
|
||||
|
||||
@property
|
||||
def is_moderator(self) -> bool:
|
||||
"""Check if user is moderator or admin"""
|
||||
if self.user.banned:
|
||||
return False
|
||||
return self.user_role and self.user_role.is_moderator
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user is admin"""
|
||||
if self.user.banned:
|
||||
return False
|
||||
return self.user_role and self.user_role.is_admin
|
||||
|
||||
@property
|
||||
def can_submit(self) -> bool:
|
||||
"""Check if user can submit content"""
|
||||
return not self.user.banned
|
||||
|
||||
@property
|
||||
def can_moderate(self) -> bool:
|
||||
"""Check if user can moderate content"""
|
||||
return self.is_moderator
|
||||
|
||||
def can_edit(self, obj_user_id) -> bool:
|
||||
"""Check if user can edit an object"""
|
||||
if self.user.banned:
|
||||
return False
|
||||
return str(self.user.id) == str(obj_user_id) or self.is_moderator
|
||||
|
||||
def can_delete(self, obj_user_id) -> bool:
|
||||
"""Check if user can delete an object"""
|
||||
if self.user.banned:
|
||||
return False
|
||||
return str(self.user.id) == str(obj_user_id) or self.is_admin
|
||||
|
||||
def require_permission(self, permission: str) -> None:
|
||||
"""
|
||||
Raise PermissionDenied if user doesn't have permission.
|
||||
|
||||
Args:
|
||||
permission: Permission to check (submit, moderate, admin)
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If user doesn't have permission
|
||||
"""
|
||||
if permission == 'submit' and not self.can_submit:
|
||||
raise PermissionDenied("You are banned from submitting content")
|
||||
elif permission == 'moderate' and not self.can_moderate:
|
||||
raise PermissionDenied("Moderator role required")
|
||||
elif permission == 'admin' and not self.is_admin:
|
||||
raise PermissionDenied("Admin role required")
|
||||
|
||||
|
||||
def get_permission_checker(request: HttpRequest) -> Optional[PermissionChecker]:
|
||||
"""
|
||||
Get permission checker for request user.
|
||||
|
||||
Args:
|
||||
request: HTTP request
|
||||
|
||||
Returns:
|
||||
PermissionChecker instance or None if not authenticated
|
||||
"""
|
||||
if not request.auth or not isinstance(request.auth, User):
|
||||
return None
|
||||
|
||||
return PermissionChecker(request.auth)
|
||||
592
django-backend/apps/users/services.py
Normal file
592
django-backend/apps/users/services.py
Normal file
@@ -0,0 +1,592 @@
|
||||
"""
|
||||
User authentication and management services.
|
||||
|
||||
Provides business logic for:
|
||||
- User registration and authentication
|
||||
- OAuth integration
|
||||
- MFA/2FA management
|
||||
- Permission and role management
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
import logging
|
||||
|
||||
from .models import User, UserRole, UserProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthenticationService:
|
||||
"""Service for handling user authentication operations"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def register_user(
|
||||
email: str,
|
||||
password: str,
|
||||
username: Optional[str] = None,
|
||||
first_name: str = '',
|
||||
last_name: str = ''
|
||||
) -> User:
|
||||
"""
|
||||
Register a new user with email and password.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
password: User's password (will be validated and hashed)
|
||||
username: Optional username (defaults to email prefix)
|
||||
first_name: User's first name
|
||||
last_name: User's last name
|
||||
|
||||
Returns:
|
||||
Created User instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If email exists or password is invalid
|
||||
"""
|
||||
# Normalize email
|
||||
email = email.lower().strip()
|
||||
|
||||
# Check if user exists
|
||||
if User.objects.filter(email=email).exists():
|
||||
raise ValidationError({'email': 'A user with this email already exists.'})
|
||||
|
||||
# Set username if not provided
|
||||
if not username:
|
||||
username = email.split('@')[0]
|
||||
# Make unique if needed
|
||||
base_username = username
|
||||
counter = 1
|
||||
while User.objects.filter(username=username).exists():
|
||||
username = f"{base_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
# Validate password
|
||||
try:
|
||||
validate_password(password)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({'password': e.messages})
|
||||
|
||||
# Create user
|
||||
user = User.objects.create_user(
|
||||
email=email,
|
||||
username=username,
|
||||
password=password,
|
||||
first_name=first_name,
|
||||
last_name=last_name
|
||||
)
|
||||
|
||||
# Create role (default: user)
|
||||
UserRole.objects.create(user=user, role='user')
|
||||
|
||||
# Create profile
|
||||
UserProfile.objects.create(user=user)
|
||||
|
||||
logger.info(f"New user registered: {user.email}")
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def authenticate_user(email: str, password: str) -> Optional[User]:
|
||||
"""
|
||||
Authenticate user with email and password.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
password: User's password
|
||||
|
||||
Returns:
|
||||
User instance if authentication successful, None otherwise
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
user = authenticate(username=email, password=password)
|
||||
|
||||
if user and user.banned:
|
||||
logger.warning(f"Banned user attempted login: {email}")
|
||||
raise ValidationError("This account has been banned.")
|
||||
|
||||
if user:
|
||||
user.last_login = timezone.now()
|
||||
user.save(update_fields=['last_login'])
|
||||
logger.info(f"User authenticated: {email}")
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_oauth_user(
|
||||
email: str,
|
||||
provider: str,
|
||||
oauth_sub: str,
|
||||
username: Optional[str] = None,
|
||||
first_name: str = '',
|
||||
last_name: str = '',
|
||||
avatar_url: str = ''
|
||||
) -> User:
|
||||
"""
|
||||
Create or get user from OAuth provider.
|
||||
|
||||
Args:
|
||||
email: User's email from OAuth provider
|
||||
provider: OAuth provider name (google, discord)
|
||||
oauth_sub: OAuth subject identifier
|
||||
username: Optional username
|
||||
first_name: User's first name
|
||||
last_name: User's last name
|
||||
avatar_url: URL to user's avatar
|
||||
|
||||
Returns:
|
||||
User instance
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
|
||||
# Check if user exists with this email
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
# Update OAuth info if not set
|
||||
if not user.oauth_provider:
|
||||
user.oauth_provider = provider
|
||||
user.oauth_sub = oauth_sub
|
||||
user.save(update_fields=['oauth_provider', 'oauth_sub'])
|
||||
return user
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Create new user
|
||||
if not username:
|
||||
username = email.split('@')[0]
|
||||
base_username = username
|
||||
counter = 1
|
||||
while User.objects.filter(username=username).exists():
|
||||
username = f"{base_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
user = User.objects.create(
|
||||
email=email,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
avatar_url=avatar_url,
|
||||
oauth_provider=provider,
|
||||
oauth_sub=oauth_sub
|
||||
)
|
||||
|
||||
# No password needed for OAuth users
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
# Create role and profile
|
||||
UserRole.objects.create(user=user, role='user')
|
||||
UserProfile.objects.create(user=user)
|
||||
|
||||
logger.info(f"OAuth user created: {email} via {provider}")
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def change_password(user: User, old_password: str, new_password: str) -> bool:
|
||||
"""
|
||||
Change user's password.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
old_password: Current password
|
||||
new_password: New password
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
ValidationError: If old password is incorrect or new password is invalid
|
||||
"""
|
||||
# Check old password
|
||||
if not user.check_password(old_password):
|
||||
raise ValidationError({'old_password': 'Incorrect password.'})
|
||||
|
||||
# Validate new password
|
||||
try:
|
||||
validate_password(new_password, user=user)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({'new_password': e.messages})
|
||||
|
||||
# Set new password
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
logger.info(f"Password changed for user: {user.email}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def reset_password(user: User, new_password: str) -> bool:
|
||||
"""
|
||||
Reset user's password (admin/forgot password flow).
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
new_password: New password
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
ValidationError: If new password is invalid
|
||||
"""
|
||||
# Validate new password
|
||||
try:
|
||||
validate_password(new_password, user=user)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({'password': e.messages})
|
||||
|
||||
# Set new password
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
logger.info(f"Password reset for user: {user.email}")
|
||||
return True
|
||||
|
||||
|
||||
class MFAService:
|
||||
"""Service for handling multi-factor authentication"""
|
||||
|
||||
@staticmethod
|
||||
def enable_totp(user: User, device_name: str = 'default') -> TOTPDevice:
|
||||
"""
|
||||
Enable TOTP-based MFA for user.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
device_name: Name for the TOTP device
|
||||
|
||||
Returns:
|
||||
TOTPDevice instance with QR code data
|
||||
"""
|
||||
# Check if device already exists
|
||||
device = TOTPDevice.objects.filter(
|
||||
user=user,
|
||||
name=device_name
|
||||
).first()
|
||||
|
||||
if not device:
|
||||
device = TOTPDevice.objects.create(
|
||||
user=user,
|
||||
name=device_name,
|
||||
confirmed=False
|
||||
)
|
||||
|
||||
return device
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def confirm_totp(user: User, token: str, device_name: str = 'default') -> bool:
|
||||
"""
|
||||
Confirm TOTP device with verification token.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
token: 6-digit TOTP token
|
||||
device_name: Name of the TOTP device
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
ValidationError: If token is invalid
|
||||
"""
|
||||
device = TOTPDevice.objects.filter(
|
||||
user=user,
|
||||
name=device_name
|
||||
).first()
|
||||
|
||||
if not device:
|
||||
raise ValidationError("TOTP device not found.")
|
||||
|
||||
# Verify token
|
||||
if not device.verify_token(token):
|
||||
raise ValidationError("Invalid verification code.")
|
||||
|
||||
# Confirm device
|
||||
device.confirmed = True
|
||||
device.save()
|
||||
|
||||
# Enable MFA on user
|
||||
user.mfa_enabled = True
|
||||
user.save(update_fields=['mfa_enabled'])
|
||||
|
||||
logger.info(f"MFA enabled for user: {user.email}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def verify_totp(user: User, token: str) -> bool:
|
||||
"""
|
||||
Verify TOTP token for authentication.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
token: 6-digit TOTP token
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
"""
|
||||
device = TOTPDevice.objects.filter(
|
||||
user=user,
|
||||
confirmed=True
|
||||
).first()
|
||||
|
||||
if not device:
|
||||
return False
|
||||
|
||||
return device.verify_token(token)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def disable_totp(user: User) -> bool:
|
||||
"""
|
||||
Disable TOTP-based MFA for user.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
# Delete all TOTP devices
|
||||
TOTPDevice.objects.filter(user=user).delete()
|
||||
|
||||
# Disable MFA on user
|
||||
user.mfa_enabled = False
|
||||
user.save(update_fields=['mfa_enabled'])
|
||||
|
||||
logger.info(f"MFA disabled for user: {user.email}")
|
||||
return True
|
||||
|
||||
|
||||
class RoleService:
|
||||
"""Service for managing user roles and permissions"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def assign_role(
|
||||
user: User,
|
||||
role: str,
|
||||
granted_by: Optional[User] = None
|
||||
) -> UserRole:
|
||||
"""
|
||||
Assign role to user.
|
||||
|
||||
Args:
|
||||
user: User to assign role to
|
||||
role: Role name (user, moderator, admin)
|
||||
granted_by: User granting the role
|
||||
|
||||
Returns:
|
||||
UserRole instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If role is invalid
|
||||
"""
|
||||
valid_roles = ['user', 'moderator', 'admin']
|
||||
if role not in valid_roles:
|
||||
raise ValidationError(f"Invalid role. Must be one of: {', '.join(valid_roles)}")
|
||||
|
||||
# Get or create role
|
||||
user_role, created = UserRole.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={'role': role, 'granted_by': granted_by}
|
||||
)
|
||||
|
||||
if not created and user_role.role != role:
|
||||
user_role.role = role
|
||||
user_role.granted_by = granted_by
|
||||
user_role.granted_at = timezone.now()
|
||||
user_role.save()
|
||||
|
||||
logger.info(f"Role '{role}' assigned to user: {user.email}")
|
||||
return user_role
|
||||
|
||||
@staticmethod
|
||||
def has_role(user: User, role: str) -> bool:
|
||||
"""
|
||||
Check if user has specific role.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
role: Role name to check
|
||||
|
||||
Returns:
|
||||
True if user has the role
|
||||
"""
|
||||
try:
|
||||
user_role = user.role
|
||||
if role == 'moderator':
|
||||
return user_role.is_moderator
|
||||
elif role == 'admin':
|
||||
return user_role.is_admin
|
||||
return user_role.role == role
|
||||
except UserRole.DoesNotExist:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_user_permissions(user: User) -> Dict[str, bool]:
|
||||
"""
|
||||
Get user's permission summary.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
|
||||
Returns:
|
||||
Dictionary of permissions
|
||||
"""
|
||||
try:
|
||||
user_role = user.role
|
||||
is_moderator = user_role.is_moderator
|
||||
is_admin = user_role.is_admin
|
||||
except UserRole.DoesNotExist:
|
||||
is_moderator = False
|
||||
is_admin = False
|
||||
|
||||
return {
|
||||
'can_submit': not user.banned,
|
||||
'can_moderate': is_moderator and not user.banned,
|
||||
'can_admin': is_admin and not user.banned,
|
||||
'can_edit_own': not user.banned,
|
||||
'can_delete_own': not user.banned,
|
||||
}
|
||||
|
||||
|
||||
class UserManagementService:
|
||||
"""Service for user profile and account management"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def update_profile(
|
||||
user: User,
|
||||
**kwargs
|
||||
) -> User:
|
||||
"""
|
||||
Update user profile information.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
**kwargs: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated User instance
|
||||
"""
|
||||
allowed_fields = [
|
||||
'first_name', 'last_name', 'username',
|
||||
'avatar_url', 'bio'
|
||||
]
|
||||
|
||||
updated_fields = []
|
||||
for field, value in kwargs.items():
|
||||
if field in allowed_fields and hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
updated_fields.append(field)
|
||||
|
||||
if updated_fields:
|
||||
user.save(update_fields=updated_fields)
|
||||
logger.info(f"Profile updated for user: {user.email}")
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def update_preferences(
|
||||
user: User,
|
||||
**kwargs
|
||||
) -> UserProfile:
|
||||
"""
|
||||
Update user preferences.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
**kwargs: Preference fields to update
|
||||
|
||||
Returns:
|
||||
Updated UserProfile instance
|
||||
"""
|
||||
profile = user.profile
|
||||
|
||||
allowed_fields = [
|
||||
'email_notifications',
|
||||
'email_on_submission_approved',
|
||||
'email_on_submission_rejected',
|
||||
'profile_public',
|
||||
'show_email'
|
||||
]
|
||||
|
||||
updated_fields = []
|
||||
for field, value in kwargs.items():
|
||||
if field in allowed_fields and hasattr(profile, field):
|
||||
setattr(profile, field, value)
|
||||
updated_fields.append(field)
|
||||
|
||||
if updated_fields:
|
||||
profile.save(update_fields=updated_fields)
|
||||
logger.info(f"Preferences updated for user: {user.email}")
|
||||
|
||||
return profile
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def ban_user(
|
||||
user: User,
|
||||
reason: str,
|
||||
banned_by: User
|
||||
) -> User:
|
||||
"""
|
||||
Ban a user.
|
||||
|
||||
Args:
|
||||
user: User to ban
|
||||
reason: Reason for ban
|
||||
banned_by: User performing the ban
|
||||
|
||||
Returns:
|
||||
Updated User instance
|
||||
"""
|
||||
user.ban(reason=reason, banned_by=banned_by)
|
||||
logger.warning(f"User banned: {user.email} by {banned_by.email}. Reason: {reason}")
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def unban_user(user: User) -> User:
|
||||
"""
|
||||
Unban a user.
|
||||
|
||||
Args:
|
||||
user: User to unban
|
||||
|
||||
Returns:
|
||||
Updated User instance
|
||||
"""
|
||||
user.unban()
|
||||
logger.info(f"User unbanned: {user.email}")
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def get_user_stats(user: User) -> Dict[str, Any]:
|
||||
"""
|
||||
Get user statistics.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
|
||||
Returns:
|
||||
Dictionary of user stats
|
||||
"""
|
||||
profile = user.profile
|
||||
|
||||
return {
|
||||
'total_submissions': profile.total_submissions,
|
||||
'approved_submissions': profile.approved_submissions,
|
||||
'reputation_score': user.reputation_score,
|
||||
'member_since': user.date_joined,
|
||||
'last_active': user.last_login,
|
||||
}
|
||||
343
django-backend/apps/users/tasks.py
Normal file
343
django-backend/apps/users/tasks.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Background tasks for user management and notifications.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def send_welcome_email(self, user_id):
|
||||
"""
|
||||
Send a welcome email to a newly registered user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the User
|
||||
|
||||
Returns:
|
||||
str: Email send result
|
||||
"""
|
||||
from apps.users.models import User
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
|
||||
}
|
||||
|
||||
html_message = render_to_string('emails/welcome.html', context)
|
||||
|
||||
send_mail(
|
||||
subject='Welcome to ThrillWiki! 🎢',
|
||||
message='',
|
||||
html_message=html_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Welcome email sent to {user.email}")
|
||||
return f"Welcome email sent to {user.email}"
|
||||
|
||||
except User.DoesNotExist:
|
||||
logger.error(f"User {user_id} not found")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error sending welcome email to user {user_id}: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def send_password_reset_email(self, user_id, token, reset_url):
|
||||
"""
|
||||
Send a password reset email with a secure token.
|
||||
|
||||
Args:
|
||||
user_id: ID of the User
|
||||
token: Password reset token
|
||||
reset_url: Full URL for password reset
|
||||
|
||||
Returns:
|
||||
str: Email send result
|
||||
"""
|
||||
from apps.users.models import User
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'reset_url': reset_url,
|
||||
'request_time': timezone.now(),
|
||||
'expiry_hours': 24, # Configurable
|
||||
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
|
||||
}
|
||||
|
||||
html_message = render_to_string('emails/password_reset.html', context)
|
||||
|
||||
send_mail(
|
||||
subject='Reset Your ThrillWiki Password',
|
||||
message='',
|
||||
html_message=html_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Password reset email sent to {user.email}")
|
||||
return f"Password reset email sent to {user.email}"
|
||||
|
||||
except User.DoesNotExist:
|
||||
logger.error(f"User {user_id} not found")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error sending password reset email: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=2)
|
||||
def cleanup_expired_tokens(self):
|
||||
"""
|
||||
Clean up expired JWT tokens and password reset tokens.
|
||||
|
||||
This task runs daily to remove old tokens from the database.
|
||||
|
||||
Returns:
|
||||
dict: Cleanup statistics
|
||||
"""
|
||||
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
|
||||
try:
|
||||
# Clean up blacklisted JWT tokens older than 7 days
|
||||
cutoff = timezone.now() - timedelta(days=7)
|
||||
|
||||
# Note: Actual implementation depends on token storage strategy
|
||||
# This is a placeholder for the concept
|
||||
|
||||
logger.info("Token cleanup completed")
|
||||
|
||||
return {
|
||||
'jwt_tokens_cleaned': 0,
|
||||
'reset_tokens_cleaned': 0,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Error cleaning up tokens: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=300)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_account_notification(self, user_id, notification_type, context_data=None):
|
||||
"""
|
||||
Send a generic account notification email.
|
||||
|
||||
Args:
|
||||
user_id: ID of the User
|
||||
notification_type: Type of notification (e.g., 'security_alert', 'profile_update')
|
||||
context_data: Additional context data for the email
|
||||
|
||||
Returns:
|
||||
str: Email send result
|
||||
"""
|
||||
from apps.users.models import User
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'notification_type': notification_type,
|
||||
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
|
||||
}
|
||||
|
||||
if context_data:
|
||||
context.update(context_data)
|
||||
|
||||
# For now, just log (would need specific templates for each type)
|
||||
logger.info(f"Account notification ({notification_type}) for user {user.email}")
|
||||
|
||||
return f"Notification sent to {user.email}"
|
||||
|
||||
except User.DoesNotExist:
|
||||
logger.error(f"User {user_id} not found")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error sending account notification: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=2)
|
||||
def cleanup_inactive_users(self, days_inactive=365):
|
||||
"""
|
||||
Clean up or flag users who haven't logged in for a long time.
|
||||
|
||||
Args:
|
||||
days_inactive: Number of days of inactivity before flagging (default: 365)
|
||||
|
||||
Returns:
|
||||
dict: Cleanup statistics
|
||||
"""
|
||||
from apps.users.models import User
|
||||
|
||||
try:
|
||||
cutoff = timezone.now() - timedelta(days=days_inactive)
|
||||
|
||||
inactive_users = User.objects.filter(
|
||||
last_login__lt=cutoff,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
count = inactive_users.count()
|
||||
|
||||
# For now, just log inactive users
|
||||
# In production, you might want to send reactivation emails
|
||||
# or mark accounts for deletion
|
||||
|
||||
logger.info(f"Found {count} inactive users (last login before {cutoff})")
|
||||
|
||||
return {
|
||||
'inactive_count': count,
|
||||
'cutoff_date': cutoff.isoformat(),
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Error cleaning up inactive users: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=300)
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_user_statistics():
|
||||
"""
|
||||
Update user-related statistics across the database.
|
||||
|
||||
Returns:
|
||||
dict: Updated statistics
|
||||
"""
|
||||
from apps.users.models import User
|
||||
from django.db.models import Count
|
||||
from datetime import timedelta
|
||||
|
||||
try:
|
||||
now = timezone.now()
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
|
||||
stats = {
|
||||
'total_users': User.objects.count(),
|
||||
'active_users': User.objects.filter(is_active=True).count(),
|
||||
'new_this_week': User.objects.filter(date_joined__gte=week_ago).count(),
|
||||
'new_this_month': User.objects.filter(date_joined__gte=month_ago).count(),
|
||||
'verified_users': User.objects.filter(email_verified=True).count(),
|
||||
'by_role': dict(
|
||||
User.objects.values('role__name')
|
||||
.annotate(count=Count('id'))
|
||||
.values_list('role__name', 'count')
|
||||
),
|
||||
}
|
||||
|
||||
logger.info(f"User statistics updated: {stats}")
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user statistics: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_bulk_notification(self, user_ids, subject, message, html_message=None):
|
||||
"""
|
||||
Send bulk email notifications to multiple users.
|
||||
|
||||
This is useful for announcements, feature updates, etc.
|
||||
|
||||
Args:
|
||||
user_ids: List of User IDs
|
||||
subject: Email subject
|
||||
message: Plain text message
|
||||
html_message: HTML version of message (optional)
|
||||
|
||||
Returns:
|
||||
dict: Send statistics
|
||||
"""
|
||||
from apps.users.models import User
|
||||
|
||||
try:
|
||||
users = User.objects.filter(id__in=user_ids, is_active=True)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
html_message=html_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send to {user.email}: {str(e)}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
result = {
|
||||
'total': len(user_ids),
|
||||
'sent': sent_count,
|
||||
'failed': failed_count,
|
||||
}
|
||||
|
||||
logger.info(f"Bulk notification sent: {result}")
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Error sending bulk notification: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=2)
|
||||
def send_email_verification_reminder(self, user_id):
|
||||
"""
|
||||
Send a reminder to users who haven't verified their email.
|
||||
|
||||
Args:
|
||||
user_id: ID of the User
|
||||
|
||||
Returns:
|
||||
str: Reminder result
|
||||
"""
|
||||
from apps.users.models import User
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
if user.email_verified:
|
||||
logger.info(f"User {user.email} already verified, skipping reminder")
|
||||
return "User already verified"
|
||||
|
||||
# Send verification reminder
|
||||
logger.info(f"Sending email verification reminder to {user.email}")
|
||||
|
||||
# In production, generate new verification token and send email
|
||||
# For now, just log
|
||||
|
||||
return f"Verification reminder sent to {user.email}"
|
||||
|
||||
except User.DoesNotExist:
|
||||
logger.error(f"User {user_id} not found")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error sending verification reminder: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
|
||||
Reference in New Issue
Block a user