Skip to content

Commit 0a196f5

Browse files
SantiagoSuHeclaude
andcommitted
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 <noreply@anthropic.com>
1 parent d5111c7 commit 0a196f5

4 files changed

Lines changed: 257 additions & 34 deletions

File tree

openedx/core/djangoapps/profile_images/images.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55

66
import binascii
7+
import hashlib
78
from collections import namedtuple
89
from contextlib import closing
910
from io import BytesIO
@@ -12,7 +13,7 @@
1213
from django.conf import settings
1314
from django.core.files.base import ContentFile
1415
from django.utils.translation import gettext as _
15-
from PIL import Image
16+
from PIL import Image, ImageDraw, ImageFont
1617

1718
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
1819

@@ -39,6 +40,101 @@
3940
}
4041

4142

43+
_AVATAR_COLORS = [
44+
'#1565C0', '#2E7D32', '#6A1B9A', '#C62828', '#E65100',
45+
'#00695C', '#4527A0', '#AD1457', '#0277BD', '#558B2F',
46+
]
47+
48+
_AVATAR_STORAGE_PREFIX = 'auto_avatars'
49+
50+
_AVATAR_FONT_PATHS = [
51+
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
52+
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
53+
'/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf',
54+
]
55+
56+
57+
def _get_avatar_color(username):
58+
"""Return a deterministic background color hex string for the given username."""
59+
index = int(hashlib.md5(username.encode('utf-8')).hexdigest(), 16) % len(_AVATAR_COLORS)
60+
return _AVATAR_COLORS[index]
61+
62+
63+
def _get_initials(name, username):
64+
"""
65+
Return 1-2 uppercase initials derived from name, falling back to username.
66+
"""
67+
if name and name.strip():
68+
parts = name.strip().split()
69+
if len(parts) >= 2:
70+
return f'{parts[0][0]}{parts[1][0]}'.upper()
71+
return parts[0][0].upper()
72+
return username[0].upper()
73+
74+
75+
def _hex_to_rgb(hex_color):
76+
"""Convert a hex color string to an (R, G, B) tuple."""
77+
hex_color = hex_color.lstrip('#')
78+
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
79+
80+
81+
def _draw_initials_image(initials, bg_color_hex, size):
82+
"""
83+
Return a PIL Image of a colored circle with centered white initials text.
84+
"""
85+
bg_color = _hex_to_rgb(bg_color_hex)
86+
image = Image.new('RGB', (size, size), bg_color)
87+
draw = ImageDraw.Draw(image)
88+
draw.ellipse([0, 0, size - 1, size - 1], fill=bg_color)
89+
90+
font_size = size // 2
91+
font = None
92+
for font_path in _AVATAR_FONT_PATHS:
93+
try:
94+
font = ImageFont.truetype(font_path, font_size)
95+
break
96+
except (OSError, IOError):
97+
continue
98+
if font is None:
99+
font = ImageFont.load_default()
100+
101+
bbox = draw.textbbox((0, 0), initials, font=font)
102+
text_w = bbox[2] - bbox[0]
103+
text_h = bbox[3] - bbox[1]
104+
x = (size - text_w) // 2 - bbox[0]
105+
y = (size - text_h) // 2 - bbox[1]
106+
draw.text((x, y), initials, fill=(255, 255, 255), font=font)
107+
108+
return image
109+
110+
111+
def generate_initials_image(username, name):
112+
"""
113+
Return a dict {size_display_name: url} for auto-generated initials avatar images.
114+
115+
Images are generated once and cached in storage using a content-addressable key
116+
based on username + name. If the name changes, a new image is generated
117+
automatically on the next call. Old files remain in storage as unreferenced
118+
orphans and can be cleaned up separately.
119+
"""
120+
storage = get_profile_image_storage()
121+
initials = _get_initials(name, username)
122+
bg_color = _get_avatar_color(username)
123+
cache_key = hashlib.md5(f'{username}{name or ""}'.encode('utf-8')).hexdigest()
124+
125+
urls = {}
126+
for size_display_name, size in settings.PROFILE_IMAGE_SIZES_MAP.items():
127+
filename = f'{_AVATAR_STORAGE_PREFIX}/{cache_key}_{size}.jpg'
128+
if not storage.exists(filename):
129+
image = _draw_initials_image(initials, bg_color, size)
130+
buffer = BytesIO()
131+
image.save(buffer, format='JPEG', quality=90)
132+
storage.save(filename, ContentFile(buffer.getvalue()))
133+
urls[size_display_name] = storage.url(filename)
134+
135+
return urls
136+
137+
42138
def create_profile_images(image_file, profile_image_names):
43139
"""
44140
Generates a set of image files based on image_file and stores them

openedx/core/djangoapps/profile_images/tests/test_images.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@
1919

2020
from ..exceptions import ImageValidationError
2121
from ..images import (
22+
_AVATAR_COLORS,
23+
_get_avatar_color,
2224
_get_exif_orientation,
25+
_get_initials,
2326
_get_valid_file_types,
2427
_update_exif_orientation,
2528
create_profile_images,
29+
generate_initials_image,
2630
remove_profile_images,
2731
validate_uploaded_image,
2832
)
@@ -243,3 +247,119 @@ def test_remove(self):
243247
deleted_names = [v[0][0] for v in mock_storage.delete.call_args_list]
244248
assert list(requested_sizes.values()) == deleted_names
245249
mock_storage.save.reset_mock()
250+
251+
252+
@skip_unless_lms
253+
class TestGetInitials(TestCase):
254+
"""
255+
Test _get_initials helper.
256+
"""
257+
258+
def test_two_word_name_returns_two_initials(self):
259+
assert _get_initials('John Doe', 'jdoe') == 'JD'
260+
261+
def test_three_word_name_uses_first_two_words(self):
262+
assert _get_initials('John Middle Doe', 'jdoe') == 'JM'
263+
264+
def test_one_word_name_returns_one_initial(self):
265+
assert _get_initials('John', 'jdoe') == 'J'
266+
267+
def test_empty_name_falls_back_to_username(self):
268+
assert _get_initials('', 'alice') == 'A'
269+
270+
def test_none_name_falls_back_to_username(self):
271+
assert _get_initials(None, 'alice') == 'A'
272+
273+
def test_whitespace_only_name_falls_back_to_username(self):
274+
assert _get_initials(' ', 'alice') == 'A'
275+
276+
def test_initials_are_uppercase(self):
277+
assert _get_initials('john doe', 'jdoe') == 'JD'
278+
279+
280+
@skip_unless_lms
281+
class TestGetAvatarColor(TestCase):
282+
"""
283+
Test _get_avatar_color helper.
284+
"""
285+
286+
def test_returns_hex_color_string(self):
287+
color = _get_avatar_color('testuser')
288+
assert color.startswith('#')
289+
assert len(color) == 7
290+
291+
def test_is_deterministic(self):
292+
assert _get_avatar_color('testuser') == _get_avatar_color('testuser')
293+
294+
def test_color_is_from_palette(self):
295+
color = _get_avatar_color('testuser')
296+
assert color in _AVATAR_COLORS
297+
298+
299+
@skip_unless_lms
300+
@override_settings(PROFILE_IMAGE_SIZES_MAP={'full': 500, 'small': 30})
301+
class TestGenerateInitialsImage(TestCase):
302+
"""
303+
Test generate_initials_image.
304+
"""
305+
306+
def _make_mock_storage(self, exists=False, saved_names=None):
307+
"""Return a mock storage that optionally records saved filenames."""
308+
m = mock.Mock()
309+
m.exists.return_value = exists
310+
m.url.side_effect = lambda name: f'/media/{name}'
311+
if saved_names is not None:
312+
m.save.side_effect = lambda name, _: saved_names.append(name)
313+
return m
314+
315+
def test_returns_url_for_each_configured_size(self):
316+
mock_storage = self._make_mock_storage()
317+
with mock.patch(
318+
'openedx.core.djangoapps.profile_images.images.get_profile_image_storage',
319+
return_value=mock_storage,
320+
):
321+
urls = generate_initials_image('testuser', 'John Doe')
322+
assert set(urls.keys()) == {'full', 'small'}
323+
324+
def test_saves_image_when_not_cached(self):
325+
mock_storage = self._make_mock_storage(exists=False)
326+
with mock.patch(
327+
'openedx.core.djangoapps.profile_images.images.get_profile_image_storage',
328+
return_value=mock_storage,
329+
):
330+
generate_initials_image('testuser', 'John Doe')
331+
assert mock_storage.save.call_count == 2 # one per size
332+
333+
def test_skips_save_when_already_cached(self):
334+
mock_storage = self._make_mock_storage(exists=True)
335+
with mock.patch(
336+
'openedx.core.djangoapps.profile_images.images.get_profile_image_storage',
337+
return_value=mock_storage,
338+
):
339+
generate_initials_image('testuser', 'John Doe')
340+
mock_storage.save.assert_not_called()
341+
342+
def test_name_change_produces_different_cache_key(self):
343+
"""Changing the user's name generates a new filename (cache invalidation)."""
344+
names_first = []
345+
names_second = []
346+
with mock.patch(
347+
'openedx.core.djangoapps.profile_images.images.get_profile_image_storage',
348+
return_value=self._make_mock_storage(saved_names=names_first),
349+
):
350+
generate_initials_image('testuser', 'John Doe')
351+
with mock.patch(
352+
'openedx.core.djangoapps.profile_images.images.get_profile_image_storage',
353+
return_value=self._make_mock_storage(saved_names=names_second),
354+
):
355+
generate_initials_image('testuser', 'Jane Doe')
356+
assert names_first != names_second
357+
358+
def test_filenames_use_auto_avatars_prefix(self):
359+
saved_names = []
360+
with mock.patch(
361+
'openedx.core.djangoapps.profile_images.images.get_profile_image_storage',
362+
return_value=self._make_mock_storage(saved_names=saved_names),
363+
):
364+
generate_initials_image('testuser', 'John Doe')
365+
assert all(name.startswith('auto_avatars/') for name in saved_names)

openedx/core/djangoapps/user_api/accounts/image_helpers.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,11 @@ def get_profile_image_urls_for_user(user, request=None):
133133
version=user.profile.profile_image_uploaded_at.strftime("%s"),
134134
)
135135
else:
136-
urls = _get_default_profile_image_urls()
136+
urls = _get_default_profile_image_urls(user)
137137
except UserProfile.DoesNotExist:
138138
# when user does not have profile it raises exception, when exception
139139
# occur we can simply get default image.
140-
urls = _get_default_profile_image_urls()
140+
urls = _get_default_profile_image_urls(user)
141141

142142
if request:
143143
for key, value in urls.items():
@@ -146,18 +146,15 @@ def get_profile_image_urls_for_user(user, request=None):
146146
return urls
147147

148148

149-
def _get_default_profile_image_urls():
149+
def _get_default_profile_image_urls(user):
150150
"""
151-
Returns a dict {size:url} for a complete set of default profile images,
152-
used as a placeholder when there are no user-submitted images.
153-
154-
TODO The result of this function should be memoized, but not in tests.
151+
Returns a dict {size:url} for a complete set of auto-generated initials avatar
152+
images for the given user, used as a placeholder when the user has not uploaded
153+
a profile photo.
155154
"""
156-
return _get_profile_image_urls(
157-
configuration_helpers.get_value('PROFILE_IMAGE_DEFAULT_FILENAME', settings.PROFILE_IMAGE_DEFAULT_FILENAME),
158-
staticfiles_storage,
159-
file_extension=settings.PROFILE_IMAGE_DEFAULT_FILE_EXTENSION,
160-
)
155+
from openedx.core.djangoapps.profile_images.images import generate_initials_image # noqa: PLC0415
156+
name = getattr(getattr(user, 'profile', None), 'name', None) or ''
157+
return generate_initials_image(user.username, name)
161158

