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