Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ak/homepage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
8 changes: 8 additions & 0 deletions config/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
2 changes: 1 addition & 1 deletion core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)

Expand Down
2 changes: 2 additions & 0 deletions libraries/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down
4 changes: 2 additions & 2 deletions news/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand All @@ -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):
Expand Down
17 changes: 9 additions & 8 deletions news/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -288,15 +289,15 @@ 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)
)
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 []
Expand All @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions static/css/v3/user-profile-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@
margin: var(--space-default) 0
}

.user-profile__edit-form {
display: contents;
}

.user-profile__badge-select {
padding: 6px;
display: flex;
Expand Down
2 changes: 1 addition & 1 deletion templates/v3/posts_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ <h1>Posts</h1>
{% 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 %}
Expand Down
22 changes: 19 additions & 3 deletions templates/v3/user_profile_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@
</div>
<hr class="card__hr" aria-hidden="true" />
<div class="card__column px-large">
{% 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 %}
</div>
<hr class="card__hr" aria-hidden="true" />
<div class="user-profile__xl-space-col px-large">
Expand Down Expand Up @@ -140,7 +144,7 @@
</div>
</div>
<hr class="card__hr" aria-hidden="true" />
{% 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 %}
<div class="card__cta_section">
{% include 'v3/includes/_button.html' with style='primary' type='button' label='Save Changes' alpine_disabled="saving" alpine_click="save()" only %}
<span class="user-profile__save-indicator" role="status" aria-live="polite">
Expand Down Expand Up @@ -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;

Expand All @@ -512,14 +525,17 @@
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',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
body: JSON.stringify({ profile_links: this.collectLinks() }),
body: JSON.stringify(payload),
});
if (!res.ok) {
let data = {};
Expand Down
72 changes: 71 additions & 1 deletion users/admin.py
Original file line number Diff line number Diff line change
@@ -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")}),
(
Expand All @@ -15,6 +60,8 @@ class EmailUserAdmin(UserAdmin):
"fields": (
"display_name",
"github_username",
"internal_role",
"role_eligibility_display",
"valid_email",
"claimed",
)
Expand Down Expand Up @@ -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(
"",
"<div><strong>{}</strong>: {}</div>",
(
(ProfileRole(role).label, ", ".join(sorted(libraries)))
for role, libraries in grouped.items()
),
) or format_html("—")
23 changes: 19 additions & 4 deletions users/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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=[
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading