first commit

This commit is contained in:
pacnpal
2024-10-28 17:09:57 -04:00
commit 2e1b4d7af7
9993 changed files with 1182741 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/.vscode
/dev.sh
/flake.nix

0
accounts/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

62
accounts/adapters.py Normal file
View File

@@ -0,0 +1,62 @@
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
User = get_user_model()
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
"""
Whether to allow sign ups.
"""
return getattr(settings, 'ACCOUNT_ALLOW_SIGNUPS', True)
def get_email_confirmation_url(self, request, emailconfirmation):
"""
Constructs the email confirmation (activation) url.
"""
site = get_current_site(request)
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
def send_confirmation_mail(self, request, emailconfirmation, signup):
"""
Sends the confirmation email.
"""
current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
ctx = {
'user': emailconfirmation.email_address.user,
'activate_url': activate_url,
'current_site': current_site,
'key': emailconfirmation.key,
}
if signup:
email_template = 'account/email/email_confirmation_signup'
else:
email_template = 'account/email/email_confirmation'
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
"""
Whether to allow social account sign ups.
"""
return getattr(settings, 'SOCIALACCOUNT_ALLOW_SIGNUPS', True)
def populate_user(self, request, sociallogin, data):
"""
Hook that can be used to further populate the user instance.
"""
user = super().populate_user(request, sociallogin, data)
if sociallogin.account.provider == 'discord':
user.discord_id = sociallogin.account.uid
return user
def save_user(self, request, sociallogin, form=None):
"""
Save the newly signed up social login.
"""
user = super().save_user(request, sociallogin, form)
return user

201
accounts/admin.py Normal file
View File

