Skip to content

feat: generate initials avatar as default profile image#38638

Draft
SantiagoSuHe wants to merge 2 commits into
openedx:masterfrom
Schema-Education:feat/initials-avatar-backend
Draft

feat: generate initials avatar as default profile image#38638
SantiagoSuHe wants to merge 2 commits into
openedx:masterfrom
Schema-Education:feat/initials-avatar-backend

Conversation

@SantiagoSuHe

@SantiagoSuHe SantiagoSuHe commented May 13, 2026

Copy link
Copy Markdown

Summary

Open edX shows a generic static placeholder when a user has not uploaded a profile photo. This PR replaces that placeholder with a personalized avatar: a colored circle with the user's initials, similar to what Google and other platforms do.

A proposal for this feature was posted in the Open edX community forum for visibility: https://discuss.openedx.org/t/profile-avatar-modernization/19046

How it works

The avatar is generated in the backend rather than the frontend so that every consumer of get_profile_image_urls_for_user() receives it automatically, with no changes needed in individual MFEs or templates. Generating a real JPEG means the image works anywhere profile photos already work, from Django-rendered pages to the edx-user-info cookie, without additional integration.

Generation is lazy: nothing happens at account creation. The first time profile image URLs are requested for a user (for example, at login), the system calls generate_initials_image(username, name). Pillow, already required by openedx-platform for resizing uploaded photos, draws a colored JPEG and saves it to storage under a key derived from md5(username + name). Subsequent requests return the cached file directly. The initials come from the first letter of the user's first and last name, and the background color is deterministically derived from the username using the Paragon light theme design token palette, so the same user always gets the same color. If the user has no name, the first letter of their username is used instead.

Screenshot 2026-06-05 at 3 37 20 PM

How the avatar changes over time:

  • First login: the JPEG is generated and saved; the URL is returned in the edx-user-info cookie.
  • Subsequent logins: the file already exists under the same key and is returned immediately.
  • Name change: the cache key includes the name, so a new key is produced. The next request generates a fresh avatar with the updated initials.
  • Photo upload: has_profile_image becomes True and the uploaded photo is served instead.
  • Photo deletion: has_profile_image goes back to False and the initials avatar is served again.

MFE headers require two additional steps outside the scope of this PR. When a MFE loads, @edx/frontend-platform decodes the JWT cookie into a basic authenticatedUser object that does not include profile image data. To surface the avatar in a MFE header, the MFE must pass hydrateAuthenticatedUser: true to initialize(), which fetches the full account payload from the accounts API. Additionally, openedx/frontend-platform#885 maps profile_image.image_url_full into authenticatedUser.avatar during hydration. Both conditions are required: hydration without the mapping leaves the avatar field empty, and the mapping without hydration means the API call never happens.

Companion PRs

Repository PR What it does
openedx/frontend-app-profile #1351 Fixes the ProfileAvatar component to render the URL returned by the backend instead of always showing the SVG placeholder when has_image is false
openedx/frontend-platform #885 Maps profile_image.image_url_full to authenticatedUser.avatar during hydration so MFE headers receive the avatar URL

Changes

openedx/core/djangoapps/profile_images/images.py

  • Added generate_initials_image(username, name) which generates a JPEG avatar for each configured size using Pillow
  • Images are cached in storage using a content-addressable key based on md5(username + name), generated only once per username/name combination
  • A name change produces a new cache key and a fresh image on the next request. The old file remains in storage as an unreferenced orphan
  • Background color is deterministically derived from the username using a 10-color palette sourced from the Paragon light theme design tokens (primary, brand, success, info, and danger families)
  • Falls back to PIL's built-in default font if system fonts are not available

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

  • Updated _get_default_profile_image_urls() to call generate_initials_image() instead of returning static placeholder URLs
  • UserProfile.has_profile_image semantics are unchanged -- it remains False until the user explicitly uploads a photo

Testing

Added tests for _get_initials(), _get_avatar_color(), and generate_initials_image() covering edge cases (empty name, None, whitespace, name change cache invalidation, storage caching behavior).

Updated existing tests in test_image_helpers.py to mock generate_initials_image for the default image path.

Dependencies

No new dependencies. Pillow is already a required dependency of openedx-platform.

Local MFE Testing

Tested locally against a Tutor dev environment with frontend-platform PR #885 applied.

  • frontend-app-account — Already had hydrateAuthenticatedUser: true upstream. Avatar works.
  • frontend-app-catalog — Does not have hydrateAuthenticatedUser: true upstream. After adding it, avatar works.
  • frontend-app-gradebook — Does not have hydrateAuthenticatedUser: true upstream. After adding it, avatar works.
  • frontend-app-learner-dashboard — Does not have hydrateAuthenticatedUser: true upstream. After adding it, avatar works.
  • frontend-app-learner-record — Does not have hydrateAuthenticatedUser: true upstream. After adding it, avatar works.
  • frontend-app-admin-console — Does not have hydrateAuthenticatedUser: true upstream. After adding it, avatar appears but renders at 500×500px — the Studio header renders <img> directly inside the button when a URL is set, bypassing Paragon's <Avatar> component which constrains the size. Needs a fix in openedx/frontend-component-header.
  • frontend-app-authoring — Does not have hydrateAuthenticatedUser: true upstream. After adding it, same Studio header size bug as admin-console.
  • frontend-app-admin-portal — Not testable locally (requires enterprise context).
  • frontend-app-learner-portal-enterprise — Not testable locally (requires enterprise microservices).
  • frontend-app-discussions — Not testable locally (forum service not included in the standard Tutor dev stack).

@SantiagoSuHe SantiagoSuHe requested a review from a team as a code owner May 13, 2026 05:46
@openedx-webhooks openedx-webhooks added open-source-contribution PR author is not from Axim or 2U core contributor PR author is a Core Contributor (who may or may not have write access to this repo). labels May 13, 2026
@openedx-webhooks