162159

163160
def set_has_profile_image(username, is_uploaded, upload_dt=None):

openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import datetime
77
import hashlib
8-
from unittest.mock import patch
8+
from unittest.mock import Mock, patch
99
from zoneinfo import ZoneInfo
1010

1111
from django.test import TestCase
@@ -17,6 +17,7 @@
1717

1818
TEST_SIZES = {'full': 50, 'small': 10}
1919
TEST_PROFILE_IMAGE_UPLOAD_DT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=ZoneInfo("UTC"))
20+
_GENERATE_INITIALS_PATH = 'openedx.core.djangoapps.profile_images.images.generate_initials_image'
2021

2122

2223
@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
3839
"""
3940
Verify correct url structure.
4041
"""
41-
assert actual_url == 'http://example-storage.com/profile-images/{name}_{size}.jpg?v={version}'\
42-
.format(name=expected_name, size=expected_pixels, version=expected_version) # noqa: UP032
42+
expected = 'http://example-storage.com/profile-images/{name}_{size}.jpg?v={version}'.format(
43+
name=expected_name, size=expected_pixels, version=expected_version,
44+
)
45+
self.assertEqual(actual_url, expected)
4346

44-
def verify_default_url(self, actual_url, expected_pixels):
47+
def verify_urls(self, actual_urls, expected_name):
4548
"""
46-
Verify correct url structure for a default profile image.
47-
"""
48-
assert actual_url == f'/static/default_{expected_pixels}.png'
49-
50-
def verify_urls(self, actual_urls, expected_name, is_default=False):
51-
"""
52-
Verify correct url dictionary structure.
49+
Verify correct url dictionary structure for an uploaded profile image.
5350
"""
5451
assert set(TEST_SIZES.keys()) == set(actual_urls.keys())
5552
for size_display_name, url in actual_urls.items():
56-
if is_default:
57-
self.verify_default_url(url, TEST_SIZES[size_display_name])
58-
else:
59-
self.verify_url(
60-
url, expected_name, TEST_SIZES[size_display_name], TEST_PROFILE_IMAGE_UPLOAD_DT.strftime("%s")
61-
)
53+
self.verify_url(
54+
url, expected_name, TEST_SIZES[size_display_name], TEST_PROFILE_IMAGE_UPLOAD_DT.strftime("%s")
55+
)
6256

