From 05d4a09f8c0fb8acd9c648dbd9adebe51b82581d Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 12 Feb 2025 15:32:04 +0100 Subject: [PATCH] Add a page to manage the groups that can create permissions --- core/templates/core/user_detail.jinja | 2 +- core/templates/core/user_tools.jinja | 33 ++++++++++++-- core/views/forms.py | 23 ++++++++++ core/views/group.py | 61 ++++++++++++++++++++++++++ docs/tutorial/groups.md | 35 +++++++++++++++ locale/fr/LC_MESSAGES/django.po | 26 ++++++----- subscription/tests/test_permissions.py | 43 ++++++++++++++++++ subscription/urls.py | 6 +++ subscription/views.py | 7 +++ 9 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 subscription/tests/test_permissions.py 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 %}
diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja index 4d22ed8b..b4519c88 100644 --- a/core/templates/core/user_tools.jinja +++ b/core/templates/core/user_tools.jinja @@ -30,11 +30,7 @@ {% if user.has_perm("core.view_userban") %}
  • {% trans %}Bans{% endtrans %}
  • {% endif %} - {% if user.has_perm("subscription.add_subscription") %} -
  • {% trans %}Subscriptions{% endtrans %}
  • - {% endif %} {% if user.is_board_member or user.is_root %} -
  • {% trans %}Subscription stats{% endtrans %}
  • {% trans %}New club{% endtrans %}
  • {% endif %} @@ -163,6 +159,35 @@
    {% 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