Story #2504: Badges Dropdown Integration - #2592
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe PR adds badge option generation and ordering, stores a user’s selected display badge, validates active user-owned badges, and replaces the profile’s static badge list with a progressively enhanced picker. ChangesProfile badge selection
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant ProfileEditor
participant BadgeDisplay
participant BadgePicker
participant ProfileForm
participant UserModel
ProfileEditor->>BadgeDisplay: build badge options
BadgeDisplay-->>ProfileEditor: return ordered badge rows
ProfileEditor->>BadgePicker: render options and initial selection
BadgePicker->>ProfileForm: submit selected badge
ProfileForm->>UserModel: validate and save display_badge
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
03d8984 to
321522a
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
users/migrations/0027_user_display_badge.py (1)
15-26: 🗄️ Data Integrity & Integration | 🔵 TrivialPlan for lock time on the
userstable.Adding this foreign key creates an index and a validated FK constraint on
users_user. On a large table this blocks writes for the duration. If the table is large in production, consider applying the index and constraint concurrently in a follow-up operation, or run the migration in a low-traffic window.The Ruff RUF012 hints on
dependenciesandoperationsare false positives for Django migrations.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@users/migrations/0027_user_display_badge.py` around lines 15 - 26, The AddField operation for User.display_badge may hold locks while creating and validating the foreign-key index and constraint on a large users_user table. Adjust the migration deployment strategy to apply these database changes concurrently in a follow-up operation, or explicitly document and schedule this migration for a low-traffic window, while preserving the existing field semantics.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@templates/v3/includes/_field_badge_select.html`:
- Around line 85-97: Add a stable, matching listbox id to the dropdown panel and
set the trigger’s aria-controls to that id in the field badge select markup,
including the equivalent trigger block noted at the additional location.
Preserve the existing focus and ARIA behavior.
- Around line 30-33: Update the selected value initialization in the Alpine
x-data expression to safely encode the interpolated value for a JavaScript
single-quoted string, using escapejs or the existing data-attribute approach.
Preserve the current selected behavior and leave the integer UserBadge.pk
interpolations unchanged.
---
Nitpick comments:
In `@users/migrations/0027_user_display_badge.py`:
- Around line 15-26: The AddField operation for User.display_badge may hold
locks while creating and validating the foreign-key index and constraint on a
large users_user table. Adjust the migration deployment strategy to apply these
database changes concurrently in a follow-up operation, or explicitly document
and schedule this migration for a low-traffic window, while preserving the
existing field semantics.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: dd1f6096-ef87-47fe-9eaa-c4402fa83ec3
📒 Files selected for processing (12)
badges/display.pybadges/enums.pybadges/tests/test_display.pystatic/css/v3/forms.cssstatic/css/v3/user-profile-page.csstemplates/v3/includes/_field_badge_select.htmltemplates/v3/user_profile_edit.htmlusers/forms.pyusers/migrations/0027_user_display_badge.pyusers/models.pyusers/tests/test_v3_profile_edit.pyusers/views.py
💤 Files with no reviewable changes (1)
- static/css/v3/user-profile-page.css
321522a to
3f605d7
Compare
49fa8fa to
e80f0a7
Compare
e80f0a7 to
0536e82
Compare
0536e82 to
6a7eb2d
Compare
6a7eb2d to
46ae9ee
Compare
46ae9ee to
16f91d7
Compare
16f91d7 to
8d47e22
Compare
75cd7a1 to
c9e129f
Compare
julioest
left a comment
There was a problem hiding this comment.
NICE!
There is one issue that needs attention. The _detail function formats the "earned" phrase by using count=tier.threshold instead of the member's actual count. For example, with five commits, my row shows "Authored 1 commit, author 7 more commits to unlock the next badge". One plus seven equals eight, but the Silver badge requires twelve. Using the true count makes sense, as five plus seven totals twelve. Also, the pluralization comes from the threshold, which explains why it says "commit" for someone with five.
| Current | With the fix |
|---|---|
![]() |
![]() |
My Maintainer row currently states "Maintaining 2 libraries, maintain 3 more", which is entirely correct. This is because both that count and the threshold happen to be two. The test_display.py file contains no assertions for detail, so a test using convenient numbers would pass either way.
I tested a solution locally, and it functions. The change was roughly this:
earned = phrases.earned.format(
count=intcomma(count), unit=_unit(phrases, count)
)
a17b36c to
ddef821
Compare
ddef821 to
3bdb12b
Compare
3bdb12b to
dfa12c9
Compare


Issue: #2504
Summary & Context
Replaces the static "Pick Badge" placeholder on the V3 profile-edit page with a working Display Badge picker, and gives the chosen badge somewhere to live: a new
User.display_badgeFK tobadges.UserBadge.The picker shows one row per badge category — the rung the member has reached for the categories they've started, the first rung for the ones they haven't — with unlocked categories leading and locked ones dimmed but legible, so the ladder reads as something to climb rather than a list of things you don't have.
This PR stores the selection; it does not yet render it on the public profile. Nothing outside the edit form reads
display_badge— that consumer arrives with the badge profile-display work.Behaviour Matrix
Each section below enumerate all of the potential cases that that a user can encounter while using the dropdown, and the test covering it. Hopefully this helps with testing / reviewing the key considerations! Each group is collapsed — open the one you are reviewing. 👇
Ordering and pre-selection — which rows show, in what order, and which one opens selected
Tests named below live in
badges/tests/test_display.pyandusers/tests/test_v3_profile_edit.pyunless a path is given — those are pre-existingbadgescoverage this PR relies on rather than adds.NULLtest_default_option_is_none_without_a_badge,test_locked_badges_read_in_catalogue_order,test_v3_update_profile_clears_display_badge_when_omittedNULLuntil the member saves the Profile sectiontest_v3_edit_page_preselects_the_best_badge_when_none_is_chosen(asserts both halves),test_earned_tier_is_selectable_and_names_its_user_badgetest_default_option_picks_the_highest_rank,test_unlocked_badges_lead_highest_rank_firstBadgeLabelordertest_default_option_breaks_rank_ties_by_catalogue_ordertest_resolve_selection_keeps_a_choice_the_picker_still_offers,test_v3_update_profile_saves_display_badgedisabledin the no-JS<select>test_unstarted_badge_shows_only_its_first_tier. Styling deliberately unasserted — it goes stale as the design shiftsRow text copies — what the second column reads in each state
UserBadgeyet to store. Clears itself on the next recalculationBadgeLabeladded without copyKeyErrorAdmin actions on a member's badges — revoke, reinstate, climb a rung
test_manually_revoked_tier_is_not_selectable,test_v3_edit_page_drops_a_revoked_display_badge,test_v3_update_profile_rejects_a_revoked_display_badgetest_a_cascade_re_earn_returns_the_row_the_member_selected— pins the same-row re-earn the resumption depends onbadges/tests/test_admin.py::test_reinstate_action_clears_manual_revocationfor the badge; resumption is the same same-row mechanism the cascade test pinsUserBadgestays active but loses its rowtest_resolve_selection_replaces_a_rung_the_member_climbed_pastUserBadgerow hard-deletedon_delete=SET_NULLclears the columntest_resolve_selection_keeps_a_choice_the_picker_still_offersThreshold and tier changes — mostly the
badgesapp's mechanics; the picker-side consequence is what we addThis section is largely guarded by tests that predate this PR — the ladder mechanics are the
badgesapp's, not the picker's. The picker-side consequence is what we add.test_services.py::test_replace_tier_keeps_the_old_tiers_holders,test_admin_badge_config.py::test_editing_a_threshold_keeps_existing_holders,test_services.py::test_grandfathering_threshold_change_does_not_revoke+test_retired_tier_still_held_keeps_its_rowtest_signals.py::test_lowering_a_threshold_awards_without_a_manual_rebuild,test_admin_badge_config.py::test_editing_a_threshold_awards_newly_qualifying_members; the follow-forward shares its mechanism withtest_resolve_selection_replaces_a_rung_the_member_climbed_pastreplace_tierexists to avoid — worth knowing when debugging a badge that vanishedtest_admin.py::test_tier_threshold_is_readonly_on_changeandtest_admin_badge_config.py::test_editing_a_threshold_retires_and_replaces_the_tierprove the admin forces Replace tiernext_tiersteps over the gap, so a Bronze holder is sent to Gold when Silver is retiredtest_services.py::test_deactivating_tier_preserves_existing_user_badges,test_retired_tier_still_held_keeps_its_row. Thenext_tiergap-stepping has no direct test — a pre-existing hole inbadges.summarytest_admin_badge_config.py::test_shifting_the_whole_ladder_up_is_accepted,test_summary.py::test_summary_names_the_next_rank_up_after_every_threshold_shiftsForm, validation and fallbacks — what is rejected, and what happens without JS
<select>, each option spelling the rank out ("Bronze Documenter — …"). Locked options aredisabledUserBadgepktest_v3_update_profile_rejects_another_members_badgetest_v3_update_profile_rejects_a_revoked_display_badgetest_v3_update_profile_clears_display_badge_when_omittedNotes and Decisions
BadgeLabeldeclaration order is load-bearing. Adding a category puts it wherever it is declared in the locked list; there is no separate ordering list to keep in step.badge_optionsreads everything fromuser_badge_summary, including which rung the member has reached (AchievementRow.held). Anything the picker needs and the summary lacks should be added to the summary rather than fetched alongside it.Changes
Storage
User.display_badge— nullable FK tobadges.UserBadgewithon_delete=SET_NULL, migrationusers/0027_user_display_badge.py. Pointing at the awarded tier rather than theBadgemeans the column carries category and rank.help_textsays so, and every reader must checkis_activerather than trusting the pointer.Picker rows —
badges/display.py(new)badge_options(user)— one row per category. A started category shows the rung held, whose copy carries the distance to the next; an unstarted one shows its first tier. Unlocked lead by rank, then locked in catalogue order.default_option(options)— the badge to open on when the member has never chosen: highest rank, catalogue order breaking ties.resolve_selection(options, selected)— keeps a stored choice when the picker still offers it, otherwise falls back to the default.PHRASES, keyed byBadgeLabel, with a generic fallback so a category added without copy renders rather than raising.badges.summary.user_badge_summary— the arithmetic and the rung reached. Picking that rung by rank is subtle enough (retired tiers, manual revocations) that a second implementation would be a second set of bugs, and reusing it means the picker adds no queries.Form and view
users/forms.py—display_badgeis aModelChoiceFieldwhose queryset is narrowed in__init__to the member's own active badges. That narrowing is the whole of the authorisation check.users/views.py—get_v3_edit_initialdrops a revoked selection before seeding, then resolves it against the picker's rows;get_v3_edit_contextsuppliesbadge_options.display_badgejoins thev3_update_profilesection and is written by_save_v3_visibility_section.badge_tierscontext and the disabledselect_titlerole field the placeholder used.Template and styles
templates/v3/includes/_field_badge_select.html(new) — Alpine dropdown with a plain<select>fallback; exactly one of the two is ever enabled, so only one value submits. The trigger shows the badge icon; the no-JS options spell the rank out instead, an<option>having nowhere to put an icon.static/css/v3/forms.css— badge-select block. The list is a grid with.badge-select__rowas a subgrid so icon, name and detail line up across rows regardless of text length.badges/enums.py— addslabel_order()besideBadgeLabel, mirroring howrank_order()sits besideTierRank, and notes in the docstring that reordering those members reorders the picker.Peer-Testing Guidelines
Empty state
Earned state
UserBadgestays active.Revocation
Fallbacks
<select>should appear with ranks spelled out ("Bronze Documenter — …") and locked options greyed out; picking an unlocked one and saving should work.Screenshots
Self-review Checklist
Frontend
Summary by CodeRabbit
New Features
Bug Fixes