diff --git a/ak/homepage.py b/ak/homepage.py
index f7be04b2d..2fc87f840 100644
--- a/ak/homepage.py
+++ b/ak/homepage.py
@@ -115,7 +115,7 @@ def build_community_posts(limit=5):
popular_entries = (
Entry.objects.ranked()
.filter(deleted_at__isnull=True, published=True)
- .select_related("author")[:limit]
+ .select_related("author", "author__displayed_profile_role_library")[:limit]
)
return [entry.to_v3_post_card_dict() for entry in popular_entries]
diff --git a/config/celery.py b/config/celery.py
index f999ceb4c..81f2c5c6c 100644
--- a/config/celery.py
+++ b/config/celery.py
@@ -137,3 +137,11 @@ def setup_periodic_tasks(sender, **kwargs):
crontab(day_of_week="mon", hour=5, minute=15),
app.signature("core.tasks.refresh_popular_search_terms"),
)
+
+ # Daily safety recompute of auto-derived profile roles. Eligibility only
+ # changes during imports, which enqueue this recompute themselves; this is a
+ # backstop after the daily import window (7:05/8:05 AM).
+ sender.add_periodic_task(
+ crontab(hour=9, minute=0),
+ app.signature("users.tasks.recompute_displayed_profile_roles"),
+ )
diff --git a/core/views.py b/core/views.py
index 0729fd92b..54a89c396 100644
--- a/core/views.py
+++ b/core/views.py
@@ -308,7 +308,7 @@ def get_v3_context_data(self, **kwargs):
recent_entries = (
Entry.objects.published()
.filter(deleted_at__isnull=True)
- .select_related("author")
+ .select_related("author", "author__displayed_profile_role_library")
.order_by("-publish_at")[:4]
)
diff --git a/libraries/tasks.py b/libraries/tasks.py
index d740e8b51..f82031a75 100644
--- a/libraries/tasks.py
+++ b/libraries/tasks.py
@@ -217,6 +217,7 @@ def update_authors_and_maintainers():
call_command("update_authors")
call_command("update_maintainers")
call_command("update_library_version_authors", "--clean")
+ app.signature("users.tasks.recompute_displayed_profile_roles").apply_async()
@app.task
@@ -232,6 +233,7 @@ def update_commits(token=None, clean=False, min_version=""):
library=library, clean=clean, min_version=min_version
)
logger.info("update_commits finished.")
+ app.signature("users.tasks.recompute_displayed_profile_roles").apply_async()
return commits_handled
diff --git a/news/models.py b/news/models.py
index ab8de75be..41bc413fd 100644
--- a/news/models.py
+++ b/news/models.py
@@ -266,7 +266,7 @@ def save(self, *args, **kwargs):
def get_absolute_url(self):
return reverse("news-detail", args=[self.slug])
- def to_v3_post_card_dict(self, author_role=None):
+ def to_v3_post_card_dict(self):
"""Dict shape consumed by `v3/includes/_post_card.html` items."""
category = ""
@@ -280,7 +280,7 @@ def to_v3_post_card_dict(self, author_role=None):
"date": self.publish_at,
"category": category,
"tag": "",
- "author": self.author.to_v3_profile_dict(role=author_role),
+ "author": self.author.to_v3_profile_dict(),
}
def can_view(self, user):
diff --git a/news/views.py b/news/views.py
index 292044f07..335691283 100644
--- a/news/views.py
+++ b/news/views.py
@@ -39,7 +39,6 @@
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData
from core.mixins import V3Mixin
-from users.profile_cards import user_profile_card
from .acl import can_approve
from .constants import (
NEWS_APPROVAL_SALT,
@@ -162,14 +161,14 @@ def get_queryset(self):
if self.request.GET.get("sort") == "popular":
result = (
self.model.objects.ranked()
- .select_related("author")
+ .select_related("author", "author__displayed_profile_role_library")
.filter(published=True, deleted_at__isnull=True)
)
else:
result = (
super()
.get_queryset()
- .select_related("author")
+ .select_related("author", "author__displayed_profile_role_library")
.filter(published=True, deleted_at__isnull=True)
)
right_now = now()
@@ -258,7 +257,9 @@ class EntryDetailView(V3Mixin, DetailView):
def get_queryset(self):
qs = super().get_queryset()
if getattr(self, "_v3_active", False):
- qs = qs.select_related("author").prefetch_related(*self.AUTHOR_PREFETCH)
+ qs = qs.select_related(
+ "author", "author__displayed_profile_role_library"
+ ).prefetch_related(*self.AUTHOR_PREFETCH)
return qs
def get_object(self, *args, **kwargs):
@@ -275,7 +276,7 @@ def get_v3_context_data(self, **kwargs):
entry = self.object
next_entry = (
Entry.objects.published()
- .select_related("author")
+ .select_related("author", "author__displayed_profile_role_library")
.prefetch_related(*self.AUTHOR_PREFETCH)
.filter(publish_at__gt=entry.publish_at, deleted_at__isnull=True)
.exclude(pk=entry.pk)
@@ -288,7 +289,7 @@ def get_v3_context_data(self, **kwargs):
# relation exists.
related_qs = (
Entry.objects.published()
- .select_related("author")
+ .select_related("author", "author__displayed_profile_role_library")
.prefetch_related(*self.AUTHOR_PREFETCH)
.filter(deleted_at__isnull=True)
.exclude(pk=entry.pk)
@@ -296,7 +297,7 @@ def get_v3_context_data(self, **kwargs):
if next_entry is not None:
related_qs = related_qs.exclude(pk=next_entry.pk)
v3_context = {
- "post_author": user_profile_card(entry.author),
+ "post_author": entry.author.to_v3_profile_dict(),
"post_tag": news_type_label(entry.tag),
"next_post_items": (
[self._post_card_item(next_entry)] if next_entry else []
@@ -317,7 +318,7 @@ def _post_card_item(cls, entry):
"url": reverse("news-detail", args=[entry.slug]),
"date": entry.publish_at,
"category": news_type_label(entry.tag),
- "author": user_profile_card(entry.author),
+ "author": entry.author.to_v3_profile_dict(),
}
def get_context_data(self, **kwargs):
diff --git a/static/css/v3/user-profile-page.css b/static/css/v3/user-profile-page.css
index 8919af7ac..6834e41ae 100644
--- a/static/css/v3/user-profile-page.css
+++ b/static/css/v3/user-profile-page.css
@@ -125,6 +125,10 @@
margin: var(--space-default) 0
}
+.user-profile__edit-form {
+ display: contents;
+}
+
.user-profile__badge-select {
padding: 6px;
display: flex;
diff --git a/templates/v3/posts_list.html b/templates/v3/posts_list.html
index 9dceb0882..12a00d168 100644
--- a/templates/v3/posts_list.html
+++ b/templates/v3/posts_list.html
@@ -38,7 +38,7 @@
Posts
{% if request.user.is_authenticated %}
{% with u=request.user %}
{% url 'v3-news-create' as create_url %}
- {% include 'v3/includes/_user_card.html' with username=u.display_name avatar_url=u.avatar_url badge_name='Bug Catcher' badge_icon_src=u.badge_url member_since=u.year_joined role='Contributor' flag_emoji=u.flag_emoji cta_url=create_url cta_label='Create Post' only %}
+ {% include 'v3/includes/_user_card.html' with username=u.display_name avatar_url=u.avatar_url badge_name='Bug Catcher' badge_icon_src=u.badge_url member_since=u.year_joined role=u.role flag_emoji=u.flag_emoji cta_url=create_url cta_label='Create Post' only %}
{% endwith %}
{% else %}
{% include 'v3/includes/_user_card.html' with logged_out=True cta_url='#' cta_label='Create Post' only %}
diff --git a/templates/v3/user_profile_edit.html b/templates/v3/user_profile_edit.html
index 2d3e7c078..d6d573307 100644
--- a/templates/v3/user_profile_edit.html
+++ b/templates/v3/user_profile_edit.html
@@ -88,7 +88,11 @@
- {% include 'v3/includes/_field_include.html' with bound_field=user_profile_form.role %}
+ {% if user_profile_form.role.field.disabled %}
+ {% include 'v3/includes/_field_include.html' with bound_field=user_profile_form.role %}
+ {% else %}
+ {% include 'v3/includes/_field_combo.html' with name=user_profile_form.role.name label=user_profile_form.role.label options=user_profile_form.role.field.choices selected=user_profile_form.role.value placeholder="Search roles..." only %}
+ {% endif %}
@@ -140,7 +144,7 @@
- {% comment %} Persists the Profile links to the current user via PATCH /api/v1/users/me/. {% endcomment %}
+ {% comment %} Persists the Profile links and role to the current user via PATCH /api/v1/users/me/. {% endcomment %}
{% include 'v3/includes/_button.html' with style='primary' type='button' label='Save Changes' alpine_disabled="saving" alpine_click="save()" only %}
@@ -497,6 +501,15 @@
return links;
},
+ // The role dropdown's value: the combo's hidden input once Alpine is
+ // ready (a sibling x-data scope, so read it from the DOM). null when the
+ // field is absent (the user holds no roles) so the payload omits role
+ // and leaves the stored value untouched.
+ roleValue() {
+ const el = [...document.querySelectorAll('[name="role"]')].find((e) => !e.disabled);
+ return el ? el.value : null;
+ },
+
async save() {
if (this.saving) return;
@@ -512,6 +525,9 @@
this.saving = true;
this.saveStatus = '';
this.saveError = '';
+ const payload = { profile_links: this.collectLinks() };
+ const role = this.roleValue();
+ if (role !== null) payload.role = role;
try {
const res = await fetch('/api/v1/users/me/', {
method: 'PATCH',
@@ -519,7 +535,7 @@
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
- body: JSON.stringify({ profile_links: this.collectLinks() }),
+ body: JSON.stringify(payload),
});
if (!res.ok) {
let data = {};
diff --git a/users/admin.py b/users/admin.py
index ad792959b..8c59d816a 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -1,12 +1,57 @@
+from collections import defaultdict
+
+from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.forms import UserChangeForm
+from django.utils.html import format_html, format_html_join
from django.utils.translation import gettext_lazy as _
-from .models import User
+from .models import ProfileRole, User
+
+
+class EmailUserAdminForm(UserChangeForm):
+ """Admin change form for User.
+
+ Staff assign the internal C++ Alliance title here (internal_role). Library
+ roles (Author/Maintainer/Contributor) and the user's featured selection are
+ derived from repo data / chosen by the user and shown read-only below.
+ """
+
+ class Meta(UserChangeForm.Meta):
+ model = User
+ fields = "__all__"
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["internal_role"].label = "C++ Alliance title"
+
+ def clean_internal_role(self):
+ """Block assigning a singular C++ Alliance title already held by someone.
+
+ The partial unique index on the model is the hard guarantee; this names
+ the current holder so staff know where to clear it first.
+ """
+ value = self.cleaned_data.get("internal_role", "")
+ if value in ProfileRole.singular_roles():
+ holder = (
+ User.objects.exclude(pk=self.instance.pk)
+ .filter(internal_role=value)
+ .first()
+ )
+ if holder:
+ raise forms.ValidationError(
+ f"{ProfileRole(value).label} is already assigned to "
+ f"{holder.display_name or holder.email}. Clear it from that "
+ "profile first."
+ )
+ return value
@admin.register(User)
class EmailUserAdmin(UserAdmin):
+ form = EmailUserAdminForm
+ readonly_fields = ("role_eligibility_display",)
fieldsets = (
(None, {"fields": ("email", "password")}),
(
@@ -15,6 +60,8 @@ class EmailUserAdmin(UserAdmin):
"fields": (
"display_name",
"github_username",
+ "internal_role",
+ "role_eligibility_display",
"valid_email",
"claimed",
)
@@ -60,3 +107,26 @@ class EmailUserAdmin(UserAdmin):
"claimed",
)
search_fields = ("email", "display_name__unaccent")
+
+ @admin.display(description=_("Derived library roles"))
+ def role_eligibility_display(self, obj):
+ """Read-only summary of the library roles the user holds.
+
+ Sourced live from `User.get_role_library_options()`. This is what the
+ user may feature; it is not editable here.
+ """
+ if obj is None or obj.pk is None:
+ return "—"
+ grouped = defaultdict(list)
+ for option in obj.get_role_library_options():
+ grouped[option["role"]].append(option["library"].name)
+ if not grouped:
+ return "—"
+ return format_html_join(
+ "",
+ "{}: {}
",
+ (
+ (ProfileRole(role).label, ", ".join(sorted(libraries)))
+ for role, libraries in grouped.items()
+ ),
+ ) or format_html("—")
diff --git a/users/forms.py b/users/forms.py
index 89040f2eb..b2e58c175 100644
--- a/users/forms.py
+++ b/users/forms.py
@@ -7,7 +7,7 @@
from allauth.account.forms import ResetPasswordKeyForm, SignupForm
-from .models import Preferences
+from .models import Preferences, compose_role_label, encode_role_option
from news.models import NEWS_MODELS
from news.acl import can_approve
@@ -227,7 +227,24 @@ class V3UserProfileForm(forms.Form):
def __init__(self, *args, **kwargs):
links = kwargs.pop("user_links", None)
commit_emails = kwargs.pop("commit_emails", None)
+ role_options = kwargs.pop("role_options", None) or []
super().__init__(*args, **kwargs)
+
+ # Choices are the roles this user holds, encoded via encode_role_option;
+ # internal C++ Alliance titles are admin-only and never offered here.
+ self.fields["role"].choices = [
+ (
+ encode_role_option(o["role"], o["library"].id if o["library"] else ""),
+ compose_role_label(o["role"], o["library"]),
+ )
+ for o in role_options
+ ]
+ if not role_options:
+ self.fields["role"].disabled = True
+ self.fields["role"].widget.attrs[
+ "placeholder"
+ ] = "Contribute to a library to unlock a role"
+
if links:
self.link_formset = V3ProfileLinkFormset(
initial=[
@@ -276,9 +293,7 @@ def __init__(self, *args, **kwargs):
avatar = forms.ImageField(required=False)
delete_avatar = forms.BooleanField(required=False)
- role = forms.ChoiceField(
- choices=[(0, "C++ Alliance Board Member")], label="Your Role"
- )
+ role = forms.ChoiceField(choices=[], required=False, label="Your Role")
select_title = forms.ChoiceField(
choices=[],
disabled=True,
diff --git a/users/migrations/0023_user_displayed_profile_role_and_more.py b/users/migrations/0023_user_displayed_profile_role_and_more.py
new file mode 100644
index 000000000..1239ff25c
--- /dev/null
+++ b/users/migrations/0023_user_displayed_profile_role_and_more.py
@@ -0,0 +1,144 @@
+# Generated by Django 6.0.2 on 2026-07-15 00:18
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("libraries", "0041_category_short_description"),
+ ("users", "0022_user_profile_links"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="displayed_profile_role",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("Author", "Author"),
+ ("Maintainer", "Maintainer"),
+ ("Contributor", "Contributor"),
+ ("chief_of_staff", "Chief of Staff, C++ Alliance"),
+ ("ceo", "CEO, C++ Alliance"),
+ ("cto", "CTO, C++ Alliance"),
+ ("cfo_coo", "CFO/COO, C++ Alliance"),
+ ("cmo", "CMO, C++ Alliance"),
+ ("board_member", "Board Member, C++ Alliance"),
+ (
+ "senior_exec_assistant",
+ "Senior Executive Assistant, C++ Alliance",
+ ),
+ (
+ "creative_production_coordinator",
+ "Creative Production Coordinator, C++ Alliance",
+ ),
+ ("software_engineer", "Software Engineer, C++ Alliance"),
+ (
+ "senior_technical_writer",
+ "Senior Technical Writer, C++ Alliance",
+ ),
+ ("executive_team_alumni", "Executive Team, C++ Alliance Alumni"),
+ (
+ "technical_committee_alumni",
+ "Technical Committee, C++ Alliance Alumni",
+ ),
+ (
+ "software_engineer_alumni",
+ "Software Engineer, C++ Alliance Alumni",
+ ),
+ (
+ "fiscal_committee_member",
+ "Fiscal Committee Member, C++ Alliance",
+ ),
+ ],
+ default="",
+ help_text="Library role the user has chosen to feature (or blank to use their default). User-controlled; C++ Alliance titles live in internal_role.",
+ max_length=64,
+ ),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="displayed_profile_role_library",
+ field=models.ForeignKey(
+ blank=True,
+ help_text='Optional library that scopes a library role, e.g. Beast -> "Boost.Beast Author". Leave blank for a generic role.',
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to="libraries.library",
+ ),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="internal_role",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("board_member", "Board Member, C++ Alliance"),
+ ("ceo", "CEO, C++ Alliance"),
+ ("cfo_coo", "CFO/COO, C++ Alliance"),
+ ("chief_of_staff", "Chief of Staff, C++ Alliance"),
+ ("cmo", "CMO, C++ Alliance"),
+ (
+ "creative_production_coordinator",
+ "Creative Production Coordinator, C++ Alliance",
+ ),
+ ("cto", "CTO, C++ Alliance"),
+ ("executive_team_alumni", "Executive Team, C++ Alliance Alumni"),
+ (
+ "fiscal_committee_member",
+ "Fiscal Committee Member, C++ Alliance",
+ ),
+ (
+ "senior_exec_assistant",
+ "Senior Executive Assistant, C++ Alliance",
+ ),
+ (
+ "senior_technical_writer",
+ "Senior Technical Writer, C++ Alliance",
+ ),
+ ("software_engineer", "Software Engineer, C++ Alliance"),
+ (
+ "software_engineer_alumni",
+ "Software Engineer, C++ Alliance Alumni",
+ ),
+ (
+ "technical_committee_alumni",
+ "Technical Committee, C++ Alliance Alumni",
+ ),
+ ],
+ default="",
+ help_text="C++ Alliance title, assigned by staff. This is the user's default displayed role; they may instead feature a library role they hold.",
+ max_length=64,
+ ),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="resolved_profile_role",
+ field=models.CharField(
+ 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.",
+ max_length=64,
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="user",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(
+ (
+ "internal_role__in",
+ ("ceo", "cfo_coo", "chief_of_staff", "cmo", "cto"),
+ )
+ ),
+ fields=("internal_role",),
+ name="unique_singular_profile_role",
+ violation_error_message="This C++ Alliance title is already assigned to another user.",
+ ),
+ ),
+ ]
diff --git a/users/models.py b/users/models.py
index ab538d19e..209841d05 100644
--- a/users/models.py
+++ b/users/models.py
@@ -192,6 +192,113 @@ class Badge(models.Model):
display_name = models.CharField(_("display name"), max_length=100, blank=True)
+class ProfileRole(models.TextChoices):
+ """Roles shown on profile surfaces.
+
+ Library-derived roles are self-selectable by users who hold them (stored in
+ `User.displayed_profile_role`); internal C++ Alliance titles are assignable
+ via the Django admin only (stored in `User.internal_role`).
+ """
+
+ # Library-derived (user-selectable)
+ AUTHOR = "Author", "Author"
+ MAINTAINER = "Maintainer", "Maintainer"
+ CONTRIBUTOR = "Contributor", "Contributor"
+
+ # Internal C++ Alliance titles (admin-only)
+ CHIEF_OF_STAFF = "chief_of_staff", "Chief of Staff, C++ Alliance"
+ CEO = "ceo", "CEO, C++ Alliance"
+ CTO = "cto", "CTO, C++ Alliance"
+ CFO_COO = "cfo_coo", "CFO/COO, C++ Alliance"
+ CMO = "cmo", "CMO, C++ Alliance"
+ BOARD_MEMBER = "board_member", "Board Member, C++ Alliance"
+ SENIOR_EXEC_ASSISTANT = (
+ "senior_exec_assistant",
+ "Senior Executive Assistant, C++ Alliance",
+ )
+ CREATIVE_PRODUCTION_COORDINATOR = (
+ "creative_production_coordinator",
+ "Creative Production Coordinator, C++ Alliance",
+ )
+ SOFTWARE_ENGINEER = "software_engineer", "Software Engineer, C++ Alliance"
+ SENIOR_TECHNICAL_WRITER = (
+ "senior_technical_writer",
+ "Senior Technical Writer, C++ Alliance",
+ )
+ EXECUTIVE_TEAM_ALUMNI = (
+ "executive_team_alumni",
+ "Executive Team, C++ Alliance Alumni",
+ )
+ TECHNICAL_COMMITTEE_ALUMNI = (
+ "technical_committee_alumni",
+ "Technical Committee, C++ Alliance Alumni",
+ )
+ SOFTWARE_ENGINEER_ALUMNI = (
+ "software_engineer_alumni",
+ "Software Engineer, C++ Alliance Alumni",
+ )
+ FISCAL_COMMITTEE_MEMBER = (
+ "fiscal_committee_member",
+ "Fiscal Committee Member, C++ Alliance",
+ )
+
+ @classmethod
+ def library_role_precedence(cls):
+ """Library roles high-to-low, used to pick a default when a user holds several."""
+ return [cls.AUTHOR.value, cls.MAINTAINER.value, cls.CONTRIBUTOR.value]
+
+ @classmethod
+ def library_roles(cls):
+ return frozenset(cls.library_role_precedence())
+
+ @classmethod
+ def internal_roles(cls):
+ return frozenset(cls.values) - cls.library_roles()
+
+ @classmethod
+ def singular_roles(cls):
+ """Internal titles that at most one user may hold.
+
+ Returns a sorted tuple so the migration constraint condition serializes
+ deterministically.
+ """
+ return (
+ cls.CEO.value,
+ cls.CFO_COO.value,
+ cls.CHIEF_OF_STAFF.value,
+ cls.CMO.value,
+ cls.CTO.value,
+ )
+
+
+def encode_role_option(role, library_id):
+ """Encode a (role, library) pair as a single dropdown option value.
+
+ A falsy `library_id` encodes a generic, library-less role (e.g. "Author:").
+ """
+ return f"{role}:{library_id or ''}"
+
+
+def decode_role_option(value):
+ """Decode an encoded option value into (role, library_id).
+
+ Returns (role, None) for an unscoped role and ("", None) for an empty value.
+ """
+ if not value:
+ return "", None
+ role, _, library_id = value.partition(":")
+ return role, (int(library_id) if library_id.isdigit() else None)
+
+
+def compose_role_label(role, library):
+ """Human label for a (role, library) pair, e.g. "Boost.Beast Author".
+
+ Without a library the plain role label is used (generic roles and titles).
+ """
+ label = ProfileRole(role).label
+ return f"{library.display_name} {label}" if library is not None else label
+
+
class User(BaseUser):
"""
Our custom user model.
@@ -249,6 +356,51 @@ class User(BaseUser):
),
)
display_name = models.CharField(max_length=255, blank=True, null=True)
+ displayed_profile_role = models.CharField(
+ max_length=64,
+ choices=ProfileRole,
+ blank=True,
+ default="",
+ help_text=_(
+ "Library role the user has chosen to feature (or blank to use their "
+ "default). User-controlled; C++ Alliance titles live in internal_role."
+ ),
+ )
+ displayed_profile_role_library = models.ForeignKey(
+ "libraries.Library",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="+",
+ help_text=_(
+ "Optional library that scopes a library role, e.g. Beast -> "
+ '"Boost.Beast Author". Leave blank for a generic role.'
+ ),
+ )
+ internal_role = models.CharField(
+ max_length=64,
+ choices=[
+ (r.value, r.label)
+ for r in sorted(ProfileRole, key=lambda r: r.value)
+ if r.value in ProfileRole.internal_roles()
+ ],
+ blank=True,
+ default="",
+ help_text=_(
+ "C++ Alliance title, assigned by staff. This is the user's default "
+ "displayed role; they may instead feature a library role they hold."
+ ),
+ )
+ 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."
+ ),
+ )
can_update_image = models.BooleanField(
_("can_update_image"),
default=True,
@@ -276,6 +428,18 @@ class User(BaseUser):
# elapsed.
delete_permanently_at = models.DateTimeField(null=True, editable=False)
+ class Meta(BaseUser.Meta):
+ constraints = [
+ models.UniqueConstraint(
+ fields=["internal_role"],
+ condition=models.Q(internal_role__in=ProfileRole.singular_roles()),
+ name="unique_singular_profile_role",
+ violation_error_message=(
+ "This C++ Alliance title is already assigned to another user."
+ ),
+ )
+ ]
+
def delete_cached_thumbnail(self):
"""Delete the cached ImageKit thumbnail so it regenerates on next access."""
if not self.profile_image:
@@ -377,12 +541,117 @@ def badge_url(self):
"""
return large_static("img/v3/badges/badge-gold-medal.png")
+ def _effective_role(self):
+ """The (role_type, library) currently displayed, or (None, None).
+
+ Reads only columns (no queries): user's chosen library role → internal
+ C++ Alliance title → auto-derived top library role (`resolved_profile_role`,
+ maintained by `recompute_displayed_profile_roles`).
+ """
+ if self.displayed_profile_role in ProfileRole.library_roles():
+ return self.displayed_profile_role, self.displayed_profile_role_library
+ if self.internal_role:
+ return self.internal_role, None
+ if self.resolved_profile_role:
+ return self.resolved_profile_role, None
+ return None, None
+
@cached_property
def role(self):
+ """Display role shown on profile surfaces (single source of truth).
+
+ A library role is scoped to its library ("Boost.Beast Author") or generic
+ ("Author"); a C++ Alliance title renders as-is. See `_effective_role`.
"""
- TODO: This is currently dummy data for testing
+ role_type, library = self._effective_role()
+ return compose_role_label(role_type, library) if role_type else ""
+
+ @property
+ def encoded_displayed_role(self):
+ """Encoded option value used to preselect the edit-page dropdown.
+
+ Empty when there is no role to show; library-less roles encode as "role:".
+ """
+ role_type, library = self._effective_role()
+ if not role_type:
+ return ""
+ return encode_role_option(role_type, library.id if library else "")
+
+ def get_role_options(self):
+ """Selectable role options for the edit-page dropdown.
+
+ A generic, library-less option (e.g. "Author") is offered at the top for
+ each role the user holds in at least one library, in precedence order,
+ followed by the library-scoped options (e.g. "Boost.Beast Author"). A
+ generic option carries `library=None`.
+
+ Users can't assign internal C++ Alliance titles, but if an admin has
+ assigned one (internal_role) it is the user's default displayed role:
+ it is offered first and preselected. It is the only title a user may
+ (re-)select.
"""
- return "Contributor"
+ scoped = self.get_role_library_options()
+ generic, seen = [], set()
+ for option in scoped: # already precedence-ordered
+ if option["role"] not in seen:
+ seen.add(option["role"])
+ generic.append({"role": option["role"], "library": None})
+ options = generic + scoped
+ if self.internal_role:
+ options.insert(0, {"role": self.internal_role, "library": None})
+ return options
+
+ def get_role_library_options(self):
+ """Eligible (role, library) pairs the user holds, ordered for selection.
+
+ Sorted by role precedence (Author > Maintainer > Contributor), then
+ commit count (most active first), then library name. Computed live
+ per-user; consumed by the edit dropdown, admin display, and profile page.
+ """
+ precedence = {
+ role: index
+ for index, role in enumerate(ProfileRole.library_role_precedence())
+ }
+
+ def sort_key(pair):
+ role, library, commit_count = pair
+ return (
+ precedence.get(role, len(precedence)),
+ -commit_count,
+ library.display_name,
+ )
+
+ pairs = sorted(self._role_library_pairs(), key=sort_key)
+ return [{"role": role, "library": library} for role, library, _ in pairs]
+
+ def _role_library_pairs(self):
+ """(role, library, commit_count) tuples the user holds, computed live."""
+ from libraries.models import Library, Commit
+ from django.db.models import Count
+
+ commit_counts = dict(
+ Commit.objects.filter(author__user_id=self.pk)
+ .values_list("library_version__library_id")
+ .annotate(n=Count("id"))
+ )
+
+ def count_for(library_id):
+ return commit_counts.get(library_id, 0)
+
+ pairs = []
+ for library in Library.objects.filter(authors=self):
+ pairs.append((ProfileRole.AUTHOR.value, library, count_for(library.id)))
+ maintained = Library.objects.filter(
+ library_version__maintainers=self
+ ).distinct()
+ for library in maintained:
+ pairs.append((ProfileRole.MAINTAINER.value, library, count_for(library.id)))
+ contributed = Library.objects.filter(id__in=commit_counts.keys())
+ for library in contributed:
+ pairs.append(
+ (ProfileRole.CONTRIBUTOR.value, library, count_for(library.id))
+ )
+ return pairs
@cached_property
def flag_emoji(self):
diff --git a/users/profile_cards.py b/users/profile_cards.py
deleted file mode 100644
index 9aaeec996..000000000
--- a/users/profile_cards.py
+++ /dev/null
@@ -1,15 +0,0 @@
-def user_profile_card(user):
- """Build the dict consumed by v3/includes/_user_profile.html.
-
- Callers should prefetch "maintainers" on the user.
- """
- is_maintainer = bool(user.maintainers.all())
- return {
- "name": user.display_name,
- "profile_url": user.github_profile_url or "",
- "role": "Maintainer" if is_maintainer else "Contributor",
- "avatar_url": user.get_avatar_url(),
- # Placeholder until real per-user badge data lands; matches the
- # /news/ post list, which reads User.badge_url directly.
- "badge_url": user.badge_url,
- }
diff --git a/users/serializers.py b/users/serializers.py
index 280456aa1..40d8ce788 100644
--- a/users/serializers.py
+++ b/users/serializers.py
@@ -4,7 +4,7 @@
from rest_framework import serializers
from .forms import SLACK_PROFILE_URL_PREFIX, V3ProfileLinkChoices
-from .models import User
+from .models import ProfileRole, User, decode_role_option
SECURE_LINK_TYPES = {V3ProfileLinkChoices.GITHUB, V3ProfileLinkChoices.WEBSITE}
SLACK_MEMBER_ID_PATTERN = re.compile(r"^[A-Z0-9]{9,11}$", re.IGNORECASE)
@@ -85,6 +85,34 @@ def validate_profile_links(self, value):
raise serializers.ValidationError(field_errors)
return value
+ # Encoded role:library selection; write-only, applied in update().
+ role = serializers.CharField(write_only=True, required=False, allow_blank=True)
+
+ def validate_role(self, value):
+ """Re-validate the encoded (role, library) against the user's own options
+ so they can never feature a role they don't hold."""
+ if value and self.instance:
+ role, library_id = decode_role_option(value)
+ eligible = {
+ (o["role"], o["library"].id if o["library"] else None)
+ for o in self.instance.get_role_options()
+ }
+ if (role, library_id) not in eligible:
+ raise serializers.ValidationError("You cannot select that role.")
+ return value
+
+ def update(self, instance, validated_data):
+ if "role" in validated_data:
+ role, library_id = decode_role_option(validated_data.pop("role"))
+ if role in ProfileRole.internal_roles():
+ # The title is the user's default; store it as a cleared override.
+ instance.displayed_profile_role = ""
+ instance.displayed_profile_role_library = None
+ else:
+ instance.displayed_profile_role = role
+ instance.displayed_profile_role_library_id = library_id
+ return super().update(instance, validated_data)
+
class Meta:
model = User
fields = (
@@ -95,6 +123,7 @@ class Meta:
"date_joined",
"data",
"profile_links",
+ "role",
)
read_only_fields = (
"id",
diff --git a/users/tasks.py b/users/tasks.py
index d0913e87c..541a4b839 100644
--- a/users/tasks.py
+++ b/users/tasks.py
@@ -5,6 +5,7 @@
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
+from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from django.conf import settings
@@ -126,3 +127,81 @@ def remove_unverified_users():
except Exception as e:
logger.exception(f"Error occurred processing unverified users for removal: {e}")
+
+
+@app.task
+def recompute_displayed_profile_roles():
+ """Recompute every user's auto-derived top library role.
+
+ Writes `User.resolved_profile_role` (the fallback shown when a user has
+ chosen neither a library role nor holds an internal title) and clears any
+ user-selected `displayed_profile_role` that is no longer valid. Enqueued
+ after author/maintainer and commit imports, with a daily safety run via beat.
+ """
+ from collections import defaultdict
+
+ from libraries.models import Commit, Library, LibraryVersion
+ from users.models import ProfileRole
+
+ # (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)
+ logger.info(
+ "recompute_displayed_profile_roles finished",
+ resolved=len(top),
+ cleared_choices=cleared_choices,
+ )
diff --git a/users/tests/test_profile_role.py b/users/tests/test_profile_role.py
new file mode 100644
index 000000000..8f5f6b8da
--- /dev/null
+++ b/users/tests/test_profile_role.py
@@ -0,0 +1,531 @@
+"""Tests for the displayed profile role feature (library-scoped roles)."""
+
+import pytest
+from model_bakery import baker
+
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+from django.db import IntegrityError, transaction
+
+from django.contrib.admin.sites import site
+
+from users.admin import EmailUserAdmin, EmailUserAdminForm
+from users.models import (
+ ProfileRole,
+ decode_role_option,
+ encode_role_option,
+)
+from users.serializers import CurrentUserSerializer
+
+User = get_user_model()
+
+
+def _apply_role(user, value):
+ """Apply a role selection through the serializer used by the AJAX save.
+
+ Saves when valid; returns the serializer so callers can assert on errors.
+ """
+ serializer = CurrentUserSerializer(
+ instance=user, data={"role": value}, partial=True
+ )
+ if serializer.is_valid():
+ serializer.save()
+ return serializer
+
+
+@pytest.fixture
+def library(db):
+ return baker.make("libraries.Library", name="Beast")
+
+
+@pytest.fixture
+def other_library(db):
+ return baker.make("libraries.Library", name="Math")
+
+
+def _make_version(library):
+ version = baker.make("versions.Version")
+ return baker.make("libraries.LibraryVersion", library=library, version=version)
+
+
+def _add_commits(user, library, n):
+ author = baker.make("libraries.CommitAuthor", name="Author", user=user)
+ lv = _make_version(library)
+ baker.make("libraries.Commit", author=author, library_version=lv, _quantity=n)
+
+
+def _recompute():
+ """Run the import-time recompute that populates resolved_profile_role.
+
+ The auto-derived role is a snapshot maintained by this task, so tests that
+ assert it must simulate the import having run.
+ """
+ from users.tasks import recompute_displayed_profile_roles
+
+ recompute_displayed_profile_roles()
+
+
+# --- option encoding helpers -------------------------------------------------
+
+
+def test_encode_decode_roundtrip():
+ assert encode_role_option("Author", 42) == "Author:42"
+ assert decode_role_option("Author:42") == ("Author", 42)
+
+
+def test_decode_empty_and_unscoped():
+ assert decode_role_option("") == ("", None)
+ assert decode_role_option("ceo:") == ("ceo", None)
+
+
+# --- ProfileRole enum --------------------------------------------------------
+
+
+def test_role_groups_are_disjoint():
+ assert ProfileRole.library_roles() == {"Author", "Maintainer", "Contributor"}
+ assert "ceo" in ProfileRole.internal_roles()
+ assert not (ProfileRole.library_roles() & ProfileRole.internal_roles())
+
+
+# --- eligibility (live per-user queries) ------------------------------------
+
+
+def test_options_empty_for_user_without_contributions(user):
+ assert user.get_role_library_options() == []
+ assert user.role == ""
+
+
+def test_author_option_and_scoped_label(user, library):
+ library.authors.add(user)
+ options = user.get_role_library_options()
+ assert [(o["role"], o["library"].id) for o in options] == [("Author", library.id)]
+ # No explicit choice -> default is the generic label for the top role.
+ _recompute()
+ assert User.objects.get(pk=user.pk).role == "Author"
+
+
+def test_precedence_orders_author_before_maintainer(user, library, other_library):
+ library.authors.add(user)
+ _make_version(other_library).maintainers.add(user)
+ roles = [o["role"] for o in user.get_role_library_options()]
+ assert roles.index("Author") < roles.index("Maintainer")
+ _recompute()
+ assert User.objects.get(pk=user.pk).role == "Author"
+
+
+def test_top_library_ranked_by_commit_count(user, library, other_library):
+ library.authors.add(user)
+ other_library.authors.add(user)
+ _add_commits(user, other_library, 5)
+ _add_commits(user, library, 1)
+ # Commit count ranks the scoped options: the busier library comes first.
+ libs = [o["library"].display_name for o in user.get_role_library_options()]
+ assert libs.index("Boost.Math") < libs.index("Boost.Beast")
+ # The default itself is the generic label.
+ _recompute()
+ assert User.objects.get(pk=user.pk).role == "Author"
+
+
+def test_contributor_role_from_commits_only(user, library):
+ _add_commits(user, library, 3)
+ roles = [o["role"] for o in user.get_role_library_options()]
+ assert roles == ["Contributor"]
+ _recompute()
+ assert User.objects.get(pk=user.pk).role == "Contributor"
+
+
+# --- display composition -----------------------------------------------------
+
+
+def test_explicit_library_scopes_the_role(user, library, other_library):
+ library.authors.add(user)
+ other_library.authors.add(user)
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = other_library
+ user.save()
+ assert User.objects.get(pk=user.pk).role == "Boost.Math Author"
+
+
+def test_internal_title_renders_without_library(user):
+ user.internal_role = ProfileRole.CEO.value
+ user.save()
+ assert user.role == "CEO, C++ Alliance"
+
+
+def test_user_library_choice_wins_over_assigned_title(user, library):
+ """A user's featured library role takes display precedence; the title
+ (internal_role) is preserved as their default, not lost."""
+ library.authors.add(user)
+ user.internal_role = ProfileRole.CEO.value
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = library
+ user.save()
+ user = User.objects.get(pk=user.pk)
+ assert user.role == "Boost.Beast Author"
+ assert user.internal_role == ProfileRole.CEO.value # not lost
+
+
+def test_encoded_displayed_role_preselects_top_option(user, library):
+ library.authors.add(user)
+ _recompute()
+ # Default preselects the generic option for the top role.
+ assert User.objects.get(pk=user.pk).encoded_displayed_role == encode_role_option(
+ "Author", ""
+ )
+
+
+def test_encoded_displayed_role_preselects_internal_title(user):
+ user.internal_role = ProfileRole.CEO.value
+ user.save()
+ assert user.encoded_displayed_role == encode_role_option(ProfileRole.CEO.value, "")
+
+
+def test_internal_title_is_first_and_default_option(user, library):
+ """An assigned internal title heads the dropdown and is the default."""
+ library.authors.add(user) # also holds a library role
+ user.internal_role = ProfileRole.CEO.value
+ user.save()
+ options = user.get_role_options()
+ assert options[0] == {"role": ProfileRole.CEO.value, "library": None}
+ # And it's the preselected value (no user library choice yet).
+ assert user.encoded_displayed_role == encode_role_option(ProfileRole.CEO.value, "")
+
+
+def test_dropdown_order_is_internal_then_generic_then_specific(
+ user, library, other_library
+):
+ """Dropdown tiers, in order: internal title, generic labels, specific ones."""
+ library.authors.add(user)
+ other_library.authors.add(user)
+ user.internal_role = ProfileRole.CEO.value
+ user.save()
+ options = user.get_role_options()
+ # Tier 1: the internal title.
+ assert options[0] == {"role": ProfileRole.CEO.value, "library": None}
+ # Tier 2: generic (library-less) library roles.
+ assert options[1] == {"role": "Author", "library": None}
+ # Tier 3: specific library-scoped options, all after the generic ones.
+ specific = [o for o in options if o["library"] is not None]
+ first_specific = options.index(specific[0])
+ assert all(o["library"] is None for o in options[:first_specific])
+
+
+def test_selecting_title_clears_library_override(user, library):
+ """Selecting the assigned title clears the user's library-role override so
+ the role falls back to the title (stored only in internal_role)."""
+ library.authors.add(user)
+ user.internal_role = ProfileRole.CEO.value
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = library
+ user.save()
+ _apply_role(user, encode_role_option(ProfileRole.CEO.value, ""))
+ user.refresh_from_db()
+ assert user.displayed_profile_role == "" # override cleared, not overwritten
+ assert user.displayed_profile_role_library_id is None
+ assert user.internal_role == ProfileRole.CEO.value
+ assert user.role == "CEO, C++ Alliance"
+
+
+def test_switching_to_library_role_keeps_title(user, library):
+ """The core of the split: featuring a library role never loses the title."""
+ library.authors.add(user)
+ user.internal_role = ProfileRole.CEO.value
+ user.save()
+ _apply_role(user, encode_role_option(ProfileRole.AUTHOR.value, library.id))
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ProfileRole.AUTHOR.value
+ assert user.internal_role == ProfileRole.CEO.value # preserved
+ assert user.role == "Boost.Beast Author"
+ # The title is still offered first so the user can switch back.
+ assert user.get_role_options()[0] == {
+ "role": ProfileRole.CEO.value,
+ "library": None,
+ }
+
+
+# --- generic (library-less) options ------------------------------------------
+
+
+def test_generic_options_prepended_per_held_role(user, library, other_library):
+ library.authors.add(user)
+ _make_version(other_library).maintainers.add(user)
+ options = user.get_role_options()
+ # One generic (library=None) entry per held role, in precedence order, first.
+ generic = [o for o in options if o["library"] is None]
+ assert [o["role"] for o in generic] == ["Author", "Maintainer"]
+ # Followed by the library-scoped options.
+ assert all(o["library"] is not None for o in options[len(generic) :])
+
+
+def test_generic_option_only_offered_for_held_roles(user, library):
+ library.authors.add(user) # Author only
+ generic_roles = {o["role"] for o in user.get_role_options() if o["library"] is None}
+ assert generic_roles == {"Author"}
+
+
+def test_generic_role_renders_without_library(user, library):
+ library.authors.add(user)
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = None
+ user.save()
+ assert User.objects.get(pk=user.pk).role == "Author"
+
+
+def test_encoded_displayed_role_for_generic_selection(user, library):
+ library.authors.add(user)
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = None
+ user.save()
+ assert user.encoded_displayed_role == encode_role_option("Author", "")
+
+
+def test_post_persists_generic_role(user, library):
+ library.authors.add(user)
+ _apply_role(user, encode_role_option(ProfileRole.AUTHOR.value, ""))
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ProfileRole.AUTHOR.value
+ assert user.displayed_profile_role_library_id is None
+ assert user.role == "Author"
+
+
+# --- edit-page role save (AJAX serializer path) ------------------------------
+
+
+def test_post_persists_scoped_role(user, library):
+ library.authors.add(user)
+ _apply_role(user, encode_role_option(ProfileRole.AUTHOR.value, library.id))
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ProfileRole.AUTHOR.value
+ assert user.displayed_profile_role_library_id == library.id
+ assert user.role == "Boost.Beast Author"
+
+
+def test_api_patch_saves_role_and_links(user, library):
+ """End-to-end AJAX path: PATCH /api/v1/users/me/ persists role and links."""
+ from rest_framework.test import APIClient
+
+ library.authors.add(user)
+ client = APIClient()
+ client.force_authenticate(user=user)
+ resp = client.patch(
+ "/api/v1/users/me/",
+ {
+ "role": encode_role_option(ProfileRole.AUTHOR.value, library.id),
+ "profile_links": {"github": "https://github.com/janedoe"},
+ },
+ format="json",
+ )
+ assert resp.status_code == 200, resp.content
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ProfileRole.AUTHOR.value
+ assert user.displayed_profile_role_library_id == library.id
+ assert user.profile_links == {"github": "https://github.com/janedoe"}
+
+
+def test_api_patch_rejects_ineligible_role(user, library, other_library):
+ """A tampered role the user doesn't hold is rejected without persisting."""
+ from rest_framework.test import APIClient
+
+ library.authors.add(user) # eligible for Beast only
+ client = APIClient()
+ client.force_authenticate(user=user)
+ resp = client.patch(
+ "/api/v1/users/me/",
+ {"role": encode_role_option(ProfileRole.AUTHOR.value, other_library.id)},
+ format="json",
+ )
+ assert resp.status_code == 400
+ assert "role" in resp.data
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ""
+
+
+def test_post_rejects_role_for_unheld_library(user, library, other_library):
+ library.authors.add(user) # eligible for Beast only
+ _recompute() # populate the auto-derived fallback
+ user.refresh_from_db() # avoid clobbering resolved_profile_role on save
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = library
+ user.save()
+ tampered = encode_role_option(ProfileRole.AUTHOR.value, other_library.id)
+ serializer = _apply_role(user, tampered)
+ assert "role" in serializer.errors
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ProfileRole.AUTHOR.value
+ assert user.displayed_profile_role_library_id == library.id
+
+
+def test_post_rejects_internal_title_from_user(user, library):
+ library.authors.add(user)
+ _recompute() # populate the auto-derived fallback
+ user.refresh_from_db() # avoid clobbering resolved_profile_role on save
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = library
+ user.save()
+ serializer = _apply_role(user, "ceo:")
+ assert "role" in serializer.errors
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ProfileRole.AUTHOR.value
+ assert user.displayed_profile_role_library_id == library.id
+
+
+def test_post_empty_value_clears_selection(user, library):
+ library.authors.add(user)
+ _recompute() # populate the auto-derived fallback
+ user.refresh_from_db() # avoid clobbering resolved_profile_role on save
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = library
+ user.save()
+ _apply_role(user, "")
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ""
+ assert user.displayed_profile_role_library_id is None
+ # Falls back to the generic auto-picked derived role.
+ assert user.role == "Author"
+
+
+# --- single-holder internal titles -------------------------------------------
+
+
+def test_singular_roles_subset():
+ singular = set(ProfileRole.singular_roles())
+ assert singular == {
+ "ceo",
+ "cfo_coo",
+ "chief_of_staff",
+ "cmo",
+ "cto",
+ }
+ # All are internal, none are library roles.
+ assert singular <= ProfileRole.internal_roles()
+
+
+def test_db_blocks_second_holder_of_singular_title(db):
+ baker.make(User, internal_role=ProfileRole.CEO.value)
+ with pytest.raises(IntegrityError):
+ with transaction.atomic():
+ baker.make(User, internal_role=ProfileRole.CEO.value)
+
+
+def test_full_clean_blocks_second_holder(db):
+ baker.make(User, internal_role=ProfileRole.CEO.value)
+ second = baker.make(User, internal_role="")
+ second.internal_role = ProfileRole.CEO.value
+ with pytest.raises(ValidationError):
+ second.full_clean()
+
+
+def test_multi_holder_titles_allow_duplicates(db):
+ baker.make(User, internal_role=ProfileRole.BOARD_MEMBER.value)
+ # No exception: multiple board members / software engineers are allowed.
+ baker.make(User, internal_role=ProfileRole.BOARD_MEMBER.value)
+ baker.make(User, internal_role=ProfileRole.SOFTWARE_ENGINEER.value)
+ baker.make(User, internal_role=ProfileRole.SOFTWARE_ENGINEER.value)
+
+
+def test_holder_can_resave_own_singular_title(db):
+ holder = baker.make(User, internal_role=ProfileRole.CTO.value)
+ holder.display_name = "Renamed"
+ holder.full_clean() # no false conflict against itself
+ holder.save()
+ holder.refresh_from_db()
+ assert holder.display_name == "Renamed"
+
+
+def test_admin_form_names_current_holder(db):
+ holder = baker.make(
+ User, display_name="Jane Doe", internal_role=ProfileRole.CEO.value
+ )
+ other = baker.make(User, internal_role="")
+ form = EmailUserAdminForm(instance=other)
+ form.cleaned_data = {"internal_role": ProfileRole.CEO.value}
+ with pytest.raises(ValidationError) as exc:
+ form.clean_internal_role()
+ assert "Jane Doe" in str(exc.value)
+ assert holder.display_name in str(exc.value)
+
+
+def test_admin_form_allows_holder_to_keep_title(db):
+ holder = baker.make(User, internal_role=ProfileRole.CEO.value)
+ form = EmailUserAdminForm(instance=holder)
+ form.cleaned_data = {"internal_role": ProfileRole.CEO.value}
+ assert form.clean_internal_role() == ProfileRole.CEO.value
+
+
+# --- admin: library roles derived/read-only, titles only ---------------------
+
+
+def test_admin_form_offers_only_internal_titles(user):
+ form = EmailUserAdminForm(instance=user)
+ values = [c[0] for c in form.fields["internal_role"].choices]
+ assert not any(v in ProfileRole.library_roles() for v in values)
+ assert ProfileRole.CEO.value in values
+ assert "" in values
+
+
+def test_admin_derived_roles_display(user, library):
+ library.authors.add(user)
+ admin_obj = EmailUserAdmin(User, site)
+ html = str(admin_obj.role_eligibility_display(user))
+ assert "Author" in html
+ assert "Beast" in html
+
+
+# --- recompute task (auto-derived role snapshot) -----------------------------
+
+
+def test_recompute_sets_highest_precedence_role(user, library, other_library):
+ library.authors.add(user) # Author
+ _make_version(other_library).maintainers.add(user) # + Maintainer
+ _recompute()
+ user.refresh_from_db()
+ assert user.resolved_profile_role == ProfileRole.AUTHOR.value
+ assert user.role == "Author"
+
+
+def test_recompute_clears_role_when_no_longer_held(user, library):
+ library.authors.add(user)
+ _recompute()
+ user.refresh_from_db()
+ assert user.resolved_profile_role == ProfileRole.AUTHOR.value
+ # Contribution revoked; the next import recompute blanks the derived role.
+ library.authors.remove(user)
+ _recompute()
+ user.refresh_from_db()
+ assert user.resolved_profile_role == ""
+ assert user.role == ""
+
+
+def test_recompute_clears_revoked_scoped_choice(user, library, other_library):
+ """A user's explicit scoped choice is cleared once eligibility is revoked,
+ so .role falls through instead of showing a role they no longer hold."""
+ library.authors.add(user)
+ other_library.authors.add(user)
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = library
+ user.save()
+ # Still an author of Beast -> choice survives the recompute.
+ _recompute()
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ProfileRole.AUTHOR.value
+ # Remove Beast authorship: the specific choice is now invalid.
+ library.authors.remove(user)
+ _recompute()
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ""
+ assert user.displayed_profile_role_library_id is None
+ # Falls through to the derived role (still an author of Math).
+ assert user.role == "Author"
+
+
+def test_recompute_clears_revoked_generic_choice(user, library):
+ """A generic choice (no library) is cleared when the role is held nowhere."""
+ library.authors.add(user)
+ user.displayed_profile_role = ProfileRole.AUTHOR.value
+ user.displayed_profile_role_library = None
+ user.save()
+ library.authors.remove(user) # no longer an author of any library
+ _recompute()
+ user.refresh_from_db()
+ assert user.displayed_profile_role == ""
+ assert user.role == ""
diff --git a/users/views.py b/users/views.py
index 38b218274..25e77aad9 100644
--- a/users/views.py
+++ b/users/views.py
@@ -117,7 +117,11 @@ def get_v3_context_data(self, **kwargs):
if self.request.GET.get("edit", "").lower() == "true":
ctx["user_profile_form"] = V3UserProfileForm(
user_links=user.profile_links,
- initial={"avatar": self.request.user.avatar_url},
+ role_options=user.get_role_options(),
+ initial={
+ "avatar": self.request.user.avatar_url,
+ "role": user.encoded_displayed_role,
+ },
)
ctx["SLACK_PROFILE_URL_PREFIX"] = SLACK_PROFILE_URL_PREFIX
ctx["badge_tiers"] = [
@@ -155,7 +159,7 @@ def get_v3_context_data(self, **kwargs):
"badge": BadgeToken.TIER_5,
},
"member_since": user.date_joined.year,
- "role": "Contributor",
+ "role": user.role,
}
# Data shared between both versions, Boost Github and Mailing List activity