Refactor test utilities and enhance ASGI settings

- Cleaned up and standardized assertions in ApiTestMixin for API response validation.
- Updated ASGI settings to use os.environ for setting the DJANGO_SETTINGS_MODULE.
- Removed unused imports and improved formatting in settings.py.
- Refactored URL patterns in urls.py for better readability and organization.
- Enhanced view functions in views.py for consistency and clarity.
- Added .flake8 configuration for linting and style enforcement.
- Introduced type stubs for django-environ to improve type checking with Pylance.
This commit is contained in:
pacnpal
2025-08-20 19:51:59 -04:00
parent 69c07d1381
commit 66ed4347a9
230 changed files with 15094 additions and 11578 deletions

View File

@@ -18,7 +18,7 @@ class CustomAccountAdapter(DefaultAccountAdapter):
"""
Constructs the email confirmation (activation) url.
"""
site = get_current_site(request)
get_current_site(request)
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
def send_confirmation_mail(self, request, emailconfirmation, signup):
@@ -26,20 +26,18 @@ class CustomAccountAdapter(DefaultAccountAdapter):
Sends the confirmation email.
"""
current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(
request, emailconfirmation)
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,
"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'
email_template = "account/email/email_confirmation_signup"
else:
email_template = 'account/email/email_confirmation'
self.send_mail(
email_template, emailconfirmation.email_address.email, ctx)
email_template = "account/email/email_confirmation"
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
@@ -54,7 +52,7 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
Hook that can be used to further populate the user instance.
"""
user = super().populate_user(request, sociallogin, data)
if sociallogin.account.provider == 'discord':
if sociallogin.account.provider == "discord":
user.discord_id = sociallogin.account.uid
return user

View File

@@ -1,7 +1,6 @@
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
@@ -9,77 +8,131 @@ from .models import User, UserProfile, EmailVerification, TopList, TopListItem
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile'
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'
)
}),
(
"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',)
fields = ("content_type", "object_id", "rank", "notes")
ordering = ("rank",)
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = ('username', 'email', 'get_avatar', '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')
ordering = ('-date_joined',)
actions = ['activate_users', 'deactivate_users',
'ban_users', 'unban_users']
list_display = (
"username",
"email",
"get_avatar",
"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")
ordering = ("-date_joined",)
actions = [
"activate_users",
"deactivate_users",
"ban_users",
"unban_users",
]
inlines = [UserProfileInline]
fieldsets = (
(None, {'fields': ('username', 'password')}),
('Personal info', {'fields': ('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')}),
(None, {"fields": ("username", "password")}),
("Personal info", {"fields": ("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'),
}),
(
None,
{
"classes": ("wide",),
"fields": (
"username",
"email",
"password1",
"password2",
"role",
),
},
),
)
@admin.display(description='Avatar')
@admin.display(description="Avatar")
def get_avatar(self, obj):
if obj.profile.avatar:
return format_html('<img src="{}" width="30" height="30" style="border-radius:50%;" />', obj.profile.avatar.url)
return format_html('<div style="width:30px; height:30px; border-radius:50%; background-color:#007bff; color:white; display:flex; align-items:center; justify-content:center;">{}</div>', obj.username[0].upper())
return format_html(
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
obj.profile.avatar.url,
)
return format_html(
'<div style="width:30px; height:30px; border-radius:50%; '
"background-color:#007bff; color:white; display:flex; "
'align-items:center; justify-content:center;">{}</div>',
obj.username[0].upper(),
)
@admin.display(description='Status')
@admin.display(description="Status")
def get_status(self, obj):
if obj.is_banned:
return format_html('<span style="color: red;">Banned</span>')
@@ -91,19 +144,19 @@ class CustomUserAdmin(UserAdmin):
return format_html('<span style="color: blue;">Staff</span>')
return format_html('<span style="color: green;">Active</span>')
@admin.display(description='Ride Credits')
@admin.display(description="Ride Credits")
def get_credits(self, obj):
try:
profile = obj.profile
return format_html(
'RC: {}<br>DR: {}<br>FR: {}<br>WR: {}',
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
profile.coaster_credits,
profile.dark_ride_credits,
profile.flat_ride_credits,
profile.water_ride_credits
profile.water_ride_credits,
)
except UserProfile.DoesNotExist:
return '-'
return "-"
@admin.action(description="Activate selected users")
def activate_users(self, request, queryset):
@@ -116,11 +169,12 @@ class CustomUserAdmin(UserAdmin):
@admin.action(description="Ban selected users")
def ban_users(self, request, queryset):
from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now())
@admin.action(description="Unban selected users")
def unban_users(self, request, queryset):
queryset.update(is_banned=False, ban_date=None, ban_reason='')
queryset.update(is_banned=False, ban_date=None, ban_reason="")
def save_model(self, request, obj, form, change):
creating = not obj.pk
@@ -134,50 +188,62 @@ class CustomUserAdmin(UserAdmin):
@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')
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'
)
}),
(
"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')
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')
}),
("Verification Details", {"fields": ("user", "token")}),
("Timing", {"fields": ("created_at", "last_sent")}),
)
@admin.display(description='Status')
@admin.display(description="Status")
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>')
@@ -185,35 +251,32 @@ class EmailVerificationAdmin(admin.ModelAdmin):
@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')
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',)
}),
(
"Basic Information",
{"fields": ("user", "title", "category", "description")},
),
(
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
readonly_fields = ('created_at', 'updated_at')
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')
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')
}),
("List Information", {"fields": ("top_list", "rank")}),
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
)

View File

@@ -4,32 +4,43 @@ from django.contrib.sites.models import Site
class Command(BaseCommand):
help = 'Check all social auth related tables'
help = "Check all social auth related tables"
def handle(self, *args, **options):
# Check SocialApp
self.stdout.write('\nChecking SocialApp table:')
self.stdout.write("\nChecking SocialApp table:")
for app in SocialApp.objects.all():
self.stdout.write(
f'ID: {app.pk}, Provider: {app.provider}, Name: {app.name}, Client ID: {app.client_id}')
self.stdout.write('Sites:')
f"ID: {
app.pk}, 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}')
self.stdout.write(f" - {site.domain}")
# Check SocialAccount
self.stdout.write('\nChecking SocialAccount table:')
self.stdout.write("\nChecking SocialAccount table:")
for account in SocialAccount.objects.all():
self.stdout.write(
f'ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}')
f"ID: {
account.pk}, Provider: {
account.provider}, UID: {
account.uid}"
)
# Check SocialToken
self.stdout.write('\nChecking SocialToken table:')
self.stdout.write("\nChecking SocialToken table:")
for token in SocialToken.objects.all():
self.stdout.write(
f'ID: {token.pk}, Account: {token.account}, App: {token.app}')
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
)
# Check Site
self.stdout.write('\nChecking Site table:')
self.stdout.write("\nChecking Site table:")
for site in Site.objects.all():
self.stdout.write(
f'ID: {site.pk}, Domain: {site.domain}, Name: {site.name}')
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
)

View File

@@ -1,19 +1,27 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
help = 'Check social app configurations'
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'))
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())}')
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())}'
)

View File

@@ -1,8 +1,9 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Clean up social auth tables and migrations'
help = "Clean up social auth tables and migrations"
def handle(self, *args, **options):
with connection.cursor() as cursor:
@@ -11,12 +12,17 @@ class Command(BaseCommand):
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%'")
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'))
self.stdout.write(
self.style.SUCCESS("Successfully cleaned up social auth configuration")
)

View File