6357
def test_get_profile_image_urls(self):
6458
"""
65-
Tests `get_profile_image_urls_for_user`
59+
Tests `get_profile_image_urls_for_user` when the user has an uploaded image.
6660
"""
6761
self.user.profile.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOAD_DT
6862
self.user.profile.save() # pylint: disable=no-member
6963
expected_name = hashlib.md5((
7064
'secret' + str(self.user.username)).encode('utf-8')).hexdigest()
71-
actual_urls = get_profile_image_urls_for_user(self.user)
72-
self.verify_urls(actual_urls, expected_name, is_default=False)
65+
mock_storage = Mock()
66+
mock_storage.url.side_effect = lambda filename: f'http://example-storage.com/profile-images/{filename}'
67+
with patch(
68+
'openedx.core.djangoapps.user_api.accounts.image_helpers.get_profile_image_storage',
69+
return_value=mock_storage,
70+
):
71+
actual_urls = get_profile_image_urls_for_user(self.user)
72+
self.verify_urls(actual_urls, expected_name)
7373

74+
def test_get_profile_image_urls_default_uses_initials_avatar(self):
75+
"""
76+
When the user has no uploaded image, URLs are generated by generate_initials_image.
77+
"""
7478
self.user.profile.profile_image_uploaded_at = None
7579
self.user.profile.save() # pylint: disable=no-member
76-
self.verify_urls(get_profile_image_urls_for_user(self.user), 'default', is_default=True)
80+
81+
expected_urls = {size: f'/avatars/{size}.jpg' for size in TEST_SIZES}
82+
with patch(_GENERATE_INITIALS_PATH, return_value=expected_urls) as mock_gen:
83+
actual_urls = get_profile_image_urls_for_user(self.user)
84+
85+
mock_gen.assert_called_once_with(self.user.username, self.user.profile.name)
86+
assert actual_urls == expected_urls

0 commit comments

Comments
 (0)