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