Skip to content

Story 2443: User Profile Integration – User Roles - #2527

Open
julhoang wants to merge 12 commits into
developfrom
julia/implement-user-roles
Open

Story 2443: User Profile Integration – User Roles #2527
julhoang wants to merge 12 commits into
developfrom
julia/implement-user-roles

Conversation

@julhoang

@julhoang julhoang commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Issue: #2443

Summary & Context

Adds a real, user-controlled displayed profile role to the User model. A user features a library role they actually hold (Author / Maintainer / Contributor, optionally scoped to a library like "Boost.Beast Author"); staff assign internal C++ Alliance titles (e.g. "Board Member") in the admin. Users can also opt out entirely with No Public Role, which hides their role on every profile surface except their own profile page. The role is resolved on read with no per-author N+1.

Changes

Profile role model & source of truth

  • Add a ProfileRole enum with two groups: library roles (Author / Maintainer / Contributor, user-selectable) and internal C++ Alliance titles (admin-only).
  • Add 5 fields to User:
    • displayed_profile_role + displayed_profile_role_library (FK) – the library role the user chose to feature, optionally scoped to one library.
    • internal_role – the staff-assigned C++ Alliance title.
    • resolved_profile_role – the auto-derived default (maintained by the recompute task, see below).
    • hide_public_role – explicit opt-out; when set, the user's role is hidden everywhere except their own profile page.
  • Make User.role the single source of truth, resolved by precedence: chosen role → internal title → auto-derived role, and empty when the user has opted out.
  • Add User.public_role for the user's own profile page: it ignores the opt-out and always shows the best available role (so the profile page never goes role-less even when the user hides it elsewhere).
  • Allow only one holder per singular title (CEO, CTO, CFO/COO, CMO, Chief of Staff) via a partial UniqueConstraint.
  • One migration adds the fields and the constraint.

Role selection (edit page)

  • The "Your Role" dropdown offers only the roles the user actually holds (generic labels first, then library-scoped). When they hold none it's disabled with a "Contribute to a library to unlock a role" hint.
  • When the user holds any role, the dropdown also offers "No Public Role – Your role won't be linked to your name elsewhere on the site." Selecting it sets hide_public_role, clears any featured library role, and removes the role from feeds, cards, and every other surface — while the user's own profile page still shows their best available role.
  • Renders as a searchable combo (_field_combo.html) with a native <select> no-JS fallback.
  • The whole profile-edit card is now one <form> saved by the existing Save Changes button. The new save handler re-validates the choice against the user's own options, so they can never feature a role they don't hold; re-selecting a real role clears the opt-out.
  • The posts-feed sidebar card shows the user's real role (or nothing, when they've opted out).

Admin: assign C++ Alliance titles

  • Add EmailUserAdminForm: labels internal_role as "C++ Alliance title" and, when a singular title is already taken, blocks the save with an error naming the current holder (the DB constraint is the hard backstop).
  • Add a read-only "Derived library roles" panel showing the roles the user holds; library roles are never editable in the admin.

