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