diff --git a/accounts/__pycache__/admin.cpython-312.pyc b/accounts/__pycache__/admin.cpython-312.pyc index 3ee27ffb..d71a5097 100644 Binary files a/accounts/__pycache__/admin.cpython-312.pyc and b/accounts/__pycache__/admin.cpython-312.pyc differ diff --git a/accounts/__pycache__/models.cpython-312.pyc b/accounts/__pycache__/models.cpython-312.pyc index 54f35abd..f543d811 100644 Binary files a/accounts/__pycache__/models.cpython-312.pyc and b/accounts/__pycache__/models.cpython-312.pyc differ diff --git a/accounts/__pycache__/views.cpython-312.pyc b/accounts/__pycache__/views.cpython-312.pyc index 3f1bcf4e..afda2d42 100644 Binary files a/accounts/__pycache__/views.cpython-312.pyc and b/accounts/__pycache__/views.cpython-312.pyc differ diff --git a/accounts/admin.py b/accounts/admin.py index 034ca678..d8ecc3e1 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -34,16 +34,16 @@ class TopListItemInline(admin.TabularInline): @admin.register(User) 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') - search_fields = ('username', 'email', 'first_name', 'last_name') + search_fields = ('username', 'email') ordering = ('-date_joined',) actions = ['activate_users', 'deactivate_users', 'ban_users', 'unban_users'] inlines = [UserProfileInline] fieldsets = ( (None, {'fields': ('username', 'password')}), - ('Personal info', {'fields': ('first_name', 'last_name', 'email', 'pending_email')}), + ('Personal info', {'fields': ('email', 'pending_email')}), ('Roles and Permissions', { 'fields': ('role', 'groups', 'user_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('', obj.profile.avatar.url) + return format_html('
{}
', obj.username[0].upper()) + get_avatar.short_description = 'Avatar' + def get_status(self, obj): if obj.is_banned: return format_html('Banned') diff --git a/accounts/management/commands/generate_letter_avatars.py b/accounts/management/commands/generate_letter_avatars.py new file mode 100644 index 00000000..51fd94ba --- /dev/null +++ b/accounts/management/commands/generate_letter_avatars.py @@ -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}")) diff --git a/accounts/management/commands/regenerate_avatars.py b/accounts/management/commands/regenerate_avatars.py new file mode 100644 index 00000000..fcb94248 --- /dev/null +++ b/accounts/management/commands/regenerate_avatars.py @@ -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}")) diff --git a/accounts/models.py b/accounts/models.py index 312f357b..5f5072d6 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -3,6 +3,10 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ import random +from PIL import Image, ImageDraw, ImageFont +from io import BytesIO +import base64 +import os def generate_random_id(model_class, id_field): """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' ) - 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, @@ -61,8 +63,9 @@ class User(AbstractUser): 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 + profile = getattr(self, 'profile', None) + if profile and profile.display_name: + return profile.display_name return self.username def save(self, *args, **kwargs): @@ -106,10 +109,21 @@ class UserProfile(models.Model): flat_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): # 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) @@ -155,7 +169,7 @@ class TopList(models.Model): user = models.ForeignKey( User, 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) category = models.CharField( @@ -170,7 +184,7 @@ class TopList(models.Model): ordering = ['-updated_at'] 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): top_list = models.ForeignKey( diff --git a/accounts/views.py b/accounts/views.py index 2bfa22b2..95a75335 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -18,7 +18,7 @@ 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 accounts.models import User, PasswordReset, TopList, EmailVerification from reviews.models import Review from email_service.services import EmailService from allauth.account.views import LoginView, SignupView @@ -101,16 +101,8 @@ class ProfileView(DetailView): 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] + top_lists_queryset = TopList.objects.filter(user=user).select_related('user', 'user__profile').prefetch_related('items') + context['top_lists'] = top_lists_queryset.order_by('-created_at')[:5] return context @@ -128,8 +120,7 @@ class SettingsView(LoginRequiredMixin, TemplateView): 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) + user.profile.display_name = request.POST.get('display_name', user.profile.display_name) if 'avatar' in request.FILES: user.profile.avatar = request.FILES['avatar'] @@ -150,7 +141,37 @@ class SettingsView(LoginRequiredMixin, TemplateView): return HttpResponseRedirect(reverse('account_login')) else: 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) def request_password_reset(request): diff --git a/avatars/letters/0_avatar.png b/avatars/letters/0_avatar.png new file mode 100644 index 00000000..386a9c5a Binary files /dev/null and b/avatars/letters/0_avatar.png differ diff --git a/avatars/letters/1_avatar.png b/avatars/letters/1_avatar.png new file mode 100644 index 00000000..bf69a3ed Binary files /dev/null and b/avatars/letters/1_avatar.png differ diff --git a/avatars/letters/2_avatar.png b/avatars/letters/2_avatar.png new file mode 100644 index 00000000..992aba8e Binary files /dev/null and b/avatars/letters/2_avatar.png differ diff --git a/avatars/letters/3_avatar.png b/avatars/letters/3_avatar.png new file mode 100644 index 00000000..8b4acc8e Binary files /dev/null and b/avatars/letters/3_avatar.png differ diff --git a/avatars/letters/4_avatar.png b/avatars/letters/4_avatar.png new file mode 100644 index 00000000..db8a5fdb Binary files /dev/null and b/avatars/letters/4_avatar.png differ diff --git a/avatars/letters/5_avatar.png b/avatars/letters/5_avatar.png new file mode 100644 index 00000000..720bfb36 Binary files /dev/null and b/avatars/letters/5_avatar.png differ diff --git a/avatars/letters/6_avatar.png b/avatars/letters/6_avatar.png new file mode 100644 index 00000000..ff823d82 Binary files /dev/null and b/avatars/letters/6_avatar.png differ diff --git a/avatars/letters/7_avatar.png b/avatars/letters/7_avatar.png new file mode 100644 index 00000000..d70771f6 Binary files /dev/null and b/avatars/letters/7_avatar.png differ diff --git a/avatars/letters/8_avatar.png b/avatars/letters/8_avatar.png new file mode 100644 index 00000000..cc7e1dbc Binary files /dev/null and b/avatars/letters/8_avatar.png differ diff --git a/avatars/letters/9_avatar.png b/avatars/letters/9_avatar.png new file mode 100644 index 00000000..6fde44c8 Binary files /dev/null and b/avatars/letters/9_avatar.png differ diff --git a/avatars/letters/A_avatar.png b/avatars/letters/A_avatar.png new file mode 100644 index 00000000..b18da02c Binary files /dev/null and b/avatars/letters/A_avatar.png differ diff --git a/avatars/letters/B_avatar.png b/avatars/letters/B_avatar.png new file mode 100644 index 00000000..d66ea1b7 Binary files /dev/null and b/avatars/letters/B_avatar.png differ diff --git a/avatars/letters/C_avatar.png b/avatars/letters/C_avatar.png new file mode 100644 index 00000000..e0ccb580 Binary files /dev/null and b/avatars/letters/C_avatar.png differ diff --git a/avatars/letters/D_avatar.png b/avatars/letters/D_avatar.png new file mode 100644 index 00000000..7dd34961 Binary files /dev/null and b/avatars/letters/D_avatar.png differ diff --git a/avatars/letters/E_avatar.png b/avatars/letters/E_avatar.png new file mode 100644 index 00000000..46626957 Binary files /dev/null and b/avatars/letters/E_avatar.png differ diff --git a/avatars/letters/F_avatar.png b/avatars/letters/F_avatar.png new file mode 100644 index 00000000..adfbfc0f Binary files /dev/null and b/avatars/letters/F_avatar.png differ diff --git a/avatars/letters/G_avatar.png b/avatars/letters/G_avatar.png new file mode 100644 index 00000000..84fa26c5 Binary files /dev/null and b/avatars/letters/G_avatar.png differ diff --git a/avatars/letters/H_avatar.png b/avatars/letters/H_avatar.png new file mode 100644 index 00000000..d0847b2d Binary files /dev/null and b/avatars/letters/H_avatar.png differ diff --git a/avatars/letters/I_avatar.png b/avatars/letters/I_avatar.png new file mode 100644 index 00000000..0fb6102b Binary files /dev/null and b/avatars/letters/I_avatar.png differ diff --git a/avatars/letters/J_avatar.png b/avatars/letters/J_avatar.png new file mode 100644 index 00000000..f6bb2945 Binary files /dev/null and b/avatars/letters/J_avatar.png differ diff --git a/avatars/letters/K_avatar.png b/avatars/letters/K_avatar.png new file mode 100644 index 00000000..5a128787 Binary files /dev/null and b/avatars/letters/K_avatar.png differ diff --git a/avatars/letters/L_avatar.png b/avatars/letters/L_avatar.png new file mode 100644 index 00000000..c32f6dac Binary files /dev/null and b/avatars/letters/L_avatar.png differ diff --git a/avatars/letters/M_avatar.png b/avatars/letters/M_avatar.png new file mode 100644 index 00000000..3a41bfbf Binary files /dev/null and b/avatars/letters/M_avatar.png differ diff --git a/avatars/letters/N_avatar.png b/avatars/letters/N_avatar.png new file mode 100644 index 00000000..63134b10 Binary files /dev/null and b/avatars/letters/N_avatar.png differ diff --git a/avatars/letters/O_avatar.png b/avatars/letters/O_avatar.png new file mode 100644 index 00000000..1327bf3d Binary files /dev/null and b/avatars/letters/O_avatar.png differ diff --git a/avatars/letters/P_avatar.png b/avatars/letters/P_avatar.png new file mode 100644 index 00000000..b0ba5843 Binary files /dev/null and b/avatars/letters/P_avatar.png differ diff --git a/avatars/letters/Q_avatar.png b/avatars/letters/Q_avatar.png new file mode 100644 index 00000000..d15ca407 Binary files /dev/null and b/avatars/letters/Q_avatar.png differ diff --git a/avatars/letters/R_avatar.png b/avatars/letters/R_avatar.png new file mode 100644 index 00000000..9f68b774 Binary files /dev/null and b/avatars/letters/R_avatar.png differ diff --git a/avatars/letters/S_avatar.png b/avatars/letters/S_avatar.png new file mode 100644 index 00000000..9dc10694 Binary files /dev/null and b/avatars/letters/S_avatar.png differ diff --git a/avatars/letters/T_avatar.png b/avatars/letters/T_avatar.png new file mode 100644 index 00000000..53af421e Binary files /dev/null and b/avatars/letters/T_avatar.png differ diff --git a/avatars/letters/U_avatar.png b/avatars/letters/U_avatar.png new file mode 100644 index 00000000..50914422 Binary files /dev/null and b/avatars/letters/U_avatar.png differ diff --git a/avatars/letters/V_avatar.png b/avatars/letters/V_avatar.png new file mode 100644 index 00000000..eca19b33 Binary files /dev/null and b/avatars/letters/V_avatar.png differ diff --git a/avatars/letters/W_avatar.png b/avatars/letters/W_avatar.png new file mode 100644 index 00000000..1f8ab220 Binary files /dev/null and b/avatars/letters/W_avatar.png differ diff --git a/avatars/letters/X_avatar.png b/avatars/letters/X_avatar.png new file mode 100644 index 00000000..ce50a79d Binary files /dev/null and b/avatars/letters/X_avatar.png differ diff --git a/avatars/letters/Y_avatar.png b/avatars/letters/Y_avatar.png new file mode 100644 index 00000000..7bf3d7b6 Binary files /dev/null and b/avatars/letters/Y_avatar.png differ diff --git a/avatars/letters/Z_avatar.png b/avatars/letters/Z_avatar.png new file mode 100644 index 00000000..cad23538 Binary files /dev/null and b/avatars/letters/Z_avatar.png differ diff --git a/media/avatars/loopy.png b/media/avatars/loopy.png new file mode 100644 index 00000000..fbcebfae Binary files /dev/null and b/media/avatars/loopy.png differ diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html index 98bf2b17..36de5fab 100644 --- a/templates/accounts/profile.html +++ b/templates/accounts/profile.html @@ -4,14 +4,20 @@ {% block title %}{{ profile_user.username }}'s Profile - ThrillWiki{% endblock %} {% block content %} -
+
-
+
- {{ profile_user.username }} + class="object-cover w-24 h-24 rounded-full"> + {% else %} +
+ {{ profile_user.username.0|upper }} +
+ {% endif %}