Role resolution & performance

  • .role reads the stored resolved_profile_role column directly, so a feed of N posts resolves every author's role with no per-author query.
  • A Celery task (recompute_displayed_profile_roles) refreshes that column after each library/commit import, plus a daily backstop. ‼️ It also clears a user's chosen role if a later import revokes their eligibility, so we never show a role they no longer hold. ‼️
  • Add select_related(...) for the scoped-role FK on the feed, homepage, and post-detail querysets.
  • Detailed contribution lists (edit dropdown, admin panel) are computed live per user. The ticket suggested a materialized view, but I chose a stored column + live queries instead:
    • Every read is a single user's slice (edit dropdown, admin) or one page of authors (feed) — all cheap via FK-indexed queries. A matview would precompute a full aggregation over ~153k commits just to serve those small slices.
    • The one place this ran per row — author role in the feed — is already handled by the resolved_profile_role column, so nothing latency-sensitive needs the matview.
    • A matview also carries standing cost: a refresh task + schedule, staleness between refreshes, and a second copy of the eligibility logic (the view's SQL alongside the live queries) that can drift.
    • At current scale (~600 eligibility rows over 153k commits) the live per-user queries are sub-millisecond, so the precompute buys nothing.

Cleanup

  • Delete users/profile_cards.py (user_profile_card); news cards use Entry.author.to_v3_profile_dict(), and to_v3_post_card_dict() drops its now-unused author_role argument.

Peer-Testing Guidelines

Setup

  1. Run docker compose exec web python manage.py migrate, then docker compose restart celery-worker celery-beat.
  2. Populate roles once: docker compose exec web python manage.py shell -c "from users.tasks import recompute_displayed_profile_roles as t; t()".

Find accounts that hold a role
3. Most accounts have no library contributions, so first find ones that do — run this SQL to list users with at least one eligible role:

WITH commit_counts AS (
    SELECT ca.user_id, lv.library_id, COUNT(*) AS commit_count
    FROM libraries_commitauthor ca
    JOIN libraries_commit c          ON c.author_id = ca.id
    JOIN libraries_libraryversion lv ON lv.id = c.library_version_id
    WHERE ca.user_id IS NOT NULL
    GROUP BY ca.user_id, lv.library_id
),
eligibility AS (
    SELECT la.user_id, la.library_id, 'Author' AS role
    FROM libraries_library_authors la
    UNION
    SELECT lm.user_id, lv.library_id, 'Maintainer' AS role
    FROM libraries_libraryversion_maintainers lm
    JOIN libraries_libraryversion lv ON lv.id = lm.libraryversion_id
    UNION
    SELECT cc.user_id, cc.library_id, 'Contributor' AS role
    FROM commit_counts cc
)
SELECT e.user_id, u.display_name, u.email,
       e.library_id, l.name AS library_name, e.role,
       COALESCE(cc.commit_count, 0) AS commit_count
FROM eligibility e
JOIN users_user u          ON u.id = e.user_id
JOIN libraries_library l   ON l.id = e.library_id
LEFT JOIN commit_counts cc ON cc.user_id = e.user_id AND cc.library_id = e.library_id
WHERE u.claimed IS TRUE
ORDER BY u.email, e.role, commit_count DESC;
  1. Spot-check a few of those users in the Users admin to confirm the roles they're eligible for match the query.
image

Exercise the role dropdown
5. Open http://localhost:8000/users/me/?edit=true. If your account has no library contributions, "Your Role" is disabled with the "Contribute to a library to unlock a role" hint. To see it populated (and pick/save a role), impersonate one of the eligible users from step 3 — ⭐️ there's a method to do this locally, reach out to me for details. ⭐️

Exercise "No Public Role"
6. As one of those eligible users, pick a role and save, then reopen the dropdown and choose "No Public Role" and save. Confirm the role no longer appears in the posts feed / user cards for that account, but their own profile page (http://localhost:8000/users/me/) still shows a role (the best available one). Re-select a real role and confirm it reappears everywhere.

Admin: C++ Alliance titles
7. In the Users admin, open your account and set a C++ Alliance title — the "Derived library roles" panel shows the eligible roles read-only. Then assign a singular title (e.g. CEO) to a second user — the save is blocked, naming the current holder.

Screenshots

Dropdown UI Updated User Profile Header Notes
Screenshot 2026-07-21 at 11 07 46 AM Screenshot 2026-07-15 at 11 13 37 AM Dropdown for a user with many roles
Screenshot 2026-07-15 at 11 09 03 AM Screenshot 2026-07-15 at 11 11 38 AM Disabled field for a user with no roles, the Profile Header role will be empty and not rendered.

Example of more roles filled out

  • Locally I've added the internal roles for some C++ Alliance folks.
  • Also notice the generic "Author" and the empty role – these are auto-determined
image

Self-review Checklist

  • Tag at least one team member from each team to review this PR
  • Link this PR to the related GitHub Project ticket

Frontend

  • UI implementation matches Figma design
  • Tested in light and dark mode
  • Responsive / mobile verified
  • Accessibility checked (keyboard navigation, etc.)
  • Ensure design tokens are used for colors, spacing, typography, etc. – No hardcoded values
  • Test without JavaScript – ‼️ Currently No-JS is not yet supported, it'll be handled in Webpage Integration: Wire Up User Profile Edit Forms for Javascript-less Support #2509 ‼️
  • No console errors or warnings

Summary by CodeRabbit

  • New Features

    • Added dynamic profile roles based on a user’s library activity, including library-specific roles.
    • Users can select an eligible role, hide their public role, or display an automatically derived role.
    • Profile and post cards now show accurate, personalized role information.
    • Added role validation to prevent invalid or unauthorized selections.
    • Staff can assign internal titles with improved validation and role visibility in administration.
  • Bug Fixes

    • Profile editing now preserves valid selections and removes outdated role assignments.
    • Role information is refreshed automatically as library contributions and responsibilities change.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

Adds dynamic library-scoped and internal profile roles, persisted role selections, validation, administrative controls, recomputation triggers, opt-out behavior, and updated v3 profile and author rendering.

Displayed profile role system

Layer / File(s) Summary
Role model and persistence
users/models.py, users/migrations/...
Defines role categories, stored role fields, library-scoped eligibility, effective-role resolution, encoding helpers, and singular internal-role constraints.
Role recomputation and triggers
users/tasks.py, libraries/tasks.py, config/celery.py
Recomputes derived roles, clears stale selections atomically, and schedules recomputation after imports, commit updates, and daily at 09:00.
Profile role selection and administration
users/forms.py, users/serializers.py, users/views.py, users/admin.py, templates/v3/..., static/css/...
Builds eligible role choices, validates and persists selections, supports no-public-role opt-out, updates profile editing controls, and exposes admin eligibility information.
V3 author and profile rendering
news/..., core/views.py, ak/homepage.py, templates/v3/posts_list.html
Uses computed profile data for author cards and eagerly loads library role relationships across v3 entry and community queries.
Role behavior tests
users/tests/test_profile_role.py
Covers eligibility, ordering, persistence, validation, administration, recomputation, edit-page seeding, and opt-out behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ProfilePage
  participant CurrentUserProfileView
  participant V3UserProfileForm
  participant CurrentUserSerializer
  participant User
  ProfilePage->>CurrentUserProfileView: request edit context
  CurrentUserProfileView->>User: compute role options
  CurrentUserProfileView->>V3UserProfileForm: seed offered role choices
  ProfilePage->>CurrentUserSerializer: submit encoded role
  CurrentUserSerializer->>User: validate and persist role selection
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: herzog0, jlchilders11, julioest, kattyode

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.01% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and clearly names the main change: user roles in profile integration.
Description check ✅ Passed The description is thorough and includes issue, summary, changes, screenshots, and checklist; only the risks section is missing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch julia/implement-user-roles

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@julhoang julhoang linked an issue Jul 15, 2026 that may be closed by this pull request
@julhoang julhoang changed the title Julia/implement user roles Story 2443: User Profile Integration – User Roles Jul 15, 2026
@julhoang
julhoang marked this pull request as ready for review July 15, 2026 18:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@users/tasks.py`:
- Around line 176-197: Update the stale-selection cleanup around stale_ids so it
cannot overwrite a concurrent legitimate selection: retain each stale user’s
exact displayed_profile_role and displayed_profile_role_library values, then
perform the clearing update inside the transaction only when both fields still
match those snapshot values. Keep the existing role-resolution updates unchanged
and ensure concurrent changes cause the conditional clear to affect no rows.

In `@users/tests/test_profile_role.py`:
- Around line 313-324: Strengthen the unauthorized-role rejection tests,
including test_post_rejects_role_for_unheld_library and the corresponding test
around the second reported range, by first assigning the user an eligible
displayed profile role for library before submitting the tampered role. After
the POST, assert the original role and library ID remain unchanged, proving the
request is rejected rather than ignored.
🪄 Autofix (Beta)

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: ff96fd6c-a85d-4211-8cda-0b492fe0003b

📥 Commits

Reviewing files that changed from the base of the PR and between 865a4ac and 9b21c3b.

📒 Files selected for processing (17)
  • ak/homepage.py
  • config/celery.py
  • core/views.py
  • libraries/tasks.py
  • news/models.py
  • news/views.py
  • static/css/v3/user-profile-page.css
  • templates/v3/posts_list.html
  • templates/v3/user_profile_edit.html
  • users/admin.py
  • users/forms.py
  • users/migrations/0022_user_displayed_profile_role_and_more.py
  • users/models.py
  • users/profile_cards.py
  • users/tasks.py
  • users/tests/test_profile_role.py
  • users/views.py
💤 Files with no reviewable changes (1)
  • users/profile_cards.py

Comment thread users/tasks.py Outdated
Comment thread users/tests/test_profile_role.py Outdated
@julhoang
julhoang force-pushed the julia/implement-user-roles branch from e212783 to 1871c93 Compare July 16, 2026 22:31
herzog0
herzog0 previously approved these changes Jul 17, 2026

@herzog0 herzog0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looking pretty good! Tested all cases and they're looking great.
Only one thing I noticed: there's no way to de-select the role if I want it.

Image

I understand this is not originally in the ticket, so I'm approving!
Let's discuss internally.

@herzog0
herzog0 dismissed their stale review July 17, 2026 16:55

Sorry, I think there's something else I didn't consider in the first review, let me finish double checking

@herzog0 herzog0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Heya! I was finally able to reproduce the issue I mentioned last week and get the step-by-step instructions for you to reproduce.

TL;DR
The field resolved_profile_role is a cache, and can become stale/invalid after running recompute_displayed_profile_roles.
If the role is not applicable to the user anymore, and they don't have valid options to set in their role field in the profile, then resolved_profile_role is filled into the selector while it's disabled (cause the user has no other valid roles to apply, by the fresh data pulled from the tables and ignoring what's cached, so the field is locked).
If you then update any unrelated field and hit save, you'll get an error, because the form still submits the cached invalid role in the request, which the backend correctly validates and sees the role is invalid.

More info the code.

Comment thread users/models.py
Comment on lines +394 to +403
resolved_profile_role = models.CharField(
max_length=64,
blank=True,
default="",
editable=False,
help_text=_(
"Auto-derived top library role, recomputed on import. Used only when "
"the user has set neither displayed_profile_role nor internal_role."
),
)

@herzog0 herzog0 Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

About the issue I mentioned in the review.

Importante note

About the time window when the error might happen, there are two:

  1. Super low criticality → happens if the recompute_displayed_profile_roles function gets delayed after the other tasks run (it clears the cached field at the end, so if this function runs pretty close to the ingestion updates, we're fine);
  2. Mid criticality → if an admin manually revokes the role in the admin panel, then the user will get an error until the next day when the task runs again;

Reproducing the error

Data setup

  1. Find a safe candidate - zero real library eligibility (so the dropdown renders disabled) and no linked social account (so the edit page doesn't 500 — a separate, unrelated local-env gap I hit while
    testing this):
  SELECT u.id, u.email, u.is_active, u.claimed                                                                                                                                                                   
  FROM users_user u                                                                                                                                                                                              
  WHERE u.claimed IS TRUE                                                                                                                                                                                        
    AND u.is_active IS TRUE                                                                                                                                                                                      
    AND u.id NOT IN (SELECT user_id FROM libraries_library_authors)                                                                                                                                              
    AND u.id NOT IN (SELECT user_id FROM libraries_libraryversion_maintainers)                                                                                                                                   
    AND u.id NOT IN (SELECT user_id FROM libraries_commitauthor WHERE user_id IS NOT NULL)                                                                                                                       
    AND u.id NOT IN (SELECT user_id FROM socialaccount_socialaccount)                                                                                                                                            
  ORDER BY u.id                                                                                                                                                                                                  
  LIMIT 20;
  1. Force the stale state on the chosen account:
UPDATE users_user                                                                                                                                                                                              
SET resolved_profile_role = 'Contributor'  -- or 'Author' / 'Maintainer'                                                                                                                                       
WHERE email = '<chosen-email>';
  1. Make sure they can actually log in (only needed if the account has no known password/unverified email):
-- Known password → plaintext "foobarone"                                                                                                                                                                      
UPDATE users_user                                                                                                                                                                                              
SET password = 'pbkdf2_sha256$1200000$UrzH9CXDMIhtHEmReBUuX4$c1qlT3Qsqarwc2tQ/SuCz0xKzfPatfTuQOABNYiXg5E='                                                                                                     
WHERE email = '<chosen-email>';                                                                                                                                                                                
                                                                                                                                                                                                               
-- Confirm the primary email is verified (allauth blocks unverified accounts)                                                                                                                                  
SELECT email, verified, "primary" FROM account_emailaddress WHERE email = '<chosen-email>';                                                                                                                    
                                                                                                                                                                                                               
-- If verified = false:                                                                                                                                                                                        
UPDATE account_emailaddress SET verified = true WHERE email = '<chosen-email>';
  1. Verify the setup before testing:
SELECT id, email, resolved_profile_role, displayed_profile_role,                                                                                                                                               
      displayed_profile_role_library_id, internal_role, is_active, claimed                                                                                                                                    
FROM users_user                                                                                                                                                                                                
WHERE email = '<chosen-email>';   

resolved_profile_role should show the value you set; displayed_profile_role / internal_role should be empty.

  1. Revert once done:
 UPDATE users_user SET resolved_profile_role = '' WHERE email = '<chosen-email>';

Steps to reproduce

  1. Run query 1, pick an id/email from the results.
  2. Run query 2 on that account.
  3. Run query 3 if the account's password/verification is unknown.
  4. Run query 4 to confirm the row looks right.
  5. Log in as that account (<chosen-email> / foobarone if query 3 was used).
  6. Go to /users/me/?edit=true - "Your Role" should render disabled/locked with the "Contribute to a library to unlock a role" placeholder (but in my tests it's actually showing an empty field, still locked).
  7. Open DevTools → Network before saving.
  8. Change any unrelated field (e.g. add a GitHub link) and click Save Changes.
  9. Inspect the PATCH to /api/v1/users/me/: the payload contains "role": "Contributor:" (or whichever role was set), the response is 400 "You cannot select that role," and the unrelated field does not save.
  10. Run query 5 to clean up.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A possible route to fix this is to never resubmit a value for a field the server told it not to offer. That won't fix the root cause but at least the user won't experience any issues anymore.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for catching this issue @herzog0 !

Following your suggestion, I've updated _field_dropdown.html so it never submits a value when the field is disabled (i.e. in this case role is omitted from the request).

Now for the dropdown's initial seed value: it now checks the stored/cached role against the real-time eligibility list from get_role_options(). If the stored role isn't in that list (i.e. we know the cache is stale), we don't pre-select an option to be on the safe side. Then the role will be set if either user picks a valid role themselves, or the cached value gets reconciled on the next daily recompute. I think this should mitigate the issue you found above. Does this approach seems reasonable to you too?

Commit ref: ee1852b

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
users/serializers.py (1)

79-83: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Normalize bare Slack Member IDs to canonical URLs.

The validation logic intentionally accepts bare Member IDs (via is_valid_slack_link), but fails to normalize them into the canonical URL before saving. If a user bypasses the frontend and submits a bare ID, it will be saved as-is, violating the database contract described in models.py ("Slack stores the full canonical CPPLang Slack profile URL") and resulting in broken profile links.

Normalize the value to the canonical URL if it's a bare Member ID.

🐛 Proposed fix to normalize the Member ID
-        slack_value = value.get(V3ProfileLinkChoices.SLACK)
-        if slack_value and not is_valid_slack_link(slack_value):
-            field_errors[V3ProfileLinkChoices.SLACK] = (
-                "Please enter a valid CPPLang profile URL or Member ID."
-            )
+        slack_value = value.get(V3ProfileLinkChoices.SLACK)
+        if slack_value:
+            if not is_valid_slack_link(slack_value):
+                field_errors[V3ProfileLinkChoices.SLACK] = (
+                    "Please enter a valid CPPLang profile URL or Member ID."
+                )
+            elif not is_url(slack_value):
+                value[V3ProfileLinkChoices.SLACK] = f"{SLACK_PROFILE_URL_PREFIX}{slack_value}"
🤖 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/serializers.py` around lines 79 - 83, Update the Slack handling near
is_valid_slack_link in the serializer to convert accepted bare Member IDs into
the canonical CPPLang Slack profile URL before saving. Preserve validation
behavior for invalid values and leave already-canonical URLs unchanged.
templates/v3/user_profile_edit.html (1)

546-559: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Remove early return to prevent dropping concurrent form errors.

Returning early here skips processing potential concurrent validation errors (like an invalid role selection) and bypasses throwing an error. As a result, the form's overall saveStatus is never updated to 'error', masking the failure state from the main UI indicator.

Instead of returning, delete the profile_links object from data after mapping the inline errors, and throw an aggregate error at the end to correctly update the form state.

🐛 Proposed fix
               // Per-link validation errors (e.g. the backend secure-URL/Slack
               // checks) come back keyed by link type; route them to their own
               // field instead of the banner next to the Save button.
               const linkErrors = data.profile_links;
+              let hasLinkErrors = false;
               if (linkErrors && typeof linkErrors === 'object' && !Array.isArray(linkErrors)) {
                 Object.entries(linkErrors).forEach(([type, msg]) => {
                   this.errors[type] = Array.isArray(msg) ? msg[0] : msg;
                 });
-                return;
+                hasLinkErrors = true;
+                delete data.profile_links;
               }
 
               const fieldErrors = Object.values(data).flat().filter(Boolean);
-              throw new Error(fieldErrors.length ? fieldErrors.join(' ') : `Save failed (${res.status})`);
+              if (fieldErrors.length) {
+                throw new Error(fieldErrors.join(' '));
+              }
+              if (hasLinkErrors) {
+                throw new Error("Please correct the link errors below.");
+              }
+              throw new Error(`Save failed (${res.status})`);
             }
             this.saveStatus = 'saved';
🤖 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 `@templates/v3/user_profile_edit.html` around lines 546 - 559, Update the
profile_links validation handling in the save error flow to remove the early
return after mapping per-link errors. After populating this.errors, delete
profile_links from data, then continue collecting remaining fieldErrors and
throw the aggregate error so saveStatus is set to 'error' for concurrent
validation failures.
🧹 Nitpick comments (1)
users/models.py (1)

359-368: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

displayed_profile_role choices are broader than the field's contract.

choices=ProfileRole allows the full enum, including internal C++ Alliance titles, even though this field is documented as "Library role the user has chosen to feature." internal_role (Line 380-386) explicitly restricts its choices to ProfileRole.internal_roles(); this field should mirror that pattern and restrict to ProfileRole.library_roles(). Currently _effective_role() happens to guard against a mis-set internal value at read time (Line 551), but the field itself accepts invalid states (e.g. via full_clean() or a raw admin edit), which is a data-integrity gap.

♻️ Proposed fix
     displayed_profile_role = models.CharField(
         max_length=64,
-        choices=ProfileRole,
+        choices=[
+            (r.value, r.label)
+            for r in ProfileRole
+            if r.value in ProfileRole.library_roles()
+        ],
         blank=True,
         default="",
🤖 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/models.py` around lines 359 - 368, Update the displayed_profile_role
field to use ProfileRole.library_roles() for its choices, matching the field’s
library-role contract and the restriction used by internal_role. Leave the
field’s default, blank behavior, and other configuration unchanged.
🤖 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.

Outside diff comments:
In `@templates/v3/user_profile_edit.html`:
- Around line 546-559: Update the profile_links validation handling in the save
error flow to remove the early return after mapping per-link errors. After
populating this.errors, delete profile_links from data, then continue collecting
remaining fieldErrors and throw the aggregate error so saveStatus is set to
'error' for concurrent validation failures.

In `@users/serializers.py`:
- Around line 79-83: Update the Slack handling near is_valid_slack_link in the
serializer to convert accepted bare Member IDs into the canonical CPPLang Slack
profile URL before saving. Preserve validation behavior for invalid values and
leave already-canonical URLs unchanged.

---

Nitpick comments:
In `@users/models.py`:
- Around line 359-368: Update the displayed_profile_role field to use
ProfileRole.library_roles() for its choices, matching the field’s library-role
contract and the restriction used by internal_role. Leave the field’s default,
blank behavior, and other configuration unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 496bc7c5-ce35-4018-b0ad-5b9168fb7197

📥 Commits

Reviewing files that changed from the base of the PR and between e212783 and ee1852b.

📒 Files selected for processing (19)
  • ak/homepage.py
  • config/celery.py
  • core/views.py
  • libraries/tasks.py
  • news/models.py
  • news/views.py
  • static/css/v3/user-profile-page.css
  • templates/v3/includes/_field_dropdown.html
  • templates/v3/posts_list.html
  • templates/v3/user_profile_edit.html
  • users/admin.py
  • users/forms.py
  • users/migrations/0023_user_displayed_profile_role_and_more.py
  • users/models.py
  • users/profile_cards.py
  • users/serializers.py
  • users/tasks.py
  • users/tests/test_profile_role.py
  • users/views.py
💤 Files with no reviewable changes (1)
  • users/profile_cards.py
🚧 Files skipped from review as they are similar to previous changes (10)
  • templates/v3/posts_list.html
  • static/css/v3/user-profile-page.css
  • config/celery.py
  • core/views.py
  • news/models.py
  • libraries/tasks.py
  • users/forms.py
  • users/admin.py
  • news/views.py
  • users/tasks.py

@julhoang
julhoang requested a review from herzog0 July 20, 2026 23:27
@julioest
julioest self-requested a review July 21, 2026 20:07

@julioest julioest left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving this one, awesome work, Julia! Dug into this locally and it holds up really well Just one thing, nothing blocking.

#2517 also adds a 0023 migration off 0022, so the two collide once they both land. I actually hit it locally, with both files present makemigrations --check bails with "Conflicting migrations detected, multiple leaf nodes ... (0023_user_country..., 0023_user_displayed...)." Your code is totally fine, it's just that whoever merges second will need to bump to 0024

@julhoang

Copy link
Copy Markdown
Collaborator Author

Thanks for the heads-up @julioest !! I'll keep an eye out for migration conflicts

@julhoang

Copy link
Copy Markdown
Collaborator Author

@julioest I've just added 1 commit to add support for selecting "No Public Role" option, would you mind re-reviewing this ticket if possibe? New commit: 20067e8

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@users/models.py`:
- Around line 274-280: Update NO_PUBLIC_ROLE_LABEL to replace the en dash with
an ASCII hyphen, keeping the label text and meaning otherwise unchanged so it
satisfies Ruff’s RUF001 check.
🪄 Autofix (Beta)

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: 8bde1d29-f120-408e-a34c-582ba46d148e

📥 Commits

Reviewing files that changed from the base of the PR and between ee1852b and 20067e8.

📒 Files selected for processing (6)
  • users/forms.py
  • users/migrations/0023_user_displayed_profile_role_and_more.py
  • users/models.py
  • users/serializers.py
  • users/tests/test_profile_role.py
  • users/views.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • users/migrations/0023_user_displayed_profile_role_and_more.py
  • users/views.py
  • users/serializers.py
  • users/forms.py

Comment thread users/models.py
@julioest

Copy link
Copy Markdown
Collaborator

@julhoang Works great, I was able to switch roles and verify

@ycanales

Copy link
Copy Markdown
Collaborator

Great work Julia, testing went good 👍

/users/me/

Visible with role visible and opted out:
image

Posts

Role visible:
image

Opted-out:
image

Edit profile: no public role

image

I'll stay tuned to this integrates cleanly onto my public profile PR.

@julhoang
julhoang force-pushed the julia/implement-user-roles branch from f2a5177 to 4df420f Compare July 23, 2026 00:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
users/models.py (1)

371-380: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Restrict displayed_profile_role choices to library roles only.

internal_role explicitly filters its choices to ProfileRole.internal_roles(), but displayed_profile_role passes the full ProfileRole enum, letting an internal title (e.g. "ceo") be assigned to this field at the model/admin level even though it's documented as "Library role the user has chosen to feature." _effective_role() happens to guard against this (its first branch checks in ProfileRole.library_roles()), so there's no current display bug, but the field's own contract doesn't enforce it — a future admin form or bulk update could silently store an invalid combination.

🛡️ Proposed fix (restrict choices; note this needs a follow-up migration for the `choices` change)
     displayed_profile_role = models.CharField(
         max_length=64,
-        choices=ProfileRole,
+        choices=[(r.value, r.label) for r in ProfileRole if r.value in ProfileRole.library_roles()],
         blank=True,
🤖 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/models.py` around lines 371 - 380, Update the displayed_profile_role
field to use only ProfileRole.library_roles() for its choices, matching the
field’s documented contract and the filtering used by internal_role. Preserve
its existing blank and default behavior, and add the required migration for the
choices change.
users/views.py (1)

395-398: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the typographic apostrophe to satisfy Ruff RUF001.

Same issue category already fixed elsewhere in this PR for NO_PUBLIC_ROLE_LABEL.

Proposed fix
             ctx["bio"] = (
                 user.biography
-                or "Add a short bio to tell the community who you are, what you work on, or what you’re passionate about."
+                or "Add a short bio to tell the community who you are, what you work on, or what you're passionate about."
             )
🤖 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/views.py` around lines 395 - 398, Update the fallback biography string
in the ctx["bio"] assignment to replace the typographic apostrophe with a plain
ASCII apostrophe, matching the existing RUF001 fixes such as
NO_PUBLIC_ROLE_LABEL.

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 `@users/tasks.py`:
- Around line 146-202: The recomputation snapshot in the task containing the
valid-role queries must be serialized to prevent older results overwriting newer
eligibility. Acquire the task’s shared execution lock before building valid,
held_any, and stale_rows, and keep the related updates within that serialized
flow; do not rely on the existing compare-and-swap, which only protects
displayed choices.

---

Nitpick comments:
In `@users/models.py`:
- Around line 371-380: Update the displayed_profile_role field to use only
ProfileRole.library_roles() for its choices, matching the field’s documented
contract and the filtering used by internal_role. Preserve its existing blank
and default behavior, and add the required migration for the choices change.

In `@users/views.py`:
- Around line 395-398: Update the fallback biography string in the ctx["bio"]
assignment to replace the typographic apostrophe with a plain ASCII apostrophe,
matching the existing RUF001 fixes such as NO_PUBLIC_ROLE_LABEL.
🪄 Autofix (Beta)

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: f097ac41-602c-4dd9-b953-480ea6ab72f6

📥 Commits

Reviewing files that changed from the base of the PR and between 20067e8 and 4df420f.

📒 Files selected for processing (19)
  • ak/homepage.py
  • config/celery.py
  • core/views.py
  • libraries/tasks.py
  • news/models.py
  • news/views.py
  • static/css/v3/user-profile-page.css
  • templates/v3/includes/_field_dropdown.html
  • templates/v3/posts_list.html
  • templates/v3/user_profile_edit.html
  • users/admin.py
  • users/forms.py
  • users/migrations/0024_user_displayed_profile_role_and_more.py
  • users/models.py
  • users/profile_cards.py
  • users/serializers.py
  • users/tasks.py
  • users/tests/test_profile_role.py
  • users/views.py
💤 Files with no reviewable changes (1)
  • users/profile_cards.py
🚧 Files skipped from review as they are similar to previous changes (10)
  • templates/v3/posts_list.html
  • templates/v3/includes/_field_dropdown.html
  • core/views.py
  • static/css/v3/user-profile-page.css
  • ak/homepage.py
  • users/forms.py
  • users/admin.py
  • news/views.py
  • users/serializers.py
  • users/tests/test_profile_role.py

Comment thread users/tasks.py
Comment on lines +146 to +202
# (user_id, library_id) pairs per role: author / maintainer / contributor.
valid = {
ProfileRole.AUTHOR.value: set(
Library.authors.through.objects.values_list("user_id", "library_id")
),
ProfileRole.MAINTAINER.value: set(
LibraryVersion.maintainers.through.objects.values_list(
"user_id", "libraryversion__library_id"
).distinct()
),
ProfileRole.CONTRIBUTOR.value: set(
Commit.objects.filter(author__user_id__isnull=False)
.values_list("author__user_id", "library_version__library_id")
.distinct()
),
}
held_any = {role: {uid for uid, _ in pairs} for role, pairs in valid.items()}

# Each user's highest-precedence role. Iterating high-to-low, setdefault
# keeps the first (highest) seen.
top = {}
for role in ProfileRole.library_role_precedence():
for uid in held_any[role]:
top.setdefault(uid, role)
by_role = defaultdict(list)
for uid, role in top.items():
by_role[role].append(uid)

# Clear explicit user choices the latest import has revoked, so `.role`
# falls through instead of displaying a role the user no longer holds.
stale_rows = [] # (uid, role, lib_id) as read in this snapshot
choosers = User.objects.exclude(displayed_profile_role="").values_list(
"id", "displayed_profile_role", "displayed_profile_role_library_id"
)
for uid, role, lib_id in choosers:
if lib_id:
ok = (uid, lib_id) in valid.get(role, set())
else:
ok = uid in held_any.get(role, set())
if not ok:
stale_rows.append((uid, role, lib_id))

cleared_choices = 0
with transaction.atomic():
User.objects.exclude(id__in=top).exclude(resolved_profile_role="").update(
resolved_profile_role=""
)
for role, ids in by_role.items():
User.objects.filter(id__in=ids).update(resolved_profile_role=role)
# Compare-and-swap: only clear rows still matching the snapshot values,
# so a selection the user saved after the snapshot survives.
for uid, role, lib_id in stale_rows:
cleared_choices += User.objects.filter(
id=uid,
displayed_profile_role=role,
displayed_profile_role_library_id=lib_id,
).update(displayed_profile_role="", displayed_profile_role_library=None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize recomputation snapshots.

Line 146 reads eligibility before any execution lock. Overlapping tasks can write snapshots out of order: an older task can restore resolved_profile_role="Author" after a newer task clears it following revoked eligibility. The compare-and-swap only protects explicit selections.

Serialize this task with a shared lock and take the snapshot after acquiring it (or version the source snapshot and conditionally apply writes).

🤖 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/tasks.py` around lines 146 - 202, The recomputation snapshot in the
task containing the valid-role queries must be serialized to prevent older
results overwriting newer eligibility. Acquire the task’s shared execution lock
before building valid, held_any, and stale_rows, and keep the related updates
within that serialized flow; do not rely on the existing compare-and-swap, which
only protects displayed choices.

@herzog0 herzog0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Heya! Thanks for the fixes :)
All looking good to me

@julhoang
julhoang force-pushed the julia/implement-user-roles branch from 4df420f to 88aa0b4 Compare July 28, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Webpage Integration: Member Roles

4 participants