profile changes

This commit is contained in:
pacnpal
2024-11-05 23:36:50 +00:00
parent 968b0b4c81
commit 51c016d560
47 changed files with 207 additions and 37 deletions

View File

@@ -34,16 +34,16 @@ class TopListItemInline(admin.TabularInline):
@admin.register(User) @admin.register(User)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
list_display = ('username', 'email', 'get_status', 'role', 'date_joined', 'last_login', 'get_credits') list_display = ('username', 'email', 'get_avatar', 'get_status', 'role', 'date_joined', 'last_login', 'get_credits')
list_filter = ('is_active', 'is_staff', 'role', 'is_banned', 'groups', 'date_joined') list_filter = ('is_active', 'is_staff', 'role', 'is_banned', 'groups', 'date_joined')
search_fields = ('username', 'email', 'first_name', 'last_name') search_fields = ('username', 'email')
ordering = ('-date_joined',) ordering = ('-date_joined',)
actions = ['activate_users', 'deactivate_users', 'ban_users', 'unban_users'] actions = ['activate_users', 'deactivate_users', 'ban_users', 'unban_users']
inlines = [UserProfileInline] inlines = [UserProfileInline]
fieldsets = ( fieldsets = (
(None, {'fields': ('username', 'password')}), (None, {'fields': ('username', 'password')}),
('Personal info', {'fields': ('first_name', 'last_name', 'email', 'pending_email')}), ('Personal info', {'fields': ('email', 'pending_email')}),
('Roles and Permissions', { ('Roles and Permissions', {
'fields': ('role', 'groups', 'user_permissions'), 'fields': ('role', 'groups', 'user_permissions'),
'description': 'Role determines group membership. Groups determine permissions.', 'description': 'Role determines group membership. Groups determine permissions.',
@@ -67,6 +67,12 @@ class CustomUserAdmin(UserAdmin):
}), }),
) )
def get_avatar(self, obj):
if obj.profile.avatar:
return format_html('<img src="{}" width="30" height="30" style="border-radius:50%;" />', obj.profile.avatar.url)
return format_html('<div style="width:30px; height:30px; border-radius:50%; background-color:#007bff; color:white; display:flex; align-items:center; justify-content:center;">{}</div>', obj.username[0].upper())
get_avatar.short_description = 'Avatar'
def get_status(self, obj): def get_status(self, obj):
if obj.is_banned: if obj.is_banned:
return format_html('<span style="color: red;">Banned</span>') return format_html('<span style="color: red;">Banned</span>')

View File

@@ -0,0 +1,44 @@
from django.core.management.base import BaseCommand
from PIL import Image, ImageDraw, ImageFont
import os
def generate_avatar(letter):
"""Generate an avatar for a given letter or number"""
avatar_size = (100, 100)
background_color = (0, 123, 255) # Blue background
text_color = (255, 255, 255) # White text
font_size = 100
# Create a blank image with background color
image = Image.new('RGB', avatar_size, background_color)
draw = ImageDraw.Draw(image)
# Load a font
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
font = ImageFont.truetype(font_path, font_size)
# Calculate text size and position using textbbox
text_bbox = draw.textbbox((0, 0), letter, font=font)
text_width, text_height = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1]
text_position = ((avatar_size[0] - text_width) / 2, (avatar_size[1] - text_height) / 2)
# Draw the text on the image
draw.text(text_position, letter, font=font, fill=text_color)
# Ensure the avatars directory exists
avatar_dir = "avatars/letters"
if not os.path.exists(avatar_dir):
os.makedirs(avatar_dir)
# Save the image to the avatars directory
avatar_path = os.path.join(avatar_dir, f"{letter}_avatar.png")
image.save(avatar_path)
class Command(BaseCommand):
help = 'Generate avatars for letters A-Z and numbers 0-9'
def handle(self, *args, **kwargs):
characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
for char in characters:
generate_avatar(char)
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))

View File

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

View File