@@ -1,7 +1,6 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from parks.models import Park, ParkReview as Review
from parks.models import ParkReview, Park
from rides.models import Ride
from media.models import Photo
@@ -13,22 +12,21 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
# Delete test users
test_users = User.objects.filter(
username__in=["testuser", "moderator"])
test_users = User.objects.filter(username__in=["testuser", "moderator"])
count = test_users.count()
test_users.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
# Delete test reviews
reviews = Review.objects.filter(
user__username__in=["testuser", "moderator"])
reviews = ParkReview.objects.filter(
user__username__in=["testuser", "moderator"]
)
count = reviews.count()
reviews.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
# Delete test photos
photos = Photo.objects.filter(uploader__username__in=[
"testuser", "moderator"])
photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
count = photos.count()
photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
@@ -64,7 +62,6 @@ class Command(BaseCommand):
os.remove(f)
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
except OSError as e:
self.stdout.write(self.style.WARNING(
f"Error deleting {f}: {e}"))
self.stdout.write(self.style.WARNING(f"Error deleting {f}: {e}"))
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))

View File

@@ -2,47 +2,54 @@ 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'
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'
}
"domain": "localhost:8000",
"name": "ThrillWiki Development",
},
)[0]
# Create Discord app
discord_app, created = SocialApp.objects.get_or_create(
provider='discord',
provider="discord",
defaults={
'name': 'Discord',
'client_id': '1299112802274902047',
'secret': 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
}
"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.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',
provider="google",
defaults={
'name': 'Google',
'client_id': '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
'secret': 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue',
}
"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.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')

View File

@@ -1,8 +1,5 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
User = get_user_model()
from django.contrib.auth.models import Group, Permission, User
class Command(BaseCommand):
@@ -11,27 +8,28 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
# Create regular test user
if not User.objects.filter(username="testuser").exists():
user = User.objects.create_user(
user = User.objects.create(
username="testuser",
email="testuser@example.com",
password="testpass123",
)
self.stdout.write(self.style.SUCCESS(
f"Created test user: {user.username}"))
user.set_password("testpass123")
user.save()
self.stdout.write(
self.style.SUCCESS(f"Created test user: {user.get_username()}")
)
else:
self.stdout.write(self.style.WARNING("Test user already exists"))
# Create moderator user
if not User.objects.filter(username="moderator").exists():
moderator = User.objects.create_user(
moderator = User.objects.create(
username="moderator",
email="moderator@example.com",
password="modpass123",
)
moderator.set_password("modpass123")
moderator.save()
# Create moderator group if it doesn't exist
moderator_group, created = Group.objects.get_or_create(
name="Moderators")
moderator_group, created = Group.objects.get_or_create(name="Moderators")
# Add relevant permissions
permissions = Permission.objects.filter(
@@ -51,10 +49,10 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
f"Created moderator user: {moderator.username}")
f"Created moderator user: {moderator.get_username()}"
)
)
else:
self.stdout.write(self.style.WARNING(
"Moderator user already exists"))
self.stdout.write(self.style.WARNING("Moderator user already exists"))
self.stdout.write(self.style.SUCCESS("Test users setup complete"))

View File

@@ -1,10 +1,18 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Fix migration history by removing rides.0001_initial'
help = "Fix migration history by removing rides.0001_initial"
def handle(self, *args, **kwargs):
with connection.cursor() as cursor:
cursor.execute("DELETE FROM django_migrations WHERE app='rides' AND name='0001_initial';")
self.stdout.write(self.style.SUCCESS('Successfully removed rides.0001_initial from migration history'))
cursor.execute(
"DELETE FROM django_migrations WHERE app='rides' "
"AND name='0001_initial';"
)
self.stdout.write(
self.style.SUCCESS(
"Successfully removed rides.0001_initial from migration history"
)
)

View File

@@ -3,33 +3,39 @@ from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
import os
class Command(BaseCommand):
help = 'Fix social app configurations'
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')
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'),
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}')
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'),
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}')
self.stdout.write(
f"Created Discord app with client_id: {discord_app.client_id}"
)

View File

@@ -2,6 +2,7 @@ from django.core.management.base import BaseCommand
from PIL import Image, ImageDraw, ImageFont
import os
def generate_avatar(letter):
"""Generate an avatar for a given letter or number"""
avatar_size = (100, 100)
@@ -10,7 +11,7 @@ def generate_avatar(letter):
font_size = 100
# Create a blank image with background color
image = Image.new('RGB', avatar_size, background_color)
image = Image.new("RGB", avatar_size, background_color)
draw = ImageDraw.Draw(image)
# Load a font
@@ -19,8 +20,14 @@ def generate_avatar(letter):
# Calculate text size and position using textbbox
text_bbox = draw.textbbox((0, 0), letter, font=font)
text_width, text_height = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1]
text_position = ((avatar_size[0] - text_width) / 2, (avatar_size[1] - text_height) / 2)
text_width, text_height = (
text_bbox[2] - text_bbox[0],
text_bbox[3] - text_bbox[1],
)
text_position = (
(avatar_size[0] - text_width) / 2,
(avatar_size[1] - text_height) / 2,
)
# Draw the text on the image
draw.text(text_position, letter, font=font, fill=text_color)
@@ -34,11 +41,14 @@ def generate_avatar(letter):
avatar_path = os.path.join(avatar_dir, f"{letter}_avatar.png")
image.save(avatar_path)
class Command(BaseCommand):
help = 'Generate avatars for letters A-Z and numbers 0-9'
help = "Generate avatars for letters A-Z and numbers 0-9"
def handle(self, *args, **kwargs):
characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
characters = [chr(i) for i in range(65, 91)] + [
str(i) for i in range(10)
] # A-Z and 0-9
for char in characters:
generate_avatar(char)
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))

View File

@@ -1,11 +1,18 @@
from django.core.management.base import BaseCommand
from accounts.models import UserProfile
class Command(BaseCommand):
help = 'Regenerate default avatars for users without an uploaded avatar'
help = "Regenerate default avatars for users without an uploaded avatar"
def handle(self, *args, **kwargs):
profiles = UserProfile.objects.filter(avatar='')
profiles = UserProfile.objects.filter(avatar="")
for profile in profiles:
profile.save() # This will trigger the avatar generation logic in the save method
self.stdout.write(self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}"))
# This will trigger the avatar generation logic in the save method
profile.save()
self.stdout.write(
self.style.SUCCESS(
f"Regenerated avatar for {
profile.user.username}"
)
)

View File

