1 Commits

Author SHA1 Message Date
imperosol
f08e343e17 feat: whitelist for user visibility 2026-03-14 16:43:13 +01:00
8 changed files with 67 additions and 18 deletions

View File

@@ -244,9 +244,8 @@ class NewsListView(TemplateView):
.filter( .filter(
date_of_birth__month=localdate().month, date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day, date_of_birth__day=localdate().day,
is_viewable=True, role__in=["STUDENT", "FORMER STUDENT"],
) )
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"), .order_by("-date_of_birth"),
key=lambda u: u.date_of_birth.year, key=lambda u: u.date_of_birth.year,
) )

View File

@@ -63,6 +63,7 @@ class UserAdmin(admin.ModelAdmin):
"scrub_pict", "scrub_pict",
"user_permissions", "user_permissions",
"groups", "groups",
"whitelisted_users",
) )
inlines = (UserBanInline,) inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"] search_fields = ["first_name", "last_name", "username"]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.12 on 2026-03-14 08:39
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0048_alter_user_options")]
operations = [
migrations.AddField(
model_name="user",
name="whitelisted_users",
field=models.ManyToManyField(
help_text=(
"If this profile is hidden, "
"the users in this list will still be able to see it."
),
related_name="visible_by_whitelist",
to=settings.AUTH_USER_MODEL,
verbose_name="whitelisted users",
),
),
]

View File

@@ -131,7 +131,7 @@ class UserQuerySet(models.QuerySet):
if user.has_perm("core.view_hidden_user"): if user.has_perm("core.view_hidden_user"):
return self return self
if user.has_perm("core.view_user"): if user.has_perm("core.view_user"):
return self.filter(is_viewable=True) return self.filter(Q(is_viewable=True) | Q(whitelisted_users=user))
if user.is_anonymous: if user.is_anonymous:
return self.none() return self.none()
return self.filter(id=user.id) return self.filter(id=user.id)
@@ -279,6 +279,15 @@ class User(AbstractUser):
), ),
default=True, default=True,
) )
whitelisted_users = models.ManyToManyField(
"User",
related_name="visible_by_whitelist",
verbose_name=_("whitelisted users"),
help_text=_(
"If this profile is hidden, "
"the users in this list will still be able to see it."
),
)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
@@ -567,10 +576,31 @@ class User(AbstractUser):
return user.is_root or user.is_board_member return user.is_root or user.is_board_member
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
"""Check if the given user can be viewed by this user.
Given users A and B. A can be viewed by B if :
- A and B are the same user
- or B has the permission to view hidden users
- or B can view users in general and A didn't hide its profile
- or B is in A's whitelist.
"""
def is_in_whitelist(u: User):
if (
hasattr(self, "_prefetched_objects_cache")
and "whitelisted_users" in self._prefetched_objects_cache
):
return u in self.whitelisted_users.all()
return self.whitelisted_users.contains(u)
return ( return (
user.id == self.id user.id == self.id
or user.has_perm("core.view_hidden_user") or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable) or (
user.has_perm("core.view_user")
and (self.is_viewable or is_in_whitelist(user))
)
) )
def get_mini_item(self): def get_mini_item(self):

View File

@@ -399,13 +399,12 @@ class TestUserQuerySetViewableBy:
return [ return [
baker.make(User), baker.make(User),
subscriber_user.make(), subscriber_user.make(),
subscriber_user.make(is_viewable=False), *subscriber_user.make(is_viewable=False, _quantity=2),
] ]
def test_admin_user(self, users: list[User]): def test_admin_user(self, users: list[User]):
user = baker.make( user = baker.make(
User, User, user_permissions=[Permission.objects.get(codename="view_hidden_user")]
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
) )
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == set(users) assert set(viewable) == set(users)
@@ -418,6 +417,12 @@ class TestUserQuerySetViewableBy:
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]} assert set(viewable) == {users[0], users[1]}
def test_whitelist(self, users: list[User]):
user = subscriber_user.make()
users[3].whitelisted_users.add(user)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1], users[3]}
@pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser]) @pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser])
def test_not_subscriber(self, users: list[User], user_factory): def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory() user = user_factory()

View File

@@ -69,7 +69,6 @@ from core.views import (
UserCreationView, UserCreationView,
UserGodfathersTreeView, UserGodfathersTreeView,
UserGodfathersView, UserGodfathersView,
UserListView,
UserMeRedirect, UserMeRedirect,
UserMiniView, UserMiniView,
UserPreferencesView, UserPreferencesView,
@@ -136,7 +135,6 @@ urlpatterns = [
"group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail" "group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail"
), ),
# User views # User views
path("user/", UserListView.as_view(), name="user_list"),
path( path(
"user/me/<path:remaining_path>/", "user/me/<path:remaining_path>/",
UserMeRedirect.as_view(), UserMeRedirect.as_view(),

View File

@@ -48,7 +48,6 @@ from django.views.generic import (
CreateView, CreateView,
DeleteView, DeleteView,
DetailView, DetailView,
ListView,
RedirectView, RedirectView,
TemplateView, TemplateView,
) )
@@ -404,13 +403,6 @@ class UserMiniView(CanViewMixin, DetailView):
template_name = "core/user_mini.jinja" template_name = "core/user_mini.jinja"
class UserListView(ListView, CanEditPropMixin):
"""Displays the user list."""
model = User
template_name = "core/user_list.jinja"
# FIXME: the edit_once fields aren't displayed to the user (as expected). # FIXME: the edit_once fields aren't displayed to the user (as expected).
# However, if the user re-add them manually in the form, they are saved. # However, if the user re-add them manually in the form, they are saved.
class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView): class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):

View File

@@ -146,7 +146,7 @@
<label for="{{ input_id }}"> <label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.is_viewable %} {%- if user.can_view(candidature.user) %}
{% if candidature.user.profile_pict %} {% if candidature.user.profile_pict %}
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}"> <img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
{% else %} {% else %}