Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion openedx/core/djangoapps/profile_images/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@


import binascii
import hashlib
from collections import namedtuple
from contextlib import closing
from io import BytesIO
Expand All @@ -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

Expand All @@ -39,6 +40,101 @@
}


_AVATAR_COLORS = [
'#1565C0', '#2E7D32', '#6A1B9A', '#C62828', '#E65100',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update to reference paragon / brand color options - may want to use the paragon label colors here (PR is still a draft so TBD)

'#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):

Check failure on line 96 in openedx/core/djangoapps/profile_images/images.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (UP024)

openedx/core/djangoapps/profile_images/images.py:96:16: UP024 Replace aliased errors with `OSError` help: Replace with builtin `OSError`
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()

Check failure on line 123 in openedx/core/djangoapps/profile_images/images.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (UP012)

openedx/core/djangoapps/profile_images/images.py:123:29: UP012 Unnecessary UTF-8 `encoding` argument to `encode` help: Remove unnecessary `encoding` argument

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
Expand Down
120 changes: 120 additions & 0 deletions openedx/core/djangoapps/profile_images/tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
21 changes: 9 additions & 12 deletions openedx/core/djangoapps/user_api/accounts/image_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
import hashlib

from django.conf import settings
from django.contrib.staticfiles.storage import staticfiles_storage

Check failure on line 9 in openedx/core/djangoapps/user_api/accounts/image_helpers.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (F401)

openedx/core/djangoapps/user_api/accounts/image_helpers.py:9:48: F401 `django.contrib.staticfiles.storage.staticfiles_storage` imported but unused help: Remove unused import: `django.contrib.staticfiles.storage.staticfiles_storage`
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.storage import default_storage, storages
from django.utils.module_loading import import_string

from common.djangoapps.student.models import UserProfile
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers

Check failure on line 15 in openedx/core/djangoapps/user_api/accounts/image_helpers.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (F401)

openedx/core/djangoapps/user_api/accounts/image_helpers.py:15:67: F401 `openedx.core.djangoapps.site_configuration.helpers` imported but unused help: Remove unused import: `openedx.core.djangoapps.site_configuration.helpers`

from ..errors import UserNotFound

Expand Down Expand Up @@ -133,11 +133,11 @@
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():
Expand All @@ -146,18 +146,15 @@
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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -38,39 +39,48 @@
"""
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,
)

Check failure on line 44 in openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (UP032)

openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py:42:20: UP032 Use f-string instead of `format` call help: Convert to f-string
self.assertEqual(actual_url, expected)

Check failure on line 45 in openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (PT009)

openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py:45:9: PT009 Use a regular `assert` instead of unittest-style `assertEqual` help: Replace `assertEqual(...)` with `assert ...`

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
Loading