4 Commits

Author SHA1 Message Date
imperosol
59d8d73c88 exclude hidden users from ajax search 2025-11-09 21:19:15 +01:00
imperosol
cb6fe18916 Show groups of Permission in admin 2025-11-09 19:16:41 +01:00
imperosol
edbf07e6b8 rename User.is_subscriber_viewable => User.is_viewable 2025-11-09 18:28:44 +01:00
imperosol
144b05e49c don't show hidden users in picture identifications 2025-11-09 15:34:00 +01:00
20 changed files with 225 additions and 62 deletions

View File

@@ -240,10 +240,11 @@ class NewsListView(TemplateView):
if not self.request.user.has_perm("core.view_user"): if not self.request.user.has_perm("core.view_user"):
return [] return []
return itertools.groupby( return itertools.groupby(
User.objects.filter( User.objects.viewable_by(self.request.user)
.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_subscriber_viewable=True, is_viewable=True,
) )
.filter(role__in=["STUDENT", "FORMER STUDENT"]) .filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"), .order_by("-date_of_birth"),

View File

@@ -74,9 +74,19 @@ class UserBanAdmin(admin.ModelAdmin):
autocomplete_fields = ("user", "ban_group") autocomplete_fields = ("user", "ban_group")
class GroupInline(admin.TabularInline):
model = Group.permissions.through
readonly_fields = ("group",)
extra = 0
def has_add_permission(self, request, obj):
return False
@admin.register(Permission) @admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin): class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",) search_fields = ("codename",)
inlines = (GroupInline,)
@admin.register(Page) @admin.register(Page)

View File

@@ -74,7 +74,7 @@ class MailingListController(ControllerBase):
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks) return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView]) @route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int): def fetch_user(self, user_id: int):
@@ -90,7 +90,9 @@ class UserController(ControllerBase):
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter( return filters.filter(
User.objects.order_by(F("last_login").desc(nulls_last=True)) User.objects.viewable_by(self.context.request.user).order_by(
F("last_login").desc(nulls_last=True)
)
) )

View File

@@ -150,7 +150,8 @@ class Command(BaseCommand):
Weekmail().save() Weekmail().save()
# Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment # Here we add a lot of test datas, that are not necessary for the Sith,
# but that provide a basic development environment
self.now = timezone.now().replace(hour=12, second=0) self.now = timezone.now().replace(hour=12, second=0)
skia = User.objects.create_user( skia = User.objects.create_user(

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-11-09 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0047_alter_notification_date_alter_notification_type")]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": [("view_hidden_user", "Can view hidden users")],
"verbose_name": "user",
"verbose_name_plural": "users",
},
),
migrations.RenameField(
model_name="user", old_name="is_subscriber_viewable", new_name="is_viewable"
),
migrations.AlterField(
model_name="user",
name="is_viewable",
field=models.BooleanField(
default=True,
verbose_name="Profile visible by subscribers",
help_text=(
"If you disable this option, only admin users "
"will be able to see your profile."
),
),
),
]

View File

@@ -180,6 +180,15 @@ class UserQuerySet(models.QuerySet):
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases)) Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
) )
def viewable_by(self, user: User) -> Self:
if user.has_perm("core.view_hidden_user"):
return self
if user.has_perm("core.view_user"):
return self.filter(is_viewable=True)
if user.is_anonymous:
return self.none()
return self.filter(id=user.id)
class CustomUserManager(UserManager.from_queryset(UserQuerySet)): class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers # see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
@@ -315,13 +324,24 @@ class User(AbstractUser):
parent_address = models.CharField( parent_address = models.CharField(
_("parent address"), max_length=128, blank=True, default="" _("parent address"), max_length=128, blank=True, default=""
) )
is_subscriber_viewable = models.BooleanField( is_viewable = models.BooleanField(
_("is subscriber viewable"), default=True _("Profile visible by subscribers"),
help_text=_(
"If you disable this option, only admin users "
"will be able to see your profile."
),
default=True,
) )
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
class Meta(AbstractUser.Meta):
abstract = False
permissions = [
("view_hidden_user", "Can view hidden users"),
]
def __str__(self): def __str__(self):
return self.get_display_name() return self.get_display_name()
@@ -604,8 +624,12 @@ class User(AbstractUser):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
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): def can_be_viewed_by(self, user: User) -> bool:
return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root return (
user.id == self.id
or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable)
)
def get_mini_item(self): def get_mini_item(self):
return """ return """

