From 0a196f5b85c80edc7927eae2c56b8d0d23a3d979 Mon Sep 17 00:00:00 2001 From: Santiago Suarez Date: Wed, 13 May 2026 00:32:57 -0500 Subject: [PATCH 1/2] feat: generate initials avatar images as default profile photo When a user has not uploaded a profile photo, generate a personalized JPEG avatar with their initials on a colored circle background instead of returning a generic static placeholder image. Images are generated on first request and cached in storage using a content-addressable key based on username + name. A name change automatically produces a new cache key and a fresh image on the next request. Co-Authored-By: Claude Sonnet 4.6 --- .../core/djangoapps/profile_images/images.py | 98 +++++++++++++- .../profile_images/tests/test_images.py | 120 ++++++++++++++++++ .../user_api/accounts/image_helpers.py | 21 ++- .../accounts/tests/test_image_helpers.py | 52 +++++--- 4 files changed, 257 insertions(+), 34 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/images.py b/openedx/core/djangoapps/profile_images/images.py index 99360a5b898b..3e9bfb85603e 100644 --- a/openedx/core/djangoapps/profile_images/images.py +++ b/openedx/core/djangoapps/profile_images/images.py @@ -4,6 +4,7 @@ import binascii +import hashlib from collections import namedtuple from contextlib import closing from io import BytesIO @@ -12,7 +13,7 @@ from django.conf import settings from django.core.files.base import ContentFile from django.utils.translation import gettext as _ -from PIL import Image +from PIL import Image, ImageDraw, ImageFont from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage @@ -39,6 +40,101 @@ } +_AVATAR_COLORS = [ + '#1565C0', '#2E7D32', '#6A1B9A', '#C62828', '#E65100', + '#00695C', '#4527A0', '#AD1457', '#0277BD', '#558B2F', +] + +_AVATAR_STORAGE_PREFIX = 'auto_avatars' + +_AVATAR_FONT_PATHS = [ + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', + '/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf', +] + + +def _get_avatar_color(username): + """Return a deterministic background color hex string for the given username.""" + index = int(hashlib.md5(username.encode('utf-8')).hexdigest(), 16) % len(_AVATAR_COLORS) + return _AVATAR_COLORS[index] + + +def _get_initials(name, username): + """ + Return 1-2 uppercase initials derived from name, falling back to username. + """ + if name and name.strip(): + parts = name.strip().split() + if len(parts) >= 2: + return f'{parts[0][0]}{parts[1][0]}'.upper() + return parts[0][0].upper() + return username[0].upper() + + +def _hex_to_rgb(hex_color): + """Convert a hex color string to an (R, G, B) tuple.""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) + + +def _draw_initials_image(initials, bg_color_hex, size): + """ + Return a PIL Image of a colored circle with centered white initials text. + """ + bg_color = _hex_to_rgb(bg_color_hex) + image = Image.new('RGB', (size, size), bg_color) + draw = ImageDraw.Draw(image) + draw.ellipse([0, 0, size - 1, size - 1], fill=bg_color) + + font_size = size // 2 + font = None + for font_path in _AVATAR_FONT_PATHS: + try: + font = ImageFont.truetype(font_path, font_size) + break + except (OSError, IOError): + continue + if font is None: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), initials, font=font) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + x = (size - text_w) // 2 - bbox[0] + y = (size - text_h) // 2 - bbox[1] + draw.text((x, y), initials, fill=(255, 255, 255), font=font) + + return image + + +def generate_initials_image(username, name): + """ + Return a dict {size_display_name: url} for auto-generated initials avatar images. + + Images are generated once and cached in storage using a content-addressable key + based on username + name. If the name changes, a new image is generated + automatically on the next call. Old files remain in storage as unreferenced + orphans and can be cleaned up separately. + """ + storage = get_profile_image_storage() + initials = _get_initials(name, username) + bg_color = _get_avatar_color(username) + cache_key = hashlib.md5(f'{username}{name or ""}'.encode('utf-8')).hexdigest() + + urls = {} + for size_display_name, size in settings.PROFILE_IMAGE_SIZES_MAP.items(): + filename = f'{_AVATAR_STORAGE_PREFIX}/{cache_key}_{size}.jpg' + if not storage.exists(filename): + image = _draw_initials_image(initials, bg_color, size) + buffer = BytesIO() + image.save(buffer, format='JPEG', quality=90) + storage.save(filename, ContentFile(buffer.getvalue())) + urls[size_display_name] = storage.url(filename) + + return urls + + def create_profile_images(image_file, profile_image_names): """ Generates a set of image files based on image_file and stores them diff --git a/openedx/core/djangoapps/profile_images/tests/test_images.py b/openedx/core/djangoapps/profile_images/tests/test_images.py index 49a8dfa528f5..4182381bf3e2 100644 --- a/openedx/core/djangoapps/profile_images/tests/test_images.py +++ b/openedx/core/djangoapps/profile_images/tests/test_images.py @@ -19,10 +19,14 @@ from ..exceptions import ImageValidationError from ..images import ( + _AVATAR_COLORS, + _get_avatar_color, _get_exif_orientation, + _get_initials, _get_valid_file_types, _update_exif_orientation, create_profile_images, + generate_initials_image, remove_profile_images, validate_uploaded_image, ) @@ -243,3 +247,119 @@ def test_remove(self): deleted_names = [v[0][0] for v in mock_storage.delete.call_args_list] assert list(requested_sizes.values()) == deleted_names mock_storage.save.reset_mock() + + +@skip_unless_lms +class TestGetInitials(TestCase): + """ + Test _get_initials helper. + """ + + def test_two_word_name_returns_two_initials(self): + assert _get_initials('John Doe', 'jdoe') == 'JD' + + def test_three_word_name_uses_first_two_words(self): + assert _get_initials('John Middle Doe', 'jdoe') == 'JM' + + def test_one_word_name_returns_one_initial(self): + assert _get_initials('John', 'jdoe') == 'J' + + def test_empty_name_falls_back_to_username(self): + assert _get_initials('', 'alice') == 'A' + + def test_none_name_falls_back_to_username(self): + assert _get_initials(None, 'alice') == 'A' + + def test_whitespace_only_name_falls_back_to_username(self): + assert _get_initials(' ', 'alice') == 'A' + + def test_initials_are_uppercase(self): + assert _get_initials('john doe', 'jdoe') == 'JD' + + +@skip_unless_lms +class TestGetAvatarColor(TestCase): + """ + Test _get_avatar_color helper. + """ + + def test_returns_hex_color_string(self): + color = _get_avatar_color('testuser') + assert color.startswith('#') + assert len(color) == 7 + + def test_is_deterministic(self): + assert _get_avatar_color('testuser') == _get_avatar_color('testuser') + + def test_color_is_from_palette(self): + color = _get_avatar_color('testuser') + assert color in _AVATAR_COLORS + + +@skip_unless_lms +@override_settings(PROFILE_IMAGE_SIZES_MAP={'full': 500, 'small': 30}) +class TestGenerateInitialsImage(TestCase): + """ + Test generate_initials_image. + """ + + def _make_mock_storage(self, exists=False, saved_names=None): + """Return a mock storage that optionally records saved filenames.""" + m = mock.Mock() + m.exists.return_value = exists + m.url.side_effect = lambda name: f'/media/{name}' + if saved_names is not None: + m.save.side_effect = lambda name, _: saved_names.append(name) + return m + + def test_returns_url_for_each_configured_size(self): + mock_storage = self._make_mock_storage() + with mock.patch( + 'openedx.core.djangoapps.profile_images.images.get_profile_image_storage', + return_value=mock_storage, + ): + urls = generate_initials_image('testuser', 'John Doe') + assert set(urls.keys()) == {'full', 'small'} + + def test_saves_image_when_not_cached(self): + mock_storage = self._make_mock_storage(exists=False) + with mock.patch( + 'openedx.core.djangoapps.profile_images.images.get_profile_image_storage', + return_value=mock_storage, + ): + generate_initials_image('testuser', 'John Doe') + assert mock_storage.save.call_count == 2 # one per size + + def test_skips_save_when_already_cached(self): + mock_storage = self._make_mock_storage(exists=True) + with mock.patch( + 'openedx.core.djangoapps.profile_images.images.get_profile_image_storage', + return_value=mock_storage, + ): + generate_initials_image('testuser', 'John Doe') + mock_storage.save.assert_not_called() + + def test_name_change_produces_different_cache_key(self): + """Changing the user's name generates a new filename (cache invalidation).""" + names_first = [] + names_second = [] + with mock.patch( + 'openedx.core.djangoapps.profile_images.images.get_profile_image_storage', + return_value=self._make_mock_storage(saved_names=names_first), + ): + generate_initials_image('testuser', 'John Doe') + with mock.patch( + 'openedx.core.djangoapps.profile_images.images.get_profile_image_storage', + return_value=self._make_mock_storage(saved_names=names_second), + ): + generate_initials_image('testuser', 'Jane Doe') + assert names_first != names_second + + def test_filenames_use_auto_avatars_prefix(self): + saved_names = [] + with mock.patch( + 'openedx.core.djangoapps.profile_images.images.get_profile_image_storage', + return_value=self._make_mock_storage(saved_names=saved_names), + ): + generate_initials_image('testuser', 'John Doe') + assert all(name.startswith('auto_avatars/') for name in saved_names) diff --git a/openedx/core/djangoapps/user_api/accounts/image_helpers.py b/openedx/core/djangoapps/user_api/accounts/image_helpers.py index bb2ff19321cb..25e128929984 100644 --- a/openedx/core/djangoapps/user_api/accounts/image_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/image_helpers.py @@ -133,11 +133,11 @@ def get_profile_image_urls_for_user(user, request=None): version=user.profile.profile_image_uploaded_at.strftime("%s"), ) else: - urls = _get_default_profile_image_urls() + urls = _get_default_profile_image_urls(user) except UserProfile.DoesNotExist: # when user does not have profile it raises exception, when exception # occur we can simply get default image. - urls = _get_default_profile_image_urls() + urls = _get_default_profile_image_urls(user) if request: for key, value in urls.items(): @@ -146,18 +146,15 @@ def get_profile_image_urls_for_user(user, request=None): return urls -def _get_default_profile_image_urls(): +def _get_default_profile_image_urls(user): """ - Returns a dict {size:url} for a complete set of default profile images, - used as a placeholder when there are no user-submitted images. - - TODO The result of this function should be memoized, but not in tests. + Returns a dict {size:url} for a complete set of auto-generated initials avatar + images for the given user, used as a placeholder when the user has not uploaded + a profile photo. """ - return _get_profile_image_urls( - configuration_helpers.get_value('PROFILE_IMAGE_DEFAULT_FILENAME', settings.PROFILE_IMAGE_DEFAULT_FILENAME), - staticfiles_storage, - file_extension=settings.PROFILE_IMAGE_DEFAULT_FILE_EXTENSION, - ) + from openedx.core.djangoapps.profile_images.images import generate_initials_image # noqa: PLC0415 + name = getattr(getattr(user, 'profile', None), 'name', None) or '' + return generate_initials_image(user.username, name) def set_has_profile_image(username, is_uploaded, upload_dt=None): diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py b/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py index d2714d5116f9..83a974c6b518 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py @@ -5,7 +5,7 @@ import datetime import hashlib -from unittest.mock import patch +from unittest.mock import Mock, patch from zoneinfo import ZoneInfo from django.test import TestCase @@ -17,6 +17,7 @@ TEST_SIZES = {'full': 50, 'small': 10} TEST_PROFILE_IMAGE_UPLOAD_DT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=ZoneInfo("UTC")) +_GENERATE_INITIALS_PATH = 'openedx.core.djangoapps.profile_images.images.generate_initials_image' @patch.dict('django.conf.settings.PROFILE_IMAGE_SIZES_MAP', TEST_SIZES, clear=True) @@ -38,39 +39,48 @@ def verify_url(self, actual_url, expected_name, expected_pixels, expected_versio """ Verify correct url structure. """ - assert actual_url == 'http://example-storage.com/profile-images/{name}_{size}.jpg?v={version}'\ - .format(name=expected_name, size=expected_pixels, version=expected_version) # noqa: UP032 + expected = 'http://example-storage.com/profile-images/{name}_{size}.jpg?v={version}'.format( + name=expected_name, size=expected_pixels, version=expected_version, + ) + self.assertEqual(actual_url, expected) - def verify_default_url(self, actual_url, expected_pixels): + def verify_urls(self, actual_urls, expected_name): """ - Verify correct url structure for a default profile image. - """ - assert actual_url == f'/static/default_{expected_pixels}.png' - - def verify_urls(self, actual_urls, expected_name, is_default=False): - """ - Verify correct url dictionary structure. + Verify correct url dictionary structure for an uploaded profile image. """ assert set(TEST_SIZES.keys()) == set(actual_urls.keys()) for size_display_name, url in actual_urls.items(): - if is_default: - self.verify_default_url(url, TEST_SIZES[size_display_name]) - else: - self.verify_url( - url, expected_name, TEST_SIZES[size_display_name], TEST_PROFILE_IMAGE_UPLOAD_DT.strftime("%s") - ) + self.verify_url( + url, expected_name, TEST_SIZES[size_display_name], TEST_PROFILE_IMAGE_UPLOAD_DT.strftime("%s") + ) def test_get_profile_image_urls(self): """ - Tests `get_profile_image_urls_for_user` + Tests `get_profile_image_urls_for_user` when the user has an uploaded image. """ self.user.profile.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOAD_DT self.user.profile.save() # pylint: disable=no-member expected_name = hashlib.md5(( 'secret' + str(self.user.username)).encode('utf-8')).hexdigest() - actual_urls = get_profile_image_urls_for_user(self.user) - self.verify_urls(actual_urls, expected_name, is_default=False) + mock_storage = Mock() + mock_storage.url.side_effect = lambda filename: f'http://example-storage.com/profile-images/{filename}' + with patch( + 'openedx.core.djangoapps.user_api.accounts.image_helpers.get_profile_image_storage', + return_value=mock_storage, + ): + actual_urls = get_profile_image_urls_for_user(self.user) + self.verify_urls(actual_urls, expected_name) + def test_get_profile_image_urls_default_uses_initials_avatar(self): + """ + When the user has no uploaded image, URLs are generated by generate_initials_image. + """ self.user.profile.profile_image_uploaded_at = None self.user.profile.save() # pylint: disable=no-member - self.verify_urls(get_profile_image_urls_for_user(self.user), 'default', is_default=True) + + expected_urls = {size: f'/avatars/{size}.jpg' for size in TEST_SIZES} + with patch(_GENERATE_INITIALS_PATH, return_value=expected_urls) as mock_gen: + actual_urls = get_profile_image_urls_for_user(self.user) + + mock_gen.assert_called_once_with(self.user.username, self.user.profile.name) + assert actual_urls == expected_urls From 60eebd4c925ced698628200334b1cc05f6cde732 Mon Sep 17 00:00:00 2001 From: Santiago Suarez Date: Thu, 4 Jun 2026 21:42:08 -0500 Subject: [PATCH 2/2] style: replace hardcoded avatar colors with Paragon light theme tokens Use hex values from the Paragon light theme design token system (primary, brand, success, info, and danger families) instead of arbitrary Material Design colors. Warning/yellow shades are excluded because they lack sufficient contrast against white text (WCAG AA). Co-Authored-By: Claude Sonnet 4.6 --- openedx/core/djangoapps/profile_images/images.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/profile_images/images.py b/openedx/core/djangoapps/profile_images/images.py index 3e9bfb85603e..6f35ecc37431 100644 --- a/openedx/core/djangoapps/profile_images/images.py +++ b/openedx/core/djangoapps/profile_images/images.py @@ -40,9 +40,20 @@ } +# Colors drawn from the Paragon light theme design tokens (primary, brand, +# success, info, and danger families). Yellow/warning shades are excluded +# because they lack sufficient contrast against white text (WCAG AA). _AVATAR_COLORS = [ - '#1565C0', '#2E7D32', '#6A1B9A', '#C62828', '#E65100', - '#00695C', '#4527A0', '#AD1457', '#0277BD', '#558B2F', + '#0A3055', # primary + '#9D0054', # brand + '#178253', # success (green) + '#006DAA', # info (teal) + '#C32D3A', # danger (red) + '#476480', # primary-400 + '#B6407F', # brand-400 + '#15754B', # success-600 + '#006299', # info-600 + '#B02934', # danger-600 ] _AVATAR_STORAGE_PREFIX = 'auto_avatars'