Compare commits

..
Author SHA1 Message Date
imperosol 0fe1a2529f style: standardize UE search style 2026-08-30 19:38:52 +02:00
21 changed files with 42 additions and 203 deletions
+2 -2
View File
@@ -46,8 +46,8 @@ class ClubAdmin(admin.ModelAdmin):
@admin.register(ClubRole) @admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin): class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency") list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name", "club__name") search_fields = ("name",)
autocomplete_fields = ("club", "linked_groups") autocomplete_fields = ("club",)
list_select_related = ("club",) list_select_related = ("club",)
list_filter = ( list_filter = (
"is_board", "is_board",
-7
View File
@@ -479,13 +479,6 @@ class ClubRoleCreateForm(forms.ModelForm):
class ClubRoleBaseFormSet(forms.BaseInlineFormSet): class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
ordering_widget = forms.HiddenInput() ordering_widget = forms.HiddenInput()
def __init__(self, *args, queryset=None, **kwargs):
if queryset is None:
queryset = self.model._default_manager
super().__init__(
*args, queryset=queryset.prefetch_related("linked_groups"), **kwargs
)
ClubRoleFormSet = forms.inlineformset_factory( ClubRoleFormSet = forms.inlineformset_factory(
Club, Club,
@@ -1,26 +0,0 @@
# Generated by Django 5.2.17 on 2026-09-01 14:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("club", "0017_linktype_clublink"),
("core", "0050_alter_sithfile_moderator"),
]
operations = [
migrations.AddField(
model_name="clubrole",
name="linked_groups",
field=models.ManyToManyField(
help_text=(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="club_roles",
to="core.group",
verbose_name="Linked groups",
),
),
]
+11 -32
View File
@@ -23,8 +23,6 @@
# #
from __future__ import annotations from __future__ import annotations
import operator
from functools import reduce
from typing import Iterable, Self from typing import Iterable, Self
from django.conf import settings from django.conf import settings
@@ -284,15 +282,6 @@ class ClubRole(OrderedModel):
"If the role is inactive, people joining the club won't be able to get it." "If the role is inactive, people joining the club won't be able to get it."
), ),
) )
linked_groups = models.ManyToManyField(
Group,
verbose_name=_("Linked groups"),
help_text=_(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="linked_roles",
)
order_with_respect_to = "club" order_with_respect_to = "club"
@@ -545,7 +534,7 @@ class Membership(models.Model):
def _remove_club_groups( def _remove_club_groups(
memberships: Iterable[Membership], memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]: ) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club and club role groups. """Remove users of those memberships from the club groups.
For example, if a user is in the Troll club board, For example, if a user is in the Troll club board,
he is in the board group and the members group of the Troll. he is in the board group and the members group of the Troll.
@@ -564,19 +553,15 @@ class Membership(models.Model):
clubs = {m.club_id for m in memberships} clubs = {m.club_id for m in memberships}
users = {m.user_id for m in memberships} users = {m.user_id for m in memberships}
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs)) groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
role_groups = [
Q(user_id=m.user_id, group__linked_roles=m.role_id) for m in memberships
]
return User.groups.through.objects.filter( return User.groups.through.objects.filter(
(Q(group__in=groups) & Q(user__in=users)) Q(group__in=groups) & Q(user__in=users)
| reduce(operator.or_, role_groups)
).delete() ).delete()
@staticmethod @staticmethod
def _add_club_groups( def _add_club_groups(
memberships: Iterable[Membership], memberships: Iterable[Membership],
) -> list[User.groups.through]: ) -> list[User.groups.through]:
"""Add users of those memberships to the club and club role groups. """Add users of those memberships to the club groups.
For example, if a user just joined the Troll club board, For example, if a user just joined the Troll club board,
he will be added in both the members group and the board group he will be added in both the members group and the board group
@@ -597,40 +582,34 @@ class Membership(models.Model):
memberships = [m for m in memberships if m.end_date is None] memberships = [m for m in memberships if m.end_date is None]
if not memberships: if not memberships:
return [] return []
nb_prefetched = sum(
1 for m in memberships if not hasattr(m, "club") or not hasattr(m, "role") if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
)
if nb_prefetched > 1:
# if more than one membership hasn't its `club` attribute set # if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with # it's less expensive to reload the whole query with
# a select_related than perform a distinct query # a select_related than perform a distinct query
# to fetch each club. # to fetch each club.
ids = {m.id for m in memberships} ids = {m.id for m in memberships}
memberships = list( memberships = list(
Membership.objects.filter(id__in=ids) Membership.objects.filter(id__in=ids).select_related("club")
.select_related("club", "role")
.prefetch_related("role__linked_groups")
) )
groups = [] club_groups = []
for membership in memberships: for membership in memberships:
groups.append( club_groups.append(
User.groups.through( User.groups.through(
user_id=membership.user_id, user_id=membership.user_id,
group_id=membership.club.members_group_id, group_id=membership.club.members_group_id,
) )
) )
if membership.role.is_board: if membership.role.is_board:
groups.append( club_groups.append(
User.groups.through( User.groups.through(
user_id=membership.user_id, user_id=membership.user_id,
group_id=membership.club.board_group_id, group_id=membership.club.board_group_id,
) )
) )
groups.extend( return User.groups.through.objects.bulk_create(
User.groups.through(user_id=membership.user_id, group_id=g.id) club_groups, ignore_conflicts=True
for g in membership.role.linked_groups.all()
) )
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
class Mailing(models.Model): class Mailing(models.Model):
-14
View File
@@ -49,20 +49,6 @@
{{ subform.is_active.help_text }} {{ subform.is_active.help_text }}
</span> </span>
</div> </div>
{% set groups = subform.instance.linked_groups.all()|list %}
{% if groups %}
<div>
<p>
<strong>{% trans %}Linked groups : {% endtrans %}</strong>
{{ groups|map(attribute="name")|join(", ") }}
</p>
<p class="helptext">
{% trans trimmed %}
Users receiving this role will also be assigned to those groups
{% endtrans %}
</p>
</div>
{% endif %}
</div> </div>
</details> </details>
</div> </div>
@@ -7,7 +7,7 @@
<form <form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}" hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#member-fragment-container" hx-target="#member-fragment-container"
id="add_club_members_form" id="add_club_members_form"
+1 -24
View File
@@ -17,7 +17,7 @@ from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, ClubRole, Membership from club.models import Club, ClubRole, Membership
from club.tests.base import TestClub from club.tests.base import TestClub
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, User
class TestMembershipQuerySet(TestClub): class TestMembershipQuerySet(TestClub):
@@ -500,29 +500,6 @@ class TestMembership(TestClub):
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group) assert self.subscriber.groups.contains(self.club.board_group)
def test_add_to_club_role_group(self):
groups = baker.make(Group, _quantity=4)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:3])
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert set(self.subscriber.groups.all()) == {
*groups[:3],
self.club.board_group,
self.club.members_group,
}
def test_remove_from_club_role_group(self):
groups = baker.make(Group, _quantity=3)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:])
self.subscriber.memberships.update(end_date=localdate())
assert set(self.subscriber.groups.all()) == {groups[0]}
def test_change_position_in_club(self): def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change""" """Test that when moving from board to members, club group change"""
membership = baker.make( membership = baker.make(
+5 -10
View File
@@ -6,15 +6,10 @@
* for more efficient tree-shaking and gzip compression. * for more efficient tree-shaking and gzip compression.
*/ */
// Must be loaded before Apline
import htmx from "htmx.org";
import "htmx.org/dist/ext/hx-alpine-compat.js";
import "htmx.org/dist/ext/hx-prompt.js";
import "htmx.org/dist/ext/hx-download.js";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices"; import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage"; import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar"; import { default as navbar } from "#core:core/navbar";
@@ -49,16 +44,16 @@ polyfillCountryFlagEmojis();
* HTMX * HTMX
*/ */
document.body.addEventListener( document.body.addEventListener(
"htmx:before:request" as keyof HTMLElementEventMap, "htmx:beforeRequest" as keyof HTMLElementEventMap,
(event) => { (event) => {
(event as CustomEvent).detail.ctx.target.ariaBusy = true; (event as CustomEvent).detail.target.ariaBusy = true;
}, },
); );
document.body.addEventListener( document.body.addEventListener(
"htmx:before:swap" as keyof HTMLElementEventMap, "htmx:beforeSwap" as keyof HTMLElementEventMap,
(event) => { (event) => {
(event as CustomEvent).detail.ctx.target.ariaBusy = null; (event as CustomEvent).detail.target.ariaBusy = null;
}, },
); );
@@ -1,6 +1,6 @@
<form <form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}" hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }" hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
> >
{% for message in messages %} {% for message in messages %}
-16
View File
@@ -141,22 +141,6 @@ class TestSearchUsersView(TestSearchUsers):
response = self.client.get(reverse("core:search")) response = self.client.get(reverse("core:search"))
assert response.status_code == 200 assert response.status_code == 200
def test_search_with_whitelist_unique(self):
"""Test that when a user has a whitelist and appears in the results,
it appears only once.
This is a regression test (cf #1463)
"""
user = subscriber_user.make(is_viewable=False)
user.whitelisted_users.add(
*subscriber_user.make(_quantity=4, _bulk_create=True)
)
self.client.force_login(user)
response = self.client.get(
reverse("core:search", query={"query": user.last_name})
)
assert response.context_data["users"] == [user]
@pytest.mark.django_db @pytest.mark.django_db
def test_user_account_not_found(client: Client): def test_user_account_not_found(client: Client):
-1
View File
@@ -65,7 +65,6 @@ class SearchView(LoginRequiredMixin, TemplateView):
UserFilterSchema(search=query) UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user)) .filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True)) .order_by(F("last_login").desc(nulls_last=True))
.distinct()
) )
clubs = list(Club.objects.filter(name__icontains=query)[:5]) clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs} return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
@@ -28,23 +28,18 @@ export class ProductAjaxSelect extends AjaxSelect {
return []; return [];
} }
// In the context in which this method is called, `this` might be shadowed private getName(item: SimpleProductSchema, sanitize: typeof escape_html): string {
// We need to call it explicitly from the class itself
private static getName(
item: SimpleProductSchema,
sanitize: typeof escape_html,
): string {
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name; return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
} }
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${ProductAjaxSelect.getName(item, sanitize)}</span> <span class="select-item-text">${this.getName(item, sanitize)}</span>
</div>`; </div>`;
} }
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${ProductAjaxSelect.getName(item, sanitize)}</span>`; return `<span>${this.getName(item, sanitize)}</span>`;
} }
} }
@@ -1,10 +1,5 @@
<div id="student_card_form"> <div id="student_card_form">
<form <form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
hx-disable="input[type='submit']"
>
{% csrf_token %} {% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p> <p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <input type="submit" value="{% trans %}Confirm{% endtrans %}" />
+1 -1
View File
@@ -217,7 +217,7 @@ C'est une technologie simple et puissante qui se veut comme le jQuery du web mod
### Htmx ### Htmx
[Site officiel](https://four.htmx.org/) [Site officiel](https://htmx.org/)
En plus de AlpineJS, linteractivité sur le site est augmentée via Htmx. En plus de AlpineJS, linteractivité sur le site est augmentée via Htmx.
C'est une librairie js qui s'utilise également au moyen d'attributs HTML à C'est une librairie js qui s'utilise également au moyen d'attributs HTML à
@@ -41,7 +41,7 @@
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}" hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#apply-election-result-fragment" hx-target="#apply-election-result-fragment"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
> >
{% csrf_token %} {% csrf_token %}
{{ form }} {{ form }}
+1 -37
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-01 18:25+0200\n" "POT-Creation-Date: 2026-08-21 14:10+0200\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"
@@ -260,18 +260,6 @@ msgstr ""
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui " "Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
"rejoignent le club." "rejoignent le club."
#: club/models.py
msgid "Linked groups"
msgstr "Groupes liés"
#: club/models.py
msgid ""
"Groups that are automatically given or removed to user receiving or losing "
"this club role"
msgstr ""
"Les groupes qui sont automatiquement donnés ou retirés quand l'utilisateur "
"reçoit ou perd ce rôle de club"
#: club/models.py election/models.py #: club/models.py election/models.py
msgid "club role" msgid "club role"
msgstr "rôle de club" msgstr "rôle de club"
@@ -497,14 +485,6 @@ msgstr "Du"
msgid "To" msgid "To"
msgstr "Au" msgstr "Au"
#: club/templates/club/club_roles.jinja
msgid "Linked groups : "
msgstr "Groupes liés : "
#: club/templates/club/club_roles.jinja
msgid "Users receiving this role will also be assigned to those groups"
msgstr "Les utilisateurs recevant ce rôle seront aussi assignés à ces groupes"
#: club/templates/club/club_roles.jinja #: club/templates/club/club_roles.jinja
msgid "" msgid ""
"Roles give rights on the club. Higher roles grant more rights, and the " "Roles give rights on the club. Higher roles grant more rights, and the "
@@ -5109,10 +5089,6 @@ msgstr "signalant"
msgid "A guide of courses available at UTBM." msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM." msgstr "Un guide de tous les cours disponibles à l'UTBM."
#: pedagogy/templates/pedagogy/guide.jinja
msgid "Search UE"
msgstr "Recherche d'UE"
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
#, python-format #, python-format
msgid "%(display_name)s" msgid "%(display_name)s"
@@ -5782,18 +5758,6 @@ msgstr "fin de la cotisation"
msgid "location" msgid "location"
msgstr "lieu" msgstr "lieu"
#: subscription/models.py
msgid "created_at"
msgstr "créé le"
#: subscription/models.py
msgid ""
"When this subscription was created. This date may differ from the start of "
"the subscription."
msgstr ""
"Quand la cotisation a été créée. Cette date peut différer du début effectif de "
"la cotisation."
#: subscription/models.py #: subscription/models.py
msgid "You can not subscribe many time for the same period" msgid "You can not subscribe many time for the same period"
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période" msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
+5 -11
View File
@@ -30,7 +30,7 @@
"easymde": "^2.21.0", "easymde": "^2.21.0",
"glob": "^13.0.6", "glob": "^13.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx.org": "^4.0.0", "htmx.org": "^2.0.10",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"lit-html": "^3.3.3", "lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
@@ -3801,16 +3801,10 @@
} }
}, },
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "4.0.0", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-4.0.0.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
"integrity": "sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==", "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
"license": "BSD-0-Clause", "license": "0BSD"
"workspaces": [
"ext/*"
],
"bin": {
"upgrade-check": "dist/scripts/upgrade-check.js"
}
}, },
"node_modules/ical.js": { "node_modules/ical.js": {
"version": "1.5.0", "version": "1.5.0",
+2 -2
View File
@@ -34,8 +34,8 @@
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@typescript/native": "npm:typescript@^7.0.2",
"rollup-plugin-visualizer": "^7.1.1", "rollup-plugin-visualizer": "^7.1.1",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.2.2" "vite": "^8.2.2"
}, },
@@ -61,7 +61,7 @@
"easymde": "^2.21.0", "easymde": "^2.21.0",
"glob": "^13.0.6", "glob": "^13.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx.org": "^4.0.0", "htmx.org": "^2.0.10",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"lit-html": "^3.3.3", "lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
+4
View File
@@ -44,6 +44,10 @@
x-model.debounce.500ms="search" x-model.debounce.500ms="search"
/> />
</fieldset> </fieldset>
<fieldset>
<input type="checkbox" class="switch" name="hide_closed_ues" id="hide-closed-ues">
<label for="hide-closed-ues">{% trans %}Show closed UEs{% endtrans %}</label>
</fieldset>
<div class="row gap-3x margin-bottom radio-guide"> <div class="row gap-3x margin-bottom radio-guide">
<fieldset> <fieldset>
{% set departments = [ {% set departments = [
@@ -1,7 +1,7 @@
<form <form
hx-post="{{ url("subscription:fragment-existing-user") }}" hx-post="{{ url("subscription:fragment-existing-user") }}"
hx-target="this" hx-target="this"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
{% csrf_token %} {% csrf_token %}
@@ -1,7 +1,7 @@
<form <form
hx-post="{{ url("subscription:fragment-new-user") }}" hx-post="{{ url("subscription:fragment-new-user") }}"
hx-target="this" hx-target="this"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
{% csrf_token %} {% csrf_token %}