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/management/commands/populate.py b/core/management/commands/populate.py index 7bb2d0ef..4c1c5379 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -116,7 +116,11 @@ class Command(BaseCommand): ) main_club.board_group.permissions.add( *Permission.objects.filter( - codename__in=["view_subscription", "add_subscription"] + codename__in=[ + "view_subscription", + "add_subscription", + "view_hidden_user", + ] ) ) bar_club = Club.objects.create( diff --git a/core/migrations/0049_user_whitelisted_users.py b/core/migrations/0049_user_whitelisted_users.py new file mode 100644 index 00000000..6970caea --- /dev/null +++ b/core/migrations/0049_user_whitelisted_users.py @@ -0,0 +1,37 @@ +# 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( + blank=True, + help_text=( + "Even 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", + ), + ), + migrations.AlterField( + model_name="preferences", + name="show_my_stats", + field=models.BooleanField( + default=False, + help_text=( + "Allow subscribers (or whitelisted users " + "if your profile is hidden) to access your AE account stats." + ), + verbose_name="show your stats to others", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 3b533751..0f0ef10e 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,16 @@ class User(AbstractUser): ), default=True, ) + whitelisted_users = models.ManyToManyField( + "User", + related_name="visible_by_whitelist", + verbose_name=_("whitelisted users"), + help_text=_( + "Even if this profile is hidden, " + "the users in this list will still be able to see it." + ), + blank=True, + ) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) objects = CustomUserManager() @@ -518,7 +528,7 @@ class User(AbstractUser): self.username = user_name return user_name - def is_owner(self, obj): + def is_owner(self, obj: models.Model): """Determine if the object is owned by the user.""" if hasattr(obj, "is_owned_by") and obj.is_owned_by(self): return True @@ -526,7 +536,7 @@ class User(AbstractUser): return True return self.is_root - def can_edit(self, obj): + def can_edit(self, obj: models.Model): """Determine if the object can be edited by the user.""" if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self): return True @@ -540,11 +550,9 @@ class User(AbstractUser): pks = list(obj.edit_groups.values_list("id", flat=True)) if any(self.is_in_group(pk=pk) for pk in pks): return True - if isinstance(obj, User) and obj == self: - return True return self.is_owner(obj) - def can_view(self, obj): + def can_view(self, obj: models.Model): """Determine if the object can be viewed by the user.""" if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self): return True @@ -563,14 +571,35 @@ class User(AbstractUser): return True return self.can_edit(obj) - def can_be_edited_by(self, user): - return user.is_root or user.is_board_member + def can_be_edited_by(self, user: User): + return user == self or 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): @@ -750,7 +779,14 @@ class Preferences(models.Model): User, related_name="_preferences", on_delete=models.CASCADE ) receive_weekmail = models.BooleanField(_("receive the Weekmail"), default=False) - show_my_stats = models.BooleanField(_("show your stats to others"), default=False) + show_my_stats = models.BooleanField( + _("show your stats to others"), + help_text=_( + "Allow subscribers (or whitelisted users " + "if your profile is hidden) to access your AE account stats." + ), + default=False, + ) notify_on_click = models.BooleanField( _("get a notification for every click"), default=False ) diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index be876c9b..2abffbba 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -157,6 +157,7 @@ form { margin-bottom: .25rem; font-size: 80%; display: block; + max-width: calc(100% - calc(var(--nf-input-size) * 2)) } fieldset { diff --git a/core/static/user/user_edit.scss b/core/static/user/user_edit.scss index 20995da6..e428956a 100644 --- a/core/static/user/user_edit.scss +++ b/core/static/user/user_edit.scss @@ -5,17 +5,6 @@ } .profile { - &-visible { - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; - padding-top: 10px; - input[type="checkbox"]+label { - max-width: unset; - } - } - &-pictures { box-sizing: border-box; display: flex; diff --git a/core/static/user/user_preferences.scss b/core/static/user/user_preferences.scss index c61142d0..ded5e08d 100644 --- a/core/static/user/user_preferences.scss +++ b/core/static/user/user_preferences.scss @@ -19,28 +19,6 @@ } } } - - &-cards, - &-trombi { - >p { - display: flex; - flex-direction: column; - align-items: flex-start; - text-align: justify; - gap: 5px; - margin: 0; - - >input, - >select { - min-width: 300px; - } - } - } - - &-submit-btn { - margin-top: 10px !important; - max-width: 100px; - } } .justify { diff --git a/core/templates/core/base/notifications.jinja b/core/templates/core/base/notifications.jinja index f941a1a6..89fb7aad 100644 --- a/core/templates/core/base/notifications.jinja +++ b/core/templates/core/base/notifications.jinja @@ -1,14 +1,11 @@
+ {% for message in messages %} + {% if message.extra_tags=="visibility" %} +
+ {{ message }} +
+ {% endif %} + {% endfor %} + {% csrf_token %} + {{ form.non_field_errors() }} +
+ {{ form.is_viewable|add_attr("x-model=isViewable") }} + {{ form.is_viewable.label_tag() }} + {{ form.is_viewable.help_text }} + {{ form.is_viewable.errors }} +
+
+ {{ form.whitelisted_users.as_field_group() }} +
+
+ {{ form.show_my_stats }} + {{ form.show_my_stats.label_tag() }} + + {{ form.show_my_stats.help_text }} + + {{ form.show_my_stats.errors }} +
+ + \ No newline at end of file diff --git a/core/templates/core/user_edit.jinja b/core/templates/core/user_edit.jinja index 46603562..9cad0dee 100644 --- a/core/templates/core/user_edit.jinja +++ b/core/templates/core/user_edit.jinja @@ -147,18 +147,7 @@ {%- endfor -%}
- {# Checkboxes #} -
-
- {{ form.is_viewable }} - {{ form.is_viewable.label_tag() }} -
- - {{ form.is_viewable.help_text }} - -
- {%- if form.instance == user -%}

{%- trans -%}Change my password{%- endtrans -%} @@ -170,7 +159,6 @@

{%- endif -%} -

diff --git a/core/templates/core/user_preferences.jinja b/core/templates/core/user_preferences.jinja index d20f708f..1aed4d94 100644 --- a/core/templates/core/user_preferences.jinja +++ b/core/templates/core/user_preferences.jinja @@ -1,7 +1,14 @@ {% extends "core/base.jinja" %} +{%- block additional_js -%} + +{%- endblock -%} + {%- block additional_css -%} + {# importing ajax-select-index is necessary for it to be applied after HTMX reload #} + + {%- endblock -%} {% block title %} @@ -11,30 +18,22 @@ {% block content %}

{% trans %}Preferences{% endtrans %}

-

{% trans %}General{% endtrans %}

-
+
+

{% trans %}Notifications{% endtrans %}

+ {% csrf_token %} - {{ form.as_p() }} - +
+ {{ form.as_p() }} +
+
-

{% trans %}Trombi{% endtrans %}

- - {% if trombi_form %} -
- {% csrf_token %} - {{ trombi_form.as_p() }} - -
- - {% else %} -

{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %} -
- {% trans %}Go to my Trombi tools{% endtrans %} -

- {% endif %} +
+

{% trans %}Visibility{% endtrans %}

+ {{ user_visibility_fragment }} +
{% if student_card_fragment %}

{% trans %}Student card{% endtrans %}

{{ student_card_fragment }} @@ -43,5 +42,21 @@ add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}

{% endif %} + +
+

{% trans %}Trombi{% endtrans %}

+ + {% if trombi_form %} +
+ {% csrf_token %} + {{ trombi_form.as_p() }} + +
+ {% else %} +

{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %} +
+ {% trans %}Go to my Trombi tools{% endtrans %} +

+ {% endif %}
{% endblock %} \ No newline at end of file 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..7e4b4e74 100644 --- a/core/urls.py +++ b/core/urls.py @@ -69,7 +69,6 @@ from core.views import ( UserCreationView, UserGodfathersTreeView, UserGodfathersView, - UserListView, UserMeRedirect, UserMiniView, UserPreferencesView, @@ -78,6 +77,7 @@ from core.views import ( UserUpdateGroupView, UserUpdateProfileView, UserView, + UserVisibilityFormFragment, delete_user_godfather, logout, notification, @@ -136,7 +136,11 @@ urlpatterns = [ "group//detail/", GroupTemplateView.as_view(), name="group_detail" ), # User views - path("user/", UserListView.as_view(), name="user_list"), + path( + "fragment/user//", + UserVisibilityFormFragment.as_view(), + name="user_visibility_fragment", + ), path( "user/me//", UserMeRedirect.as_view(), diff --git a/core/views/forms.py b/core/views/forms.py index f593c1d0..e46daf92 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -48,12 +48,13 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget from PIL import Image from antispam.forms import AntiSpamEmailField -from core.models import Gift, Group, Page, PageRev, SithFile, User +from core.models import Gift, Group, Page, PageRev, Preferences, SithFile, User from core.utils import resize_image from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectGroup, AutoCompleteSelectMultipleGroup, + AutoCompleteSelectMultipleUser, AutoCompleteSelectUser, ) from core.views.widgets.markdown import MarkdownInput @@ -179,7 +180,6 @@ class UserProfileForm(forms.ModelForm): "school", "promo", "forum_signature", - "is_viewable", ] widgets = { "date_of_birth": SelectDate, @@ -264,6 +264,38 @@ class UserProfileForm(forms.ModelForm): self._post_clean() +class UserVisibilityForm(forms.ModelForm): + class Meta: + model = User + fields = ["is_viewable", "whitelisted_users"] + widgets = { + "is_viewable": forms.CheckboxInput(attrs={"class": "switch"}), + "whitelisted_users": AutoCompleteSelectMultipleUser, + } + + __preferences_fields = forms.fields_for_model( + Preferences, + ["show_my_stats"], + widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})}, + ) + show_my_stats = __preferences_fields["show_my_stats"] + + def __init__( + self, *args, initial: dict | None = None, instance: User | None = None, **kwargs + ): + if instance: + initial = initial or {} + initial["show_my_stats"] = instance.preferences.show_my_stats + super().__init__(*args, initial=initial, instance=instance, **kwargs) + + def save(self, commit=True) -> User: # noqa: FBT002 + instance = super().save(commit=commit) + if commit: + instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"] + instance.preferences.save() + return instance + + class UserGroupsForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" diff --git a/core/views/user.py b/core/views/user.py index 3d370b43..00d5610a 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -28,10 +28,12 @@ from datetime import timedelta from operator import itemgetter from smtplib import SMTPException +from django.contrib import messages from django.contrib.auth import login, views from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import PermissionDenied from django.db.models import DateField, F, QuerySet, Sum from django.db.models.functions import Trunc @@ -48,7 +50,6 @@ from django.views.generic import ( CreateView, DeleteView, DetailView, - ListView, RedirectView, TemplateView, ) @@ -65,8 +66,9 @@ from core.views.forms import ( UserGodfathersForm, UserGroupsForm, UserProfileForm, + UserVisibilityForm, ) -from core.views.mixins import TabedViewMixin, UseFragmentsMixin +from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin from counter.models import Refilling, Selling from eboutic.models import Invoice from trombi.views import UserTrombiForm @@ -411,13 +413,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): @@ -475,6 +470,30 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView): current_tab = "clubs" +class UserVisibilityFormFragment(FragmentMixin, SuccessMessageMixin, UpdateView): + model = User + form_class = UserVisibilityForm + template_name = "core/fragment/user_visibility.jinja" + pk_url_kwarg = "user_id" + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"label_suffix": ""} + + def form_valid(self, form): + response = super().form_valid(form) + messages.success( + self.request, _("Visibility parameters updated."), extra_tags="visibility" + ) + return response + + def render_fragment(self, request, **kwargs) -> SafeString: + self.object = kwargs.get("user") + return super().render_fragment(request, **kwargs) + + def get_success_url(self, **kwargs): + return self.request.path + + class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView): """Edit a user's preferences.""" @@ -488,7 +507,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update current_tab = "prefs" def get_form_kwargs(self): - return super().get_form_kwargs() | {"instance": self.object.preferences} + return super().get_form_kwargs() | { + "instance": self.object.preferences, + "label_suffix": "", + } def get_success_url(self): return self.request.path @@ -498,6 +520,9 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update from counter.views.student_card import StudentCardFormFragment res = super().get_fragment_context_data() + res["user_visibility_fragment"] = UserVisibilityFormFragment.as_fragment()( + self.request, user=self.object + ) if hasattr(self.object, "customer"): res["student_card_fragment"] = StudentCardFormFragment.as_fragment()( self.request, customer=self.object.customer 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 @@