openedx-webhooks commented May 13, 2026

Copy link
Copy Markdown

Thanks for the pull request, @SantiagoSuHe!

This repository is currently maintained by @openedx/wg-maintenance-openedx-platform.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

🔘 Update the status of your PR

Your PR is currently marked as a draft. After completing the steps above, update its status by clicking "Ready for Review", or removing "WIP" from the title, as appropriate.


Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

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>
@SantiagoSuHe SantiagoSuHe force-pushed the feat/initials-avatar-backend branch from 8657283 to 0a196f5 Compare May 13, 2026 06:05


_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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hi @marcotuts, I've updated the color palette to use values from the Paragon light theme design tokens. The new palette draws from the primary, brand, success, info, and danger color families (two shades per family for variety). I excluded the warning/yellow shades since they don't provide sufficient contrast against white text (WCAG AA).

Here's a preview of the palette: https://coolors.co/palette/0a3055-9d0054-178253-006daa-c32d3a-476480-b6407f-15754b-006299-b02934

Do these colors look good to you, or would you suggest any adjustments?

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 <noreply@anthropic.com>
@SantiagoSuHe

SantiagoSuHe commented Jun 12, 2026

Copy link
Copy Markdown
Author

Update: feedback round and reconsidering the approach

After a feedback round with @edschema, we identified some weaknesses in this PR's implementation. Sharing them here along with the alternatives we are considering.

Context: how upstream works today (without this PR)

There is a pre-existing bug, independent of this PR: an uploaded profile photo is never shown in the MFE headers. Headers only read authenticatedUser.avatar, but hydrated account data lands under authenticatedUser.profileImage and nothing maps it into avatar. Most MFEs don't enable hydrateAuthenticatedUser: true anyway. frontend-component-header#670 fixed this for the Studio header only.

Fixing it requires enabling hydration in each MFE: one extra accounts API call per logged-in user, per MFE they navigate to. Today's default avatar costs zero (a bundled SVG and static files), and the extra call would only benefit the minority of users with an uploaded photo.

How this PR works, and where its weaknesses are

This PR generates the initials avatar as JPEGs (one per size in PROFILE_IMAGE_SIZES_MAP), stores them in the profile-image storage backend (S3 in production), and serves their URLs through the existing profile_image.image_url_* fields.

  1. It needs the same header fix above. And since every user gets an initials avatar, the extra API call per user per MFE now applies to 100% of users, not just those with photos. Significant traffic at scale, against today's zero.

  2. Server-side storage I/O on the read path. A forum thread is still a single API call, but the server checks storage existence for each of the 4 sizes, per unique posting user, on every request, with no caching. On S3 these are billable HEAD requests that block the LMS worker. Plus 4 stored files per photo-less user.

Alternative approaches we are considering

Both render the avatar on the client, matching today's zero-cost economics: initials derived from the username, a deterministic background color (hash against a theme-token palette), and a colored circle with text. No image is generated, stored, or fetched. They differ in where the logic lives:

Option 1: implement the fallback locally at each avatar surface.

The avatar generation flow would work like this, taking the header (the main surface) as the example:

  1. The username is already in the browser, for free. At login, the LMS sets a cookie containing a JWT with the user's basic data (username, email, userId, roles). On every MFE load, frontend-platform decodes that cookie locally and builds the authenticatedUser object — no API call involved. This means the username is available in every MFE, for every logged-in user, at zero cost. (This is also why the initials fallback does not need hydrateAuthenticatedUser or the extra accounts API call — that's only needed for uploaded photos.)

  2. The header's Avatar component decides what to render. Today it does: photo URL present → render <img>; absent → render the bundled grey SVG. The change replaces that second branch: instead of the grey icon, render the initials avatar. The Avatar component doesn't receive the username today (only src/size/alt), but its direct parent already holds it — threading it down is a one-prop change.

  3. The initials avatar is plain HTML/CSS, not an image. A circular <span> whose background color comes from hashing the username into a fixed palette (the same 10 WCAG-safe theme-token colors already reviewed in this PR), with the username's first letter centered in white. Deterministic: the same username produces the same color on every surface, with nothing generated, stored, or fetched. Computationally it's equivalent to the grey SVG the header renders today.

  4. Every MFE inherits it via a normal dependency bump. Since all standard headers come from frontend-component-header, no per-MFE changes are needed.

Example implementation: openedx/frontend-component-header#672 (draft) implements exactly this flow for the headers. Verified locally against a vanilla backend on the Profile, Learner Dashboard and Account MFEs: all three render the initials avatar from the JWT username with zero extra requests, while everything else keeps today's behavior.

The same small change is then repeated at the two other surfaces, where the username is also already in scope: discussions (post/comment/reply authors — the author field already arrives in the existing single batched forum API call; the fallback branch of the avatar src currently points to the grey static URL) and the profile page body (the username is the page's route param).

Drawback: the rendering logic (~15 lines: hash, palette, initial) gets duplicated in three repos.

Option 2: add an initials variant to Paragon's Avatar and migrate all surfaces to it. Centralized logic; each surface still needs a small change to adopt it (discussions already uses it and passes the author; header and profile body use local implementations). Trade-off: rollout depends on a Paragon release plus consumers bumping it.


We are looking for feedback on this analysis and opinions on which route would be the best way forward. Depending on that, this PR will likely be reworked or closed in favor of one of the client-side approaches.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core contributor PR author is a Core Contributor (who may or may not have write access to this repo). open-source-contribution PR author is not from Axim or 2U

Projects

Status: Waiting on Author

Development

Successfully merging this pull request may close these issues.

4 participants