diff --git a/core/templates/core/user_detail.jinja b/core/templates/core/user_detail.jinja
index 22db9abe..5fceb126 100644
--- a/core/templates/core/user_detail.jinja
+++ b/core/templates/core/user_detail.jinja
@@ -197,9 +197,9 @@
+
{% endif %}
-
{% if user.is_root or user.is_board_member %}
{% endif %}
+ {% if user.has_perm("subscription.add_subscription") or user.has_perm("auth.change_perm") or user.is_root or user.is_board_member %}
+
+
{% trans %}Subscriptions{% endtrans %}
+
+
+ {% endif %}
+
{% if user.memberships.filter(end_date=None).all().count() > 0 %}
{% trans %}Club tools{% endtrans %}
diff --git a/core/views/forms.py b/core/views/forms.py
index 0c0ec0e2..8fcceefd 100644
--- a/core/views/forms.py
+++ b/core/views/forms.py
@@ -28,6 +28,7 @@ from captcha.fields import CaptchaField
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
+from django.contrib.auth.models import Permission
from django.contrib.staticfiles.management.commands.collectstatic import (
staticfiles_storage,
)
@@ -440,3 +441,25 @@ class GiftForm(forms.ModelForm):
id=user_id
)
self.fields["user"].widget = forms.HiddenInput()
+
+
+class PermissionGroupsForm(forms.ModelForm):
+ """Manage the groups that have a specific permission."""
+
+ class Meta:
+ model = Permission
+ fields = []
+
+ groups = forms.ModelMultipleChoiceField(
+ Group.objects.all(), widget=AutoCompleteSelectMultipleGroup, required=False
+ )
+
+ def __init__(self, instance: Permission, **kwargs):
+ super().__init__(instance=instance, **kwargs)
+ self.fields["groups"].initial = instance.group_set.all()
+
+ def save(self, commit: bool = True): # noqa FTB001
+ instance = super().save(commit=False)
+ if commit:
+ instance.group_set.set(self.cleaned_data["groups"])
+ return instance
diff --git a/core/views/group.py b/core/views/group.py
index e17db138..017b550f 100644
--- a/core/views/group.py
+++ b/core/views/group.py
@@ -17,6 +17,9 @@
from django import forms
from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.contrib.auth.models import Permission
+from django.core.exceptions import ImproperlyConfigured
+from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView
@@ -25,6 +28,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin
from core.models import Group, User
from core.views import DetailFormView
+from core.views.forms import PermissionGroupsForm
from core.views.widgets.select import AutoCompleteSelectMultipleUser
# Forms
@@ -130,3 +134,60 @@ class GroupDeleteView(CanEditMixin, DeleteView):
pk_url_kwarg = "group_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("core:group_list")
+
+
+class PermissionGroupsUpdateView(PermissionRequiredMixin, UpdateView):
+ """Manage the groups that have a specific permission.
+
+ Notes:
+ This is an `UpdateView`, but unlike typical `UpdateView`,
+ it doesn't accept url arguments to retrieve the object
+ to update.
+ As such, a `PermissionGroupsUpdateView` can only deal with
+ a single hardcoded permission.
+
+ This is not a limitation, but an on-purpose design,
+ mainly for security matters.
+
+ Example:
+ ```python
+ class AddSubscriptionGroupsView(PermissionGroupsUpdateView):
+ permission = "subscription.add_subscription"
+ success_url = reverse_lazy("foo:bar")
+ ```
+ """
+
+ permission_required = "auth.change_permission"
+ template_name = "core/edit.jinja"
+ form_class = PermissionGroupsForm
+ permission = None
+
+ def get_object(self, *args, **kwargs):
+ if not self.permission:
+ raise ImproperlyConfigured(
+ f"{self.__class__.__name__} is missing the permission attribute. "
+ "Please fill it with either a permission string "
+ "or a Permission object."
+ )
+ if isinstance(self.permission, Permission):
+ return self.permission
+ if isinstance(self.permission, str):
+ try:
+ app_label, codename = self.permission.split(".")
+ except ValueError as e:
+ raise ValueError(
+ "Permission name should be in the form "
+ "app_label.permission_codename."
+ ) from e
+ return get_object_or_404(
+ Permission, codename=codename, content_type__app_label=app_label
+ )
+ raise TypeError(
+ f"{self.__class__.__name__}.permission "
+ f"must be a string or a permission instance."
+ )
+
+ def get_success_url(self):
+ # if children classes define a success url, return it,
+ # else stay on the same page
+ return self.success_url or self.request.path
diff --git a/docs/tutorial/groups.md b/docs/tutorial/groups.md
index bccd713f..2c67b3f0 100644
--- a/docs/tutorial/groups.md
+++ b/docs/tutorial/groups.md
@@ -228,3 +228,38 @@ Les groupes de ban existants sont les suivants :
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
- `Banned to subscribe` : les utilisateurs interdits de cotisation
+
+## Groupes liés à une permission
+
+Certaines actions sur le site demandent une permission en particulier,
+que l'on veut donner ou retirer n'importe quand.
+
+Prenons par exemple les cotisations : lors de l'intégration,
+on veut permettre aux membres du bureau de l'Integ
+de créer des cotisations, et pareil pour les membres du bureau
+de la Welcome Week pendant cette dernière.
+
+Dans ces cas-là, il est pertinent de mettre à disposition
+des administrateurs du site une page leur permettant
+de gérer quels groupes ont une permission donnée.
+Pour ce faire, il existe
+[PermissionGroupsUpdateView][core.views.PermissionGroupsUpdateView].
+
+Pour l'utiliser, il suffit de créer une vue qui en hérite
+et de lui dire quelle est la permission dont on veut gérer
+les groupes :
+
+```python
+from core.views.group import PermissionGroupsUpdateView
+
+
+class SubscriptionPermissionView(PermissionGroupsUpdateView):
+ permission = "subscription.add_subscription"
+```
+
+Configurez l'url de la vue, et c'est tout !
+La page ainsi générée contiendra un formulaire
+avec un unique champ permettant de sélectionner des groupes.
+Par défaut, seuls les utilisateurs avec la permission
+`auth.change_permission` auront accès à ce formulaire
+(donc, normalement, uniquement les utilisateurs Root).
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index dc6b5ee6..3f881bd0 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-01-19 18:12+0100\n"
+"POT-Creation-Date: 2025-02-12 15:32+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal \n"
@@ -2914,7 +2914,7 @@ msgstr "Blouse"
msgid "Not subscribed"
msgstr "Non cotisant"
-#: core/templates/core/user_detail.jinja
+#: core/templates/core/user_detail.jinja core/templates/core/user_tools.jinja
#: subscription/templates/subscription/subscription.jinja
msgid "New subscription"
msgstr "Nouvelle cotisation"
@@ -3146,15 +3146,6 @@ msgstr "Supprimer les messages forum d'un utilisateur"
msgid "Bans"
msgstr "Bans"
-#: core/templates/core/user_tools.jinja
-msgid "Subscriptions"
-msgstr "Cotisations"
-
-#: core/templates/core/user_tools.jinja
-#: subscription/templates/subscription/stats.jinja
-msgid "Subscription stats"
-msgstr "Statistiques de cotisation"
-
#: core/templates/core/user_tools.jinja counter/forms.py
#: counter/views/mixins.py
msgid "Counters"
@@ -3227,6 +3218,19 @@ msgstr "Modérer les fichiers"
msgid "Moderate pictures"
msgstr "Modérer les photos"
+#: core/templates/core/user_tools.jinja
+msgid "Subscriptions"
+msgstr "Cotisations"
+
+#: core/templates/core/user_tools.jinja
+msgid "Manage permissions"
+msgstr "Gérer les permissions"
+
+#: core/templates/core/user_tools.jinja
+#: subscription/templates/subscription/stats.jinja
+msgid "Subscription stats"
+msgstr "Statistiques de cotisation"
+
#: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja
msgid "Create UV"
msgstr "Créer UV"
diff --git a/subscription/tests/test_permissions.py b/subscription/tests/test_permissions.py
new file mode 100644
index 00000000..fcc290e9
--- /dev/null
+++ b/subscription/tests/test_permissions.py
@@ -0,0 +1,43 @@
+from django.contrib.auth.models import Permission
+from django.test import TestCase
+from django.urls import reverse
+from model_bakery import baker
+from pytest_django.asserts import assertRedirects
+
+from club.models import Club, Membership
+from core.baker_recipes import subscriber_user
+from core.models import User
+
+
+class TestSubscriptionPermission(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.user: User = subscriber_user.make()
+ cls.admin = baker.make(User, is_superuser=True)
+ cls.club = baker.make(Club)
+ baker.make(Membership, user=cls.user, club=cls.club, role=7)
+
+ def test_give_permission(self):
+ self.client.force_login(self.admin)
+ response = self.client.post(
+ reverse("subscription:perms"), {"groups": [self.club.board_group_id]}
+ )
+ assertRedirects(response, reverse("subscription:perms"))
+ assert self.user.has_perm("subscription.add_subscription")
+
+ def test_remove_permission(self):
+ self.client.force_login(self.admin)
+ response = self.client.post(reverse("subscription:perms"), {"groups": []})
+ assertRedirects(response, reverse("subscription:perms"))
+ assert not self.user.has_perm("subscription.add_subscription")
+
+ def test_subscription_page_access(self):
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("subscription:subscription"))
+ assert response.status_code == 403
+
+ self.club.board_group.permissions.add(
+ Permission.objects.get(codename="add_subscription")
+ )
+ response = self.client.get(reverse("subscription:subscription"))
+ assert response.status_code == 200
diff --git a/subscription/urls.py b/subscription/urls.py
index 47dbf21e..3d3c9996 100644
--- a/subscription/urls.py
+++ b/subscription/urls.py
@@ -20,6 +20,7 @@ from subscription.views import (
CreateSubscriptionNewUserFragment,
NewSubscription,
SubscriptionCreatedFragment,
+ SubscriptionPermissionView,
SubscriptionsStatsView,
)
@@ -41,5 +42,10 @@ urlpatterns = [
SubscriptionCreatedFragment.as_view(),
name="creation-success",
),
+ path(
+ "perms/",
+ SubscriptionPermissionView.as_view(),
+ name="perms",
+ ),
path("stats/", SubscriptionsStatsView.as_view(), name="stats"),
]
diff --git a/subscription/views.py b/subscription/views.py
index ce1ad4d7..5e5d9420 100644
--- a/subscription/views.py
+++ b/subscription/views.py
@@ -21,6 +21,7 @@ from django.utils.timezone import localdate
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView
+from core.views.group import PermissionGroupsUpdateView
from counter.apps import PAYMENT_METHOD
from subscription.forms import (
SelectionDateForm,
@@ -77,6 +78,12 @@ class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView):
context_object_name = "subscription"
+class SubscriptionPermissionView(PermissionGroupsUpdateView):
+ """Manage the groups that have access to the subscription creation page."""
+
+ permission = "subscription.add_subscription"
+
+
class SubscriptionsStatsView(FormView):
template_name = "subscription/stats.jinja"
form_class = SelectionDateForm