@@ -0,0 +1,201 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html
from django.urls import reverse
from django.contrib.auth.models import Group
from .models import User, UserProfile, EmailVerification, TopList, TopListItem
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile'
fieldsets = (
('Personal Info', {
'fields': ('display_name', 'avatar', 'pronouns', 'bio')
}),
('Social Media', {
'fields': ('twitter', 'instagram', 'youtube', 'discord')
}),
('Ride Credits', {
'fields': (
'coaster_credits',
'dark_ride_credits',
'flat_ride_credits',
'water_ride_credits'
)
}),
)
class TopListItemInline(admin.TabularInline):
model = TopListItem
extra = 1
fields = ('content_type', 'object_id', 'rank', 'notes')
ordering = ('rank',)
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = ('username', 'email', 'get_status', 'role', 'date_joined', 'last_login', 'get_credits')
list_filter = ('is_active', 'is_staff', 'role', 'is_banned', 'groups', 'date_joined')
search_fields = ('username', 'email', 'first_name', 'last_name')
ordering = ('-date_joined',)
actions = ['activate_users', 'deactivate_users', 'ban_users', 'unban_users']
inlines = [UserProfileInline]
fieldsets = (
(None, {'fields': ('username', 'password')}),
('Personal info', {'fields': ('first_name', 'last_name', 'email', 'pending_email')}),
('Roles and Permissions', {
'fields': ('role', 'groups', 'user_permissions'),
'description': 'Role determines group membership. Groups determine permissions.',
}),
('Status', {
'fields': ('is_active', 'is_staff', 'is_superuser'),
'description': 'These are automatically managed based on role.',
}),
('Ban Status', {
'fields': ('is_banned', 'ban_reason', 'ban_date'),
}),
('Preferences', {
'fields': ('theme_preference',),
}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('username', 'email', 'password1', 'password2', 'role'),
}),
)
def get_status(self, obj):
if obj.is_banned:
return format_html('<span style="color: red;">Banned</span>')
if not obj.is_active:
return format_html('<span style="color: orange;">Inactive</span>')
if obj.is_superuser:
return format_html('<span style="color: purple;">Superuser</span>')
if obj.is_staff:
return format_html('<span style="color: blue;">Staff</span>')
return format_html('<span style="color: green;">Active</span>')
get_status.short_description = 'Status'
def get_credits(self, obj):
try:
profile = obj.profile
return format_html(
'RC: {}<br>DR: {}<br>FR: {}<br>WR: {}',
profile.coaster_credits,
profile.dark_ride_credits,
profile.flat_ride_credits,
profile.water_ride_credits
)
except UserProfile.DoesNotExist:
return '-'
get_credits.short_description = 'Ride Credits'
def activate_users(self, request, queryset):
queryset.update(is_active=True)
activate_users.short_description = "Activate selected users"
def deactivate_users(self, request, queryset):
queryset.update(is_active=False)
deactivate_users.short_description = "Deactivate selected users"
def ban_users(self, request, queryset):
from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now())
ban_users.short_description = "Ban selected users"
def unban_users(self, request, queryset):
queryset.update(is_banned=False, ban_date=None, ban_reason='')
unban_users.short_description = "Unban selected users"
def save_model(self, request, obj, form, change):
creating = not obj.pk
super().save_model(request, obj, form, change)
if creating and obj.role != User.Roles.USER:
# Ensure new user with role gets added to appropriate group
group = Group.objects.filter(name=obj.role).first()
if group:
obj.groups.add(group)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'display_name', 'coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')
list_filter = ('coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')
search_fields = ('user__username', 'user__email', 'display_name', 'bio')
fieldsets = (
('User Information', {
'fields': ('user', 'display_name', 'avatar', 'pronouns', 'bio')
}),
('Social Media', {
'fields': ('twitter', 'instagram', 'youtube', 'discord')
}),
('Ride Credits', {
'fields': (
'coaster_credits',
'dark_ride_credits',
'flat_ride_credits',
'water_ride_credits'
)
}),
)
@admin.register(EmailVerification)
class EmailVerificationAdmin(admin.ModelAdmin):
list_display = ('user', 'created_at', 'last_sent', 'is_expired')
list_filter = ('created_at', 'last_sent')
search_fields = ('user__username', 'user__email', 'token')
readonly_fields = ('created_at', 'last_sent')
fieldsets = (
('Verification Details', {
'fields': ('user', 'token')
}),
('Timing', {
'fields': ('created_at', 'last_sent')
}),
)
def is_expired(self, obj):
from django.utils import timezone
from datetime import timedelta
if timezone.now() - obj.last_sent > timedelta(days=1):
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
is_expired.short_description = 'Status'
@admin.register(TopList)
class TopListAdmin(admin.ModelAdmin):
list_display = ('title', 'user', 'category', 'created_at', 'updated_at')
list_filter = ('category', 'created_at', 'updated_at')
search_fields = ('title', 'user__username', 'description')
inlines = [TopListItemInline]
fieldsets = (
('Basic Information', {
'fields': ('user', 'title', 'category', 'description')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
@admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin):
list_display = ('top_list', 'content_type', 'object_id', 'rank')
list_filter = ('top_list__category', 'rank')
search_fields = ('top_list__title', 'notes')
ordering = ('top_list', 'rank')
fieldsets = (
('List Information', {
'fields': ('top_list', 'rank')
}),
('Item Details', {
'fields': ('content_type', 'object_id', 'notes')
}),
)

9
accounts/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
def ready(self):
import accounts.signals # noqa

View File

@@ -0,0 +1,30 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
from django.contrib.sites.models import Site
class Command(BaseCommand):
help = 'Check all social auth related tables'
def handle(self, *args, **options):
# Check SocialApp
self.stdout.write('\nChecking SocialApp table:')
for app in SocialApp.objects.all():
self.stdout.write(f'ID: {app.id}, Provider: {app.provider}, Name: {app.name}, Client ID: {app.client_id}')
self.stdout.write('Sites:')
for site in app.sites.all():
self.stdout.write(f' - {site.domain}')
# Check SocialAccount
self.stdout.write('\nChecking SocialAccount table:')
for account in SocialAccount.objects.all():
self.stdout.write(f'ID: {account.id}, Provider: {account.provider}, UID: {account.uid}')
# Check SocialToken
self.stdout.write('\nChecking SocialToken table:')
for token in SocialToken.objects.all():
self.stdout.write(f'ID: {token.id}, Account: {token.account}, App: {token.app}')
# Check Site
self.stdout.write('\nChecking Site table:')
for site in Site.objects.all():
self.stdout.write(f'ID: {site.id}, Domain: {site.domain}, Name: {site.name}')

View File

@@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
help = 'Check social app configurations'
def handle(self, *args, **options):
social_apps = SocialApp.objects.all()
if not social_apps:
self.stdout.write(self.style.ERROR('No social apps found'))
return
for app in social_apps:
self.stdout.write(self.style.SUCCESS(f'\nProvider: {app.provider}'))
self.stdout.write(f'Name: {app.name}')
self.stdout.write(f'Client ID: {app.client_id}')
self.stdout.write(f'Secret: {app.secret}')
self.stdout.write(f'Sites: {", ".join(str(site.domain) for site in app.sites.all())}')

View File

@@ -0,0 +1,22 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Clean up social auth tables and migrations'
def handle(self, *args, **options):
with connection.cursor() as cursor:
# Drop social auth tables
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialapp")
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialapp_sites")
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialaccount")
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialtoken")
# Remove migration records
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
cursor.execute("DELETE FROM django_migrations WHERE app='accounts' AND name LIKE '%social%'")
# Reset sequences
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
self.stdout.write(self.style.SUCCESS('Successfully cleaned up social auth configuration'))

View File

@@ -0,0 +1,48 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
help = 'Create social apps for authentication'
def handle(self, *args, **options):
# Get the default site
site = Site.objects.get_or_create(
id=1,
defaults={
'domain': 'localhost:8000',
'name': 'ThrillWiki Development'
}
)[0]
# Create Discord app
discord_app, created = SocialApp.objects.get_or_create(
provider='discord',
defaults={
'name': 'Discord',
'client_id': '1299112802274902047',
'secret': 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
}
)
if not created:
discord_app.client_id = '1299112802274902047'
discord_app.secret = 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11'
discord_app.save()
discord_app.sites.add(site)
self.stdout.write(f'{"Created" if created else "Updated"} Discord app')
# Create Google app
google_app, created = SocialApp.objects.get_or_create(
provider='google',
defaults={
'name': 'Google',
'client_id': '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
'secret': 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue',
}
)
if not created:
google_app.client_id = '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com'
google_app.secret = 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue'
google_app.save()
google_app.sites.add(site)
self.stdout.write(f'{"Created" if created else "Updated"} Google app')

View File

@@ -0,0 +1,35 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
import os
class Command(BaseCommand):
help = 'Fix social app configurations'
def handle(self, *args, **options):
# Delete all existing social apps
SocialApp.objects.all().delete()
self.stdout.write('Deleted all existing social apps')
# Get the default site
site = Site.objects.get(id=1)
# Create Google provider
google_app = SocialApp.objects.create(
provider='google',
name='Google',
client_id=os.getenv('GOOGLE_CLIENT_ID'),
secret=os.getenv('GOOGLE_CLIENT_SECRET'),
)
google_app.sites.add(site)
self.stdout.write(f'Created Google app with client_id: {google_app.client_id}')
# Create Discord provider
discord_app = SocialApp.objects.create(
provider='discord',
name='Discord',
client_id=os.getenv('DISCORD_CLIENT_ID'),
secret=os.getenv('DISCORD_CLIENT_SECRET'),
)
discord_app.sites.add(site)
self.stdout.write(f'Created Discord app with client_id: {discord_app.client_id}')

View File

@@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.db import connection
class Command(BaseCommand):
help = 'Reset social apps configuration'
def handle(self, *args, **options):
# Delete all social apps using raw SQL to bypass Django's ORM
with connection.cursor() as cursor:
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
cursor.execute("DELETE FROM socialaccount_socialapp")
# Get the default site
site = Site.objects.get(id=1)
# Create Discord app
discord_app = SocialApp.objects.create(
provider='discord',
name='Discord',
client_id='1299112802274902047',
secret='ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
)
discord_app.sites.add(site)
self.stdout.write(f'Created Discord app with ID: {discord_app.id}')
# Create Google app
google_app = SocialApp.objects.create(
provider='google',
name='Google',
client_id='135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
secret='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',
)
google_app.sites.add(site)
self.stdout.write(f'Created Google app with ID: {google_app.id}')

View File

@@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Reset social auth configuration'
def handle(self, *args, **options):
with connection.cursor() as cursor:
# Delete all social apps
cursor.execute("DELETE FROM socialaccount_socialapp")
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
# Reset sequences
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'")
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'")
self.stdout.write(self.style.SUCCESS('Successfully reset social auth configuration'))

View File

@@ -0,0 +1,42 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from accounts.models import User
from accounts.signals import create_default_groups
class Command(BaseCommand):
help = 'Set up default groups and permissions for user roles'
def handle(self, *args, **options):
self.stdout.write('Creating default groups and permissions...')
try:
# Create default groups with permissions
create_default_groups()
# Sync existing users with groups based on their roles
users = User.objects.exclude(role=User.Roles.USER)
for user in users:
group = Group.objects.filter(name=user.role).first()
if group:
user.groups.add(group)
# Update staff/superuser status based on role
if user.role == User.Roles.SUPERUSER:
user.is_superuser = True
user.is_staff = True
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
user.is_staff = True
user.save()
self.stdout.write(self.style.SUCCESS('Successfully set up groups and permissions'))
# Print summary
for group in Group.objects.all():
self.stdout.write(f'\nGroup: {group.name}')
self.stdout.write('Permissions:')
for perm in group.permissions.all():
self.stdout.write(f' - {perm.codename}')
except Exception as e:
self.stdout.write(self.style.ERROR(f'Error setting up groups: {str(e)}'))

View File

@@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
class Command(BaseCommand):
help = 'Set up default site'
def handle(self, *args, **options):
# Delete any existing sites
Site.objects.all().delete()
# Create default site
site = Site.objects.create(
id=1,
domain='localhost:8000',
name='ThrillWiki Development'
)
self.stdout.write(self.style.SUCCESS(f'Created site: {site.domain}'))

View File

@@ -0,0 +1,63 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
from dotenv import load_dotenv
import os
class Command(BaseCommand):
help = 'Sets up social authentication apps'
def handle(self, *args, **kwargs):
# Load environment variables
load_dotenv()
# Get environment variables
google_client_id = os.getenv('GOOGLE_CLIENT_ID')
google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET')
discord_client_id = os.getenv('DISCORD_CLIENT_ID')
discord_client_secret = os.getenv('DISCORD_CLIENT_SECRET')
if not all([google_client_id, google_client_secret, discord_client_id, discord_client_secret]):
self.stdout.write(self.style.ERROR('Missing required environment variables'))
return
# Get or create the default site
site, _ = Site.objects.get_or_create(
id=1,
defaults={
'domain': 'localhost:8000',
'name': 'localhost'
}
)
# Set up Google
google_app, created = SocialApp.objects.get_or_create(
provider='google',
defaults={
'name': 'Google',
'client_id': google_client_id,
'secret': google_client_secret,
}
)
if not created:
google_app.client_id = google_client_id
google_app.[SECRET-REMOVED]
google_app.save()
google_app.sites.add(site)
# Set up Discord
discord_app, created = SocialApp.objects.get_or_create(
provider='discord',
defaults={
'name': 'Discord',
'client_id': discord_client_id,
'secret': discord_client_secret,
}
)
if not created:
discord_app.client_id = discord_client_id
discord_app.[SECRET-REMOVED]
discord_app.save()
discord_app.sites.add(site)
self.stdout.write(self.style.SUCCESS('Successfully set up social auth apps'))

View File

@@ -0,0 +1,60 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from allauth.socialaccount.models import SocialApp
User = get_user_model()
class Command(BaseCommand):
help = 'Set up social authentication through admin interface'
def handle(self, *args, **options):
# Get or create the default site
site, _ = Site.objects.get_or_create(
id=1,
defaults={
'domain': 'localhost:8000',
'name': 'ThrillWiki Development'
}
)
if not _:
site.domain = 'localhost:8000'
site.name = 'ThrillWiki Development'
site.save()
self.stdout.write(f'{"Created" if _ else "Updated"} site: {site.domain}')
# Create superuser if it doesn't exist
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
self.stdout.write('Created superuser: admin/admin')
self.stdout.write(self.style.SUCCESS('''
Social auth setup instructions:
1. Run the development server:
python manage.py runserver
2. Go to the admin interface:
http://localhost:8000/admin/
3. Log in with:
Username: admin
Password: admin
4. Add social applications:
- Go to "Social applications" under "Social Accounts"
- Add Discord app:
Provider: discord
Name: Discord
Client id: 1299112802274902047
Secret key: ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11
Sites: Add "localhost:8000"
- Add Google app:
Provider: google
Name: Google
Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com
Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue
Sites: Add "localhost:8000"
'''))

View File

@@ -0,0 +1,60 @@
from django.core.management.base import BaseCommand
from django.urls import reverse
from django.test import Client
from allauth.socialaccount.models import SocialApp
from urllib.parse import urljoin
class Command(BaseCommand):
help = 'Test Discord OAuth2 authentication flow'
def handle(self, *args, **options):
client = Client(HTTP_HOST='localhost:8000')
# Get Discord app
try:
discord_app = SocialApp.objects.get(provider='discord')
self.stdout.write('Found Discord app configuration:')
self.stdout.write(f'Client ID: {discord_app.client_id}')
# Test login URL
login_url = '/accounts/discord/login/'
response = client.get(login_url, HTTP_HOST='localhost:8000')
self.stdout.write(f'\nTesting login URL: {login_url}')
self.stdout.write(f'Status code: {response.status_code}')
if response.status_code == 302:
redirect_url = response['Location']
self.stdout.write(f'Redirects to: {redirect_url}')
# Parse OAuth2 parameters
self.stdout.write('\nOAuth2 Parameters:')
if 'client_id=' in redirect_url:
self.stdout.write('✓ client_id parameter present')
if 'redirect_uri=' in redirect_url:
self.stdout.write('✓ redirect_uri parameter present')
if 'scope=' in redirect_url:
self.stdout.write('✓ scope parameter present')
if 'response_type=' in redirect_url:
self.stdout.write('✓ response_type parameter present')
if 'code_challenge=' in redirect_url:
self.stdout.write('✓ PKCE enabled (code_challenge present)')
# Show callback URL
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
self.stdout.write(callback_url)
# Show frontend login URL
frontend_url = 'http://localhost:5173'
self.stdout.write('\nFrontend configuration:')
self.stdout.write(f'Frontend URL: {frontend_url}')
self.stdout.write('Discord login button should use:')
self.stdout.write('/accounts/discord/login/?process=login')
# Show allauth URLs
self.stdout.write('\nAllauth URLs:')
self.stdout.write('Login URL: /accounts/discord/login/?process=login')
self.stdout.write('Callback URL: /accounts/discord/login/callback/')
except SocialApp.DoesNotExist:
self.stdout.write(self.style.ERROR('Discord app not found'))

View File

@@ -0,0 +1,20 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
class Command(BaseCommand):
help = 'Update social apps to be associated with all sites'
def handle(self, *args, **options):
# Get all sites
sites = Site.objects.all()
# Update each social app
for app in SocialApp.objects.all():
self.stdout.write(f'Updating {app.provider} app...')
# Clear existing sites
app.sites.clear()
# Add all sites
for site in sites:
app.sites.add(site)
self.stdout.write(f'Added sites: {", ".join(site.domain for site in sites)}')

View File

@@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.urls import reverse
from django.conf import settings
class Command(BaseCommand):
help = 'Verify Discord OAuth2 settings'
def handle(self, *args, **options):
# Get Discord app
try:
discord_app = SocialApp.objects.get(provider='discord')
self.stdout.write('Found Discord app configuration:')
self.stdout.write(f'Client ID: {discord_app.client_id}')
self.stdout.write(f'Secret: {discord_app.secret}')
# Get sites
sites = discord_app.sites.all()
self.stdout.write('\nAssociated sites:')
for site in sites:
self.stdout.write(f'- {site.domain} ({site.name})')
# Show callback URL
callback_url = f'http://localhost:8000/accounts/discord/login/callback/'
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
self.stdout.write(callback_url)
# Show OAuth2 settings
self.stdout.write('\nOAuth2 settings in settings.py:')
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get('discord', {})
self.stdout.write(f'PKCE Enabled: {discord_settings.get("OAUTH_PKCE_ENABLED", False)}')
self.stdout.write(f'Scopes: {discord_settings.get("SCOPE", [])}')
except SocialApp.DoesNotExist:
self.stdout.write(self.style.ERROR('Discord app not found'))

View File

@@ -0,0 +1,133 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('user_id', models.CharField(editable=False, help_text='Unique identifier for this user that remains constant even if the username changes', max_length=10, unique=True)),
('first_name', models.CharField(default='', max_length=150, verbose_name='first name')),
('last_name', models.CharField(default='', max_length=150, verbose_name='last name')),
('role', models.CharField(choices=[('USER', 'User'), ('MODERATOR', 'Moderator'), ('ADMIN', 'Admin'), ('SUPERUSER', 'Superuser')], default='USER', max_length=10)),
('is_banned', models.BooleanField(default=False)),
('ban_reason', models.TextField(blank=True)),
('ban_date', models.DateTimeField(blank=True, null=True)),
('pending_email', models.EmailField(blank=True, max_length=254, null=True)),
('theme_preference', models.CharField(choices=[('light', 'Light'), ('dark', 'Dark')], default='light', max_length=5)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='EmailVerification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_sent', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Email Verification',
'verbose_name_plural': 'Email Verifications',
},
),
migrations.CreateModel(
name='PasswordReset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('used', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Password Reset',
'verbose_name_plural': 'Password Resets',
},
),
migrations.CreateModel(
name='TopList',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100)),
('category', models.CharField(choices=[('RC', 'Roller Coaster'), ('DR', 'Dark Ride'), ('FR', 'Flat Ride'), ('WR', 'Water Ride'), ('PK', 'Park')], max_length=2)),
('description', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='top_lists', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('profile_id', models.CharField(editable=False, help_text='Unique identifier for this profile that remains constant', max_length=10, unique=True)),
('display_name', models.CharField(help_text='This is the name that will be displayed on the site', max_length=50, unique=True)),
('avatar', models.ImageField(blank=True, upload_to='avatars/')),
('pronouns', models.CharField(blank=True, max_length=50)),
('bio', models.TextField(blank=True, max_length=500)),
('twitter', models.URLField(blank=True)),
('instagram', models.URLField(blank=True)),
('youtube', models.URLField(blank=True)),
('discord', models.CharField(blank=True, max_length=100)),
('coaster_credits', models.IntegerField(default=0)),
('dark_ride_credits', models.IntegerField(default=0)),
('flat_ride_credits', models.IntegerField(default=0)),
('water_ride_credits', models.IntegerField(default=0)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='TopListItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('rank', models.PositiveIntegerField()),
('notes', models.TextField(blank=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('top_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='accounts.toplist')),
],
options={
'ordering': ['rank'],
'unique_together': {('top_list', 'rank')},
},
),
]

View File

194
accounts/models.py Normal file
View File

@@ -0,0 +1,194 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import random
def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True:
# Try to get a 4-digit number first
new_id = str(random.randint(1000, 9999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
# If all 4-digit numbers are taken, try 5 digits
new_id = str(random.randint(10000, 99999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
class User(AbstractUser):
class Roles(models.TextChoices):
USER = 'USER', _('User')
MODERATOR = 'MODERATOR', _('Moderator')
ADMIN = 'ADMIN', _('Admin')
SUPERUSER = 'SUPERUSER', _('Superuser')
class ThemePreference(models.TextChoices):
LIGHT = 'light', _('Light')
DARK = 'dark', _('Dark')
# Read-only ID
user_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text='Unique identifier for this user that remains constant even if the username changes'
)
first_name = models.CharField(_('first name'), max_length=150, default='')
last_name = models.CharField(_('last name'), max_length=150, default='')
role = models.CharField(
max_length=10,
choices=Roles.choices,
default=Roles.USER,
)
is_banned = models.BooleanField(default=False)
ban_reason = models.TextField(blank=True)
ban_date = models.DateTimeField(null=True, blank=True)
pending_email = models.EmailField(blank=True, null=True)
theme_preference = models.CharField(
max_length=5,
choices=ThemePreference.choices,
default=ThemePreference.LIGHT,
)
def __str__(self):
return self.get_display_name()
def get_absolute_url(self):
return reverse('profile', kwargs={'username': self.username})
def get_display_name(self):
"""Get the user's display name, falling back to username if not set"""
if hasattr(self, 'profile') and self.profile.display_name:
return self.profile.display_name
return self.username
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = generate_random_id(User, 'user_id')
super().save(*args, **kwargs)
class UserProfile(models.Model):
# Read-only ID
profile_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text='Unique identifier for this profile that remains constant'
)
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='profile'
)
display_name = models.CharField(
max_length=50,
unique=True,
help_text="This is the name that will be displayed on the site"
)
avatar = models.ImageField(upload_to='avatars/', blank=True)
pronouns = models.CharField(max_length=50, blank=True)
bio = models.TextField(max_length=500, blank=True)
# Social media links
twitter = models.URLField(blank=True)
instagram = models.URLField(blank=True)
youtube = models.URLField(blank=True)
discord = models.CharField(max_length=100, blank=True)
# Ride statistics
coaster_credits = models.IntegerField(default=0)
dark_ride_credits = models.IntegerField(default=0)
flat_ride_credits = models.IntegerField(default=0)
water_ride_credits = models.IntegerField(default=0)
def save(self, *args, **kwargs):
# If no display name is set, use the username
if not self.display_name:
self.display_name = self.user.username
if not self.profile_id:
self.profile_id = generate_random_id(UserProfile, 'profile_id')
super().save(*args, **kwargs)
def __str__(self):
return self.display_name
class EmailVerification(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
last_sent = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Email verification for {self.user.username}"
class Meta:
verbose_name = "Email Verification"
verbose_name_plural = "Email Verifications"
class PasswordReset(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
def __str__(self):
return f"Password reset for {self.user.username}"
class Meta:
verbose_name = "Password Reset"
verbose_name_plural = "Password Resets"
class TopList(models.Model):
class Categories(models.TextChoices):
ROLLER_COASTER = 'RC', _('Roller Coaster')
DARK_RIDE = 'DR', _('Dark Ride')
FLAT_RIDE = 'FR', _('Flat Ride')
WATER_RIDE = 'WR', _('Water Ride')
PARK = 'PK', _('Park')
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='top_lists'
)
title = models.CharField(max_length=100)
category = models.CharField(
max_length=2,
choices=Categories.choices
)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at']
def __str__(self):
return f"{self.user.get_display_name()}'s {self.get_category_display()} Top List: {self.title}"
class TopListItem(models.Model):
top_list = models.ForeignKey(
TopList,
on_delete=models.CASCADE,
related_name='items'
)
content_type = models.ForeignKey(
'contenttypes.ContentType',
on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField()
rank = models.PositiveIntegerField()
notes = models.TextField(blank=True)
class Meta:
ordering = ['rank']
unique_together = [['top_list', 'rank']]
def __str__(self):
return f"#{self.rank} in {self.top_list.title}"

152
accounts/signals.py Normal file
View File

@@ -0,0 +1,152 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.contrib.auth.models import Group
from django.db import transaction
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
import requests
from .models import User, UserProfile, EmailVerification
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
"""Create UserProfile for new users"""
try:
if created:
# Create profile
profile = UserProfile.objects.create(user=instance)
# If user has a social account with avatar, download it
social_account = instance.socialaccount_set.first()
if social_account:
extra_data = social_account.extra_data
avatar_url = None
if social_account.provider == 'google':
avatar_url = extra_data.get('picture')
elif social_account.provider == 'discord':
avatar = extra_data.get('avatar')
discord_id = extra_data.get('id')
if avatar:
avatar_url = f'https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png'
if avatar_url:
try:
response = requests.get(avatar_url)
if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content)
img_temp.flush()
file_name = f"avatar_{instance.username}.png"
profile.avatar.save(
file_name,
File(img_temp),
save=True
)
except Exception as e:
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
except Exception as e:
print(f"Error creating profile for user {instance.username}: {str(e)}")
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
"""Ensure UserProfile exists and is saved"""
try:
if not hasattr(instance, 'profile'):
UserProfile.objects.create(user=instance)
instance.profile.save()
except Exception as e:
print(f"Error saving profile for user {instance.username}: {str(e)}")
@receiver(pre_save, sender=User)
def sync_user_role_with_groups(sender, instance, **kwargs):
"""Sync user role with Django groups"""
if instance.pk: # Only for existing users
try:
old_instance = User.objects.get(pk=instance.pk)
if old_instance.role != instance.role:
# Role has changed, update groups
with transaction.atomic():
# Remove from old role group if exists
if old_instance.role != User.Roles.USER:
old_group = Group.objects.filter(name=old_instance.role).first()
if old_group:
instance.groups.remove(old_group)
# Add to new role group
if instance.role != User.Roles.USER:
new_group, _ = Group.objects.get_or_create(name=instance.role)
instance.groups.add(new_group)
# Special handling for superuser role
if instance.role == User.Roles.SUPERUSER:
instance.is_superuser = True
instance.is_staff = True
elif old_instance.role == User.Roles.SUPERUSER:
# If removing superuser role, remove superuser status
instance.is_superuser = False
if instance.role not in [User.Roles.ADMIN, User.Roles.MODERATOR]:
instance.is_staff = False
# Handle staff status for admin and moderator roles
if instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
instance.is_staff = True
elif old_instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
# If removing admin/moderator role, remove staff status
if instance.role not in [User.Roles.SUPERUSER]:
instance.is_staff = False
except User.DoesNotExist:
pass
except Exception as e:
print(f"Error syncing role with groups for user {instance.username}: {str(e)}")
def create_default_groups():
"""
Create default groups with appropriate permissions.
Call this in a migration or management command.
"""
try:
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
# Create Moderator group
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
moderator_permissions = [
# Review moderation permissions
'change_review', 'delete_review',
'change_reviewreport', 'delete_reviewreport',
# Edit moderation permissions
'change_parkedit', 'delete_parkedit',
'change_rideedit', 'delete_rideedit',
'change_companyedit', 'delete_companyedit',
'change_manufactureredit', 'delete_manufactureredit',
]
# Create Admin group
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
admin_permissions = moderator_permissions + [
# User management permissions
'change_user', 'delete_user',
# Content management permissions
'add_park', 'change_park', 'delete_park',
'add_ride', 'change_ride', 'delete_ride',
'add_company', 'change_company', 'delete_company',
'add_manufacturer', 'change_manufacturer', 'delete_manufacturer',
]
# Assign permissions to groups
for codename in moderator_permissions:
try:
perm = Permission.objects.get(codename=codename)
moderator_group.permissions.add(perm)
except Permission.DoesNotExist:
print(f"Permission not found: {codename}")
for codename in admin_permissions:
try:
perm = Permission.objects.get(codename=codename)
admin_group.permissions.add(perm)
except Permission.DoesNotExist:
print(f"Permission not found: {codename}")
except Exception as e:
print(f"Error creating default groups: {str(e)}")

3
accounts/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

21
accounts/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
app_name = 'accounts'
urlpatterns = [
# Authentication views
path('login/', auth_views.LoginView.as_view(template_name='accounts/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('password_change/', auth_views.PasswordChangeView.as_view(), name='password_change'),
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),
path('password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'),
path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
# Profile views
path('profile/', views.user_redirect_view, name='profile_redirect'),
path('settings/', views.SettingsView.as_view(), name='settings'),
]

235
accounts/views.py Normal file
View File

@@ -0,0 +1,235 @@
from django.views.generic import DetailView, TemplateView
from django.contrib.auth import get_user_model, login, authenticate
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from allauth.socialaccount.providers.discord.views import DiscordOAuth2Adapter
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from django.db.models import Prefetch
from django.http import HttpResponseRedirect
from django.urls import reverse
from accounts.models import User, PasswordReset
from reviews.models import Review
from email_service.services import EmailService
User = get_user_model()
@login_required
def user_redirect_view(request):
"""Redirect /user/ to the logged-in user's profile"""
return redirect('profile', username=request.user.username)
def email_required(request):
"""Handle cases where social auth provider doesn't provide an email"""
sociallogin = request.session.get('socialaccount_sociallogin')
if not sociallogin:
messages.error(request, 'No social login in progress')
return redirect('/')
if request.method == 'POST':
email = request.POST.get('email')
if email:
sociallogin.user.email = email
sociallogin.save()
login(request, sociallogin.user)
del request.session['socialaccount_sociallogin']
messages.success(request, 'Successfully logged in')
return redirect('/')
else:
messages.error(request, 'Email is required')
return render(request, 'accounts/email_required.html', {'error': 'Email is required'})
return render(request, 'accounts/email_required.html')
class ProfileView(DetailView):
model = User
template_name = 'accounts/profile.html'
context_object_name = 'profile_user'
slug_field = 'username'
slug_url_kwarg = 'username'
def get_queryset(self):
# Optimize the base queryset with select_related
return User.objects.select_related('profile')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.get_object()
# Get user's reviews with optimized queries
reviews_queryset = Review.objects.filter(
user=user,
is_published=True
).select_related(
'user',
'user__profile',
'content_type'
).prefetch_related(
'content_object' # This will fetch the related ride/park/etc.
).order_by('-created_at')[:5]
context['recent_reviews'] = reviews_queryset
# Get user's top lists with optimized queries
context['top_lists'] = user.top_lists.select_related(
'user',
'user__profile'
).prefetch_related(
Prefetch('items', queryset=(
user.top_lists.through.objects.select_related(
'content_type'
).prefetch_related('content_object')
))
).order_by('-created_at')[:5]
return context
class SettingsView(LoginRequiredMixin, TemplateView):
template_name = 'accounts/settings.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['user'] = self.request.user
return context
def post(self, request, *args, **kwargs):
action = request.POST.get('action')
if action == 'update_profile':
# Handle profile updates
user = request.user
user.first_name = request.POST.get('first_name', user.first_name)
user.last_name = request.POST.get('last_name', user.last_name)
if 'avatar' in request.FILES:
user.profile.avatar = request.FILES['avatar']
user.profile.save()
user.save()
messages.success(request, 'Profile updated successfully')
elif action == 'change_password':
# Handle password change
old_password = request.POST.get('old_password')
new_password = request.POST.get('new_password')
if request.user.check_password(old_password):
request.user.set_password(new_password)
request.user.save()
messages.success(request, 'Password changed successfully')
return HttpResponseRedirect(reverse('account_login'))
else:
messages.error(request, 'Current password is incorrect')
return self.get(request, *args, **kwargs)
def request_password_reset(request):
"""Request a password reset email"""
if request.method == 'POST':
email = request.POST.get('email')
if not email:
messages.error(request, 'Email is required')
return redirect('account_reset_password')
try:
user = User.objects.get(email=email)
# Generate token
token = get_random_string(64)
# Save token with expiry
PasswordReset.objects.update_or_create(
user=user,
defaults={
'token': token,
'expires_at': timezone.now() + timedelta(hours=24)
}
)
# Get current site
site = get_current_site(request)
# Send reset email
reset_url = reverse('password_reset_confirm', kwargs={'token': token})
context = {
'user': user,
'reset_url': reset_url,
'site_name': site.name,
}
email_html = render_to_string('accounts/email/password_reset.html', context)
# Use EmailService instead of send_mail
EmailService.send_email(
to=email,
subject='Reset your password',
text='Click the link to reset your password',
site=site,
html=email_html
)
messages.success(request, 'Password reset email sent')
return redirect('account_login')
except User.DoesNotExist:
# Still show success to prevent email enumeration
messages.success(request, 'Password reset email sent')
return redirect('account_login')
return render(request, 'accounts/password_reset.html')
def reset_password(request, token):
"""Reset password using token"""
try:
# Get valid reset token
reset = PasswordReset.objects.select_related('user').get(
token=token,
expires_at__gt=timezone.now(),
used=False
)
if request.method == 'POST':
new_password = request.POST.get('new_password')
if new_password:
# Reset password
user = reset.user
user.set_password(new_password)
user.save()
# Mark token as used
reset.used = True
reset.save()
# Get current site
site = get_current_site(request)
# Send confirmation email
context = {
'user': user,
'site_name': site.name,
}
email_html = render_to_string('accounts/email/password_reset_complete.html', context)
# Use EmailService instead of send_mail
EmailService.send_email(
to=user.email,
subject='Password Reset Complete',
text='Your password has been reset successfully.',
site=site,
html=email_html
)
messages.success(request, 'Password reset successfully')
return redirect('account_login')
else:
messages.error(request, 'New password is required')
return render(request, 'accounts/password_reset_confirm.html', {'token': token})
except PasswordReset.DoesNotExist:
messages.error(request, 'Invalid or expired reset token')
return redirect('account_reset_password')

0
companies/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

17
companies/admin.py Normal file
View File

@@ -0,0 +1,17 @@
from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin
from .models import Company, Manufacturer
@admin.register(Company)
class CompanyAdmin(SimpleHistoryAdmin):
list_display = ('name', 'headquarters', 'website', 'created_at')
search_fields = ('name', 'headquarters', 'description')
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at')
@admin.register(Manufacturer)
class ManufacturerAdmin(SimpleHistoryAdmin):
list_display = ('name', 'headquarters', 'website', 'created_at')
search_fields = ('name', 'headquarters', 'description')
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at')

9
companies/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class CompaniesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'companies'
verbose_name = 'Companies'
def ready(self):
import companies.signals # noqa

View File

@@ -0,0 +1,105 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Company',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('headquarters', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('founded_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name_plural': 'companies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Manufacturer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('headquarters', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('founded_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='HistoricalCompany',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('headquarters', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('founded_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical company',
'verbose_name_plural': 'historical companies',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalManufacturer',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('headquarters', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('founded_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical manufacturer',
'verbose_name_plural': 'historical manufacturers',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.1.2 on 2024-10-28 20:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('companies', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='company',
name='total_parks',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='company',
name='total_rides',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='historicalcompany',
name='total_parks',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='historicalcompany',
name='total_rides',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='historicalmanufacturer',
name='total_rides',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='historicalmanufacturer',
name='total_roller_coasters',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='manufacturer',
name='total_rides',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='manufacturer',
name='total_roller_coasters',
field=models.PositiveIntegerField(default=0),
),
]

View File

83
companies/models.py Normal file
View File

@@ -0,0 +1,83 @@
from django.db import models
from django.contrib.contenttypes.fields import GenericRelation
from django.utils.text import slugify
from simple_history.models import HistoricalRecords
class Company(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
headquarters = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
founded_date = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
photos = GenericRelation('media.Photo')
history = HistoricalRecords()
# Stats fields
total_parks = models.PositiveIntegerField(default=0)
total_rides = models.PositiveIntegerField(default=0)
class Meta:
verbose_name_plural = "companies"
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@classmethod
def get_by_slug(cls, slug):
"""Get company by current or historical slug"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
history = cls.history.filter(slug=slug).order_by('-history_date').first()
if history:
return cls.objects.get(id=history.id), True
raise cls.DoesNotExist("No company found with this slug")
class Manufacturer(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
headquarters = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
founded_date = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
photos = GenericRelation('media.Photo')
history = HistoricalRecords()
# Stats fields
total_rides = models.PositiveIntegerField(default=0)
total_roller_coasters = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@classmethod
def get_by_slug(cls, slug):
"""Get manufacturer by current or historical slug"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
history = cls.history.filter(slug=slug).order_by('-history_date').first()
if history:
return cls.objects.get(id=history.id), True
raise cls.DoesNotExist("No manufacturer found with this slug")

41
companies/signals.py Normal file
View File

@@ -0,0 +1,41 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from parks.models import Park
from rides.models import Ride
from .models import Company, Manufacturer
@receiver([post_save, post_delete], sender=Park)
def update_company_stats(sender, instance, **kwargs):
"""Update company statistics when a park is added, modified, or deleted."""
if instance.owner:
# Update total parks
total_parks = Park.objects.filter(owner=instance.owner).count()
total_rides = Ride.objects.filter(park__owner=instance.owner).count()
Company.objects.filter(id=instance.owner.id).update(
total_parks=total_parks,
total_rides=total_rides
)
@receiver([post_save, post_delete], sender=Ride)
def update_manufacturer_stats(sender, instance, **kwargs):
"""Update manufacturer statistics when a ride is added, modified, or deleted."""
if instance.manufacturer:
# Update total rides and roller coasters
total_rides = Ride.objects.filter(manufacturer=instance.manufacturer).count()
total_roller_coasters = Ride.objects.filter(
manufacturer=instance.manufacturer,
category='RC'
).count()
Manufacturer.objects.filter(id=instance.manufacturer.id).update(
total_rides=total_rides,
total_roller_coasters=total_roller_coasters
)
@receiver(post_save, sender=Ride)
def update_company_ride_stats(sender, instance, **kwargs):
"""Update company ride statistics when a ride is added or modified."""
if instance.park and instance.park.owner:
total_rides = Ride.objects.filter(park__owner=instance.park.owner).count()
Company.objects.filter(id=instance.park.owner.id).update(total_rides=total_rides)

3
companies/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
companies/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = 'companies'
urlpatterns = [
# Company URLs
path('', views.CompanyListView.as_view(), name='company_list'),
path('<slug:slug>/', views.CompanyDetailView.as_view(), name='company_detail'),
# Manufacturer URLs
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/<slug:slug>/', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'),
]

105
companies/views.py Normal file
View File

@@ -0,0 +1,105 @@
from django.views.generic import DetailView, ListView
from django.shortcuts import get_object_or_404
from .models import Company, Manufacturer
from rides.models import Ride
from parks.models import Park
from core.views import SlugRedirectMixin
class CompanyDetailView(SlugRedirectMixin, DetailView):
model = Company
template_name = 'companies/company_detail.html'
context_object_name = 'company'
def get_object(self, queryset=None):
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
# Try to get by current or historical slug
return self.model.get_by_slug(slug)[0]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['parks'] = Park.objects.filter(
owner=self.object
).select_related('owner')
return context
def get_redirect_url_pattern(self):
return 'company_detail'
class ManufacturerDetailView(SlugRedirectMixin, DetailView):
model = Manufacturer
template_name = 'companies/manufacturer_detail.html'
context_object_name = 'manufacturer'
def get_object(self, queryset=None):
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
# Try to get by current or historical slug
return self.model.get_by_slug(slug)[0]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['rides'] = Ride.objects.filter(
manufacturer=self.object
).select_related('park', 'coaster_stats')
return context
def get_redirect_url_pattern(self):
return 'manufacturer_detail'
class CompanyListView(ListView):
model = Company
template_name = 'companies/company_list.html'
context_object_name = 'companies'
paginate_by = 12
def get_queryset(self):
queryset = Company.objects.all()
# Filter by country if specified
country = self.request.GET.get('country')
if country:
queryset = queryset.filter(headquarters__icontains=country)
# Search by name if specified
search = self.request.GET.get('search')
if search:
queryset = queryset.filter(name__icontains=search)
return queryset.order_by('name')
class ManufacturerListView(ListView):
model = Manufacturer
template_name = 'companies/manufacturer_list.html'
context_object_name = 'manufacturers'
paginate_by = 12
def get_queryset(self):
queryset = Manufacturer.objects.all()
# Filter by country if specified
country = self.request.GET.get('country')
if country:
queryset = queryset.filter(headquarters__icontains=country)
# Search by name if specified
search = self.request.GET.get('search')
if search:
queryset = queryset.filter(name__icontains=search)
return queryset.order_by('name')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Add stats for filtering
context['total_manufacturers'] = self.model.objects.count()
context['total_rides'] = Ride.objects.filter(
manufacturer__isnull=False
).count()
context['total_roller_coasters'] = Ride.objects.filter(
manufacturer__isnull=False,
category='RC'
).count()
return context

6
cookiejar Normal file
View File

@@ -0,0 +1,6 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1731262680 sessionid angfimq8dc7q8qmn064ud2s2svheq5es
localhost FALSE / FALSE 1761502680 csrftoken 8C8T7QuLCNRoSYeothorKYe6PYadNtOO

0
core/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

34
core/admin.py Normal file
View File

@@ -0,0 +1,34 @@
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.utils.html import format_html
from .models import SlugHistory
@admin.register(SlugHistory)
class SlugHistoryAdmin(admin.ModelAdmin):
list_display = ['content_object_link', 'old_slug', 'created_at']
list_filter = ['content_type', 'created_at']
search_fields = ['old_slug', 'object_id']
readonly_fields = ['content_type', 'object_id', 'old_slug', 'created_at']
date_hierarchy = 'created_at'
ordering = ['-created_at']
def content_object_link(self, obj):
"""Create a link to the related object's admin page"""
try:
url = obj.content_object.get_absolute_url()
return format_html(
'<a href="{}">{}</a>',
url,
str(obj.content_object)
)
except (AttributeError, ValueError):
return str(obj.content_object)
content_object_link.short_description = 'Object'
def has_add_permission(self, request):
"""Disable manual creation of slug history records"""
return False
def has_change_permission(self, request, obj=None):
"""Disable editing of slug history records"""
return False

5
core/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='SlugHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.CharField(max_length=50)),
('old_slug', models.SlugField(max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
],
options={
'verbose_name_plural': 'Slug histories',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['content_type', 'object_id'], name='core_slughi_content_8bbf56_idx'), models.Index(fields=['old_slug'], name='core_slughi_old_slu_aaef7f_idx')],
},
),
]

View File

Binary file not shown.

92
core/models.py Normal file
View File

@@ -0,0 +1,92 @@
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify
class SlugHistory(models.Model):
"""
Model for tracking slug changes across all models that use slugs.
Uses generic relations to work with any model.
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.CharField(max_length=50) # Using CharField to work with our custom IDs
content_object = GenericForeignKey('content_type', 'object_id')
old_slug = models.SlugField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=['old_slug']),
]
verbose_name_plural = 'Slug histories'
ordering = ['-created_at']
def __str__(self):
return f"Old slug '{self.old_slug}' for {self.content_object}"
class SluggedModel(models.Model):
"""
Abstract base model that provides slug functionality with history tracking.
"""
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
class Meta:
abstract = True
def save(self, *args, **kwargs):
# Get the current instance from DB if it exists
if self.pk:
try:
old_instance = self.__class__.objects.get(pk=self.pk)
# If slug has changed, save the old one to history
if old_instance.slug != self.slug:
SlugHistory.objects.create(
content_type=ContentType.objects.get_for_model(self),
object_id=getattr(self, self.get_id_field_name()),
old_slug=old_instance.slug
)
except self.__class__.DoesNotExist:
pass
# Generate slug if not set
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_id_field_name(self):
"""
Returns the name of the read-only ID field for this model.
Should be overridden by subclasses.
"""
raise NotImplementedError(
"Subclasses of SluggedModel must implement get_id_field_name()"
)
@classmethod
def get_by_slug(cls, slug):
"""
Get an object by its current or historical slug.
Returns (object, is_old_slug) tuple.
"""
try:
# Try to get by current slug first
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Try to find in slug history
history = SlugHistory.objects.filter(
content_type=ContentType.objects.get_for_model(cls),
old_slug=slug
).order_by('-created_at').first()
if history:
return cls.objects.get(
**{cls.get_id_field_name(): history.object_id}
), True
raise cls.DoesNotExist(
f"{cls.__name__} with slug '{slug}' does not exist"
)

3
core/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

43
core/views.py Normal file
View File

@@ -0,0 +1,43 @@
from django.shortcuts import redirect
from django.urls import reverse
class SlugRedirectMixin:
"""
Mixin that handles redirects for old slugs.
Requires the model to inherit from SluggedModel.
"""
def get(self, request, *args, **kwargs):
# Get the object using current or historical slug
try:
self.object = self.get_object()
# Check if we used an old slug
current_slug = kwargs.get(self.slug_url_kwarg)
if current_slug != self.object.slug:
# Get the URL pattern name from the view
url_pattern = self.get_redirect_url_pattern()
# Build kwargs for reverse()
reverse_kwargs = self.get_redirect_url_kwargs()
# Redirect to the current slug URL
return redirect(
reverse(url_pattern, kwargs=reverse_kwargs),
permanent=True
)
return super().get(request, *args, **kwargs)
except self.model.DoesNotExist:
return super().get(request, *args, **kwargs)
def get_redirect_url_pattern(self):
"""
Get the URL pattern name for redirects.
Should be overridden by subclasses.
"""
raise NotImplementedError(
"Subclasses must implement get_redirect_url_pattern()"
)
def get_redirect_url_kwargs(self):
"""
Get the kwargs for reverse() when redirecting.
Should be overridden by subclasses if they need custom kwargs.
"""
return {self.slug_url_kwarg: self.object.slug}

View File

@@ -0,0 +1,150 @@
# Authentication System Setup
## Overview
This document outlines the setup of the authentication system, including both social and regular authentication.
## Backend Changes
### 1. Package Installation
```bash
pip install django-allauth==0.65.1 dj-rest-auth==6.0.0 djangorestframework==3.15.2 django-cors-headers==4.5.0
```
### 2. Configuration Files Modified
- thrillwiki/settings.py
- Added authentication apps
- Configured REST Framework
- Added CORS settings
- Added social auth providers
- Updated redirect URLs
- thrillwiki/urls.py
- Added dj-rest-auth URLs
- Added social auth URLs
### 3. New Files Created
- accounts/adapters.py
- Custom social account adapter
- Handles missing emails
- Sets profile pictures from social providers
### 4. Modified Files
- accounts/views.py
- Added email collection endpoint
- Updated authentication views
- accounts/urls.py
- Added new authentication endpoints
## Frontend Changes
### 1. Package Installation
```bash
npm install react-router-dom@6 axios@latest @react-oauth/google@latest
```
### 2. New Components Created
- src/contexts/AuthContext.tsx
- src/contexts/AuthProvider.tsx
- src/pages/Login.tsx
- src/pages/DiscordRedirect.tsx
- src/pages/EmailRequired.tsx
### 3. New Assets
- public/google-icon.svg
- public/discord-icon.svg
### 4. Modified Files
- src/App.tsx
- Added Google OAuth provider
- Added new routes
- src/api/client.ts
- Added authentication endpoints
- Added token handling
## Development Environment Setup
### Backend Setup
1. Create ***REMOVED*** file:
```env
DJANGO_SECRET_KEY=your_secret_key
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
CORS_ALLOWED_ORIGINS=http://localhost:5173
# OAuth Credentials
GOOGLE_OAUTH2_CLIENT_ID=your_google_client_id
GOOGLE_OAUTH2_CLIENT_SECRET=your_google_client_secret
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
# Database
DB_NAME=thrillwiki
DB_USER=postgres
DB_PASSWORD=postgres
DB_HOST=localhost
DB_PORT=5432
```
2. Run migrations:
```bash
python manage.py makemigrations
python manage.py migrate
```
### Frontend Setup
1. Create ***REMOVED*** file:
```env
VITE_API_URL=http://localhost:8000
VITE_GOOGLE_CLIENT_ID=your_google_client_id
VITE_DISCORD_CLIENT_ID=your_discord_client_id
```
2. Install dependencies:
```bash
cd frontend
npm install
```
## Testing Instructions
### Backend Testing
1. Start Django development server:
```bash
python manage.py runserver
```
2. Test endpoints:
- Regular auth: http://localhost:8000/api/auth/login/
- Social auth: http://localhost:8000/api/auth/google/login/
- User info: http://localhost:8000/api/auth/user/
### Frontend Testing
1. Start Vite development server:
```bash
cd frontend
npm run dev
```
2. Test flows:
- Regular login: http://localhost:5173/login
- Google login: Click "Continue with Google"
- Discord login: Click "Continue with Discord"
- Protected route: http://localhost:5173/settings
## Testing Checklist
- [ ] Regular login/registration
- [ ] Google OAuth flow
- [ ] Discord OAuth flow
- [ ] Email collection for social auth
- [ ] Profile picture import
- [ ] Protected route access
- [ ] Token persistence
- [ ] Error handling
- [ ] Logout functionality
## Notes
- Ensure all OAuth credentials are properly set up in Google Cloud Console and Discord Developer Portal
- Test all flows in incognito mode to avoid cached credentials
- Verify CSRF protection is working
- Check token expiration handling
- Test error scenarios (network issues, invalid credentials)

View File

@@ -0,0 +1,37 @@
# Changes Made - February 14, 2024
## Reactivated Removal
- Removed all reactivated-related files and configurations
- Cleaned up old client directory and unused configuration files
## Frontend Updates
- Updated to latest versions of all packages including Vite, React, and Material UI
- Configured Vite for optimal development experience
- Set up proper CORS and CSRF settings for Vite development server
- Improved build configuration with proper chunking and optimization
- Removed webpack configuration in favor of Vite
## Development Environment
- Created new development startup script (dev.sh)
- Updated frontend environment variables
- Configured HMR (Hot Module Replacement) for better development experience
- Set up proper proxy configuration for API and media files
## Configuration Updates
- Updated Django settings to work with Vite development server
- Added proper CORS and CSRF configurations for development
- Improved authentication backend configuration
## How to Run Development Environment
1. Ensure PostgreSQL is running and database is created
2. Set up your ***REMOVED*** file with necessary environment variables
3. Run migrations: `python manage.py migrate`
4. Install frontend dependencies: `cd frontend && npm install`
5. Start development servers: `./dev.sh`
The development environment will start both Django (port 8000) and Vite (port 5173) servers and open the application in your default browser.
## Next Steps
- Set up Netlify configuration for frontend deployment
- Configure production environment variables
- Set up CI/CD pipeline

View File

@@ -0,0 +1,143 @@
# Frontend Setup - February 14, 2024
## Technology Stack
### Core Technologies
- React 18.2.0
- TypeScript 5.2.2
- Material UI 5.14.17
- React Router 6.18.0
### Build Tools
- Webpack 5.89.0
- Babel 7.23.2
- CSS Loader 6.8.1
- Style Loader 3.3.3
### Development Tools
- Webpack Dev Server 4.15.1
- React Refresh Webpack Plugin 0.5.11
- TypeScript Compiler
- ESLint
## Features Implemented
### Core Features
1. Authentication
- Login/Register pages
- Social authentication support
- Protected routes
- User role management
2. Theme System
- Dark/Light mode toggle
- System preference detection
- Theme persistence
- Custom Material UI theme
3. Navigation
- Responsive navbar
- Mobile hamburger menu
- Search functionality
- User menu
4. Park Management
- Park listing with filters
- Park details page
- Ride listing
- Ride details page
5. User Features
- Profile pages
- Ride credits tracking
- Review system
- Photo uploads
### Technical Features
1. Performance
- Code splitting with React.lazy()
- Route-based chunking
- Image optimization
- Webpack optimization
2. Type Safety
- Full TypeScript integration
- Type-safe API calls
- Interface definitions
- Strict type checking
3. State Management
- React hooks
- Context API
- Local storage integration
- Form state management
4. UI/UX
- Responsive design
- Loading states
- Error boundaries
- Toast notifications
## Project Structure
```
frontend/
├── src/
│ ├── components/ # Reusable UI components
│ ├── pages/ # Route components
│ ├── hooks/ # Custom React hooks
│ ├── api/ # API client and utilities
│ ├── types/ # TypeScript definitions
│ └── utils/ # Helper functions
├── public/ # Static assets
└── webpack.config.js # Build configuration
```
## Development Workflow
1. Start Development Server:
```bash
npm start
```
2. Build for Production:
```bash
npm run build
```
3. Type Checking:
```bash
npm run type-check
```
4. Linting:
```bash
npm run lint
```
## Next Steps
1. Implement Edit System
- Inline editing for parks/rides
- Edit history tracking
- Moderation workflow
2. Review System
- Review submission
- Rating system
- Review moderation
3. Photo Management
- Photo upload
- Gallery system
- Photo moderation
4. Admin Interface
- User management
- Content moderation
- Statistics dashboard
5. Testing
- Unit tests
- Integration tests
- End-to-end tests

View File

@@ -0,0 +1,78 @@
# ThrillWiki Initial Setup
## Project Overview
ThrillWiki is a database website focused on rides and attractions in the amusement and theme park industries. The site features detailed statistics, photos, and user reviews for parks and rides worldwide.
## Technical Stack
- Backend: Django 5.1.2
- Frontend: React + Material UI + Alpine.js + HTMX
- Database: PostgreSQL
- Authentication: django-allauth (with Discord and Google OAuth support)
- Email: ForwardEmail.net SMTP
## Key Features
- Full authentication system with social login support
- Responsive design for desktop and mobile
- Light/dark theme support
- Rich media support for ride and park photos
- User review system with average ratings
- Inline editing for authenticated users
- Page history tracking
- Advanced search and filtering capabilities
## Project Structure
```
thrillwiki/
├── accounts/ # User authentication and profiles
├── api/ # REST API endpoints
├── docs/ # Project documentation
├── frontend/ # React frontend application
├── media/ # User-uploaded content
├── parks/ # Park-related models and views
├── reviews/ # User reviews functionality
├── rides/ # Ride-related models and views
├── static/ # Static assets
├── templates/ # Django templates
└── thrillwiki/ # Project settings and core configuration
```
## Setup Instructions
1. Create and activate a virtual environment:
```bash
python -m venv venv
source venv/bin/activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Configure environment variables:
- Copy ***REMOVED***.example to ***REMOVED***
- Update the variables with your specific values
4. Set up the database:
```bash
python manage.py migrate
```
5. Create a superuser:
```bash
python manage.py createsuperuser
```
6. Run the development server:
```bash
python manage.py runserver
```
## Next Steps
- [ ] Implement user models and authentication views
- [ ] Create park and ride models
- [ ] Set up review system
- [ ] Implement frontend components
- [ ] Configure social authentication
- [ ] Set up email verification
- [ ] Implement search and filtering
- [ ] Add media handling

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

36
email_service/admin.py Normal file
View File

@@ -0,0 +1,36 @@
from django.contrib import admin
from django.contrib.sites.models import Site
from django.contrib.sites.shortcuts import get_current_site
from .models import EmailConfiguration
@admin.register(EmailConfiguration)
class EmailConfigurationAdmin(admin.ModelAdmin):
list_display = ('site', 'from_name', 'from_email', 'reply_to', 'updated_at')
list_select_related = ('site',)
search_fields = ('site__domain', 'from_name', 'from_email', 'reply_to')
readonly_fields = ('created_at', 'updated_at')
fieldsets = (
(None, {
'fields': ('site',)
}),
('Email Settings', {
'fields': (
'api_key',
('from_name', 'from_email'),
'reply_to'
),
'description': 'Configure the email settings. The From field in emails will appear as "From Name <from@email.com>"'
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
})
)
def get_queryset(self, request):
return super().get_queryset(request).select_related('site')
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "site":
kwargs["queryset"] = Site.objects.all().order_by('domain')
return super().formfield_for_foreignkey(db_field, request, **kwargs)

6
email_service/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmailServiceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "email_service"

91
email_service/backends.py Normal file
View File

@@ -0,0 +1,91 @@
from django.core.mail.backends.base import BaseEmailBackend
from django.contrib.sites.shortcuts import get_current_site
from django.core.mail.message import sanitize_address
from .services import EmailService
from .models import EmailConfiguration
class ForwardEmailBackend(BaseEmailBackend):
def __init__(self, fail_silently=False, **kwargs):
super().__init__(fail_silently=fail_silently)
self.site = kwargs.get('site', None)
def send_messages(self, email_messages):
"""
Send one or more EmailMessage objects and return the number of email
messages sent.
"""
if not email_messages:
return 0
num_sent = 0
for message in email_messages:
try:
sent = self._send(message)
if sent:
num_sent += 1
except Exception as e:
if not self.fail_silently:
raise
return num_sent
def _send(self, email_message):
"""Send an EmailMessage object."""
if not email_message.recipients():
return False
# Get the first recipient (ForwardEmail API sends to one recipient at a time)
to_email = email_message.to[0]
# Get site from connection or instance
if hasattr(email_message, 'connection') and hasattr(email_message.connection, 'site'):
site = email_message.connection.site
else:
site = self.site
if not site:
raise ValueError("Either request or site must be provided")
# Get the site's email configuration
try:
config = EmailConfiguration.objects.get(site=site)
except EmailConfiguration.DoesNotExist:
raise ValueError(f"Email configuration not found for site: {site.domain}")
# Get the from email, falling back to site's default if not provided
if email_message.from_email:
from_email = sanitize_address(email_message.from_email, email_message.encoding)
else:
from_email = config.default_from_email
# Extract clean email address
from_email = EmailService.extract_email(from_email)
# Get reply-to from message headers or use default
reply_to = None
if hasattr(email_message, 'reply_to') and email_message.reply_to:
reply_to = email_message.reply_to[0]
elif hasattr(email_message, 'extra_headers') and 'Reply-To' in email_message.extra_headers:
reply_to = email_message.extra_headers['Reply-To']
# Get message content
if email_message.content_subtype == 'html':
# If it's HTML content, we'll send it as text for now
# You could extend this to support HTML emails if needed
text = email_message.body
else:
text = email_message.body
try:
EmailService.send_email(
to=to_email,
subject=email_message.subject,
text=text,
from_email=from_email,
reply_to=reply_to,
site=site
)
return True
except Exception as e:
if not self.fail_silently:
raise
return False

View File

@@ -0,0 +1,150 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.test import RequestFactory, Client
from allauth.account.models import EmailAddress
from accounts.adapters import CustomAccountAdapter
from email_service.services import EmailService
from django.conf import settings
import uuid
User = get_user_model()
class Command(BaseCommand):
help = 'Test all email flows in the application'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.factory = RequestFactory()
self.client = Client(enforce_csrf_checks=False) # Disable CSRF for testing
self.adapter = CustomAccountAdapter()
self.site = Site.objects.get_current()
# Generate unique test data
unique_id = str(uuid.uuid4())[:8]
self.test_username = f'testuser_{unique_id}'
self.test_email = f'test_{unique_id}@thrillwiki.com'
self.test_[PASSWORD-REMOVED]"
self.new_[PASSWORD-REMOVED]"
# Add testserver to ALLOWED_HOSTS
if 'testserver' not in settings.ALLOWED_HOSTS:
settings.ALLOWED_HOSTS.append('testserver')
def handle(self, *args, **options):
self.stdout.write('Starting email flow tests...\n')
# Clean up any existing test users
User.objects.filter(email__endswith='@thrillwiki.com').delete()
# Test registration email
self.test_registration()
# Create a test user for other flows
user = User.objects.create_user(
username=f'testuser2_{str(uuid.uuid4())[:8]}',
email=f'test2_{str(uuid.uuid4())[:8]}@thrillwiki.com',
password=self.test_password
)
EmailAddress.objects.create(
user=user,
email=user.email,
primary=True,
verified=True
)
# Log in the test user
self.client.force_login(user)
# Test other flows
self.test_password_change(user)
self.test_email_change(user)
self.test_password_reset(user)
# Cleanup
User.objects.filter(email__endswith='@thrillwiki.com').delete()
self.stdout.write(self.style.SUCCESS('All email flow tests completed!\n'))
def test_registration(self):
"""Test registration email flow"""
self.stdout.write('Testing registration email...')
try:
# Use dj-rest-auth registration endpoint
response = self.client.post('/api/auth/registration/', {
'username': self.test_username,
'email': self.test_email,
'password1': self.test_password,
'password2': self.test_password
}, content_type='application/json')
if response.status_code in [200, 201, 204]:
self.stdout.write(self.style.SUCCESS('Registration email test passed!\n'))
else:
self.stdout.write(
self.style.WARNING(
f'Registration returned status {response.status_code}: {response.content.decode()}\n'
)
)
except Exception as e:
self.stdout.write(self.style.ERROR(f'Registration email test failed: {str(e)}\n'))
def test_password_change(self, user):
"""Test password change using dj-rest-auth"""
self.stdout.write('Testing password change email...')
try:
response = self.client.post('/api/auth/password/change/', {
'old_password': self.test_password,
'new_password1': self.new_password,
'new_password2': self.new_password
}, content_type='application/json')
if response.status_code == 200:
self.stdout.write(self.style.SUCCESS('Password change email test passed!\n'))
else:
self.stdout.write(
self.style.WARNING(
f'Password change returned status {response.status_code}: {response.content.decode()}\n'
)
)
except Exception as e:
self.stdout.write(self.style.ERROR(f'Password change email test failed: {str(e)}\n'))
def test_email_change(self, user):
"""Test email change verification"""
self.stdout.write('Testing email change verification...')
try:
new_email = f'newemail_{str(uuid.uuid4())[:8]}@thrillwiki.com'
response = self.client.post('/api/auth/email/', {
'email': new_email
}, content_type='application/json')
if response.status_code == 200:
self.stdout.write(self.style.SUCCESS('Email change verification test passed!\n'))
else:
self.stdout.write(
self.style.WARNING(
f'Email change returned status {response.status_code}: {response.content.decode()}\n'
)
)
except Exception as e:
self.stdout.write(self.style.ERROR(f'Email change verification test failed: {str(e)}\n'))
def test_password_reset(self, user):
"""Test password reset using dj-rest-auth"""
self.stdout.write('Testing password reset email...')
try:
# Request password reset
response = self.client.post('/api/auth/password/reset/', {
'email': user.email
}, content_type='application/json')
if response.status_code == 200:
self.stdout.write(self.style.SUCCESS('Password reset email test passed!\n'))
else:
self.stdout.write(
self.style.WARNING(
f'Password reset returned status {response.status_code}: {response.content.decode()}\n'
)
)
except Exception as e:
self.stdout.write(self.style.ERROR(f'Password reset email test failed: {str(e)}\n'))

View File

@@ -0,0 +1,213 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.core.mail import send_mail, get_connection
from django.conf import settings
import requests
import json
import os
from email_service.models import EmailConfiguration
from email_service.services import EmailService
from email_service.backends import ForwardEmailBackend
class Command(BaseCommand):
help = 'Test the email service functionality'
def add_arguments(self, parser):
parser.add_argument(
'--to',
type=str,
help='Recipient email address (optional, defaults to current user\'s email)',
)
parser.add_argument(
'--api-key',
type=str,
help='ForwardEmail API key (optional, will use configured value)',
)
parser.add_argument(
'--from-email',
type=str,
help='Sender email address (optional, will use configured value)',
)
def get_config(self):
"""Get email configuration from database or environment"""
try:
site = Site.objects.get(id=settings.SITE_ID)
config = EmailConfiguration.objects.get(site=site)
return {
'api_key': config.api_key,
'from_email': config.default_from_email,
'site': site
}
except (Site.DoesNotExist, EmailConfiguration.DoesNotExist):
# Try environment variables
api_key = os***REMOVED***iron.get('FORWARD_EMAIL_API_KEY')
from_email = os***REMOVED***iron.get('FORWARD_EMAIL_FROM')
if not api_key or not from_email:
self.stdout.write(self.style.WARNING(
'No configuration found in database or environment variables.\n'
'Please either:\n'
'1. Configure email settings in Django admin, or\n'
'2. Set environment variables FORWARD_EMAIL_API_KEY and FORWARD_EMAIL_FROM, or\n'
'3. Provide --api-key and --from-email arguments'
))
return None
return {
'api_key': api_key,
'from_email': from_email,
'site': Site.objects.get(id=settings.SITE_ID)
}
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Starting email service tests...'))
# Get configuration
config = self.get_config()
if not config and not (options['api_key'] and options['from_email']):
self.stdout.write(self.style.ERROR('No email configuration available. Tests aborted.'))
return
# Use provided values or fall back to config
api_key = options['api_key'] or config['api_key']
from_email = options['from_email'] or config['from_email']
site = config['site']
# If no recipient specified, use the from_email address for testing
to_email = options['to'] or 'test@thrillwiki.com'
self.stdout.write(self.style.SUCCESS(f'Using configuration:'))
self.stdout.write(f' From: {from_email}')
self.stdout.write(f' To: {to_email}')
self.stdout.write(f' API Key: {"*" * len(api_key)}')
self.stdout.write(f' Site: {site.domain}')
try:
# 1. Test site configuration
config = self.test_site_configuration(api_key, from_email)
# 2. Test direct service
self.test_email_service_directly(to_email, config.site)
# 3. Test API endpoint
self.test_api_endpoint(to_email)
# 4. Test Django email backend
self.test_email_backend(to_email, config.site)
self.stdout.write(self.style.SUCCESS('\nAll tests completed successfully! 🎉'))
except Exception as e:
self.stdout.write(self.style.ERROR(f'\nTests failed: {str(e)}'))
def test_site_configuration(self, api_key, from_email):
"""Test creating and retrieving site configuration"""
self.stdout.write('\nTesting site configuration...')
try:
# Get or create default site
site = Site.objects.get_or_create(
id=settings.SITE_ID,
defaults={
'domain': 'example.com',
'name': 'example.com'
}
)[0]
# Create or update email configuration
config, created = EmailConfiguration.objects.update_or_create(
site=site,
defaults={
'api_key': api_key,
'default_from_email': from_email
}
)
action = 'Created new' if created else 'Updated existing'
self.stdout.write(self.style.SUCCESS(f'{action} site configuration'))
return config
except Exception as e:
self.stdout.write(self.style.ERROR(f'✗ Site configuration failed: {str(e)}'))
raise
def test_api_endpoint(self, to_email):
"""Test sending email via the API endpoint"""
self.stdout.write('\nTesting API endpoint...')
try:
# Make request to the API endpoint
response = requests.post(
'http://127.0.0.1:8000/api/email/send-email/',
json={
'to': to_email,
'subject': 'Test Email via API',
'text': 'This is a test email sent via the API endpoint.'
},
headers={
'Content-Type': 'application/json',
}
)
if response.status_code == 200:
self.stdout.write(self.style.SUCCESS('✓ API endpoint test successful'))
else:
self.stdout.write(
self.style.ERROR(
f'✗ API endpoint test failed with status {response.status_code}: {response.text}'
)
)
raise Exception(f"API test failed: {response.text}")
except requests.exceptions.ConnectionError:
self.stdout.write(
self.style.ERROR(
'✗ API endpoint test failed: Could not connect to server. '
'Make sure the Django development server is running.'
)
)
raise Exception("Could not connect to Django server")
except Exception as e:
self.stdout.write(self.style.ERROR(f'✗ API endpoint test failed: {str(e)}'))
raise
def test_email_backend(self, to_email, site):
"""Test sending email via Django's email backend"""
self.stdout.write('\nTesting Django email backend...')
try:
# Create a connection with site context
backend = ForwardEmailBackend(fail_silently=False, site=site)
# Debug output
self.stdout.write(f' Debug: Using from_email: {site.email_config.default_from_email}')
self.stdout.write(f' Debug: Using to_email: {to_email}')
send_mail(
subject='Test Email via Backend',
message='This is a test email sent via the Django email backend.',
from_email=site.email_config.default_from_email, # Explicitly set from_email
recipient_list=[to_email],
fail_silently=False,
connection=backend
)
self.stdout.write(self.style.SUCCESS('✓ Email backend test successful'))
except Exception as e:
self.stdout.write(self.style.ERROR(f'✗ Email backend test failed: {str(e)}'))
raise
def test_email_service_directly(self, to_email, site):
"""Test sending email directly via EmailService"""
self.stdout.write('\nTesting EmailService directly...')
try:
response = EmailService.send_email(
to=to_email,
subject='Test Email via Service',
text='This is a test email sent directly via the EmailService.',
site=site
)
self.stdout.write(self.style.SUCCESS('✓ Direct EmailService test successful'))
return response
except Exception as e:
self.stdout.write(self.style.ERROR(f'✗ Direct EmailService test failed: {str(e)}'))
raise

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('sites', '0002_alter_domain_unique'),
]
operations = [
migrations.CreateModel(
name='EmailConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('api_key', models.CharField(max_length=255)),
('from_email', models.EmailField(max_length=254)),
('from_name', models.CharField(help_text='The name that will appear in the From field of emails', max_length=255)),
('reply_to', models.EmailField(max_length=254)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
],
options={
'verbose_name': 'Email Configuration',
'verbose_name_plural': 'Email Configurations',
},
),
]

View File

Some files were not shown because too many files have changed in this diff Show More