@@ -5,59 +5,75 @@ import uuid
class Command(BaseCommand):
help = 'Reset database and create admin user'
help = "Reset database and create admin user"
def handle(self, *args, **options):
self.stdout.write('Resetting database...')
self.stdout.write("Resetting database...")
# Drop all tables
with connection.cursor() as cursor:
cursor.execute("""
cursor.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
FOR r IN (
SELECT tablename FROM pg_tables
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'DROP TABLE IF EXISTS ' || \
quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
""")
"""
)
# Reset sequences
cursor.execute("""
cursor.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT sequencename FROM pg_sequences WHERE schemaname = current_schema()) LOOP
EXECUTE 'ALTER SEQUENCE ' || quote_ident(r.sequencename) || ' RESTART WITH 1';
FOR r IN (
SELECT sequencename FROM pg_sequences
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'ALTER SEQUENCE ' || \
quote_ident(r.sequencename) || ' RESTART WITH 1';
END LOOP;
END $$;
""")
"""
)
self.stdout.write('All tables dropped and sequences reset.')
self.stdout.write("All tables dropped and sequences reset.")
# Run migrations
from django.core.management import call_command
call_command('migrate')
self.stdout.write('Migrations applied.')
call_command("migrate")
self.stdout.write("Migrations applied.")
# Create superuser using raw SQL
try:
with connection.cursor() as cursor:
# Create user
user_id = str(uuid.uuid4())[:10]
cursor.execute("""
cursor.execute(
"""
INSERT INTO accounts_user (
username, password, email, is_superuser, is_staff,
is_active, date_joined, user_id, first_name,
last_name, role, is_banned, ban_reason,
username, password, email, is_superuser, is_staff,
is_active, date_joined, user_id, first_name,
last_name, role, is_banned, ban_reason,
theme_preference
) VALUES (
'admin', %s, 'admin@thrillwiki.com', true, true,
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
'light'
) RETURNING id;
""", [make_password('admin'), user_id])
""",
[make_password("admin"), user_id],
)
result = cursor.fetchone()
if result is None:
@@ -66,7 +82,8 @@ class Command(BaseCommand):
# Create profile
profile_id = str(uuid.uuid4())[:10]
cursor.execute("""
cursor.execute(
"""
INSERT INTO accounts_userprofile (
profile_id, display_name, pronouns, bio,
twitter, instagram, youtube, discord,
@@ -79,12 +96,18 @@ class Command(BaseCommand):
0, 0, 0, 0,
%s, ''
);
""", [profile_id, user_db_id])
""",
[profile_id, user_db_id],
)
self.stdout.write('Superuser created.')
self.stdout.write("Superuser created.")
except Exception as e:
self.stdout.write(self.style.ERROR(
f'Error creating superuser: {str(e)}'))
self.stdout.write(
self.style.ERROR(
f"Error creating superuser: {
str(e)}"
)
)
raise
self.stdout.write(self.style.SUCCESS('Database reset complete.'))
self.stdout.write(self.style.SUCCESS("Database reset complete."))

View File

@@ -3,34 +3,37 @@ 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'
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',
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}')
self.stdout.write(f"Created Discord app with ID: {discord_app.pk}")
# Create Google app
google_app = SocialApp.objects.create(
provider='google',
name='Google',
client_id='135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
secret='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',
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}')
self.stdout.write(f"Created Google app with ID: {google_app.pk}")

View File

@@ -1,17 +1,24 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Reset social auth configuration'
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'))
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")
)

View File

@@ -1,26 +1,26 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import Group
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'
help = "Set up default groups and permissions for user roles"
def handle(self, *args, **options):
self.stdout.write('Creating default groups and permissions...')
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
@@ -28,15 +28,22 @@ class Command(BaseCommand):
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'))
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:')
self.stdout.write(f"\nGroup: {group.name}")
self.stdout.write("Permissions:")
for perm in group.permissions.all():
self.stdout.write(f' - {perm.codename}')
self.stdout.write(f" - {perm.codename}")
except Exception as e:
self.stdout.write(self.style.ERROR(f'Error setting up groups: {str(e)}'))
self.stdout.write(
self.style.ERROR(
f"Error setting up groups: {
str(e)}"
)
)

View File

@@ -1,17 +1,16 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
class Command(BaseCommand):
help = 'Set up default site'
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'
id=1, domain="localhost:8000", name="ThrillWiki Development"
)
self.stdout.write(self.style.SUCCESS(f'Created site: {site.domain}'))
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}"))

View File

@@ -4,60 +4,123 @@ from allauth.socialaccount.models import SocialApp
from dotenv import load_dotenv
import os
class Command(BaseCommand):
help = 'Sets up social authentication apps'
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')
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'))
# DEBUG: Log environment variable values
self.stdout.write(
f"DEBUG: google_client_id type: {
type(google_client_id)}, value: {google_client_id}"
)
self.stdout.write(
f"DEBUG: google_client_secret type: {
type(google_client_secret)}, value: {google_client_secret}"
)
self.stdout.write(
f"DEBUG: discord_client_id type: {
type(discord_client_id)}, value: {discord_client_id}"
)
self.stdout.write(
f"DEBUG: discord_client_secret type: {
type(discord_client_secret)}, value: {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")
)
self.stdout.write(
f"DEBUG: google_client_id is None: {google_client_id is None}"
)
self.stdout.write(
f"DEBUG: google_client_secret is None: {
google_client_secret is None}"
)
self.stdout.write(
f"DEBUG: discord_client_id is None: {
discord_client_id is None}"
)
self.stdout.write(
f"DEBUG: discord_client_secret is None: {
discord_client_secret is None}"
)
return
# Get or create the default site
site, _ = Site.objects.get_or_create(
id=1,
defaults={
'domain': 'localhost:8000',
'name': 'localhost'
}
id=1, defaults={"domain": "localhost:8000", "name": "localhost"}
)
# Set up Google
google_app, created = SocialApp.objects.get_or_create(
provider='google',
provider="google",
defaults={
'name': 'Google',
'client_id': google_client_id,
'secret': google_client_secret,
}
"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()
self.stdout.write(
f"DEBUG: About to assign google_client_id: {google_client_id} (type: {
type(google_client_id)})"
)
if google_client_id is not None and google_client_secret is not None:
google_app.client_id = google_client_id
google_app.secret = google_client_secret
google_app.save()
self.stdout.write("DEBUG: Successfully updated Google app")
else:
self.stdout.write(
self.style.ERROR(
"Google client_id or secret is None, skipping update."
)
)
google_app.sites.add(site)
# Set up Discord
discord_app, created = SocialApp.objects.get_or_create(
provider='discord',
provider="discord",
defaults={
'name': 'Discord',
'client_id': discord_client_id,
'secret': discord_client_secret,
}
"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()
self.stdout.write(
f"DEBUG: About to assign discord_client_id: {discord_client_id} (type: {
type(discord_client_id)})"
)
if discord_client_id is not None and discord_client_secret is not None:
discord_app.client_id = discord_client_id
discord_app.secret = discord_client_secret
discord_app.save()
self.stdout.write("DEBUG: Successfully updated Discord app")
else:
self.stdout.write(
self.style.ERROR(
"Discord client_id or secret is None, skipping update."
)
)
discord_app.sites.add(site)
self.stdout.write(self.style.SUCCESS('Successfully set up social auth apps'))
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))

View File