View File

@@ -7,10 +7,13 @@
.profile { .profile {
&-visible { &-visible {
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding-top: 10px; padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
} }
&-pictures { &-pictures {
@@ -116,23 +119,19 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: var(--nf-input-size) 10px;
justify-content: center; justify-content: center;
} }
&-field { &-field {
display: flex; display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 10px;
width: 100%; width: 100%;
max-width: 330px; max-width: 330px;
min-width: 300px; min-width: 300px;
@media (max-width: 750px) { @media (max-width: 750px) {
gap: 4px;
max-width: 100%; max-width: 100%;
} }
@@ -145,22 +144,6 @@
} }
} }
&-label {
text-align: left !important;
}
&-content {
> * {
box-sizing: border-box;
text-align: left !important;
margin: 0;
> * {
text-align: left !important;
}
}
}
textarea { textarea {
height: 7rem; height: 7rem;
} }

View File

@@ -116,12 +116,12 @@
{# All fields #} {# All fields #}
<div class="profile-fields"> <div class="profile-fields">
{%- for field in form -%} {%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%} {%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
{%- continue -%} {%- continue -%}
{%- endif -%} {%- endif -%}
<div class="profile-field"> <div class="profile-field">
<div class="profile-field-label">{{ field.label }}</div> {{ field.label_tag() }}
<div class="profile-field-content"> <div class="profile-field-content">
{{ field }} {{ field }}
{%- if field.errors -%} {%- if field.errors -%}
@@ -136,7 +136,7 @@
<div class="profile-fields"> <div class="profile-fields">
{%- for field in [form.quote, form.forum_signature] -%} {%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field"> <div class="profile-field">
<div class="profile-field-label">{{ field.label }}</div> {{ field.label_tag() }}
<div class="profile-field-content"> <div class="profile-field-content">
{{ field }} {{ field }}
{%- if field.errors -%} {%- if field.errors -%}
@@ -149,8 +149,13 @@
{# Checkboxes #} {# Checkboxes #}
<div class="profile-visible"> <div class="profile-visible">
{{ form.is_subscriber_viewable }} <div class="row">
{{ form.is_subscriber_viewable.label }} {{ form.is_viewable }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
</div> </div>
<div class="final-actions"> <div class="final-actions">

View File

@@ -55,7 +55,7 @@ class TestFetchFamilyApi(TestCase):
assert response.status_code == 403 assert response.status_code == 403
def test_fetch_family_hidden_user(self): def test_fetch_family_hidden_user(self):
self.main_user.is_subscriber_viewable = False self.main_user.is_viewable = False
self.main_user.save() self.main_user.save()
for user_to_login, error_code in [ for user_to_login, error_code in [
(self.main_user, 200), (self.main_user, 200),

View File

@@ -3,6 +3,7 @@ from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import Permission
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
@@ -18,7 +19,7 @@ from core.baker_recipes import (
subscriber_user, subscriber_user,
very_old_subscriber_user, very_old_subscriber_user,
) )
from core.models import Group, User from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling from counter.models import Counter, Customer, Refilling, Selling
@@ -368,3 +369,38 @@ class TestRedirectMe:
def test_promo_has_logo(promo): def test_promo_has_logo(promo):
user = baker.make(User, promo=promo) user = baker.make(User, promo=promo)
assert user.promo_has_logo() assert user.promo_has_logo()
@pytest.mark.django_db
class TestUserQuerySetViewableBy:
@pytest.fixture
def users(self) -> list[User]:
return [
baker.make(User),
subscriber_user.make(),
subscriber_user.make(is_viewable=False),
]
def test_admin_user(self, users: list[User]):
user = baker.make(
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)
@pytest.mark.parametrize(
"user_factory", [old_subscriber_user.make, subscriber_user.make]
)
def test_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User), lambda: AnonymousUser()]
)
def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists()

View File

@@ -202,7 +202,7 @@ class UserProfileForm(forms.ModelForm):
"school", "school",
"promo", "promo",
"forum_signature", "forum_signature",
"is_subscriber_viewable", "is_viewable",
] ]
widgets = { widgets = {
"date_of_birth": SelectDate, "date_of_birth": SelectDate,
@@ -211,8 +211,8 @@ class UserProfileForm(forms.ModelForm):
"quote": forms.Textarea, "quote": forms.Textarea,
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, label_suffix: str = "", **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, label_suffix=label_suffix, **kwargs)
# Image fields are injected here to override the file field provided by the model # Image fields are injected here to override the file field provided by the model
# This would be better if we could have a SithImage sort of model input instead of a generic SithFile # This would be better if we could have a SithImage sort of model input instead of a generic SithFile

View File

@@ -103,9 +103,7 @@ def password_root_change(request, user_id):
"""Allows a root user to change someone's password.""" """Allows a root user to change someone's password."""
if not request.user.is_root: if not request.user.is_root:
raise PermissionDenied raise PermissionDenied
user = User.objects.filter(id=user_id).first() user = get_object_or_404(User, id=user_id)
if not user:
raise Http404("User not found")
if request.method == "POST": if request.method == "POST":
form = views.SetPasswordForm(user=user, data=request.POST) form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid(): if form.is_valid():

View File

@@ -141,7 +141,7 @@
<label for="{{ input_id }}"> <label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.is_subscriber_viewable %} {%- if user.is_viewable %}
{% 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 %}

View File

@@ -199,7 +199,7 @@ class Galaxy(models.Model):
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]: ) -> QuerySet[User]:
return ( return (
User.objects.filter(is_subscriber_viewable=True) User.objects.filter(is_viewable=True)
.exclude(subscriptions=None) .exclude(subscriptions=None)
.annotate( .annotate(
pictures_count=Count("pictures"), pictures_count=Count("pictures"),

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-07 14:50+0100\n" "POT-Creation-Date: 2025-11-09 18:03+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -1532,8 +1532,15 @@ msgid "parent address"
msgstr "adresse des parents" msgstr "adresse des parents"
#: core/models.py #: core/models.py
msgid "is subscriber viewable" msgid "Profile visible by subscribers"
msgstr "profil visible par les cotisants" msgstr "Profil visible par les cotisants"
#: core/models.py
msgid ""
"If you disable this option, only admin users will be able to see your "
"profile."
msgstr ""
"Si vous désactivez cette option, seuls les admins pourront voir votre profil."
#: core/models.py #: core/models.py
msgid "A user with that username already exists" msgid "A user with that username already exists"
@@ -5112,14 +5119,6 @@ msgstr "Membre de Sbarro ou de l'ESTA"
msgid "One semester Welcome Week" msgid "One semester Welcome Week"
msgstr "Un semestre Welcome Week" msgstr "Un semestre Welcome Week"
#: sith/settings.py
msgid "One month for free"
msgstr "Un mois gratuit"
#: sith/settings.py
msgid "Two months for free"
msgstr "Deux mois gratuits"
#: sith/settings.py #: sith/settings.py
msgid "Eurok's volunteer" msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes" msgstr "Bénévole Eurockéennes"
@@ -5133,7 +5132,9 @@ msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py #: sith/settings.py
msgid "GA staff member (2 weeks)" #, fuzzy
#| msgid "GA staff member (2 weeks)"
msgid "GA staff member"
msgstr "Membre staff GA (2 semaines)" msgstr "Membre staff GA (2 semaines)"
#: sith/settings.py #: sith/settings.py
@@ -5677,3 +5678,12 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#~ msgid "is viewable"
#~ msgstr "profil visible"
#~ msgid "One month for free"
#~ msgstr "Un mois gratuit"
#~ msgid "Two months for free"
#~ msgstr "Deux mois gratuits"

View File

@@ -105,7 +105,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
self.can_see_hidden = True self.can_see_hidden = True
if not (request.user.is_board_member or request.user.is_root): if not (request.user.is_board_member or request.user.is_root):
self.can_see_hidden = False self.can_see_hidden = False
self.init_query = self.init_query.exclude(is_subscriber_viewable=False) self.init_query = self.init_query.filter(is_viewable=True)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -130,7 +130,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
else: else:
q = [] q = []
if not self.can_see_hidden and len(q) > 0: if not self.can_see_hidden and len(q) > 0:
q = [user for user in q if user.is_subscriber_viewable] q = [user for user in q if user.is_viewable]
else: else:
search_dict = {} search_dict = {}
for key, value in self.valid_form.items(): for key, value in self.valid_form.items():

View File

@@ -143,11 +143,14 @@ class PicturesController(ControllerBase):
"/{picture_id}/identified", "/{picture_id}/identified",
permissions=[IsAuthenticated, CanView], permissions=[IsAuthenticated, CanView],
response=list[IdentifiedUserSchema], response=list[IdentifiedUserSchema],
url_name="picture_identifications",
) )
def fetch_identifications(self, picture_id: int): def fetch_identifications(self, picture_id: int):
"""Fetch the users that have been identified on the given picture.""" """Fetch the users that have been identified on the given picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id) picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.people.select_related("user") return picture.people.viewable_by(self.context.request.user).select_related(
"user"
)
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView]) @route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]): def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):

View File

@@ -265,6 +265,15 @@ def sas_notification_callback(notif: Notification):
notif.param = str(count) notif.param = str(count)
class PeoplePictureRelationQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self
if user.was_subscribed:
return self.filter(Q(user_id=user.id) | Q(user__is_viewable=True))
return self.filter(user_id=user.id)
class PeoplePictureRelation(models.Model): class PeoplePictureRelation(models.Model):
"""The PeoplePictureRelation class makes the connection between User and Picture.""" """The PeoplePictureRelation class makes the connection between User and Picture."""
@@ -281,6 +290,8 @@ class PeoplePictureRelation(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
objects = PeoplePictureRelationQuerySet.as_manager()
class Meta: class Meta:
unique_together = ["user", "picture"] unique_together = ["user", "picture"]

View File

@@ -186,6 +186,29 @@ class TestPictureRelation(TestSas):
assert res.status_code == 404 assert res.status_code == 404
assert PeoplePictureRelation.objects.count() == relation_count assert PeoplePictureRelation.objects.count() == relation_count
def test_fetch_relations_including_hidden_users(self):
"""Test that normal subscribers users cannot see hidden profiles"""
picture = self.album_a.children_pictures.last()
self.user_a.is_viewable = False
self.user_a.save()
url = reverse("api:picture_identifications", kwargs={"picture_id": picture.id})
# a normal subscriber user shouldn't see user_a as identified
self.client.force_login(subscriber_user.make())
response = self.client.get(url)
data = {user["user"]["id"] for user in response.json()}
assert data == {self.user_b.id, self.user_c.id}
# an admin should see everyone
self.client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
)
response = self.client.get(url)
data = {user["user"]["id"] for user in response.json()}
assert data == {self.user_a.id, self.user_b.id, self.user_c.id}
class TestPictureModeration(TestSas): class TestPictureModeration(TestSas):
@classmethod @classmethod

View File

@@ -1,10 +1,11 @@
import pytest
from django.test import TestCase from django.test import TestCase
from model_bakery import baker from model_bakery import baker
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
from sas.baker_recipes import picture_recipe from sas.baker_recipes import picture_recipe
from sas.models import Picture from sas.models import PeoplePictureRelation, Picture
class TestPictureQuerySet(TestCase): class TestPictureQuerySet(TestCase):
@@ -44,3 +45,25 @@ class TestPictureQuerySet(TestCase):
user.pictures.create(picture=self.pictures[1]) # moderated user.pictures.create(picture=self.pictures[1]) # moderated
pictures = list(Picture.objects.viewable_by(user)) pictures = list(Picture.objects.viewable_by(user))
assert pictures == [self.pictures[1]] assert pictures == [self.pictures[1]]
@pytest.mark.django_db
def test_identifications_viewable_by_user():
picture = baker.make(Picture)
identifications = baker.make(
PeoplePictureRelation, picture=picture, _quantity=10, _bulk_create=True
)
identifications[0].user.is_viewable = False
identifications[0].user.save()
assert (
list(picture.people.viewable_by(old_subscriber_user.make()))
== identifications[1:]
)
assert (
list(picture.people.viewable_by(baker.make(User, is_superuser=True)))
== identifications
)
assert list(picture.people.viewable_by(identifications[1].user)) == [
identifications[1]
]