5 Commits

Author SHA1 Message Date
imperosol
8dd6cefad5 add translations 2026-03-14 23:15:28 +01:00
imperosol
ad4f7fb765 include visibility settings in the user preferences page 2026-03-14 23:14:56 +01:00
imperosol
1d672a5fc2 feat: whitelist for user visibility 2026-03-14 23:14:02 +01:00
thomas girod
d374ea9651 Merge pull request #1318 from ae-utbm/vite
upgrade to vite 8
2026-03-13 09:48:42 +01:00
imperosol
10a4e71b7a upgrade to vite 8
FASTER FASTER FASTER FASTER FASTER FASTER
2026-03-13 09:46:12 +01:00
23 changed files with 1013 additions and 1453 deletions

3
.gitignore vendored
View File

@@ -24,6 +24,9 @@ node_modules/
# compiled documentation # compiled documentation
site/ site/
# rollup-bundle-visualizer report
.bundle-size-report.html
### Redis ### ### Redis ###
# Ignore redis binary dump (dump.rdb) files # Ignore redis binary dump (dump.rdb) files

View File

@@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["**/static/**"] "includes": ["**/static/**", "vite.config.mts"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,

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,33 @@
# 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=(
"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 to access your AE account stats.",
verbose_name="show your stats to others",
),
),
]

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=_(
"Even 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()
@@ -518,7 +527,7 @@ class User(AbstractUser):
self.username = user_name self.username = user_name
return 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.""" """Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self): if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True return True
@@ -526,7 +535,7 @@ class User(AbstractUser):
return True return True
return self.is_root 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.""" """Determine if the object can be edited by the user."""
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self): if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True return True
@@ -540,11 +549,9 @@ class User(AbstractUser):
pks = list(obj.edit_groups.values_list("id", flat=True)) pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks): if any(self.is_in_group(pk=pk) for pk in pks):
return True return True
if isinstance(obj, User) and obj == self:
return True
return self.is_owner(obj) 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.""" """Determine if the object can be viewed by the user."""
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self): if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True return True
@@ -563,14 +570,35 @@ class User(AbstractUser):
return True return True
return self.can_edit(obj) return self.can_edit(obj)
def can_be_edited_by(self, user): def can_be_edited_by(self, user: User):
return user.is_root or user.is_board_member return user == self or 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):
@@ -750,7 +778,11 @@ class Preferences(models.Model):
User, related_name="_preferences", on_delete=models.CASCADE User, related_name="_preferences", on_delete=models.CASCADE
) )
receive_weekmail = models.BooleanField(_("receive the Weekmail"), default=False) 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 to access your AE account stats."),
default=False,
)
notify_on_click = models.BooleanField( notify_on_click = models.BooleanField(
_("get a notification for every click"), default=False _("get a notification for every click"), default=False
) )

View File

@@ -26,7 +26,6 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card")); window.alert(gettext("Unsupported NFC card"));
}); });
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
ndef.addEventListener("reading", (event: NDEFReadingEvent) => { ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan"); this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase(); this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();

View File

@@ -157,6 +157,7 @@ form {
margin-bottom: .25rem; margin-bottom: .25rem;
font-size: 80%; font-size: 80%;
display: block; display: block;
max-width: calc(100% - calc(var(--nf-input-size) * 2))
} }
fieldset { fieldset {

View File

@@ -5,17 +5,6 @@
} }
.profile { .profile {
&-visible {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
}
&-pictures { &-pictures {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;

View File

@@ -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 { .justify {

View File

@@ -1,14 +1,11 @@
<div id="quick-notifications" <div id="quick-notifications"
x-data="{ x-data="{
messages: [ messages: [
{% if messages %} {%- for message in messages -%}
{% for message in messages %} {%- if not message.extra_tags -%}
{ { tag: '{{ message.tags }}', text: '{{ message }}' },
tag: '{{ message.tags }}', {%- endif -%}
text: '{{ message }}', {%- endfor -%}
},
{% endfor %}
{% endif %}
] ]
}" }"
@quick-notification-add="(e) => messages.push(e?.detail)" @quick-notification-add="(e) => messages.push(e?.detail)"

View File

@@ -0,0 +1,33 @@
<form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
>
{% for message in messages %}
{% if message.extra_tags=="visibility" %}
<div class="alert alert-success">
{{ message }}
</div>
{% endif %}
{% endfor %}
{% csrf_token %}
{{ form.non_field_errors() }}
<fieldset class="form-group">
{{ form.is_viewable|add_attr("x-model=isViewable") }}
{{ form.is_viewable.label_tag() }}
<span class="helptext">{{ form.is_viewable.help_text }}</span>
{{ form.is_viewable.errors }}
</fieldset>
<fieldset class="form-group" x-show="!isViewable">
{{ form.whitelisted_users.as_field_group() }}
</fieldset>
<fieldset class="form-group" x-show="isViewable">
{{ form.show_my_stats }}
{{ form.show_my_stats.label_tag() }}
<span class="helptext">
{{ form.show_my_stats.help_text }}
</span>
{{ form.show_my_stats.errors }}
</fieldset>
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
</form>

View File

@@ -147,18 +147,7 @@
{%- endfor -%} {%- endfor -%}
</div> </div>
{# Checkboxes #}
<div class="profile-visible">
<div class="row">
{{ form.is_viewable }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
</div>
<div class="final-actions"> <div class="final-actions">
{%- if form.instance == user -%} {%- if form.instance == user -%}
<p> <p>
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a> <a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
@@ -170,7 +159,6 @@
</a> </a>
</p> </p>
{%- endif -%} {%- endif -%}
<p> <p>
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" /> <input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
</p> </p>

View File

@@ -1,7 +1,13 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{%- block additional_js -%}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{%- endblock -%}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('user/user_preferences.scss') }}"> <link rel="stylesheet" href="{{ static('user/user_preferences.scss') }}">
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}
@@ -11,30 +17,22 @@
{% block content %} {% block content %}
<div class="main"> <div class="main">
<h2>{% trans %}Preferences{% endtrans %}</h2> <h2>{% trans %}Preferences{% endtrans %}</h2>
<h3>{% trans %}General{% endtrans %}</h3> <br />
<form class="form form-general" action="" method="post" enctype="multipart/form-data"> <h3>{% trans %}Notifications{% endtrans %}</h3>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} <div class="form form-general">
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" /> {{ form.as_p() }}
</div>
<input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" />
</form> </form>
<h3>{% trans %}Trombi{% endtrans %}</h3> <br />
<h3>{% trans %}Visibility{% endtrans %}</h3>
{% if trombi_form %}
<form class="form form-trombi" action="{{ url('trombi:user_tools') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ trombi_form.as_p() }}
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
</form>
{% else %}
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<br />
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
</p>
{% endif %}
{{ user_visibility_fragment }}
<br />
{% if student_card_fragment %} {% if student_card_fragment %}
<h3>{% trans %}Student card{% endtrans %}</h3> <h3>{% trans %}Student card{% endtrans %}</h3>
{{ student_card_fragment }} {{ student_card_fragment }}
@@ -43,5 +41,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 %} add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
</p> </p>
{% endif %} {% endif %}
<br />
<h3>{% trans %}Trombi{% endtrans %}</h3>
{% if trombi_form %}
<form action="{{ url('trombi:user_tools') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ trombi_form.as_p() }}
<input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" />
</form>
{% else %}
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<br />
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
</p>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

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,
@@ -78,6 +77,7 @@ from core.views import (
UserUpdateGroupView, UserUpdateGroupView,
UserUpdateProfileView, UserUpdateProfileView,
UserView, UserView,
UserVisibilityFormFragment,
delete_user_godfather, delete_user_godfather,
logout, logout,
notification, notification,
@@ -136,7 +136,11 @@ 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(
"fragment/user/<int:user_id>/",
UserVisibilityFormFragment.as_view(),
name="user_visibility_fragment",
),
path( path(
"user/me/<path:remaining_path>/", "user/me/<path:remaining_path>/",
UserMeRedirect.as_view(), UserMeRedirect.as_view(),

View File

@@ -48,12 +48,13 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
from antispam.forms import AntiSpamEmailField 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.utils import resize_image
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
AutoCompleteSelectGroup, AutoCompleteSelectGroup,
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser, AutoCompleteSelectUser,
) )
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
@@ -179,7 +180,6 @@ class UserProfileForm(forms.ModelForm):
"school", "school",
"promo", "promo",
"forum_signature", "forum_signature",
"is_viewable",
] ]
widgets = { widgets = {
"date_of_birth": SelectDate, "date_of_birth": SelectDate,
@@ -264,6 +264,38 @@ class UserProfileForm(forms.ModelForm):
self._post_clean() 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): class UserGroupsForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"

View File

@@ -28,10 +28,12 @@ from datetime import timedelta
from operator import itemgetter from operator import itemgetter
from smtplib import SMTPException from smtplib import SMTPException
from django.contrib import messages
from django.contrib.auth import login, views from django.contrib.auth import login, views
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import DateField, F, QuerySet, Sum from django.db.models import DateField, F, QuerySet, Sum
from django.db.models.functions import Trunc from django.db.models.functions import Trunc
@@ -48,7 +50,6 @@ from django.views.generic import (
CreateView, CreateView,
DeleteView, DeleteView,
DetailView, DetailView,
ListView,
RedirectView, RedirectView,
TemplateView, TemplateView,
) )
@@ -65,8 +66,9 @@ from core.views.forms import (
UserGodfathersForm, UserGodfathersForm,
UserGroupsForm, UserGroupsForm,
UserProfileForm, UserProfileForm,
UserVisibilityForm,
) )
from core.views.mixins import TabedViewMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
from eboutic.models import Invoice from eboutic.models import Invoice
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@@ -404,13 +406,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):
@@ -468,6 +463,30 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
current_tab = "clubs" 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): class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView):
"""Edit a user's preferences.""" """Edit a user's preferences."""
@@ -481,7 +500,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
current_tab = "prefs" current_tab = "prefs"
def get_form_kwargs(self): 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): def get_success_url(self):
return self.request.path return self.request.path
@@ -491,6 +513,9 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
res = super().get_fragment_context_data() res = super().get_fragment_context_data()
res["user_visibility_fragment"] = UserVisibilityFormFragment.as_fragment()(
self.request, user=self.object
)
if hasattr(self.object, "customer"): if hasattr(self.object, "customer"):
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()( res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
self.request, customer=self.object.customer self.request, customer=self.object.customer

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 %}

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-10 10:28+0100\n" "POT-Creation-Date: 2026-03-14 23:09+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"
@@ -551,8 +551,9 @@ msgstr ""
#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja #: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja #: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/create.jinja core/templates/core/edit.jinja #: core/templates/core/create.jinja core/templates/core/edit.jinja
#: core/templates/core/file_edit.jinja core/templates/core/page/edit.jinja #: core/templates/core/file_edit.jinja
#: core/templates/core/page/prop.jinja #: core/templates/core/fragment/user_visibility.jinja
#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja
#: core/templates/core/user_godfathers.jinja #: core/templates/core/user_godfathers.jinja
#: core/templates/core/user_godfathers_tree.jinja #: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja #: core/templates/core/user_preferences.jinja
@@ -1547,6 +1548,17 @@ msgid ""
msgstr "" msgstr ""
"Si vous désactivez cette option, seuls les admins pourront voir votre profil." "Si vous désactivez cette option, seuls les admins pourront voir votre profil."
#: core/models.py
msgid "whitelisted users"
msgstr "utilisateurs whitelistés"
#: core/models.py
msgid ""
"Even if this profile is hidden, the users in this list will still be able to see "
"it."
msgstr ""
"Même si ce profil est caché, les utilisateurs sur cette liste pourront toujours le voir."
#: core/models.py #: core/models.py
msgid "A user with that username already exists" msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
@@ -1603,6 +1615,10 @@ msgstr "recevoir le Weekmail"
msgid "show your stats to others" msgid "show your stats to others"
msgstr "montrez vos statistiques aux autres" msgstr "montrez vos statistiques aux autres"
#: core/models.py
msgid "Allow subscribers to access your AE account stats."
msgstr "Autoriser les cotisants à accéder aux statistiques de votre compte AE"
#: core/models.py #: core/models.py
msgid "get a notification for every click" msgid "get a notification for every click"
msgstr "avoir une notification pour chaque click" msgstr "avoir une notification pour chaque click"
@@ -2612,21 +2628,12 @@ msgid "Preferences"
msgstr "Préférences" msgstr "Préférences"
#: core/templates/core/user_preferences.jinja #: core/templates/core/user_preferences.jinja
msgid "General" msgid "Notifications"
msgstr "Général" msgstr "Notifications"
#: core/templates/core/user_preferences.jinja trombi/views.py
msgid "Trombi"
msgstr "Trombi"
#: core/templates/core/user_preferences.jinja #: core/templates/core/user_preferences.jinja
#, python-format msgid "Visibility"
msgid "You already choose to be in that Trombi: %(trombi)s." msgstr "Visibilité"
msgstr "Vous avez déjà choisi ce Trombi: %(trombi)s."
#: core/templates/core/user_preferences.jinja
msgid "Go to my Trombi tools"
msgstr "Allez à mes outils de Trombi"
#: core/templates/core/user_preferences.jinja #: core/templates/core/user_preferences.jinja
#: counter/templates/counter/counter_click.jinja #: counter/templates/counter/counter_click.jinja
@@ -2645,6 +2652,19 @@ msgstr ""
"aurez besoin d'un lecteur NFC. Nous enregistrons l'UID de la carte qui fait " "aurez besoin d'un lecteur NFC. Nous enregistrons l'UID de la carte qui fait "
"14 caractères de long." "14 caractères de long."
#: core/templates/core/user_preferences.jinja trombi/views.py
msgid "Trombi"
msgstr "Trombi"
#: core/templates/core/user_preferences.jinja
#, python-format
msgid "You already choose to be in that Trombi: %(trombi)s."
msgstr "Vous avez déjà choisi ce Trombi: %(trombi)s."
#: core/templates/core/user_preferences.jinja
msgid "Go to my Trombi tools"
msgstr "Allez à mes outils de Trombi"
#: core/templates/core/user_stats.jinja #: core/templates/core/user_stats.jinja
#, python-format #, python-format
msgid "%(user_name)s's stats" msgid "%(user_name)s's stats"
@@ -2925,6 +2945,10 @@ msgstr "Photos"
msgid "Account" msgid "Account"
msgstr "Compte" msgstr "Compte"
#: core/views/user.py
msgid "Visibility parameters updated."
msgstr "Paramètres de visibilité mis à jour."
#: counter/apps.py counter/models.py #: counter/apps.py counter/models.py
msgid "counter" msgid "counter"
msgstr "comptoir" msgstr "comptoir"

2047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,6 @@
"compile-dev": "vite build --mode development", "compile-dev": "vite build --mode development",
"serve": "vite build --mode development --watch --minify false", "serve": "vite build --mode development --watch --minify false",
"openapi": "openapi-ts", "openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "tsc && biome check --write" "check": "tsc && biome check --write"
}, },
"keywords": [], "keywords": [],
@@ -35,10 +33,9 @@
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^8.0.0"
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.2.0"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.15.8", "@alpinejs/sort": "^3.15.8",

View File

@@ -1,14 +1,17 @@
// biome-ignore lint/correctness/noNodejsModules: this is backend side
import { parse, resolve } from "node:path"; import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject"; import inject from "@rollup/plugin-inject";
import { glob } from "glob"; import { glob } from "glob";
import type { Rollup } from "vite"; import { visualizer } from "rollup-plugin-visualizer";
import { type AliasOptions, defineConfig, type UserConfig } from "vite"; import {
type AliasOptions,
defineConfig,
type PluginOption,
type Rollup,
type UserConfig,
} from "vite";
import tsconfig from "./tsconfig.json"; import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled"); const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
const vendored = resolve(outDir, "vendored");
const nodeModules = resolve(__dirname, "node_modules");
const collectedFiles = glob.sync( const collectedFiles = glob.sync(
"./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)", "./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)",
); );
@@ -42,7 +45,6 @@ function getRelativeAssetPath(path: string): string {
return relativePath.join("/"); return relativePath.join("/");
} }
// biome-ignore lint/style/noDefaultExport: this is recommended by documentation
export default defineConfig((config: UserConfig) => { export default defineConfig((config: UserConfig) => {
return { return {
base: "/static/bundled/", base: "/static/bundled/",
@@ -86,6 +88,7 @@ export default defineConfig((config: UserConfig) => {
Alpine: "alpinejs", Alpine: "alpinejs",
htmx: "htmx.org", htmx: "htmx.org",
}), }),
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
], ],
} satisfies UserConfig; } satisfies UserConfig;
}); });