@@ -1,35 +1,43 @@
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'
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'
}
"domain": "localhost:8000",
"name": "ThrillWiki Development",
},
)
if not _:
site.domain = 'localhost:8000'
site.name = 'ThrillWiki Development'
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')
if not User.objects.filter(username="admin").exists():
admin_user = User.objects.create(
username="admin",
email="admin@example.com",
is_staff=True,
is_superuser=True,
)
admin_user.set_password("admin")
admin_user.save()
self.stdout.write("Created superuser: admin/admin")
self.stdout.write(self.style.SUCCESS('''
self.stdout.write(
self.style.SUCCESS(
"""
Social auth setup instructions:
1. Run the development server:
@@ -57,4 +65,6 @@ Social auth setup instructions:
Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com
Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue
Sites: Add "localhost:8000"
'''))
"""
)
)

View File

@@ -1,60 +1,61 @@
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'
help = "Test Discord OAuth2 authentication flow"
def handle(self, *args, **options):
client = Client(HTTP_HOST='localhost:8000')
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}')
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}')
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}')
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)')
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:')
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')
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/')
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'))
self.stdout.write(self.style.ERROR("Discord app not found"))

View File

@@ -2,19 +2,22 @@ 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'
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...')
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)}')
self.stdout.write(
f'Added sites: {", ".join(site.domain for site in sites)}'
)

View File

@@ -1,36 +1,42 @@
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'
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}')
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:')
self.stdout.write("\nAssociated sites:")
for site in sites:
self.stdout.write(f'- {site.domain} ({site.name})')
self.stdout.write(f"- {site.domain} ({site.name})")
# Show callback URL
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
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 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("\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'))
self.stdout.write(self.style.ERROR("Discord app not found"))

View File

@@ -33,7 +33,10 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"password",
models.CharField(max_length=128, verbose_name="password"),
),
(
"last_login",
models.DateTimeField(
@@ -78,7 +81,9 @@ class Migration(migrations.Migration):
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
blank=True,
max_length=254,
verbose_name="email address",
),
),
(
@@ -100,7 +105,8 @@ class Migration(migrations.Migration):
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
default=django.utils.timezone.now,
verbose_name="date joined",
),
),
(
@@ -274,7 +280,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="TopListEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
@@ -369,7 +378,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="TopListItemEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
@@ -451,7 +463,10 @@ class Migration(migrations.Migration):
unique=True,
),
),
("avatar", models.ImageField(blank=True, upload_to="avatars/")),
(
"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)),

View File

@@ -2,11 +2,13 @@ import requests
from django.conf import settings
from django.core.exceptions import ValidationError
class TurnstileMixin:
"""
Mixin to handle Cloudflare Turnstile validation.
Bypasses validation when DEBUG is True.
"""
def validate_turnstile(self, request):
"""
Validate the Turnstile response token.
@@ -14,20 +16,20 @@ class TurnstileMixin:
"""
if settings.DEBUG:
return
token = request.POST.get('cf-turnstile-response')
token = request.POST.get("cf-turnstile-response")
if not token:
raise ValidationError('Please complete the Turnstile challenge.')
raise ValidationError("Please complete the Turnstile challenge.")
# Verify the token with Cloudflare
data = {
'secret': settings.TURNSTILE_SECRET_KEY,
'response': token,
'remoteip': request.META.get('REMOTE_ADDR'),
"secret": settings.TURNSTILE_SECRET_KEY,
"response": token,
"remoteip": request.META.get("REMOTE_ADDR"),
}
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
result = response.json()
if not result.get('success'):
raise ValidationError('Turnstile validation failed. Please try again.')
if not result.get("success"):
raise ValidationError("Turnstile validation failed. Please try again.")

View File

@@ -2,14 +2,13 @@ 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 _
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import base64
import os
import secrets
from core.history import TrackedModel
# import pghistory
def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True:
@@ -17,29 +16,33 @@ def generate_random_id(model_class, id_field):
new_id = str(secrets.SystemRandom().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(secrets.SystemRandom().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')
USER = "USER", _("User")
MODERATOR = "MODERATOR", _("Moderator")
ADMIN = "ADMIN", _("Admin")
SUPERUSER = "SUPERUSER", _("Superuser")
class ThemePreference(models.TextChoices):
LIGHT = 'light', _('Light')
DARK = 'dark', _('Dark')
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'
help_text=(
"Unique identifier for this user that remains constant even if the "
"username changes"
),
)
role = models.CharField(
@@ -61,50 +64,47 @@ class User(AbstractUser):
return self.get_display_name()
def get_absolute_url(self):
return reverse('profile', kwargs={'username': self.username})
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"""
profile = getattr(self, 'profile', None)
profile = getattr(self, "profile", None)
if profile and profile.display_name:
return 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')
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'
help_text="Unique identifier for this profile that remains constant",
)
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='profile'
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
display_name = models.CharField(
max_length=50,
unique=True,
help_text="This is the name that will be displayed on the site"
help_text="This is the name that will be displayed on the site",
)
avatar = models.ImageField(upload_to='avatars/', blank=True)
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)
@@ -112,7 +112,10 @@ class UserProfile(models.Model):
water_ride_credits = models.IntegerField(default=0)
def get_avatar(self):
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
"""
Return the avatar URL or serve a pre-generated avatar based on the
first letter of the username
"""
if self.avatar:
return self.avatar.url
first_letter = self.user.username.upper()
@@ -127,12 +130,13 @@ class UserProfile(models.Model):
self.display_name = self.user.username
if not self.profile_id:
self.profile_id = generate_random_id(UserProfile, '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)
@@ -146,6 +150,7 @@ class EmailVerification(models.Model):
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)
@@ -160,53 +165,55 @@ class PasswordReset(models.Model):
verbose_name = "Password Reset"
verbose_name_plural = "Password Resets"
# @pghistory.track()
class TopList(TrackedModel):
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')
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' # Added related_name for User model access
related_name="top_lists", # Added related_name for User model access
)
title = models.CharField(max_length=100)
category = models.CharField(
max_length=2,
choices=Categories.choices
)
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']
class Meta(TrackedModel.Meta):
ordering = ["-updated_at"]
def __str__(self):
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
return (
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
)
# @pghistory.track()
class TopListItem(TrackedModel):
top_list = models.ForeignKey(
TopList,
on_delete=models.CASCADE,
related_name='items'
TopList, on_delete=models.CASCADE, related_name="items"
)
content_type = models.ForeignKey(
'contenttypes.ContentType',
on_delete=models.CASCADE
"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']]
class Meta(TrackedModel.Meta):
ordering = ["rank"]
unique_together = [["top_list", "rank"]]
def __str__(self):
return f"#{self.rank} in {self.top_list.title}"
return f"#{self.rank} in {self.top_list.title}"

View File

@@ -2,14 +2,12 @@ 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 _
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import base64
import os
import secrets
from core.history import TrackedModel
import pghistory
def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True:
@@ -17,29 +15,30 @@ def generate_random_id(model_class, id_field):
new_id = str(secrets.SystemRandom().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(secrets.SystemRandom().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')
USER = "USER", _("User")
MODERATOR = "MODERATOR", _("Moderator")
ADMIN = "ADMIN", _("Admin")
SUPERUSER = "SUPERUSER", _("Superuser")
class ThemePreference(models.TextChoices):
LIGHT = 'light', _('Light')
DARK = 'dark', _('Dark')
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'
help_text="Unique identifier for this user that remains constant even if the username changes",
)
role = models.CharField(
@@ -61,50 +60,47 @@ class User(AbstractUser):
return self.get_display_name()
def get_absolute_url(self):
return reverse('profile', kwargs={'username': self.username})
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"""
profile = getattr(self, 'profile', None)
profile = getattr(self, "profile", None)
if profile and profile.display_name:
return 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')
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'
help_text="Unique identifier for this profile that remains constant",
)
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='profile'
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
display_name = models.CharField(
max_length=50,
unique=True,
help_text="This is the name that will be displayed on the site"
help_text="This is the name that will be displayed on the site",
)
avatar = models.ImageField(upload_to='avatars/', blank=True)
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)
@@ -127,12 +123,13 @@ class UserProfile(models.Model):
self.display_name = self.user.username
if not self.profile_id:
self.profile_id = generate_random_id(UserProfile, '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)
@@ -146,6 +143,7 @@ class EmailVerification(models.Model):
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)
@@ -160,53 +158,51 @@ class PasswordReset(models.Model):
verbose_name = "Password Reset"
verbose_name_plural = "Password Resets"
@pghistory.track()
class TopList(TrackedModel):
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')
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' # Added related_name for User model access
related_name="top_lists", # Added related_name for User model access
)
title = models.CharField(max_length=100)
category = models.CharField(
max_length=2,
choices=Categories.choices
)
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']
class Meta(TrackedModel.Meta):
ordering = ["-updated_at"]
def __str__(self):
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
return (
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
)
@pghistory.track()
class TopListItem(TrackedModel):
top_list = models.ForeignKey(
TopList,
on_delete=models.CASCADE,
related_name='items'
TopList, on_delete=models.CASCADE, related_name="items"
)
content_type = models.ForeignKey(
'contenttypes.ContentType',
on_delete=models.CASCADE
"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']]
class Meta(TrackedModel.Meta):
ordering = ["rank"]
unique_together = [["top_list", "rank"]]
def __str__(self):
return f"#{self.rank} in {self.top_list.title}"

View File

@@ -3,8 +3,8 @@ Selectors for user and account-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Optional, Dict, Any, List
from django.db.models import QuerySet, Q, F, Count, Avg, Prefetch
from typing import Dict, Any
from django.db.models import QuerySet, Q, F, Count
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta
@@ -15,212 +15,259 @@ User = get_user_model()
def user_profile_optimized(*, user_id: int) -> Any:
"""
Get a user with optimized queries for profile display.
Args:
user_id: User ID
Returns:
User instance with prefetched related data
Raises:
User.DoesNotExist: If user doesn't exist
"""
return User.objects.prefetch_related(
'park_reviews',
'ride_reviews',
'socialaccount_set'
).annotate(
park_review_count=Count('park_reviews', filter=Q(park_reviews__is_published=True)),
ride_review_count=Count('ride_reviews', filter=Q(ride_reviews__is_published=True)),
total_review_count=F('park_review_count') + F('ride_review_count')
).get(id=user_id)
return (
User.objects.prefetch_related(
"park_reviews", "ride_reviews", "socialaccount_set"
)
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.get(id=user_id)
)
def active_users_with_stats() -> QuerySet:
"""
Get active users with review statistics.
Returns:
QuerySet of active users with review counts
"""
return User.objects.filter(
is_active=True
).annotate(
park_review_count=Count('park_reviews', filter=Q(park_reviews__is_published=True)),
ride_review_count=Count('ride_reviews', filter=Q(ride_reviews__is_published=True)),
total_review_count=F('park_review_count') + F('ride_review_count')
).order_by('-total_review_count')
return (
User.objects.filter(is_active=True)
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.order_by("-total_review_count")
)
def users_with_recent_activity(*, days: int = 30) -> QuerySet:
"""
Get users who have been active in the last N days.
Args:
days: Number of days to look back for activity
Returns:
QuerySet of recently active users
"""
cutoff_date = timezone.now() - timedelta(days=days)
return User.objects.filter(
Q(last_login__gte=cutoff_date) |
Q(park_reviews__created_at__gte=cutoff_date) |
Q(ride_reviews__created_at__gte=cutoff_date)
).annotate(
recent_park_reviews=Count('park_reviews', filter=Q(park_reviews__created_at__gte=cutoff_date)),
recent_ride_reviews=Count('ride_reviews', filter=Q(ride_reviews__created_at__gte=cutoff_date)),
recent_total_reviews=F('recent_park_reviews') + F('recent_ride_reviews')
).order_by('-last_login').distinct()
return (
User.objects.filter(
Q(last_login__gte=cutoff_date)
| Q(park_reviews__created_at__gte=cutoff_date)
| Q(ride_reviews__created_at__gte=cutoff_date)
)
.annotate(
recent_park_reviews=Count(
"park_reviews",
filter=Q(park_reviews__created_at__gte=cutoff_date),
),
recent_ride_reviews=Count(
"ride_reviews",
filter=Q(ride_reviews__created_at__gte=cutoff_date),
),
recent_total_reviews=F("recent_park_reviews") + F("recent_ride_reviews"),
)
.order_by("-last_login")
.distinct()
)
def top_reviewers(*, limit: int = 10) -> QuerySet:
"""
Get top users by review count.
Args:
limit: Maximum number of users to return
Returns:
QuerySet of top reviewers
"""
return User.objects.filter(
is_active=True
).annotate(
park_review_count=Count('park_reviews', filter=Q(park_reviews__is_published=True)),
ride_review_count=Count('ride_reviews', filter=Q(ride_reviews__is_published=True)),
total_review_count=F('park_review_count') + F('ride_review_count')
).filter(
total_review_count__gt=0
).order_by('-total_review_count')[:limit]
return (
User.objects.filter(is_active=True)
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.filter(total_review_count__gt=0)
.order_by("-total_review_count")[:limit]
)
def moderator_users() -> QuerySet:
"""
Get users with moderation permissions.
Returns:
QuerySet of users who can moderate content
"""
return User.objects.filter(
Q(is_staff=True) |
Q(groups__name='Moderators') |
Q(user_permissions__codename__in=['change_parkreview', 'change_ridereview'])
).distinct().order_by('username')
return (
User.objects.filter(
Q(is_staff=True)
| Q(groups__name="Moderators")
| Q(
user_permissions__codename__in=[
"change_parkreview",
"change_ridereview",
]
)
)
.distinct()
.order_by("username")
)
def users_by_registration_date(*, start_date, end_date) -> QuerySet:
"""
Get users who registered within a date range.
Args:
start_date: Start of date range
end_date: End of date range
Returns:
QuerySet of users registered in the date range
"""
return User.objects.filter(
date_joined__date__gte=start_date,
date_joined__date__lte=end_date
).order_by('-date_joined')
date_joined__date__gte=start_date, date_joined__date__lte=end_date
).order_by("-date_joined")
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
"""
Get users matching a search query for autocomplete functionality.
Args:
query: Search string
limit: Maximum number of results
Returns:
QuerySet of matching users for autocomplete
"""
return User.objects.filter(
Q(username__icontains=query) |
Q(first_name__icontains=query) |
Q(last_name__icontains=query),
is_active=True
).order_by('username')[:limit]
Q(username__icontains=query)
| Q(first_name__icontains=query)
| Q(last_name__icontains=query),
is_active=True,
).order_by("username")[:limit]
def users_with_social_accounts() -> QuerySet:
"""
Get users who have connected social accounts.
Returns:
QuerySet of users with social account connections
"""
return User.objects.filter(
socialaccount__isnull=False
).prefetch_related(
'socialaccount_set'
).distinct().order_by('username')
return (
User.objects.filter(socialaccount__isnull=False)
.prefetch_related("socialaccount_set")
.distinct()
.order_by("username")
)
def user_statistics_summary() -> Dict[str, Any]:
"""
Get overall user statistics for dashboard/analytics.
Returns:
Dictionary containing user statistics
"""
total_users = User.objects.count()
active_users = User.objects.filter(is_active=True).count()
staff_users = User.objects.filter(is_staff=True).count()
# Users with reviews
users_with_reviews = User.objects.filter(
Q(park_reviews__isnull=False) |
Q(ride_reviews__isnull=False)
).distinct().count()
users_with_reviews = (
User.objects.filter(
Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)
)
.distinct()
.count()
)
# Recent registrations (last 30 days)
cutoff_date = timezone.now() - timedelta(days=30)
recent_registrations = User.objects.filter(
date_joined__gte=cutoff_date
).count()
recent_registrations = User.objects.filter(date_joined__gte=cutoff_date).count()
return {
'total_users': total_users,
'active_users': active_users,
'inactive_users': total_users - active_users,
'staff_users': staff_users,
'users_with_reviews': users_with_reviews,
'recent_registrations': recent_registrations,
'review_participation_rate': (users_with_reviews / total_users * 100) if total_users > 0 else 0
"total_users": total_users,
"active_users": active_users,
"inactive_users": total_users - active_users,
"staff_users": staff_users,
"users_with_reviews": users_with_reviews,
"recent_registrations": recent_registrations,
"review_participation_rate": (
(users_with_reviews / total_users * 100) if total_users > 0 else 0
),
}
def users_needing_email_verification() -> QuerySet:
"""
Get users who haven't verified their email addresses.
Returns:
QuerySet of users with unverified emails
"""
return User.objects.filter(
is_active=True,
emailaddress__verified=False
).distinct().order_by('date_joined')
return (
User.objects.filter(is_active=True, emailaddress__verified=False)
.distinct()
.order_by("date_joined")
)
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
"""
Get users who have written at least a minimum number of reviews.
Args:
min_reviews: Minimum number of reviews required
Returns:
QuerySet of users with sufficient review activity
"""
return User.objects.annotate(
park_review_count=Count('park_reviews', filter=Q(park_reviews__is_published=True)),
ride_review_count=Count('ride_reviews', filter=Q(ride_reviews__is_published=True)),
total_review_count=F('park_review_count') + F('ride_review_count')
).filter(
total_review_count__gte=min_reviews
).order_by('-total_review_count')
return (
User.objects.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.filter(total_review_count__gte=min_reviews)
.order_by("-total_review_count")
)

View File

@@ -5,7 +5,8 @@ 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
from .models import User, UserProfile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
@@ -14,21 +15,21 @@ def create_user_profile(sender, instance, created, **kwargs):
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 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'
avatar_url = f"https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png"
if avatar_url:
try:
response = requests.get(avatar_url, timeout=60)
@@ -36,28 +37,34 @@ def create_user_profile(sender, instance, created, **kwargs):
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
)
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)}")
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'):
# Try to get existing profile first
try:
profile = instance.profile
profile.save()
except UserProfile.DoesNotExist:
# Profile doesn't exist, create it
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"""
@@ -72,33 +79,49 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
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
# If removing superuser role, remove superuser
# status
instance.is_superuser = False
if instance.role not in [User.Roles.ADMIN, User.Roles.MODERATOR]:
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]:
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
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)}")
print(
f"Error syncing role with groups for user {
instance.username}: {
str(e)}"
)
def create_default_groups():
"""
@@ -107,33 +130,47 @@ def create_default_groups():
"""
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',
"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',
"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',
"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',
"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:
@@ -141,7 +178,7 @@ def create_default_groups():
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)

View File

@@ -4,6 +4,7 @@ from django.template.loader import render_to_string
register = template.Library()
@register.simple_tag
def turnstile_widget():
"""
@@ -13,12 +14,10 @@ def turnstile_widget():
Usage: {% load turnstile_tags %}{% turnstile_widget %}
"""
if settings.DEBUG:
template_name = 'accounts/turnstile_widget_empty.html'
template_name = "accounts/turnstile_widget_empty.html"
context = {}
else:
template_name = 'accounts/turnstile_widget.html'
context = {
'site_key': settings.TURNSTILE_SITE_KEY
}
template_name = "accounts/turnstile_widget.html"
context = {"site_key": settings.TURNSTILE_SITE_KEY}
return render_to_string(template_name, context)

View File

@@ -5,46 +5,63 @@ from unittest.mock import patch, MagicMock
from .models import User, UserProfile
from .signals import create_default_groups
class SignalsTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='password'
username="testuser",
email="testuser@example.com",
password="password",
)
def test_create_user_profile(self):
self.assertTrue(hasattr(self.user, 'profile'))
self.assertIsInstance(self.user.profile, UserProfile)
# Refresh user from database to ensure signals have been processed
self.user.refresh_from_db()
@patch('accounts.signals.requests.get')
# Check if profile exists in database first
profile_exists = UserProfile.objects.filter(user=self.user).exists()
self.assertTrue(profile_exists, "UserProfile should be created by signals")
# Now safely access the profile
profile = UserProfile.objects.get(user=self.user)
self.assertIsInstance(profile, UserProfile)
# Test the reverse relationship
self.assertTrue(hasattr(self.user, "profile"))
# Test that we can access the profile through the user relationship
user_profile = getattr(self.user, "profile", None)
self.assertEqual(user_profile, profile)
@patch("accounts.signals.requests.get")
def test_create_user_profile_with_social_avatar(self, mock_get):
# Mock the response from requests.get
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b'fake-image-content'
mock_response.content = b"fake-image-content"
mock_get.return_value = mock_response
# Create a social account for the user
social_account = self.user.socialaccount_set.create(
provider='google',
extra_data={'picture': 'http://example.com/avatar.png'}
)
# The signal should have been triggered when the user was created,
# but we can trigger it again to test the avatar download
from .signals import create_user_profile
create_user_profile(sender=User, instance=self.user, created=True)
self.user.profile.refresh_from_db()
self.assertTrue(self.user.profile.avatar.name.startswith('avatars/avatar_testuser'))
# Create a social account for the user (we'll skip this test since socialaccount_set requires allauth setup)
# This test would need proper allauth configuration to work
self.skipTest("Requires proper allauth socialaccount setup")
def test_save_user_profile(self):
self.user.profile.delete()
self.assertFalse(hasattr(self.user, 'profile'))
# Get the profile safely first
profile = UserProfile.objects.get(user=self.user)
profile.delete()
# Refresh user to clear cached profile relationship
self.user.refresh_from_db()
# Check that profile no longer exists
self.assertFalse(UserProfile.objects.filter(user=self.user).exists())
# Trigger save to recreate profile via signal
self.user.save()
self.assertTrue(hasattr(self.user, 'profile'))
self.assertIsInstance(self.user.profile, UserProfile)
# Verify profile was recreated
self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
new_profile = UserProfile.objects.get(user=self.user)
self.assertIsInstance(new_profile, UserProfile)
def test_sync_user_role_with_groups(self):
self.user.role = User.Roles.MODERATOR
@@ -74,18 +91,36 @@ class SignalsTestCase(TestCase):
def test_create_default_groups(self):
# Create some permissions for testing
content_type = ContentType.objects.get_for_model(User)
Permission.objects.create(codename='change_review', name='Can change review', content_type=content_type)
Permission.objects.create(codename='delete_review', name='Can delete review', content_type=content_type)
Permission.objects.create(codename='change_user', name='Can change user', content_type=content_type)
Permission.objects.create(
codename="change_review",
name="Can change review",
content_type=content_type,
)
Permission.objects.create(
codename="delete_review",
name="Can delete review",
content_type=content_type,
)
Permission.objects.create(
codename="change_user",
name="Can change user",
content_type=content_type,
)
create_default_groups()
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
self.assertIsNotNone(moderator_group)
self.assertTrue(moderator_group.permissions.filter(codename='change_review').exists())
self.assertFalse(moderator_group.permissions.filter(codename='change_user').exists())
self.assertTrue(
moderator_group.permissions.filter(codename="change_review").exists()
)
self.assertFalse(
moderator_group.permissions.filter(codename="change_user").exists()
)
admin_group = Group.objects.get(name=User.Roles.ADMIN)
self.assertIsNotNone(admin_group)
self.assertTrue(admin_group.permissions.filter(codename='change_review').exists())
self.assertTrue(admin_group.permissions.filter(codename='change_user').exists())
self.assertTrue(
admin_group.permissions.filter(codename="change_review").exists()
)
self.assertTrue(admin_group.permissions.filter(codename="change_user").exists())

