diff --git a/com/views.py b/com/views.py index ea5b742d..456ba6ec 100644 --- a/com/views.py +++ b/com/views.py @@ -244,9 +244,8 @@ class NewsListView(TemplateView): .filter( date_of_birth__month=localdate().month, 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"), key=lambda u: u.date_of_birth.year, ) diff --git a/core/admin.py b/core/admin.py index a21086a0..74c0c0ab 100644 --- a/core/admin.py +++ b/core/admin.py @@ -63,6 +63,7 @@ class UserAdmin(admin.ModelAdmin): "scrub_pict", "user_permissions", "groups", + "whitelisted_users", ) inlines = (UserBanInline,) search_fields = ["first_name", "last_name", "username"] diff --git a/core/migrations/0049_user_whitelisted_users.py b/core/migrations/0049_user_whitelisted_users.py new file mode 100644 index 00000000..6e09619a --- /dev/null +++ b/core/migrations/0049_user_whitelisted_users.py @@ -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", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 3b533751..80546d36 100644 --- a/core/models.py +++ b/core/models.py @@ -131,7 +131,7 @@ class UserQuerySet(models.QuerySet): if user.has_perm("core.view_hidden_user"): return self 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: return self.none() return self.filter(id=user.id) @@ -279,6 +279,15 @@ class User(AbstractUser): ), 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) objects = CustomUserManager() @@ -567,10 +576,31 @@ class User(AbstractUser): return user.is_root or user.is_board_member 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 ( user.id == self.id 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): diff --git a/core/tests/test_user.py b/core/tests/test_user.py index 8714b4f1..5dd3e62f 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -399,13 +399,12 @@ class TestUserQuerySetViewableBy: return [ baker.make(User), 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]): user = baker.make( - User, - user_permissions=[Permission.objects.get(codename="view_hidden_user")], + 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) 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) 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]) def test_not_subscriber(self, users: list[User], user_factory): user = user_factory() diff --git a/core/urls.py b/core/urls.py index 71f76261..e617d6b0 100644 --- a/core/urls.py +++ b/core/urls.py @@ -69,7 +69,6 @@ from core.views import ( UserCreationView, UserGodfathersTreeView, UserGodfathersView, - UserListView, UserMeRedirect, UserMiniView, UserPreferencesView, @@ -136,7 +135,6 @@ urlpatterns = [ "group//detail/", GroupTemplateView.as_view(), name="group_detail" ), # User views - path("user/", UserListView.as_view(), name="user_list"), path( "user/me//", UserMeRedirect.as_view(), diff --git a/core/views/user.py b/core/views/user.py index a8aaa85a..9991aa2f 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -48,7 +48,6 @@ from django.views.generic import ( CreateView, DeleteView, DetailView, - ListView, RedirectView, TemplateView, ) @@ -404,13 +403,6 @@ class UserMiniView(CanViewMixin, DetailView): 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). # However, if the user re-add them manually in the form, they are saved. class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView): diff --git a/election/templates/election/election_detail.jinja b/election/templates/election/election_detail.jinja index 6a491d9d..9f690cb0 100644 --- a/election/templates/election/election_detail.jinja +++ b/election/templates/election/election_detail.jinja @@ -146,7 +146,7 @@