mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:51:09 -05:00
first commit
This commit is contained in:
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
BIN
accounts/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/adapters.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/adapters.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/admin.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/apps.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/apps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/models.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/signals.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/signals.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/urls.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/views.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/views.cpython-311.pyc
Normal file
Binary file not shown.
62
accounts/adapters.py
Normal file
62
accounts/adapters.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from django.conf import settings
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
def is_open_for_signup(self, request):
|
||||
"""
|
||||
Whether to allow sign ups.
|
||||
"""
|
||||
return getattr(settings, 'ACCOUNT_ALLOW_SIGNUPS', True)
|
||||
|
||||
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||
"""
|
||||
Constructs the email confirmation (activation) url.
|
||||
"""
|
||||
site = get_current_site(request)
|
||||
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
|
||||
|
||||
def send_confirmation_mail(self, request, emailconfirmation, signup):
|
||||
"""
|
||||
Sends the confirmation email.
|
||||
"""
|
||||
current_site = get_current_site(request)
|
||||
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
|
||||
ctx = {
|
||||
'user': emailconfirmation.email_address.user,
|
||||
'activate_url': activate_url,
|
||||
'current_site': current_site,
|
||||
'key': emailconfirmation.key,
|
||||
}
|
||||
if signup:
|
||||
email_template = 'account/email/email_confirmation_signup'
|
||||
else:
|
||||
email_template = 'account/email/email_confirmation'
|
||||
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
||||
|
||||
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def is_open_for_signup(self, request, sociallogin):
|
||||
"""
|
||||
Whether to allow social account sign ups.
|
||||
"""
|
||||
return getattr(settings, 'SOCIALACCOUNT_ALLOW_SIGNUPS', True)
|
||||
|
||||
def populate_user(self, request, sociallogin, data):
|
||||
"""
|
||||
Hook that can be used to further populate the user instance.
|
||||
"""
|
||||
user = super().populate_user(request, sociallogin, data)
|
||||
if sociallogin.account.provider == 'discord':
|
||||
user.discord_id = sociallogin.account.uid
|
||||
return user
|
||||
|
||||
def save_user(self, request, sociallogin, form=None):
|
||||
"""
|
||||
Save the newly signed up social login.
|
||||
"""
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
return user
|
||||
201
accounts/admin.py
Normal file
201
accounts/admin.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import Group
|
||||
from .models import User, UserProfile, EmailVerification, TopList, TopListItem
|
||||
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = 'Profile'
|
||||
fieldsets = (
|
||||
('Personal Info', {
|
||||
'fields': ('display_name', 'avatar', 'pronouns', 'bio')
|
||||
}),
|
||||
('Social Media', {
|
||||
'fields': ('twitter', 'instagram', 'youtube', 'discord')
|
||||
}),
|
||||
('Ride Credits', {
|
||||
'fields': (
|
||||
'coaster_credits',
|
||||
'dark_ride_credits',
|
||||
'flat_ride_credits',
|
||||
'water_ride_credits'
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
class TopListItemInline(admin.TabularInline):
|
||||
model = TopListItem
|
||||
extra = 1
|
||||
fields = ('content_type', 'object_id', 'rank', 'notes')
|
||||
ordering = ('rank',)
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_display = ('username', 'email', 'get_status', 'role', 'date_joined', 'last_login', 'get_credits')
|
||||
list_filter = ('is_active', 'is_staff', 'role', 'is_banned', 'groups', 'date_joined')
|
||||
search_fields = ('username', 'email', 'first_name', 'last_name')
|
||||
ordering = ('-date_joined',)
|
||||
actions = ['activate_users', 'deactivate_users', 'ban_users', 'unban_users']
|
||||
inlines = [UserProfileInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
('Personal info', {'fields': ('first_name', 'last_name', 'email', 'pending_email')}),
|
||||
('Roles and Permissions', {
|
||||
'fields': ('role', 'groups', 'user_permissions'),
|
||||
'description': 'Role determines group membership. Groups determine permissions.',
|
||||
}),
|
||||
('Status', {
|
||||
'fields': ('is_active', 'is_staff', 'is_superuser'),
|
||||
'description': 'These are automatically managed based on role.',
|
||||
}),
|
||||
('Ban Status', {
|
||||
'fields': ('is_banned', 'ban_reason', 'ban_date'),
|
||||
}),
|
||||
('Preferences', {
|
||||
'fields': ('theme_preference',),
|
||||
}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('username', 'email', 'password1', 'password2', 'role'),
|
||||
}),
|
||||
)
|
||||
|
||||
def get_status(self, obj):
|
||||
if obj.is_banned:
|
||||
return format_html('<span style="color: red;">Banned</span>')
|
||||
if not obj.is_active:
|
||||
return format_html('<span style="color: orange;">Inactive</span>')
|
||||
if obj.is_superuser:
|
||||
return format_html('<span style="color: purple;">Superuser</span>')
|
||||
if obj.is_staff:
|
||||
return format_html('<span style="color: blue;">Staff</span>')
|
||||
return format_html('<span style="color: green;">Active</span>')
|
||||
get_status.short_description = 'Status'
|
||||
|
||||
def get_credits(self, obj):
|
||||
try:
|
||||
profile = obj.profile
|
||||
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
|
||||
)
|
||||
except UserProfile.DoesNotExist:
|
||||
return '-'
|
||||
get_credits.short_description = 'Ride Credits'
|
||||
|
||||
def activate_users(self, request, queryset):
|
||||
queryset.update(is_active=True)
|
||||
activate_users.short_description = "Activate selected users"
|
||||
|
||||
def deactivate_users(self, request, queryset):
|
||||
queryset.update(is_active=False)
|
||||
deactivate_users.short_description = "Deactivate selected users"
|
||||
|
||||
def ban_users(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
queryset.update(is_banned=True, ban_date=timezone.now())
|
||||
ban_users.short_description = "Ban selected users"
|
||||
|
||||
def unban_users(self, request, queryset):
|
||||
queryset.update(is_banned=False, ban_date=None, ban_reason='')
|
||||
unban_users.short_description = "Unban selected users"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
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):
|
||||
list_display = ('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')
|
||||
|
||||
fieldsets = (
|
||||
('User Information', {
|
||||
'fields': ('user', 'display_name', 'avatar', 'pronouns', 'bio')
|
||||
}),
|
||||
('Social Media', {
|
||||
'fields': ('twitter', 'instagram', 'youtube', 'discord')
|
||||
}),
|
||||
('Ride Credits', {
|
||||
'fields': (
|
||||
'coaster_credits',
|
||||
'dark_ride_credits',
|
||||
'flat_ride_credits',
|
||||
'water_ride_credits'
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(EmailVerification)
|
||||
class EmailVerificationAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'created_at', 'last_sent', 'is_expired')
|
||||
list_filter = ('created_at', 'last_sent')
|
||||
search_fields = ('user__username', 'user__email', 'token')
|
||||
readonly_fields = ('created_at', 'last_sent')
|
||||
|
||||
fieldsets = (
|
||||
('Verification Details', {
|
||||
'fields': ('user', 'token')
|
||||
}),
|
||||
('Timing', {
|
||||
'fields': ('created_at', 'last_sent')
|
||||
}),
|
||||
)
|
||||
|
||||
def is_expired(self, obj):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
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>')
|
||||
is_expired.short_description = 'Status'
|
||||
|
||||
@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]
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('user', 'title', 'category', 'description')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
@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')
|
||||
}),
|
||||
)
|
||||
9
accounts/apps.py
Normal file
9
accounts/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
|
||||
def ready(self):
|
||||
import accounts.signals # noqa
|
||||
30
accounts/management/commands/check_all_social_tables.py
Normal file
30
accounts/management/commands/check_all_social_tables.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check all social auth related tables'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check SocialApp
|
||||
self.stdout.write('\nChecking SocialApp table:')
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(f'ID: {app.id}, Provider: {app.provider}, Name: {app.name}, Client ID: {app.client_id}')
|
||||
self.stdout.write('Sites:')
|
||||
for site in app.sites.all():
|
||||
self.stdout.write(f' - {site.domain}')
|
||||
|
||||
# Check SocialAccount
|
||||
self.stdout.write('\nChecking SocialAccount table:')
|
||||
for account in SocialAccount.objects.all():
|
||||
self.stdout.write(f'ID: {account.id}, Provider: {account.provider}, UID: {account.uid}')
|
||||
|
||||
# Check SocialToken
|
||||
self.stdout.write('\nChecking SocialToken table:')
|
||||
for token in SocialToken.objects.all():
|
||||
self.stdout.write(f'ID: {token.id}, Account: {token.account}, App: {token.app}')
|
||||
|
||||
# Check Site
|
||||
self.stdout.write('\nChecking Site table:')
|
||||
for site in Site.objects.all():
|
||||
self.stdout.write(f'ID: {site.id}, Domain: {site.domain}, Name: {site.name}')
|
||||
19
accounts/management/commands/check_social_apps.py
Normal file
19
accounts/management/commands/check_social_apps.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check social app configurations'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
social_apps = SocialApp.objects.all()
|
||||
|
||||
if not social_apps:
|
||||
self.stdout.write(self.style.ERROR('No social apps found'))
|
||||
return
|
||||
|
||||
for app in social_apps:
|
||||
self.stdout.write(self.style.SUCCESS(f'\nProvider: {app.provider}'))
|
||||
self.stdout.write(f'Name: {app.name}')
|
||||
self.stdout.write(f'Client ID: {app.client_id}')
|
||||
self.stdout.write(f'Secret: {app.secret}')
|
||||
self.stdout.write(f'Sites: {", ".join(str(site.domain) for site in app.sites.all())}')
|
||||
22
accounts/management/commands/cleanup_social_auth.py
Normal file
22
accounts/management/commands/cleanup_social_auth.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up social auth tables and migrations'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with connection.cursor() as cursor:
|
||||
# Drop social auth tables
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialapp")
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialapp_sites")
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialaccount")
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialtoken")
|
||||
|
||||
# Remove migration records
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='accounts' AND name LIKE '%social%'")
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully cleaned up social auth configuration'))
|
||||
48
accounts/management/commands/create_social_apps.py
Normal file
48
accounts/management/commands/create_social_apps.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create social apps for authentication'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get the default site
|
||||
site = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'ThrillWiki Development'
|
||||
}
|
||||
)[0]
|
||||
|
||||
# Create Discord app
|
||||
discord_app, created = SocialApp.objects.get_or_create(
|
||||
provider='discord',
|
||||
defaults={
|
||||
'name': 'Discord',
|
||||
'client_id': '1299112802274902047',
|
||||
'secret': 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
|
||||
}
|
||||
)
|
||||
if not created:
|
||||
discord_app.client_id = '1299112802274902047'
|
||||
discord_app.secret = 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11'
|
||||
discord_app.save()
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f'{"Created" if created else "Updated"} Discord app')
|
||||
|
||||
# Create Google app
|
||||
google_app, created = SocialApp.objects.get_or_create(
|
||||
provider='google',
|
||||
defaults={
|
||||
'name': 'Google',
|
||||
'client_id': '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
|
||||
'secret': 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue',
|
||||
}
|
||||
)
|
||||
if not created:
|
||||
google_app.client_id = '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com'
|
||||
google_app.secret = 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue'
|
||||
google_app.save()
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f'{"Created" if created else "Updated"} Google app')
|
||||
35
accounts/management/commands/fix_social_apps.py
Normal file
35
accounts/management/commands/fix_social_apps.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
import os
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fix social app configurations'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete all existing social apps
|
||||
SocialApp.objects.all().delete()
|
||||
self.stdout.write('Deleted all existing social apps')
|
||||
|
||||
# Get the default site
|
||||
site = Site.objects.get(id=1)
|
||||
|
||||
# Create Google provider
|
||||
google_app = SocialApp.objects.create(
|
||||
provider='google',
|
||||
name='Google',
|
||||
client_id=os.getenv('GOOGLE_CLIENT_ID'),
|
||||
secret=os.getenv('GOOGLE_CLIENT_SECRET'),
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f'Created Google app with client_id: {google_app.client_id}')
|
||||
|
||||
# Create Discord provider
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider='discord',
|
||||
name='Discord',
|
||||
client_id=os.getenv('DISCORD_CLIENT_ID'),
|
||||
secret=os.getenv('DISCORD_CLIENT_SECRET'),
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f'Created Discord app with client_id: {discord_app.client_id}')
|
||||
36
accounts/management/commands/reset_social_apps.py
Normal file
36
accounts/management/commands/reset_social_apps.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.db import connection
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reset social apps configuration'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete all social apps using raw SQL to bypass Django's ORM
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp")
|
||||
|
||||
# Get the default site
|
||||
site = Site.objects.get(id=1)
|
||||
|
||||
# Create Discord app
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider='discord',
|
||||
name='Discord',
|
||||
client_id='1299112802274902047',
|
||||
secret='ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f'Created Discord app with ID: {discord_app.id}')
|
||||
|
||||
# Create Google app
|
||||
google_app = SocialApp.objects.create(
|
||||
provider='google',
|
||||
name='Google',
|
||||
client_id='135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
|
||||
secret='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',
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f'Created Google app with ID: {google_app.id}')
|
||||
17
accounts/management/commands/reset_social_auth.py
Normal file
17
accounts/management/commands/reset_social_auth.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reset social auth configuration'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with connection.cursor() as cursor:
|
||||
# Delete all social apps
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp")
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'")
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully reset social auth configuration'))
|
||||
42
accounts/management/commands/setup_groups.py
Normal file
42
accounts/management/commands/setup_groups.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from accounts.models import User
|
||||
from accounts.signals import create_default_groups
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up default groups and permissions for user roles'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Creating default groups and permissions...')
|
||||
|
||||
try:
|
||||
# Create default groups with permissions
|
||||
create_default_groups()
|
||||
|
||||
# Sync existing users with groups based on their roles
|
||||
users = User.objects.exclude(role=User.Roles.USER)
|
||||
for user in users:
|
||||
group = Group.objects.filter(name=user.role).first()
|
||||
if group:
|
||||
user.groups.add(group)
|
||||
|
||||
# Update staff/superuser status based on role
|
||||
if user.role == User.Roles.SUPERUSER:
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully set up groups and permissions'))
|
||||
|
||||
# Print summary
|
||||
for group in Group.objects.all():
|
||||
self.stdout.write(f'\nGroup: {group.name}')
|
||||
self.stdout.write('Permissions:')
|
||||
for perm in group.permissions.all():
|
||||
self.stdout.write(f' - {perm.codename}')
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Error setting up groups: {str(e)}'))
|
||||
17
accounts/management/commands/setup_site.py
Normal file
17
accounts/management/commands/setup_site.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up default site'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete any existing sites
|
||||
Site.objects.all().delete()
|
||||
|
||||
# Create default site
|
||||
site = Site.objects.create(
|
||||
id=1,
|
||||
domain='localhost:8000',
|
||||
name='ThrillWiki Development'
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'Created site: {site.domain}'))
|
||||
63
accounts/management/commands/setup_social_auth.py
Normal file
63
accounts/management/commands/setup_social_auth.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Sets up social authentication apps'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Get environment variables
|
||||
google_client_id = os.getenv('GOOGLE_CLIENT_ID')
|
||||
google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET')
|
||||
discord_client_id = os.getenv('DISCORD_CLIENT_ID')
|
||||
discord_client_secret = os.getenv('DISCORD_CLIENT_SECRET')
|
||||
|
||||
if not all([google_client_id, google_client_secret, discord_client_id, discord_client_secret]):
|
||||
self.stdout.write(self.style.ERROR('Missing required environment variables'))
|
||||
return
|
||||
|
||||
# Get or create the default site
|
||||
site, _ = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'localhost'
|
||||
}
|
||||
)
|
||||
|
||||
# Set up Google
|
||||
google_app, created = SocialApp.objects.get_or_create(
|
||||
provider='google',
|
||||
defaults={
|
||||
'name': 'Google',
|
||||
'client_id': google_client_id,
|
||||
'secret': google_client_secret,
|
||||
}
|
||||
)
|
||||
if not created:
|
||||
google_app.client_id = google_client_id
|
||||
google_app.[SECRET-REMOVED]
|
||||
google_app.save()
|
||||
google_app.sites.add(site)
|
||||
|
||||
# Set up Discord
|
||||
discord_app, created = SocialApp.objects.get_or_create(
|
||||
provider='discord',
|
||||
defaults={
|
||||
'name': 'Discord',
|
||||
'client_id': discord_client_id,
|
||||
'secret': discord_client_secret,
|
||||
}
|
||||
)
|
||||
if not created:
|
||||
discord_app.client_id = discord_client_id
|
||||
discord_app.[SECRET-REMOVED]
|
||||
discord_app.save()
|
||||
discord_app.sites.add(site)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully set up social auth apps'))
|
||||
60
accounts/management/commands/setup_social_auth_admin.py
Normal file
60
accounts/management/commands/setup_social_auth_admin.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up social authentication through admin interface'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get or create the default site
|
||||
site, _ = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'ThrillWiki Development'
|
||||
}
|
||||
)
|
||||
if not _:
|
||||
site.domain = 'localhost:8000'
|
||||
site.name = 'ThrillWiki Development'
|
||||
site.save()
|
||||
self.stdout.write(f'{"Created" if _ else "Updated"} site: {site.domain}')
|
||||
|
||||
# Create superuser if it doesn't exist
|
||||
if not User.objects.filter(username='admin').exists():
|
||||
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
|
||||
self.stdout.write('Created superuser: admin/admin')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('''
|
||||
Social auth setup instructions:
|
||||
|
||||
1. Run the development server:
|
||||
python manage.py runserver
|
||||
|
||||
2. Go to the admin interface:
|
||||
http://localhost:8000/admin/
|
||||
|
||||
3. Log in with:
|
||||
Username: admin
|
||||
Password: admin
|
||||
|
||||
4. Add social applications:
|
||||
- Go to "Social applications" under "Social Accounts"
|
||||
- Add Discord app:
|
||||
Provider: discord
|
||||
Name: Discord
|
||||
Client id: 1299112802274902047
|
||||
Secret key: ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11
|
||||
Sites: Add "localhost:8000"
|
||||
|
||||
- Add Google app:
|
||||
Provider: google
|
||||
Name: Google
|
||||
Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com
|
||||
Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue
|
||||
Sites: Add "localhost:8000"
|
||||
'''))
|
||||
60
accounts/management/commands/test_discord_auth.py
Normal file
60
accounts/management/commands/test_discord_auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.urls import reverse
|
||||
from django.test import Client
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from urllib.parse import urljoin
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test Discord OAuth2 authentication flow'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
client = Client(HTTP_HOST='localhost:8000')
|
||||
|
||||
# Get Discord app
|
||||
try:
|
||||
discord_app = SocialApp.objects.get(provider='discord')
|
||||
self.stdout.write('Found Discord app configuration:')
|
||||
self.stdout.write(f'Client ID: {discord_app.client_id}')
|
||||
|
||||
# Test login URL
|
||||
login_url = '/accounts/discord/login/'
|
||||
response = client.get(login_url, HTTP_HOST='localhost:8000')
|
||||
self.stdout.write(f'\nTesting login URL: {login_url}')
|
||||
self.stdout.write(f'Status code: {response.status_code}')
|
||||
|
||||
if response.status_code == 302:
|
||||
redirect_url = response['Location']
|
||||
self.stdout.write(f'Redirects to: {redirect_url}')
|
||||
|
||||
# Parse OAuth2 parameters
|
||||
self.stdout.write('\nOAuth2 Parameters:')
|
||||
if 'client_id=' in redirect_url:
|
||||
self.stdout.write('✓ client_id parameter present')
|
||||
if 'redirect_uri=' in redirect_url:
|
||||
self.stdout.write('✓ redirect_uri parameter present')
|
||||
if 'scope=' in redirect_url:
|
||||
self.stdout.write('✓ scope parameter present')
|
||||
if 'response_type=' in redirect_url:
|
||||
self.stdout.write('✓ response_type parameter present')
|
||||
if 'code_challenge=' in redirect_url:
|
||||
self.stdout.write('✓ PKCE enabled (code_challenge present)')
|
||||
|
||||
# Show callback URL
|
||||
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
|
||||
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
|
||||
self.stdout.write(callback_url)
|
||||
|
||||
# Show frontend login URL
|
||||
frontend_url = 'http://localhost:5173'
|
||||
self.stdout.write('\nFrontend configuration:')
|
||||
self.stdout.write(f'Frontend URL: {frontend_url}')
|
||||
self.stdout.write('Discord login button should use:')
|
||||
self.stdout.write('/accounts/discord/login/?process=login')
|
||||
|
||||
# Show allauth URLs
|
||||
self.stdout.write('\nAllauth URLs:')
|
||||
self.stdout.write('Login URL: /accounts/discord/login/?process=login')
|
||||
self.stdout.write('Callback URL: /accounts/discord/login/callback/')
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR('Discord app not found'))
|
||||
20
accounts/management/commands/update_social_apps_sites.py
Normal file
20
accounts/management/commands/update_social_apps_sites.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Update social apps to be associated with all sites'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get all sites
|
||||
sites = Site.objects.all()
|
||||
|
||||
# Update each social app
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(f'Updating {app.provider} app...')
|
||||
# Clear existing sites
|
||||
app.sites.clear()
|
||||
# Add all sites
|
||||
for site in sites:
|
||||
app.sites.add(site)
|
||||
self.stdout.write(f'Added sites: {", ".join(site.domain for site in sites)}')
|
||||
36
accounts/management/commands/verify_discord_settings.py
Normal file
36
accounts/management/commands/verify_discord_settings.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Verify Discord OAuth2 settings'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get Discord app
|
||||
try:
|
||||
discord_app = SocialApp.objects.get(provider='discord')
|
||||
self.stdout.write('Found Discord app configuration:')
|
||||
self.stdout.write(f'Client ID: {discord_app.client_id}')
|
||||
self.stdout.write(f'Secret: {discord_app.secret}')
|
||||
|
||||
# Get sites
|
||||
sites = discord_app.sites.all()
|
||||
self.stdout.write('\nAssociated sites:')
|
||||
for site in sites:
|
||||
self.stdout.write(f'- {site.domain} ({site.name})')
|
||||
|
||||
# Show callback URL
|
||||
callback_url = f'http://localhost:8000/accounts/discord/login/callback/'
|
||||
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
|
||||
self.stdout.write(callback_url)
|
||||
|
||||
# Show OAuth2 settings
|
||||
self.stdout.write('\nOAuth2 settings in settings.py:')
|
||||
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get('discord', {})
|
||||
self.stdout.write(f'PKCE Enabled: {discord_settings.get("OAUTH_PKCE_ENABLED", False)}')
|
||||
self.stdout.write(f'Scopes: {discord_settings.get("SCOPE", [])}')
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR('Discord app not found'))
|
||||
133
accounts/migrations/0001_initial.py
Normal file
133
accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# Generated by Django 5.1.2 on 2024-10-28 20:17
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('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')),
|
||||
('user_id', models.CharField(editable=False, help_text='Unique identifier for this user that remains constant even if the username changes', max_length=10, unique=True)),
|
||||
('first_name', models.CharField(default='', max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(default='', max_length=150, verbose_name='last name')),
|
||||
('role', models.CharField(choices=[('USER', 'User'), ('MODERATOR', 'Moderator'), ('ADMIN', 'Admin'), ('SUPERUSER', 'Superuser')], default='USER', max_length=10)),
|
||||
('is_banned', models.BooleanField(default=False)),
|
||||
('ban_reason', models.TextField(blank=True)),
|
||||
('ban_date', models.DateTimeField(blank=True, null=True)),
|
||||
('pending_email', models.EmailField(blank=True, max_length=254, null=True)),
|
||||
('theme_preference', models.CharField(choices=[('light', 'Light'), ('dark', 'Dark')], default='light', max_length=5)),
|
||||
('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={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EmailVerification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Email Verification',
|
||||
'verbose_name_plural': 'Email Verifications',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PasswordReset',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Password Reset',
|
||||
'verbose_name_plural': 'Password Resets',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TopList',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100)),
|
||||
('category', models.CharField(choices=[('RC', 'Roller Coaster'), ('DR', 'Dark Ride'), ('FR', 'Flat Ride'), ('WR', 'Water Ride'), ('PK', 'Park')], max_length=2)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='top_lists', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('profile_id', models.CharField(editable=False, help_text='Unique identifier for this profile that remains constant', max_length=10, unique=True)),
|
||||
('display_name', models.CharField(help_text='This is the name that will be displayed on the site', max_length=50, unique=True)),
|
||||
('avatar', models.ImageField(blank=True, upload_to='avatars/')),
|
||||
('pronouns', models.CharField(blank=True, max_length=50)),
|
||||
('bio', models.TextField(blank=True, max_length=500)),
|
||||
('twitter', models.URLField(blank=True)),
|
||||
('instagram', models.URLField(blank=True)),
|
||||
('youtube', models.URLField(blank=True)),
|
||||
('discord', models.CharField(blank=True, max_length=100)),
|
||||
('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)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TopListItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('rank', models.PositiveIntegerField()),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('content_type', models.ForeignKey(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='accounts.toplist')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['rank'],
|
||||
'unique_together': {('top_list', 'rank')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
BIN
accounts/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
accounts/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
accounts/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
accounts/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
194
accounts/models.py
Normal file
194
accounts/models.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import random
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
||||
while True:
|
||||
# Try to get a 4-digit number first
|
||||
new_id = str(random.randint(1000, 9999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
# If all 4-digit numbers are taken, try 5 digits
|
||||
new_id = str(random.randint(10000, 99999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
class User(AbstractUser):
|
||||
class Roles(models.TextChoices):
|
||||
USER = 'USER', _('User')
|
||||
MODERATOR = 'MODERATOR', _('Moderator')
|
||||
ADMIN = 'ADMIN', _('Admin')
|
||||
SUPERUSER = 'SUPERUSER', _('Superuser')
|
||||
|
||||
class ThemePreference(models.TextChoices):
|
||||
LIGHT = 'light', _('Light')
|
||||
DARK = 'dark', _('Dark')
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique identifier for this user that remains constant even if the username changes'
|
||||
)
|
||||
|
||||
first_name = models.CharField(_('first name'), max_length=150, default='')
|
||||
last_name = models.CharField(_('last name'), max_length=150, default='')
|
||||
role = models.CharField(
|
||||
max_length=10,
|
||||
choices=Roles.choices,
|
||||
default=Roles.USER,
|
||||
)
|
||||
is_banned = models.BooleanField(default=False)
|
||||
ban_reason = models.TextField(blank=True)
|
||||
ban_date = models.DateTimeField(null=True, blank=True)
|
||||
pending_email = models.EmailField(blank=True, null=True)
|
||||
theme_preference = models.CharField(
|
||||
max_length=5,
|
||||
choices=ThemePreference.choices,
|
||||
default=ThemePreference.LIGHT,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.get_display_name()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('profile', kwargs={'username': self.username})
|
||||
|
||||
def get_display_name(self):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
if hasattr(self, 'profile') and self.profile.display_name:
|
||||
return self.profile.display_name
|
||||
return self.username
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = generate_random_id(User, 'user_id')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class UserProfile(models.Model):
|
||||
# Read-only ID
|
||||
profile_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique identifier for this profile that remains constant'
|
||||
)
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='profile'
|
||||
)
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text="This is the name that will be displayed on the site"
|
||||
)
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True)
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If no display name is set, use the username
|
||||
if not self.display_name:
|
||||
self.display_name = self.user.username
|
||||
if not self.profile_id:
|
||||
self.profile_id = generate_random_id(UserProfile, 'profile_id')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
return f"Email verification for {self.user.username}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Email Verification"
|
||||
verbose_name_plural = "Email Verifications"
|
||||
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
return f"Password reset for {self.user.username}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Password Reset"
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
class TopList(models.Model):
|
||||
class Categories(models.TextChoices):
|
||||
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
||||
DARK_RIDE = 'DR', _('Dark Ride')
|
||||
FLAT_RIDE = 'FR', _('Flat Ride')
|
||||
WATER_RIDE = 'WR', _('Water Ride')
|
||||
PARK = 'PK', _('Park')
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='top_lists'
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=Categories.choices
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.get_display_name()}'s {self.get_category_display()} Top List: {self.title}"
|
||||
|
||||
class TopListItem(models.Model):
|
||||
top_list = models.ForeignKey(
|
||||
TopList,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items'
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
'contenttypes.ContentType',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
rank = models.PositiveIntegerField()
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['rank']
|
||||
unique_together = [['top_list', 'rank']]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
152
accounts/signals.py
Normal file
152
accounts/signals.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import transaction
|
||||
from django.core.files import File
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
import requests
|
||||
from .models import User, UserProfile, EmailVerification
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
"""Create UserProfile for new users"""
|
||||
try:
|
||||
if created:
|
||||
# Create profile
|
||||
profile = UserProfile.objects.create(user=instance)
|
||||
|
||||
# If user has a social account with avatar, download it
|
||||
social_account = instance.socialaccount_set.first()
|
||||
if social_account:
|
||||
extra_data = social_account.extra_data
|
||||
avatar_url = None
|
||||
|
||||
if social_account.provider == 'google':
|
||||
avatar_url = extra_data.get('picture')
|
||||
elif social_account.provider == 'discord':
|
||||
avatar = extra_data.get('avatar')
|
||||
discord_id = extra_data.get('id')
|
||||
if avatar:
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png'
|
||||
|
||||
if avatar_url:
|
||||
try:
|
||||
response = requests.get(avatar_url)
|
||||
if response.status_code == 200:
|
||||
img_temp = NamedTemporaryFile(delete=True)
|
||||
img_temp.write(response.content)
|
||||
img_temp.flush()
|
||||
|
||||
file_name = f"avatar_{instance.username}.png"
|
||||
profile.avatar.save(
|
||||
file_name,
|
||||
File(img_temp),
|
||||
save=True
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
|
||||
except Exception as e:
|
||||
print(f"Error creating profile for user {instance.username}: {str(e)}")
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
"""Ensure UserProfile exists and is saved"""
|
||||
try:
|
||||
if not hasattr(instance, 'profile'):
|
||||
UserProfile.objects.create(user=instance)
|
||||
instance.profile.save()
|
||||
except Exception as e:
|
||||
print(f"Error saving profile for user {instance.username}: {str(e)}")
|
||||
|
||||
@receiver(pre_save, sender=User)
|
||||
def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
"""Sync user role with Django groups"""
|
||||
if instance.pk: # Only for existing users
|
||||
try:
|
||||
old_instance = User.objects.get(pk=instance.pk)
|
||||
if old_instance.role != instance.role:
|
||||
# Role has changed, update groups
|
||||
with transaction.atomic():
|
||||
# Remove from old role group if exists
|
||||
if old_instance.role != User.Roles.USER:
|
||||
old_group = Group.objects.filter(name=old_instance.role).first()
|
||||
if old_group:
|
||||
instance.groups.remove(old_group)
|
||||
|
||||
# Add to new role group
|
||||
if instance.role != User.Roles.USER:
|
||||
new_group, _ = Group.objects.get_or_create(name=instance.role)
|
||||
instance.groups.add(new_group)
|
||||
|
||||
# Special handling for superuser role
|
||||
if instance.role == User.Roles.SUPERUSER:
|
||||
instance.is_superuser = True
|
||||
instance.is_staff = True
|
||||
elif old_instance.role == User.Roles.SUPERUSER:
|
||||
# If removing superuser role, remove superuser status
|
||||
instance.is_superuser = False
|
||||
if instance.role not in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
instance.is_staff = False
|
||||
|
||||
# Handle staff status for admin and moderator roles
|
||||
if instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
instance.is_staff = True
|
||||
elif old_instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
# If removing admin/moderator role, remove staff status
|
||||
if instance.role not in [User.Roles.SUPERUSER]:
|
||||
instance.is_staff = False
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error syncing role with groups for user {instance.username}: {str(e)}")
|
||||
|
||||
def create_default_groups():
|
||||
"""
|
||||
Create default groups with appropriate permissions.
|
||||
Call this in a migration or management command.
|
||||
"""
|
||||
try:
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
# Create Moderator group
|
||||
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
|
||||
moderator_permissions = [
|
||||
# Review moderation permissions
|
||||
'change_review', 'delete_review',
|
||||
'change_reviewreport', 'delete_reviewreport',
|
||||
# Edit moderation permissions
|
||||
'change_parkedit', 'delete_parkedit',
|
||||
'change_rideedit', 'delete_rideedit',
|
||||
'change_companyedit', 'delete_companyedit',
|
||||
'change_manufactureredit', 'delete_manufactureredit',
|
||||
]
|
||||
|
||||
# Create Admin group
|
||||
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
|
||||
admin_permissions = moderator_permissions + [
|
||||
# User management permissions
|
||||
'change_user', 'delete_user',
|
||||
# Content management permissions
|
||||
'add_park', 'change_park', 'delete_park',
|
||||
'add_ride', 'change_ride', 'delete_ride',
|
||||
'add_company', 'change_company', 'delete_company',
|
||||
'add_manufacturer', 'change_manufacturer', 'delete_manufacturer',
|
||||
]
|
||||
|
||||
# Assign permissions to groups
|
||||
for codename in moderator_permissions:
|
||||
try:
|
||||
perm = Permission.objects.get(codename=codename)
|
||||
moderator_group.permissions.add(perm)
|
||||
except Permission.DoesNotExist:
|
||||
print(f"Permission not found: {codename}")
|
||||
|
||||
for codename in admin_permissions:
|
||||
try:
|
||||
perm = Permission.objects.get(codename=codename)
|
||||
admin_group.permissions.add(perm)
|
||||
except Permission.DoesNotExist:
|
||||
print(f"Permission not found: {codename}")
|
||||
except Exception as e:
|
||||
print(f"Error creating default groups: {str(e)}")
|
||||
3
accounts/tests.py
Normal file
3
accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
21
accounts/urls.py
Normal file
21
accounts/urls.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from . import views
|
||||
|
||||
app_name = 'accounts'
|
||||
|
||||
urlpatterns = [
|
||||
# Authentication views
|
||||
path('login/', auth_views.LoginView.as_view(template_name='accounts/login.html'), name='login'),
|
||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||
path('password_change/', auth_views.PasswordChangeView.as_view(), name='password_change'),
|
||||
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),
|
||||
path('password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'),
|
||||
path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
|
||||
path('reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
|
||||
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
|
||||
|
||||
# Profile views
|
||||
path('profile/', views.user_redirect_view, name='profile_redirect'),
|
||||
path('settings/', views.SettingsView.as_view(), name='settings'),
|
||||
]
|
||||
235
accounts/views.py
Normal file
235
accounts/views.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from django.views.generic import DetailView, TemplateView
|
||||
from django.contrib.auth import get_user_model, login, authenticate
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
|
||||
from allauth.socialaccount.providers.discord.views import DiscordOAuth2Adapter
|
||||
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.db.models import Prefetch
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from accounts.models import User, PasswordReset
|
||||
from reviews.models import Review
|
||||
from email_service.services import EmailService
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@login_required
|
||||
def user_redirect_view(request):
|
||||
"""Redirect /user/ to the logged-in user's profile"""
|
||||
return redirect('profile', username=request.user.username)
|
||||
|
||||
def email_required(request):
|
||||
"""Handle cases where social auth provider doesn't provide an email"""
|
||||
sociallogin = request.session.get('socialaccount_sociallogin')
|
||||
if not sociallogin:
|
||||
messages.error(request, 'No social login in progress')
|
||||
return redirect('/')
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.POST.get('email')
|
||||
if email:
|
||||
sociallogin.user.email = email
|
||||
sociallogin.save()
|
||||
login(request, sociallogin.user)
|
||||
del request.session['socialaccount_sociallogin']
|
||||
messages.success(request, 'Successfully logged in')
|
||||
return redirect('/')
|
||||
else:
|
||||
messages.error(request, 'Email is required')
|
||||
return render(request, 'accounts/email_required.html', {'error': 'Email is required'})
|
||||
|
||||
return render(request, 'accounts/email_required.html')
|
||||
|
||||
class ProfileView(DetailView):
|
||||
model = User
|
||||
template_name = 'accounts/profile.html'
|
||||
context_object_name = 'profile_user'
|
||||
slug_field = 'username'
|
||||
slug_url_kwarg = 'username'
|
||||
|
||||
def get_queryset(self):
|
||||
# Optimize the base queryset with select_related
|
||||
return User.objects.select_related('profile')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.get_object()
|
||||
|
||||
# Get user's reviews with optimized queries
|
||||
reviews_queryset = Review.objects.filter(
|
||||
user=user,
|
||||
is_published=True
|
||||
).select_related(
|
||||
'user',
|
||||
'user__profile',
|
||||
'content_type'
|
||||
).prefetch_related(
|
||||
'content_object' # This will fetch the related ride/park/etc.
|
||||
).order_by('-created_at')[:5]
|
||||
|
||||
context['recent_reviews'] = reviews_queryset
|
||||
|
||||
# Get user's top lists with optimized queries
|
||||
context['top_lists'] = user.top_lists.select_related(
|
||||
'user',
|
||||
'user__profile'
|
||||
).prefetch_related(
|
||||
Prefetch('items', queryset=(
|
||||
user.top_lists.through.objects.select_related(
|
||||
'content_type'
|
||||
).prefetch_related('content_object')
|
||||
))
|
||||
).order_by('-created_at')[:5]
|
||||
|
||||
return context
|
||||
|
||||
class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
template_name = 'accounts/settings.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['user'] = self.request.user
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'update_profile':
|
||||
# Handle profile updates
|
||||
user = request.user
|
||||
user.first_name = request.POST.get('first_name', user.first_name)
|
||||
user.last_name = request.POST.get('last_name', user.last_name)
|
||||
|
||||
if 'avatar' in request.FILES:
|
||||
user.profile.avatar = request.FILES['avatar']
|
||||
user.profile.save()
|
||||
|
||||
user.save()
|
||||
messages.success(request, 'Profile updated successfully')
|
||||
|
||||
elif action == 'change_password':
|
||||
# Handle password change
|
||||
old_password = request.POST.get('old_password')
|
||||
new_password = request.POST.get('new_password')
|
||||
|
||||
if request.user.check_password(old_password):
|
||||
request.user.set_password(new_password)
|
||||
request.user.save()
|
||||
messages.success(request, 'Password changed successfully')
|
||||
return HttpResponseRedirect(reverse('account_login'))
|
||||
else:
|
||||
messages.error(request, 'Current password is incorrect')
|
||||
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def request_password_reset(request):
|
||||
"""Request a password reset email"""
|
||||
if request.method == 'POST':
|
||||
email = request.POST.get('email')
|
||||
if not email:
|
||||
messages.error(request, 'Email is required')
|
||||
return redirect('account_reset_password')
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
# Generate token
|
||||
token = get_random_string(64)
|
||||
# Save token with expiry
|
||||
PasswordReset.objects.update_or_create(
|
||||
user=user,
|
||||
defaults={
|
||||
'token': token,
|
||||
'expires_at': timezone.now() + timedelta(hours=24)
|
||||
}
|
||||
)
|
||||
|
||||
# Get current site
|
||||
site = get_current_site(request)
|
||||
|
||||
# Send reset email
|
||||
reset_url = reverse('password_reset_confirm', kwargs={'token': token})
|
||||
context = {
|
||||
'user': user,
|
||||
'reset_url': reset_url,
|
||||
'site_name': site.name,
|
||||
}
|
||||
email_html = render_to_string('accounts/email/password_reset.html', context)
|
||||
|
||||
# Use EmailService instead of send_mail
|
||||
EmailService.send_email(
|
||||
to=email,
|
||||
subject='Reset your password',
|
||||
text='Click the link to reset your password',
|
||||
site=site,
|
||||
html=email_html
|
||||
)
|
||||
|
||||
messages.success(request, 'Password reset email sent')
|
||||
return redirect('account_login')
|
||||
except User.DoesNotExist:
|
||||
# Still show success to prevent email enumeration
|
||||
messages.success(request, 'Password reset email sent')
|
||||
return redirect('account_login')
|
||||
|
||||
return render(request, 'accounts/password_reset.html')
|
||||
|
||||
def reset_password(request, token):
|
||||
"""Reset password using token"""
|
||||
try:
|
||||
# Get valid reset token
|
||||
reset = PasswordReset.objects.select_related('user').get(
|
||||
token=token,
|
||||
expires_at__gt=timezone.now(),
|
||||
used=False
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
new_password = request.POST.get('new_password')
|
||||
if new_password:
|
||||
# Reset password
|
||||
user = reset.user
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
# Mark token as used
|
||||
reset.used = True
|
||||
reset.save()
|
||||
|
||||
# Get current site
|
||||
site = get_current_site(request)
|
||||
|
||||
# Send confirmation email
|
||||
context = {
|
||||
'user': user,
|
||||
'site_name': site.name,
|
||||
}
|
||||
email_html = render_to_string('accounts/email/password_reset_complete.html', context)
|
||||
|
||||
# Use EmailService instead of send_mail
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject='Password Reset Complete',
|
||||
text='Your password has been reset successfully.',
|
||||
site=site,
|
||||
html=email_html
|
||||
)
|
||||
|
||||
messages.success(request, 'Password reset successfully')
|
||||
return redirect('account_login')
|
||||
else:
|
||||
messages.error(request, 'New password is required')
|
||||
|
||||
return render(request, 'accounts/password_reset_confirm.html', {'token': token})
|
||||
except PasswordReset.DoesNotExist:
|
||||
messages.error(request, 'Invalid or expired reset token')
|
||||
return redirect('account_reset_password')
|
||||
Reference in New Issue
Block a user