@@ -3,6 +3,10 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import random import random
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import base64
import os
def generate_random_id(model_class, id_field): def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed""" """Generate a random ID starting at 4 digits, expanding to 5 if needed"""
@@ -36,8 +40,6 @@ class User(AbstractUser):
help_text='Unique identifier for this user that remains constant even if the username changes' help_text='Unique identifier for this user that remains constant even if the username changes'
) )
first_name = models.CharField(_('first name'), max_length=150, default='')
last_name = models.CharField(_('last name'), max_length=150, default='')
role = models.CharField( role = models.CharField(
max_length=10, max_length=10,
choices=Roles.choices, choices=Roles.choices,
@@ -61,8 +63,9 @@ class User(AbstractUser):
def get_display_name(self): def get_display_name(self):
"""Get the user's display name, falling back to username if not set""" """Get the user's display name, falling back to username if not set"""
if hasattr(self, 'profile') and self.profile.display_name: profile = getattr(self, 'profile', None)
return self.profile.display_name if profile and profile.display_name:
return profile.display_name
return self.username return self.username
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@@ -106,10 +109,21 @@ class UserProfile(models.Model):
flat_ride_credits = models.IntegerField(default=0) flat_ride_credits = models.IntegerField(default=0)
water_ride_credits = models.IntegerField(default=0) water_ride_credits = models.IntegerField(default=0)
def get_avatar(self):
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
if self.avatar:
return self.avatar.url
first_letter = self.user.username[0].upper()
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
if os.path.exists(avatar_path):
return f"/{avatar_path}"
return "/static/images/default-avatar.png"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# If no display name is set, use the username # If no display name is set, use the username
if not self.display_name: if not self.display_name:
self.display_name = self.user.username self.display_name = self.user.username
if not self.profile_id: if not self.profile_id:
self.profile_id = generate_random_id(UserProfile, 'profile_id') self.profile_id = generate_random_id(UserProfile, 'profile_id')
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -155,7 +169,7 @@ class TopList(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
User, User,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='top_lists' related_name='top_lists' # Added related_name for User model access
) )
title = models.CharField(max_length=100) title = models.CharField(max_length=100)
category = models.CharField( category = models.CharField(
@@ -170,7 +184,7 @@ class TopList(models.Model):
ordering = ['-updated_at'] ordering = ['-updated_at']
def __str__(self): def __str__(self):
return f"{self.user.get_display_name()}'s {self.get_category_display()} Top List: {self.title}" return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
class TopListItem(models.Model): class TopListItem(models.Model):
top_list = models.ForeignKey( top_list = models.ForeignKey(

View File

@@ -18,7 +18,7 @@ from django.contrib.sites.shortcuts import get_current_site
from django.db.models import Prefetch from django.db.models import Prefetch
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from accounts.models import User, PasswordReset from accounts.models import User, PasswordReset, TopList, EmailVerification
from reviews.models import Review from reviews.models import Review
from email_service.services import EmailService from email_service.services import EmailService
from allauth.account.views import LoginView, SignupView from allauth.account.views import LoginView, SignupView
@@ -101,16 +101,8 @@ class ProfileView(DetailView):
context['recent_reviews'] = reviews_queryset context['recent_reviews'] = reviews_queryset
# Get user's top lists with optimized queries # Get user's top lists with optimized queries
context['top_lists'] = user.top_lists.select_related( top_lists_queryset = TopList.objects.filter(user=user).select_related('user', 'user__profile').prefetch_related('items')
'user', context['top_lists'] = top_lists_queryset.order_by('-created_at')[:5]
'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 return context
@@ -128,8 +120,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
if action == 'update_profile': if action == 'update_profile':
# Handle profile updates # Handle profile updates
user = request.user user = request.user
user.first_name = request.POST.get('first_name', user.first_name) user.profile.display_name = request.POST.get('display_name', user.profile.display_name)
user.last_name = request.POST.get('last_name', user.last_name)
if 'avatar' in request.FILES: if 'avatar' in request.FILES:
user.profile.avatar = request.FILES['avatar'] user.profile.avatar = request.FILES['avatar']
@@ -151,6 +142,36 @@ class SettingsView(LoginRequiredMixin, TemplateView):
else: else:
messages.error(request, 'Current password is incorrect') messages.error(request, 'Current password is incorrect')
elif action == 'change_email':
# Handle email change with verification
new_email = request.POST.get('new_email')
if new_email:
token = get_random_string(64)
EmailVerification.objects.update_or_create(
user=request.user,
defaults={'token': token}
)
site = get_current_site(request)
verification_url = reverse('verify_email', kwargs={'token': token})
context = {
'user': request.user,
'verification_url': verification_url,
'site_name': site.name,
}
email_html = render_to_string('accounts/email/verify_email.html', context)
EmailService.send_email(
to=new_email,
subject='Verify your new email address',
text='Click the link to verify your new email address',
site=site,
html=email_html
)
request.user.pending_email = new_email
request.user.save()
messages.success(request, 'Verification email sent to your new email address')
else:
messages.error(request, 'New email is required')
return self.get(request, *args, **kwargs) return self.get(request, *args, **kwargs)
def request_password_reset(request): def request_password_reset(request):

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
media/avatars/loopy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -4,14 +4,20 @@
{% block title %}{{ profile_user.username }}'s Profile - ThrillWiki{% endblock %} {% block title %}{{ profile_user.username }}'s Profile - ThrillWiki{% endblock %}
{% block content %} {% block content %}
<div class="container mx-auto px-4"> <div class="container px-4 mx-auto">
<!-- Profile Header --> <!-- Profile Header -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden"> <div class="overflow-hidden bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-6"> <div class="p-6">
<div class="flex items-center"> <div class="flex items-center">
<img src="{{ profile_user.profile.avatar.url|default:'/static/images/default-avatar.png' }}" {% if profile_user.profile.avatar %}
<img src="{{ profile_user.profile.avatar.url }}"
alt="{{ profile_user.username }}" alt="{{ profile_user.username }}"
class="w-24 h-24 rounded-full object-cover"> class="object-cover w-24 h-24 rounded-full">
{% else %}
<div class="flex items-center justify-center w-24 h-24 text-white rounded-full bg-gradient-to-br from-primary to-secondary">
{{ profile_user.username.0|upper }}
</div>
{% endif %}
<div class="ml-6"> <div class="ml-6">
<h1 class="text-2xl font-bold"> <h1 class="text-2xl font-bold">
{{ profile_user.profile.display_name|default:profile_user.username }} {{ profile_user.profile.display_name|default:profile_user.username }}
@@ -31,14 +37,14 @@
{% if profile_user.profile.bio %} {% if profile_user.profile.bio %}
<div class="mt-6"> <div class="mt-6">
<h2 class="text-lg font-semibold mb-2">About Me</h2> <h2 class="mb-2 text-lg font-semibold">About Me</h2>
<p class="text-gray-600 dark:text-gray-400">{{ profile_user.profile.bio }}</p> <p class="text-gray-600 dark:text-gray-400">{{ profile_user.profile.bio }}</p>
</div> </div>
{% endif %} {% endif %}
<!-- Social Links --> <!-- Social Links -->
{% if profile_user.profile.twitter or profile_user.profile.instagram or profile_user.profile.youtube or profile_user.profile.discord %} {% if profile_user.profile.twitter or profile_user.profile.instagram or profile_user.profile.youtube or profile_user.profile.discord %}
<div class="mt-4 flex space-x-4"> <div class="flex mt-4 space-x-4">
{% if profile_user.profile.twitter %} {% if profile_user.profile.twitter %}
<a href="{{ profile_user.profile.twitter }}" target="_blank" rel="noopener noreferrer" <a href="{{ profile_user.profile.twitter }}" target="_blank" rel="noopener noreferrer"
class="text-gray-600 dark:text-gray-400 hover:text-blue-500"> class="text-gray-600 dark:text-gray-400 hover:text-blue-500">
@@ -68,27 +74,27 @@
</div> </div>
<!-- Statistics --> <!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6"> <div class="grid grid-cols-1 gap-4 mt-6 md:grid-cols-4">
<div class="card"> <div class="card">
<div class="card-body text-center"> <div class="text-center card-body">
<div class="stat-value">{{ profile_user.profile.coaster_credits }}</div> <div class="stat-value">{{ profile_user.profile.coaster_credits }}</div>
<div class="stat-label">Coaster Credits</div> <div class="stat-label">Coaster Credits</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-body text-center"> <div class="text-center card-body">
<div class="stat-value">{{ profile_user.profile.dark_ride_credits }}</div> <div class="stat-value">{{ profile_user.profile.dark_ride_credits }}</div>
<div class="stat-label">Dark Ride Credits</div> <div class="stat-label">Dark Ride Credits</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-body text-center"> <div class="text-center card-body">
<div class="stat-value">{{ profile_user.profile.flat_ride_credits }}</div> <div class="stat-value">{{ profile_user.profile.flat_ride_credits }}</div>
<div class="stat-label">Flat Ride Credits</div> <div class="stat-label">Flat Ride Credits</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-body text-center"> <div class="text-center card-body">
<div class="stat-value">{{ profile_user.profile.water_ride_credits }}</div> <div class="stat-value">{{ profile_user.profile.water_ride_credits }}</div>
<div class="stat-label">Water Ride Credits</div> <div class="stat-label">Water Ride Credits</div>
</div> </div>
@@ -96,7 +102,7 @@
</div> </div>
<!-- Recent Activity --> <!-- Recent Activity -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6"> <div class="grid grid-cols-1 gap-6 mt-6 md:grid-cols-2">
<!-- Recent Reviews --> <!-- Recent Reviews -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -105,7 +111,7 @@
<div class="card-body"> <div class="card-body">
{% for review in recent_reviews %} {% for review in recent_reviews %}
<div class="mb-4 last:mb-0"> <div class="mb-4 last:mb-0">
<div class="flex justify-between items-start"> <div class="flex items-start justify-between">
<div> <div>
<h3 class="font-medium">{{ review.title }}</h3> <h3 class="font-medium">{{ review.title }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
@@ -113,7 +119,7 @@
</p> </p>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<span class="text-yellow-400 mr-1"></span> <span class="mr-1 text-yellow-400"></span>
<span>{{ review.rating }}/10</span> <span>{{ review.rating }}/10</span>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,68 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Settings - ThrillWiki{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<h1 class="mb-4 text-2xl font-bold">Settings</h1>
<div class="p-6 overflow-hidden bg-white rounded-lg shadow dark:bg-gray-800">
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="update_profile">
<h2 class="mb-4 text-lg font-semibold">Update Profile</h2>
<div class="mb-4">
<label for="display_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Display Name</label>
<input type="text" name="display_name" id="display_name" value="{{ user.profile.display_name }}" class="block w-full mt-1 border-gray-300 rounded-md shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="mb-4">
<label for="avatar" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar</label>
<input type="file" name="avatar" id="avatar" class="block w-full mt-1 text-gray-900 dark:text-gray-300">
</div>
<button type="submit" class="px-4 py-2 text-white bg-blue-500 rounded-md">Update Profile</button>
</form>
</div>
<div class="p-6 mt-6 overflow-hidden bg-white rounded-lg shadow dark:bg-gray-800">
<form method="POST">
{% csrf_token %}
<input type="hidden" name="action" value="change_email">
<h2 class="mb-4 text-lg font-semibold">Change Email</h2>
<div class="mb-4">
<label for="new_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Email</label>
<input type="email" name="new_email" id="new_email" class="block w-full mt-1 border-gray-300 rounded-md shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<button type="submit" class="px-4 py-2 text-white bg-blue-500 rounded-md">Change Email</button>
</form>
</div>
<div class="p-6 mt-6 overflow-hidden bg-white rounded-lg shadow dark:bg-gray-800">
<form method="POST">
{% csrf_token %}
<input type="hidden" name="action" value="change_password">
<h2 class="mb-4 text-lg font-semibold">Change Password</h2>
<div class="mb-4">
<label for="old_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Current Password</label>
<input type="password" name="old_password" id="old_password" class="block w-full mt-1 border-gray-300 rounded-md shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="mb-4">
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password</label>
<input type="password" name="new_password" id="new_password" class="block w-full mt-1 border-gray-300 rounded-md shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<button type="submit" class="px-4 py-2 text-white bg-blue-500 rounded-md">Change Password</button>
</form>
</div>
</div>
{% endblock %}