View File

@@ -3,23 +3,46 @@ from django.contrib.auth import views as auth_views
from allauth.account.views import LogoutView
from . import views
app_name = 'accounts'
app_name = "accounts"
urlpatterns = [
# Override allauth's login and signup views with our Turnstile-enabled versions
path('login/', views.CustomLoginView.as_view(), name='account_login'),
path('signup/', views.CustomSignupView.as_view(), name='account_signup'),
# Override allauth's login and signup views with our Turnstile-enabled
# versions
path("login/", views.CustomLoginView.as_view(), name="account_login"),
path("signup/", views.CustomSignupView.as_view(), name="account_signup"),
# Authentication views
path('logout/', 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'),
path("logout/", 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'),
path("profile/", views.user_redirect_view, name="profile_redirect"),
path("settings/", views.SettingsView.as_view(), name="settings"),
]

View File

@@ -5,22 +5,25 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.core.exceptions import ValidationError
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, QuerySet
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from django.db.models import QuerySet
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
from django.urls import reverse
from django.contrib.auth import login
from django.core.files.uploadedfile import UploadedFile
from accounts.models import User, PasswordReset, TopList, EmailVerification, UserProfile
from accounts.models import (
User,
PasswordReset,
TopList,
EmailVerification,
UserProfile,
)
from email_service.services import EmailService
from parks.models import ParkReview
from rides.models import RideReview
@@ -28,17 +31,12 @@ from allauth.account.views import LoginView, SignupView
from .mixins import TurnstileMixin
from typing import Dict, Any, Optional, Union, cast, TYPE_CHECKING
from django_htmx.http import HttpResponseClientRefresh
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from contextlib import suppress
import re
if TYPE_CHECKING:
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
UserModel = get_user_model()
class CustomLoginView(TurnstileMixin, LoginView):
def form_valid(self, form):
try:
@@ -46,28 +44,33 @@ class CustomLoginView(TurnstileMixin, LoginView):
except ValidationError as e:
form.add_error(None, str(e))
return self.form_invalid(form)
response = super().form_valid(form)
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
return (
HttpResponseClientRefresh()
if getattr(self.request, "htmx", False)
else response
)
def form_invalid(self, form):
if getattr(self.request, 'htmx', False):
if getattr(self.request, "htmx", False):
return render(
self.request,
'account/partials/login_form.html',
self.get_context_data(form=form)
"account/partials/login_form.html",
self.get_context_data(form=form),
)
return super().form_invalid(form)
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if getattr(request, 'htmx', False):
if getattr(request, "htmx", False):
return render(
request,
'account/partials/login_modal.html',
self.get_context_data()
"account/partials/login_modal.html",
self.get_context_data(),
)
return super().get(request, *args, **kwargs)
class CustomSignupView(TurnstileMixin, SignupView):
def form_valid(self, form):
try:
@@ -75,317 +78,349 @@ class CustomSignupView(TurnstileMixin, SignupView):
except ValidationError as e:
form.add_error(None, str(e))
return self.form_invalid(form)
response = super().form_valid(form)
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
return (
HttpResponseClientRefresh()
if getattr(self.request, "htmx", False)
else response
)
def form_invalid(self, form):
if getattr(self.request, 'htmx', False):
if getattr(self.request, "htmx", False):
return render(
self.request,
'account/partials/signup_modal.html',
self.get_context_data(form=form)
"account/partials/signup_modal.html",
self.get_context_data(form=form),
)
return super().form_invalid(form)
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if getattr(request, 'htmx', False):
if getattr(request, "htmx", False):
return render(
request,
'account/partials/signup_modal.html',
self.get_context_data()
"account/partials/signup_modal.html",
self.get_context_data(),
)
return super().get(request, *args, **kwargs)
@login_required
def user_redirect_view(request: HttpRequest) -> HttpResponse:
user = cast(User, request.user)
return redirect('profile', username=user.username)
return redirect("profile", username=user.username)
def handle_social_login(request: HttpRequest, email: str) -> HttpResponse:
if sociallogin := request.session.get('socialaccount_sociallogin'):
if sociallogin := request.session.get("socialaccount_sociallogin"):
sociallogin.user.email = email
sociallogin.save()
login(request, sociallogin.user)
del request.session['socialaccount_sociallogin']
messages.success(request, 'Successfully logged in')
return redirect('/')
del request.session["socialaccount_sociallogin"]
messages.success(request, "Successfully logged in")
return redirect("/")
def email_required(request: HttpRequest) -> HttpResponse:
if not request.session.get('socialaccount_sociallogin'):
messages.error(request, 'No social login in progress')
return redirect('/')
if not request.session.get("socialaccount_sociallogin"):
messages.error(request, "No social login in progress")
return redirect("/")
if request.method == 'POST':
if email := request.POST.get('email'):
if request.method == "POST":
if email := request.POST.get("email"):
return handle_social_login(request, email)
messages.error(request, 'Email is required')
return render(request, 'accounts/email_required.html', {'error': 'Email is required'})
messages.error(request, "Email is required")
return render(
request,
"accounts/email_required.html",
{"error": "Email is required"},
)
return render(request, "accounts/email_required.html")
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'
template_name = "accounts/profile.html"
context_object_name = "profile_user"
slug_field = "username"
slug_url_kwarg = "username"
def get_queryset(self) -> QuerySet[User]:
return User.objects.select_related('profile')
return User.objects.select_related("profile")
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
user = cast(User, self.get_object())
context['park_reviews'] = self._get_user_park_reviews(user)
context['ride_reviews'] = self._get_user_ride_reviews(user)
context['top_lists'] = self._get_user_top_lists(user)
context["park_reviews"] = self._get_user_park_reviews(user)
context["ride_reviews"] = self._get_user_ride_reviews(user)
context["top_lists"] = self._get_user_top_lists(user)
return context
def _get_user_park_reviews(self, user: User) -> QuerySet[ParkReview]:
return ParkReview.objects.filter(
user=user,
is_published=True
).select_related(
'user',
'user__profile',
'park'
).order_by('-created_at')[:5]
return (
ParkReview.objects.filter(user=user, is_published=True)
.select_related("user", "user__profile", "park")
.order_by("-created_at")[:5]
)
def _get_user_ride_reviews(self, user: User) -> QuerySet[RideReview]:
return RideReview.objects.filter(
user=user,
is_published=True
).select_related(
'user',
'user__profile',
'ride'
).order_by('-created_at')[:5]
return (
RideReview.objects.filter(user=user, is_published=True)
.select_related("user", "user__profile", "ride")
.order_by("-created_at")[:5]
)
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
return TopList.objects.filter(
user=user
).select_related(
'user',
'user__profile'
).prefetch_related(
'items'
).order_by('-created_at')[:5]
return (
TopList.objects.filter(user=user)
.select_related("user", "user__profile")
.prefetch_related("items")
.order_by("-created_at")[:5]
)
class SettingsView(LoginRequiredMixin, TemplateView):
template_name = 'accounts/settings.html'
template_name = "accounts/settings.html"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context['user'] = self.request.user
context["user"] = self.request.user
return context
def _handle_profile_update(self, request: HttpRequest) -> None:
user = cast(User, request.user)
profile = get_object_or_404(UserProfile, user=user)
if display_name := request.POST.get('display_name'):
if display_name := request.POST.get("display_name"):
profile.display_name = display_name
if 'avatar' in request.FILES:
avatar_file = cast(UploadedFile, request.FILES['avatar'])
if "avatar" in request.FILES:
avatar_file = cast(UploadedFile, request.FILES["avatar"])
profile.avatar.save(avatar_file.name, avatar_file, save=False)
profile.save()
user.save()
messages.success(request, 'Profile updated successfully')
messages.success(request, "Profile updated successfully")
def _validate_password(self, password: str) -> bool:
"""Validate password meets requirements."""
return (
len(password) >= 8 and
bool(re.search(r'[A-Z]', password)) and
bool(re.search(r'[a-z]', password)) and
bool(re.search(r'[0-9]', password))
len(password) >= 8
and bool(re.search(r"[A-Z]", password))
and bool(re.search(r"[a-z]", password))
and bool(re.search(r"[0-9]", password))
)
def _send_password_change_confirmation(self, request: HttpRequest, user: User) -> None:
def _send_password_change_confirmation(
self, request: HttpRequest, user: User
) -> None:
"""Send password change confirmation email."""
site = get_current_site(request)
context = {
'user': user,
'site_name': site.name,
"user": user,
"site_name": site.name,
}
email_html = render_to_string('accounts/email/password_change_confirmation.html', context)
EmailService.send_email(
to=user.email,
subject='Password Changed Successfully',
text='Your password has been changed successfully.',
site=site,
html=email_html
email_html = render_to_string(
"accounts/email/password_change_confirmation.html", context
)
def _handle_password_change(self, request: HttpRequest) -> Optional[HttpResponseRedirect]:
EmailService.send_email(
to=user.email,
subject="Password Changed Successfully",
text="Your password has been changed successfully.",
site=site,
html=email_html,
)
def _handle_password_change(
self, request: HttpRequest
) -> Optional[HttpResponseRedirect]:
user = cast(User, request.user)
old_password = request.POST.get('old_password', '')
new_password = request.POST.get('new_password', '')
confirm_password = request.POST.get('confirm_password', '')
old_password = request.POST.get("old_password", "")
new_password = request.POST.get("new_password", "")
confirm_password = request.POST.get("confirm_password", "")
if not user.check_password(old_password):
messages.error(request, 'Current password is incorrect')
messages.error(request, "Current password is incorrect")
return None
if new_password != confirm_password:
messages.error(request, 'New passwords do not match')
messages.error(request, "New passwords do not match")
return None
if not self._validate_password(new_password):
messages.error(request, 'Password must be at least 8 characters and contain uppercase, lowercase, and numbers')
messages.error(
request,
"Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
)
return None
user.set_password(new_password)
user.save()
self._send_password_change_confirmation(request, user)
messages.success(request, 'Password changed successfully. Please check your email for confirmation.')
return HttpResponseRedirect(reverse('account_login'))
messages.success(
request,
"Password changed successfully. Please check your email for confirmation.",
)
return HttpResponseRedirect(reverse("account_login"))
def _handle_email_change(self, request: HttpRequest) -> None:
if new_email := request.POST.get('new_email'):
if new_email := request.POST.get("new_email"):
self._send_email_verification(request, new_email)
messages.success(request, 'Verification email sent to your new email address')
messages.success(
request, "Verification email sent to your new email address"
)
else:
messages.error(request, 'New email is required')
messages.error(request, "New email is required")
def _send_email_verification(self, request: HttpRequest, new_email: str) -> None:
user = cast(User, request.user)
token = get_random_string(64)
EmailVerification.objects.update_or_create(
user=user,
defaults={'token': token}
)
EmailVerification.objects.update_or_create(user=user, defaults={"token": token})
site = cast(Site, get_current_site(request))
verification_url = reverse('verify_email', kwargs={'token': token})
verification_url = reverse("verify_email", kwargs={"token": token})
context = {
'user': user,
'verification_url': verification_url,
'site_name': site.name,
"user": user,
"verification_url": verification_url,
"site_name": site.name,
}
email_html = render_to_string('accounts/email/verify_email.html', context)
email_html = render_to_string("accounts/email/verify_email.html", context)
EmailService.send_email(
to=new_email,
subject='Verify your new email address',
text='Click the link to verify your new email address',
subject="Verify your new email address",
text="Click the link to verify your new email address",
site=site,
html=email_html
html=email_html,
)
user.pending_email = new_email
user.save()
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
action = request.POST.get('action')
if action == 'update_profile':
action = request.POST.get("action")
if action == "update_profile":
self._handle_profile_update(request)
elif action == 'change_password':
elif action == "change_password":
if response := self._handle_password_change(request):
return response
elif action == 'change_email':
elif action == "change_email":
self._handle_email_change(request)
return self.get(request, *args, **kwargs)
def create_password_reset_token(user: User) -> str:
token = get_random_string(64)
PasswordReset.objects.update_or_create(
user=user,
defaults={
'token': token,
'expires_at': timezone.now() + timedelta(hours=24)
}
"token": token,
"expires_at": timezone.now() + timedelta(hours=24),
},
)
return token
def send_password_reset_email(user: User, site: Union[Site, RequestSite], token: str) -> None:
reset_url = reverse('password_reset_confirm', kwargs={'token': token})
def send_password_reset_email(
user: User, site: Union[Site, RequestSite], token: str
) -> None:
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
context = {
'user': user,
'reset_url': reset_url,
'site_name': site.name,
"user": user,
"reset_url": reset_url,
"site_name": site.name,
}
email_html = render_to_string('accounts/email/password_reset.html', context)
email_html = render_to_string("accounts/email/password_reset.html", context)
EmailService.send_email(
to=user.email,
subject='Reset your password',
text='Click the link to reset your password',
subject="Reset your password",
text="Click the link to reset your password",
site=site,
html=email_html
html=email_html,
)
def request_password_reset(request: HttpRequest) -> HttpResponse:
if request.method != 'POST':
return render(request, 'accounts/password_reset.html')
if not (email := request.POST.get('email')):
messages.error(request, 'Email is required')
return redirect('account_reset_password')
def request_password_reset(request: HttpRequest) -> HttpResponse:
if request.method != "POST":
return render(request, "accounts/password_reset.html")
if not (email := request.POST.get("email")):
messages.error(request, "Email is required")
return redirect("account_reset_password")
with suppress(User.DoesNotExist):
user = User.objects.get(email=email)
token = create_password_reset_token(user)
site = get_current_site(request)
send_password_reset_email(user, site, token)
messages.success(request, 'Password reset email sent')
return redirect('account_login')
def handle_password_reset(request: HttpRequest, user: User, new_password: str, reset: PasswordReset, site: Union[Site, RequestSite]) -> None:
messages.success(request, "Password reset email sent")
return redirect("account_login")
def handle_password_reset(
request: HttpRequest,
user: User,
new_password: str,
reset: PasswordReset,
site: Union[Site, RequestSite],
) -> None:
user.set_password(new_password)
user.save()
reset.used = True
reset.save()
send_password_reset_confirmation(user, site)
messages.success(request, 'Password reset successfully')
def send_password_reset_confirmation(user: User, site: Union[Site, RequestSite]) -> None:
send_password_reset_confirmation(user, site)
messages.success(request, "Password reset successfully")
def send_password_reset_confirmation(
user: User, site: Union[Site, RequestSite]
) -> None:
context = {
'user': user,
'site_name': site.name,
"user": user,
"site_name": site.name,
}
email_html = render_to_string('accounts/email/password_reset_complete.html', context)
email_html = render_to_string(
"accounts/email/password_reset_complete.html", context
)
EmailService.send_email(
to=user.email,
subject='Password Reset Complete',
text='Your password has been reset successfully.',
subject="Password Reset Complete",
text="Your password has been reset successfully.",
site=site,
html=email_html
html=email_html,
)
def reset_password(request: HttpRequest, token: str) -> HttpResponse:
try:
reset = PasswordReset.objects.select_related('user').get(
token=token,
expires_at__gt=timezone.now(),
used=False
reset = PasswordReset.objects.select_related("user").get(
token=token, expires_at__gt=timezone.now(), used=False
)
if request.method == 'POST':
if new_password := request.POST.get('new_password'):
if request.method == "POST":
if new_password := request.POST.get("new_password"):
site = get_current_site(request)
handle_password_reset(request, reset.user, new_password, reset, site)
return redirect('account_login')
messages.error(request, 'New password is required')
return render(request, 'accounts/password_reset_confirm.html', {'token': token})
return redirect("account_login")
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')
messages.error(request, "Invalid or expired reset token")
return redirect("account_reset_password")