diff --git a/.github/actions/setup_project/action.yml b/.github/actions/setup_project/action.yml
index 909bf757..2d2aae89 100644
--- a/.github/actions/setup_project/action.yml
+++ b/.github/actions/setup_project/action.yml
@@ -4,7 +4,7 @@ runs:
   using: composite
   steps:
     - name: Install apt packages
-      uses: awalsh128/cache-apt-pkgs-action@latest
+      uses: awalsh128/cache-apt-pkgs-action@v1.4.3
       with:
         packages: gettext
         version: 1.0  # increment to reset cache
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9a436879..57f36d6f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,7 +41,7 @@ jobs:
           uv run coverage report
           uv run coverage html
       - name: Archive code coverage results
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
-          name: coverage-report
+          name: coverage-report-${{ matrix.pytest-mark }}
           path: coverage_report
diff --git a/club/views.py b/club/views.py
index de5ccaee..767f5788 100644
--- a/club/views.py
+++ b/club/views.py
@@ -256,7 +256,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
         kwargs["request_user"] = self.request.user
-        kwargs["club"] = self.get_object()
+        kwargs["club"] = self.object
         kwargs["club_members"] = self.members
         return kwargs
 
@@ -273,9 +273,9 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
         users = data.pop("users", [])
         users_old = data.pop("users_old", [])
         for user in users:
-            Membership(club=self.get_object(), user=user, **data).save()
+            Membership(club=self.object, user=user, **data).save()
         for user in users_old:
-            membership = self.get_object().get_membership_for(user)
+            membership = self.object.get_membership_for(user)
             membership.end_date = timezone.now()
             membership.save()
         return resp
@@ -285,9 +285,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
         return super().dispatch(request, *args, **kwargs)
 
     def get_success_url(self, **kwargs):
-        return reverse_lazy(
-            "club:club_members", kwargs={"club_id": self.get_object().id}
-        )
+        return reverse_lazy("club:club_members", kwargs={"club_id": self.object.id})
 
 
 class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts
index 11a15a32..3c78f98f 100644
--- a/com/static/bundled/com/components/ics-calendar-index.ts
+++ b/com/static/bundled/com/components/ics-calendar-index.ts
@@ -152,6 +152,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 
   async connectedCallback() {
     super.connectedCallback();
+    const cacheInvalidate = `?invalidate=${Date.now()}`;
     this.calendar = new Calendar(this.node, {
       plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
       locales: [frLocale, enLocale],
@@ -161,11 +162,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
       headerToolbar: this.currentToolbar(),
       eventSources: [
         {
-          url: await makeUrl(calendarCalendarInternal),
+          url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
           format: "ics",
         },
         {
-          url: await makeUrl(calendarCalendarExternal),
+          url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
           format: "ics",
         },
       ],
diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss
index 21aa55d7..6c86cce0 100644
--- a/com/static/com/components/ics-calendar.scss
+++ b/com/static/com/components/ics-calendar.scss
@@ -75,10 +75,10 @@ ics-calendar {
   }
 
   td {
-    overflow-x: visible; // Show events on multiple days
+    overflow: visible; // Show events on multiple days
   }
 
-	//Reset from style.scss
+  //Reset from style.scss
   table {
     box-shadow: none;
     border-radius: 0px;
@@ -86,13 +86,13 @@ ics-calendar {
     margin: 0px;
   }
 
-	// Reset from style.scss
+  // Reset from style.scss
   thead {
     background-color: white;
     color: black;
   }
 
-	// Reset from style.scss
+  // Reset from style.scss
   tbody>tr {
     &:nth-child(even):not(.highlight) {
       background: white;
diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss
index bcbf8273..d073c4ac 100644
--- a/com/static/com/css/news-list.scss
+++ b/com/static/com/css/news-list.scss
@@ -36,6 +36,11 @@
     &:not(:first-of-type) {
       margin: 2em 0 1em 0;
     }
+
+    .feed {
+      float: right;
+      color: #f26522;
+    }
   }
 
   @media screen and (max-width: $small-devices) {
diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja
index 168a95b4..0f1f4301 100644
--- a/com/templates/com/news_list.jinja
+++ b/com/templates/com/news_list.jinja
@@ -8,6 +8,9 @@
 {% block additional_css %}
   <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
   <link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
+
+  {# Atom feed discovery, not really css but also goes there #}
+  <link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
 {% endblock %}
 
 {% block additional_js %}
@@ -19,7 +22,10 @@
   <div id="news">
     <div id="left_column" class="news_column">
       {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__is_moderated=True).datetimes('start_date', 'day') %}
-      <h3>{% trans %}Events today and the next few days{% endtrans %}</h3>
+      <h3>
+        {% trans %}Events today and the next few days{% endtrans %}
+        <a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
+      </h3>
       {% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
         <a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
           <i class="fa fa-plus"></i>
@@ -73,7 +79,10 @@
         </div>
       {% endif %}
 
-      <h3>{% trans %}All coming events{% endtrans %}</h3>
+      <h3>
+        {% trans %}All coming events{% endtrans %}
+        <a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
+      </h3>
       <ics-calendar locale="{{ get_language() }}"></ics-calendar>
     </div>
 
diff --git a/com/tests/test_views.py b/com/tests/test_views.py
index 100a83ef..f80839ab 100644
--- a/com/tests/test_views.py
+++ b/com/tests/test_views.py
@@ -17,6 +17,7 @@ from unittest.mock import patch
 
 import pytest
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
 from django.urls import reverse
@@ -24,7 +25,7 @@ from django.utils import html
 from django.utils.timezone import localtime, now
 from django.utils.translation import gettext as _
 from model_bakery import baker
-from pytest_django.asserts import assertRedirects
+from pytest_django.asserts import assertNumQueries, assertRedirects
 
 from club.models import Club, Membership
 from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
@@ -319,3 +320,15 @@ class TestNewsCreation(TestCase):
                 self.valid_payload,
             )
             mocked.assert_called()
+
+
+@pytest.mark.django_db
+def test_feed(client):
+    """Smoke test that checks that the atom feed is working"""
+    Site.objects.clear_cache()
+    with assertNumQueries(2):
+        # get sith domain with Site api: 1 request
+        # get all news and related info: 1 request
+        resp = client.get(reverse("com:news_feed"))
+        assert resp.status_code == 200
+        assert resp.headers["Content-Type"] == "application/rss+xml; charset=utf-8"
diff --git a/com/urls.py b/com/urls.py
index 592e653b..8afbfd12 100644
--- a/com/urls.py
+++ b/com/urls.py
@@ -25,6 +25,7 @@ from com.views import (
     NewsCreateView,
     NewsDeleteView,
     NewsDetailView,
+    NewsFeed,
     NewsListView,
     NewsModerateView,
     NewsUpdateView,
@@ -73,6 +74,7 @@ urlpatterns = [
         name="weekmail_article_edit",
     ),
     path("news/", NewsListView.as_view(), name="news_list"),
+    path("news/feed/", NewsFeed(), name="news_feed"),
     path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
     path("news/create/", NewsCreateView.as_view(), name="news_new"),
     path("news/<int:news_id>/edit/", NewsUpdateView.as_view(), name="news_edit"),
diff --git a/com/views.py b/com/views.py
index a6faf214..0ab8fc1c 100644
--- a/com/views.py
+++ b/com/views.py
@@ -26,8 +26,10 @@ from datetime import timedelta
 from smtplib import SMTPRecipientsRefused
 from typing import Any
 
+from dateutil.relativedelta import relativedelta
 from django.conf import settings
 from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
+from django.contrib.syndication.views import Feed
 from django.core.exceptions import PermissionDenied, ValidationError
 from django.db.models import Max
 from django.forms.models import modelform_factory
@@ -268,6 +270,34 @@ class NewsDetailView(CanViewMixin, DetailView):
         return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
 
 
+class NewsFeed(Feed):
+    title = _("News")
+    link = reverse_lazy("com:news_list")
+    description = _("All incoming events")
+
+    def items(self):
+        return (
+            NewsDate.objects.filter(
+                news__is_moderated=True,
+                end_date__gte=timezone.now() - (relativedelta(months=6)),
+            )
+            .select_related("news", "news__author")
+            .order_by("-start_date")
+        )
+
+    def item_title(self, item: NewsDate):
+        return item.news.title
+
+    def item_description(self, item: NewsDate):
+        return item.news.summary
+
+    def item_link(self, item: NewsDate):
+        return item.news.get_absolute_url()
+
+    def item_author_name(self, item: NewsDate):
+        return item.news.author.get_display_name()
+
+
 # Weekmail
 
 
diff --git a/core/auth/api_permissions.py b/core/auth/api_permissions.py
index 4d83143e..6a28f13c 100644
--- a/core/auth/api_permissions.py
+++ b/core/auth/api_permissions.py
@@ -37,8 +37,11 @@ Example:
     ```
 """
 
+import operator
+from functools import reduce
 from typing import Any
 
+from django.contrib.auth.models import Permission
 from django.http import HttpRequest
 from ninja_extra import ControllerBase
 from ninja_extra.permissions import BasePermission
@@ -56,6 +59,46 @@ class IsInGroup(BasePermission):
         return request.user.is_in_group(pk=self._group_pk)
 
 
+class HasPerm(BasePermission):
+    """Check that the user has the required perm.
+
+    If multiple perms are given, a comparer function can also be passed,
+    in order to change the way perms are checked.
+
+    Example:
+        ```python
+        # this route will require both permissions
+        @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
+        def foo(self): ...
+
+        # This route will require at least one of the perm,
+        # but it's not mandatory to have all of them
+        @route.put(
+            "/bar",
+            permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
+        )
+        def bar(self): ...
+    """
+
+    def __init__(
+        self, perms: str | Permission | list[str | Permission], op=operator.and_
+    ):
+        """
+        Args:
+            perms: a permission or a list of permissions the user must have
+            op: An operator to combine multiple permissions (in most cases,
+                it will be either `operator.and_` or `operator.or_`)
+        """
+        super().__init__()
+        if not isinstance(perms, (list, tuple, set)):
+            perms = [perms]
+        self._operator = op
+        self._perms = perms
+
+    def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
+        return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
+
+
 class IsRoot(BasePermission):
     """Check that the user is root."""
 
diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py
index 5e0f099d..53638699 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -92,7 +92,12 @@ class Command(BaseCommand):
             raise Exception("Never call this command in prod. Never.")
 
         Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
-        Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
+
+        site = Site.objects.get_current()
+        site.domain = settings.SITH_URL
+        site.name = settings.SITH_NAME
+        site.save()
+
         groups = self._create_groups()
         self._create_ban_groups()
 
@@ -120,6 +125,11 @@ class Command(BaseCommand):
             unix_name=settings.SITH_MAIN_CLUB["unix_name"],
             address=settings.SITH_MAIN_CLUB["address"],
         )
+        main_club.board_group.permissions.add(
+            *Permission.objects.filter(
+                codename__in=["view_subscription", "add_subscription"]
+            )
+        )
         bar_club = Club.objects.create(
             id=2,
             name=settings.SITH_BAR_MANAGER["name"],
@@ -895,13 +905,16 @@ Welcome to the wiki page!
 
         subscribers = Group.objects.create(name="Subscribers")
         subscribers.permissions.add(
-            *list(perms.filter(codename__in=["add_news", "add_uvcommentreport"]))
+            *list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
         )
         old_subscribers = Group.objects.create(name="Old subscribers")
         old_subscribers.permissions.add(
             *list(
                 perms.filter(
                     codename__in=[
+                        "view_uv",
+                        "view_uvcomment",
+                        "add_uvcommentreport",
                         "view_user",
                         "view_picture",
                         "view_album",
@@ -973,9 +986,9 @@ Welcome to the wiki page!
         )
         pedagogy_admin.permissions.add(
             *list(
-                perms.filter(content_type__app_label="pedagogy").values_list(
-                    "pk", flat=True
-                )
+                perms.filter(content_type__app_label="pedagogy")
+                .exclude(codename__in=["change_uvcomment"])
+                .values_list("pk", flat=True)
             )
         )
         self.reset_index("core", "auth")
diff --git a/core/models.py b/core/models.py
index b1caa912..4748f311 100644
--- a/core/models.py
+++ b/core/models.py
@@ -417,29 +417,6 @@ class User(AbstractUser):
     def is_board_member(self) -> bool:
         return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
 
-    @cached_property
-    def can_read_subscription_history(self) -> bool:
-        if self.is_root or self.is_board_member:
-            return True
-
-        from club.models import Club
-
-        for club in Club.objects.filter(
-            id__in=settings.SITH_CAN_READ_SUBSCRIPTION_HISTORY
-        ):
-            if club in self.clubs_with_rights:
-                return True
-        return False
-
-    @cached_property
-    def can_create_subscription(self) -> bool:
-        return self.is_root or (
-            self.memberships.board()
-            .ongoing()
-            .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
-            .exists()
-        )
-
     @cached_property
     def is_launderette_manager(self):
         from club.models import Club
@@ -679,14 +656,6 @@ class AnonymousUser(AuthAnonymousUser):
     def __init__(self):
         super().__init__()
 
-    @property
-    def can_create_subscription(self):
-        return False
-
-    @property
-    def can_read_subscription_history(self):
-        return False
-
     @property
     def was_subscribed(self):
         return False
diff --git a/core/templates/core/edit.jinja b/core/templates/core/edit.jinja
index 25c6bd74..82c7a035 100644
--- a/core/templates/core/edit.jinja
+++ b/core/templates/core/edit.jinja
@@ -1,19 +1,40 @@
 {% extends "core/base.jinja" %}
 
+{# if the template context has the `object_name` variable,
+   then this one will be used in the page title,
+   instead of the result of `str(object)` #}
+{% if object and not object_name %}
+  {% set object_name=object %}
+{% endif %}
+
 {% block title %}
-  {% if object %}
-    {% trans obj=object %}Edit {{ obj }}{% endtrans %}
+  {% if object_name %}
+    {% trans name=object_name %}Edit {{ name }}{% endtrans %}
   {% else %}
     {% trans %}Save{% endtrans %}
   {% endif %}
 {% endblock %}
 
 {% block content %}
-  {% if object %}
-    <h2>{% trans obj=object %}Edit {{ obj }}{% endtrans %}</h2>
+  {% if object_name %}
+    <h2>{% trans name=object_name %}Edit {{ name }}{% endtrans %}</h2>
   {% else %}
     <h2>{% trans %}Save{% endtrans %}</h2>
   {% endif %}
+  {% if messages %}
+    <div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
+      <span class="alert-main">
+        {% for message in messages %}
+          {% if message.level_tag == "success" %}
+            {{ message }}
+          {% endif %}
+        {% endfor %}
+      </span>
+      <span class="clickable" @click="show_alert = false">
+        <i class="fa fa-close"></i>
+      </span>
+    </div>
+  {% endif %}
   <form action="" method="post" enctype="multipart/form-data">
     {% csrf_token %}
     {{ form.as_p() }}
diff --git a/core/templates/core/user_detail.jinja b/core/templates/core/user_detail.jinja
index cb93b1cd..5fceb126 100644
--- a/core/templates/core/user_detail.jinja
+++ b/core/templates/core/user_detail.jinja
@@ -166,7 +166,7 @@
   </div>
 {% endif %}
 <br>
-{% if profile.was_subscribed and (user == profile or user.can_read_subscription_history)%}
+{% if profile.was_subscribed and (user == profile or user.has_perm("subscription.view_subscription")) %}
   <div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
     <div class="collapse-header clickable" @click="collapsed = !collapsed">
       <span class="collapse-header-text">
@@ -197,9 +197,9 @@
       </table>
     </div>
   </div>
+  <hr>
 {% endif %}
 
-<hr>
 <div>
   {% if user.is_root or user.is_board_member %}
     <form class="form-gifts" action="{{ url('core:user_gift_create', user_id=profile.id) }}" method="post">
diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja
index 177b4199..4c9b9462 100644
--- a/core/templates/core/user_tools.jinja
+++ b/core/templates/core/user_tools.jinja
@@ -13,7 +13,7 @@
     <h3>{% trans %}User Tools{% endtrans %}</h3>
 
     <div class="container">
-      {% if user.can_create_subscription or user.is_root or user.is_board_member %}
+      {% if user.has_perm("subscription.view_userban") or user.is_root or user.is_board_member %}
         <div>
           <h4>{% trans %}Sith management{% endtrans %}</h4>
           <ul>
@@ -21,16 +21,16 @@
               <li><a href="{{ url('core:group_list') }}">{% trans %}Groups{% endtrans %}</a></li>
               <li><a href="{{ url('rootplace:merge') }}">{% trans %}Merge users{% endtrans %}</a></li>
               <li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
-              <li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
+              <li>
+                <a href="{{ url('rootplace:delete_forum_messages') }}">
+                  {% trans %}Delete user's forum messages{% endtrans %}
+                </a>
+              </li>
             {% endif %}
             {% if user.has_perm("core.view_userban") %}
               <li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
             {% endif %}
-            {% if user.can_create_subscription or user.is_root %}
-              <li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
-            {% endif %}
             {% if user.is_board_member or user.is_root %}
-              <li><a href="{{ url('subscription:stats') }}">{% trans %}Subscription stats{% endtrans %}</a></li>
               <li><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></li>
             {% endif %}
           </ul>
@@ -42,152 +42,202 @@
         {% set is_admin_on_a_counter = true %}
       {% endfor %}
 
-      {% if
-      is_admin_on_a_counter
-      or user.is_root
-      or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
-      %}
+      {% if is_admin_on_a_counter or user.is_root or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) %}
+        <div>
+          <h4>{% trans %}Counters{% endtrans %}</h4>
+          <ul>
+            {% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) %}
+              <li>
+                <a href="{{ url('counter:admin_list') }}">
+                  {% trans %}General counters management{% endtrans %}
+                </a>
+              </li>
+              <li>
+                <a href="{{ url('counter:product_list') }}">
+                  {% trans %}Products management{% endtrans %}
+                </a>
+              </li>
+              <li>
+                <a href="{{ url('counter:product_type_list') }}">
+                  {% trans %}Product types management{% endtrans %}
+                </a>
+              </li>
+              <li>
+                <a href="{{ url('counter:cash_summary_list') }}">
+                  {% trans %}Cash register summaries{% endtrans %}
+                </a>
+              </li>
+              <li>
+                <a href="{{ url('counter:invoices_call') }}">
+                  {% trans %}Invoices call{% endtrans %}
+                </a>
+              </li>
+              <li>
+                <a href="{{ url('counter:eticket_list') }}">
+                  {% trans %}Etickets{% endtrans %}
+                </a>
+              </li>
+            {% endif %}
+          </ul>
+          <ul>
+            {% for b in settings.SITH_COUNTER_BARS %}
+              {% if user.is_in_group(name=b[1]+" admin") %}
+                {% set c = Counter.objects.filter(id=b[0]).first() %}
+
+                <li class="rows counter">
+                  <a href="{{ url('counter:details', counter_id=b[0]) }}">{{ b[1] }}</a>
+
+                  <span>
+                    <span>
+                      <a class="button" href="{{ url('counter:admin', counter_id=b[0]) }}">
+                        {% trans %}Edit{% endtrans %}
+                      </a>
+                      <a class="button" href="{{ url('counter:stats', counter_id=b[0]) }}">
+                        {% trans %}Stats{% endtrans %}
+                      </a>
+                    </span>
+                  </span>
+                </li>
+              {% endif %}
+            {% endfor %}
+          </ul>
+        </div>
+      {% endif %}
+
+      {% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
+        <div>
+          <h4>{% trans %}Accounting{% endtrans %}</h4>
+          <ul>
+            {% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
+              <li><a href="{{ url('accounting:refound_account') }}">{% trans %}Refound Account{% endtrans %}</a></li>
+              <li><a href="{{ url('accounting:bank_list') }}">{% trans %}General accounting{% endtrans %}</a></li>
+              <li><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></li>
+            {% endif %}
+
+            {% for m in user.memberships.filter(end_date=None).filter(role__gte=7).all() -%}
+              {%- for b in m.club.bank_accounts.all() %}
+                <li class="rows">
+                  <strong>{% trans %}Bank account: {% endtrans %}</strong>
+                  <a href="{{ url('accounting:bank_details', b_account_id=b.id) }}">{{ b }}</a>
+                </li>
+              {%- endfor %}
+
+              {% if m.club.club_account.exists() -%}
+                {% for ca in m.club.club_account.all() %}
+                  <li class="rows">
+                    <strong>{% trans %}Club account: {% endtrans %}</strong>
+                    <a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca }}</a>
+                  </li>
+                {%- endfor %}
+              {%- endif -%}
+            {%- endfor %}
+          </ul>
+        </div>
+      {% endif %}
+
+      {% if user.is_root or user.is_com_admin or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
+        <div>
+          <h4>{% trans %}Communication{% endtrans %}</h4>
+          <ul>
+            {% if user.is_com_admin or user.is_root %}
+              <li><a href="{{ url('com:weekmail_article') }}">{% trans %}Create weekmail article{% endtrans %}</a></li>
+              <li><a href="{{ url('com:weekmail') }}">{% trans %}Weekmail{% endtrans %}</a></li>
+              <li><a href="{{ url('com:weekmail_destinations') }}">{% trans %}Weekmail destinations{% endtrans %}</a></li>
+              <li><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></li>
+              <li><a href="{{ url('com:news_admin_list') }}">{% trans %}Moderate news{% endtrans %}</a></li>
+              <li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
+              <li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
+              <li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
+              <li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
+              <li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
+              <li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
+            {% endif %}
+            {% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
+              <li><a href="{{ url('sas:moderation') }}">{% trans %}Moderate pictures{% endtrans %}</a></li>
+            {% endif %}
+          </ul>
+        </div>
+      {% endif %}
+
+      {% if user.has_perm("subscription.add_subscription") or user.has_perm("auth.change_perm") or user.is_root or user.is_board_member %}
+        <div>
+          <h4>{% trans %}Subscriptions{% endtrans %}</h4>
+          <ul>
+            {% if user.has_perm("subscription.add_subscription") %}
+              <li>
+                <a href="{{ url("subscription:subscription") }}">
+                  {% trans %}New subscription{% endtrans %}
+                </a>
+              </li>
+            {% endif %}
+            {% if user.has_perm("auth.change_permission") %}
+              <li>
+                <a href="{{ url("subscription:perms") }}">
+                  {% trans %}Manage permissions{% endtrans %}
+                </a>
+              </li>
+            {% endif %}
+            {% if user.is_root or user.is_board_member %}
+              <li>
+                <a href="{{ url("subscription:stats") }}">
+                  {% trans %}Subscription stats{% endtrans %}
+                </a>
+              </li>
+            {% endif %}
+          </ul>
+        </div>
+      {% endif %}
+
+      {% if user.memberships.filter(end_date=None).all().count() > 0 %}
+        <div>
+          <h4>{% trans %}Club tools{% endtrans %}</h4>
+          <ul>
+            {% for m in user.memberships.filter(end_date=None).all() %}
+              <li><a href="{{ url('club:tools', club_id=m.club.id) }}">{{ m.club }}</a></li>
+            {% endfor %}
+          </ul>
+        </div>
+      {% endif %}
+
+      {% if user.has_perm("pedagogy.add_uv") or user.has_perm("pedagogy.delete_uvcomment") %}
+        <div>
+          <h4>{% trans %}Pedagogy{% endtrans %}</h4>
+          <ul>
+            {% if user.has_perm("pedagogy.add_uv") %}
+              <li>
+                <a href="{{ url("pedagogy:uv_create") }}">
+                  {% trans %}Create UV{% endtrans %}
+                </a>
+              </li>
+            {% endif %}
+            {% if user.has_perm("pedagogy.delete_uvcomment") %}
+              <li>
+                <a href="{{ url("pedagogy:moderation") }}">
+                  {% trans %}Moderate comments{% endtrans %}
+                </a>
+              </li>
+            {% endif %}
+          </ul>
+        </div>
+      {% endif %}
+
       <div>
-        <h4>{% trans %}Counters{% endtrans %}</h4>
+        <h4>{% trans %}Elections{% endtrans %}</h4>
         <ul>
-          {% if user.is_root
-          or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
-          %}
-          <li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
-          <li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
-          <li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
-          <li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
-          <li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
-          <li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
-{% endif %}
-</ul>
-<ul>
-  {% for b in settings.SITH_COUNTER_BARS %}
-    {% if user.is_in_group(name=b[1]+" admin") %}
-      {% set c = Counter.objects.filter(id=b[0]).first() %}
+          <li><a href="{{ url('election:list') }}">{% trans %}See available elections{% endtrans %}</a></li>
+          <li><a href="{{ url('election:list_archived') }}">{% trans %}See archived elections{% endtrans %}</a></li>
+          {%- if user.is_subscribed -%}
+            <li><a href="{{ url('election:create') }}">{% trans %}Create a new election{% endtrans %}</a></li>
+          {%- endif -%}
+        </ul>
+      </div>
 
-      <li class="rows counter">
-        <a href="{{ url('counter:details', counter_id=b[0]) }}">{{ b[1] }}</a>
-
-        <span>
-          <span>
-            <a class="button" href="{{ url('counter:admin', counter_id=b[0]) }}">{% trans %}Edit{% endtrans %}</a>
-            <a class="button" href="{{ url('counter:stats', counter_id=b[0]) }}">{% trans %}Stats{% endtrans %}</a>
-          </span>
-        </span>
-      </li>
-    {% endif %}
-  {% endfor %}
-</ul>
-</div>
-{% endif %}
-
-{% if
-user.is_root
-or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
-or user.memberships.ongoing().filter(role__gte=7).count() > 10
-%}
-<div>
-  <h4>{% trans %}Accounting{% endtrans %}</h4>
-  <ul>
-    {% if user.is_root
-    or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
-    %}
-    <li><a href="{{ url('accounting:refound_account') }}">{% trans %}Refound Account{% endtrans %}</a></li>
-    <li><a href="{{ url('accounting:bank_list') }}">{% trans %}General accounting{% endtrans %}</a></li>
-    <li><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></li>
-    {% endif %}
-
-    {% for m in user.memberships.filter(end_date=None).filter(role__gte=7).all() -%}
-      {%- for b in m.club.bank_accounts.all() %}
-        <li class="rows">
-          <strong>{% trans %}Bank account: {% endtrans %}</strong>
-          <a href="{{ url('accounting:bank_details', b_account_id=b.id) }}">{{ b }}</a>
-        </li>
-      {%- endfor %}
-
-      {% if m.club.club_account.exists() -%}
-        {% for ca in m.club.club_account.all() %}
-          <li class="rows">
-            <strong>{% trans %}Club account: {% endtrans %}</strong>
-            <a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca }}</a>
-          </li>
-        {%- endfor %}
-      {%- endif -%}
-    {%- endfor %}
-  </ul>
-</div>
-{% endif %}
-
-{% if
-user.is_root
-or user.is_com_admin
-or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
-%}
-<div>
-  <h4>{% trans %}Communication{% endtrans %}</h4>
-  <ul>
-    {% if user.is_com_admin or user.is_root %}
-      <li><a href="{{ url('com:weekmail_article') }}">{% trans %}Create weekmail article{% endtrans %}</a></li>
-      <li><a href="{{ url('com:weekmail') }}">{% trans %}Weekmail{% endtrans %}</a></li>
-      <li><a href="{{ url('com:weekmail_destinations') }}">{% trans %}Weekmail destinations{% endtrans %}</a></li>
-      <li><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></li>
-      <li><a href="{{ url('com:news_admin_list') }}">{% trans %}Moderate news{% endtrans %}</a></li>
-      <li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
-      <li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
-      <li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
-      <li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
-      <li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
-      <li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
-    {% endif %}
-    {% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
-      <li><a href="{{ url('sas:moderation') }}">{% trans %}Moderate pictures{% endtrans %}</a></li>
-    {% endif %}
-  </ul>
-</div>
-{% endif %}
-
-{% if user.memberships.filter(end_date=None).all().count() > 0 %}
-  <div>
-    <h4>{% trans %}Club tools{% endtrans %}</h4>
-    <ul>
-      {% for m in user.memberships.filter(end_date=None).all() %}
-        <li><a href="{{ url('club:tools', club_id=m.club.id) }}">{{ m.club }}</a></li>
-      {% endfor %}
-    </ul>
-  </div>
-{% endif %}
-
-{% if
-user.is_root
-or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
-%}
-<div>
-  <h4>{% trans %}Pedagogy{% endtrans %}</h4>
-  <ul>
-    <li><a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a></li>
-    <li><a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a></li>
-  </ul>
-</div>
-{% endif %}
-
-<div>
-  <h4>{% trans %}Elections{% endtrans %}</h4>
-  <ul>
-    <li><a href="{{ url('election:list') }}">{% trans %}See available elections{% endtrans %}</a></li>
-    <li><a href="{{ url('election:list_archived') }}">{% trans %}See archived elections{% endtrans %}</a></li>
-    {%- if user.is_subscribed -%}
-      <li><a href="{{ url('election:create') }}">{% trans %}Create a new election{% endtrans %}</a></li>
-    {%- endif -%}
-  </ul>
-</div>
-
-<div>
-  <h4>{% trans %}Other tools{% endtrans %}</h4>
-  <ul>
-    <li><a href="{{ url('trombi:user_tools') }}">{% trans %}Trombi tools{% endtrans %}</a></li>
-  </ul>
-</div>
-</div>
-</main>
+      <div>
+        <h4>{% trans %}Other tools{% endtrans %}</h4>
+        <ul>
+          <li><a href="{{ url('trombi:user_tools') }}">{% trans %}Trombi tools{% endtrans %}</a></li>
+        </ul>
+      </div>
+    </div>
+  </main>
 {% endblock %}
\ No newline at end of file
diff --git a/core/tests/test_user.py b/core/tests/test_user.py
index 0e14bad8..9b2209b3 100644
--- a/core/tests/test_user.py
+++ b/core/tests/test_user.py
@@ -8,6 +8,7 @@ from django.urls import reverse
 from django.utils.timezone import now
 from model_bakery import baker, seq
 from model_bakery.recipe import Recipe, foreign_key
+from pytest_django.asserts import assertRedirects
 
 from com.models import News
 from core.baker_recipes import (
@@ -15,7 +16,7 @@ from core.baker_recipes import (
     subscriber_user,
     very_old_subscriber_user,
 )
-from core.models import User
+from core.models import Group, User
 from counter.models import Counter, Refilling, Selling
 from eboutic.models import Invoice, InvoiceItem
 
@@ -198,3 +199,23 @@ def test_user_added_to_public_group():
     user = baker.make(User)
     assert user.groups.filter(pk=settings.SITH_GROUP_PUBLIC_ID).exists()
     assert user.is_in_group(pk=settings.SITH_GROUP_PUBLIC_ID)
+
+
+@pytest.mark.django_db
+def test_user_update_groups(client: Client):
+    client.force_login(baker.make(User, is_superuser=True))
+    manageable_groups = baker.make(Group, is_manually_manageable=True, _quantity=3)
+    hidden_groups = baker.make(Group, is_manually_manageable=False, _quantity=4)
+    user = baker.make(User, groups=[*manageable_groups[1:], *hidden_groups[:3]])
+    response = client.post(
+        reverse("core:user_groups", kwargs={"user_id": user.id}),
+        data={"groups": [manageable_groups[0].id, manageable_groups[1].id]},
+    )
+    assertRedirects(response, user.get_absolute_url())
+    # only the manually manageable groups should have changed
+    assert set(user.groups.all()) == {
+        Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID),
+        manageable_groups[0],
+        manageable_groups[1],
+        *hidden_groups[:3],
+    }
diff --git a/core/views/__init__.py b/core/views/__init__.py
index c8152f78..a53671d5 100644
--- a/core/views/__init__.py
+++ b/core/views/__init__.py
@@ -28,8 +28,7 @@ from django.http import (
     HttpResponseServerError,
 )
 from django.shortcuts import render
-from django.utils.functional import cached_property
-from django.views.generic.detail import SingleObjectMixin
+from django.views.generic.detail import BaseDetailView
 from django.views.generic.edit import FormView
 from sentry_sdk import last_event_id
 
@@ -54,17 +53,12 @@ def internal_servor_error(request):
     return HttpResponseServerError(render(request, "core/500.jinja"))
 
 
-class DetailFormView(SingleObjectMixin, FormView):
+class DetailFormView(FormView, BaseDetailView):
     """Class that allow both a detail view and a form view."""
 
-    def get_object(self):
-        """Get current group from id in url."""
-        return self.cached_object
-
-    @cached_property
-    def cached_object(self):
-        """Optimisation on group retrieval."""
-        return super().get_object()
+    def post(self, request, *args, **kwargs):
+        self.object = self.get_object()
+        return super().post(request, *args, **kwargs)
 
 
 # F403: those star-imports would be hellish to refactor
diff --git a/core/views/forms.py b/core/views/forms.py
index a0cdfb6b..381fc8a3 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,
 )
@@ -323,6 +324,19 @@ class UserGroupsForm(forms.ModelForm):
         model = User
         fields = ["groups"]
 
+    def save(self, *args, **kwargs) -> User:
+        # make the super method manage error without persisting in db
+        super().save(commit=False)
+        # Don't forget to add the non-manageable groups when setting groups,
+        # or the user would lose all of those when the form is submitted
+        self.instance.groups.set(
+            [
+                *self.cleaned_data["groups"],
+                *self.instance.groups.filter(is_manually_manageable=False),
+            ]
+        )
+        return self.instance
+
 
 class UserGodfathersForm(forms.Form):
     type = forms.ChoiceField(
@@ -427,3 +441,28 @@ 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(),
+        label=_("Groups"),
+        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..ba6b406d 100644
--- a/core/views/group.py
+++ b/core/views/group.py
@@ -17,6 +17,10 @@
 
 from django import forms
 from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.contrib.auth.models import Permission
+from django.contrib.messages.views import SuccessMessageMixin
+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 +29,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 +135,62 @@ 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, SuccessMessageMixin, 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 SubscriptionPermissionView(PermissionGroupsUpdateView):
+            permission = "subscription.add_subscription"
+        ```
+    """
+
+    permission_required = "auth.change_permission"
+    template_name = "core/edit.jinja"
+    form_class = PermissionGroupsForm
+    permission = None
+    success_message = _("Groups have been successfully updated.")
+
+    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/core/views/user.py b/core/views/user.py
index 5647d720..d742a6f5 100644
--- a/core/views/user.py
+++ b/core/views/user.py
@@ -66,7 +66,6 @@ from core.views.forms import (
 )
 from core.views.mixins import QuickNotifMixin, TabedViewMixin
 from counter.models import Refilling, Selling
-from counter.views.student_card import StudentCardFormView
 from eboutic.models import Invoice
 from subscription.models import Subscription
 from trombi.views import UserTrombiForm
@@ -566,6 +565,8 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
         if not hasattr(self.object, "trombi_user"):
             kwargs["trombi_form"] = UserTrombiForm()
         if hasattr(self.object, "customer"):
+            from counter.views.student_card import StudentCardFormView
+
             kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
                 self.object.customer
             ).render(self.request)
diff --git a/counter/models.py b/counter/models.py
index 9fade666..2ac691e0 100644
--- a/counter/models.py
+++ b/counter/models.py
@@ -52,7 +52,8 @@ class CustomerQuerySet(models.QuerySet):
     def update_amount(self) -> int:
         """Update the amount of all customers selected by this queryset.
 
-        The result is given as the sum of all refills minus the sum of all purchases.
+        The result is given as the sum of all refills
+        minus the sum of all purchases paid with the AE account.
 
         Returns:
             The number of updated rows.
@@ -73,7 +74,9 @@ class CustomerQuerySet(models.QuerySet):
             .values("res")
         )
         money_out = Subquery(
-            Selling.objects.filter(customer=OuterRef("pk"))
+            Selling.objects.filter(
+                customer=OuterRef("pk"), payment_method="SITH_ACCOUNT"
+            )
             .values("customer_id")
             .annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
             .values("res")
diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py
index fb64759c..27ce62bc 100644
--- a/counter/tests/test_counter.py
+++ b/counter/tests/test_counter.py
@@ -937,13 +937,23 @@ class TestClubCounterClickAccess(TestCase):
         assert res.status_code == 403
 
     def test_board_member(self):
+        """By default, board members should be able to click on office counters"""
         baker.make(Membership, club=self.counter.club, user=self.user, role=3)
         self.client.force_login(self.user)
         res = self.client.get(self.click_url)
         assert res.status_code == 200
 
     def test_barman(self):
+        """Sellers should be able to click on office counters"""
         self.counter.sellers.add(self.user)
         self.client.force_login(self.user)
         res = self.client.get(self.click_url)
-        assert res.status_code == 403
+        assert res.status_code == 200
+
+    def test_both_barman_and_board_member(self):
+        """If the user is barman and board member, he should be authorized as well."""
+        self.counter.sellers.add(self.user)
+        baker.make(Membership, club=self.counter.club, user=self.user, role=3)
+        self.client.force_login(self.user)
+        res = self.client.get(self.click_url)
+        assert res.status_code == 200
diff --git a/counter/tests/test_customer.py b/counter/tests/test_customer.py
index 7153142d..bc3f4fb4 100644
--- a/counter/tests/test_customer.py
+++ b/counter/tests/test_customer.py
@@ -442,6 +442,7 @@ def test_update_balance():
             _quantity=len(customers),
             unit_price=10,
             quantity=1,
+            payment_method="SITH_ACCOUNT",
             _save_related=True,
         ),
         *sale_recipe.prepare(
@@ -449,10 +450,26 @@ def test_update_balance():
             _quantity=3,
             unit_price=5,
             quantity=2,
+            payment_method="SITH_ACCOUNT",
             _save_related=True,
         ),
         sale_recipe.prepare(
-            customer=customers[4], quantity=1, unit_price=50, _save_related=True
+            customer=customers[4],
+            quantity=1,
+            unit_price=50,
+            payment_method="SITH_ACCOUNT",
+            _save_related=True,
+        ),
+        *sale_recipe.prepare(
+            # all customers also bought products without using their AE account.
+            # All purchases made with another mean than the AE account should
+            # be ignored when updating the account balance.
+            customer=iter(customers),
+            _quantity=len(customers),
+            unit_price=50,
+            quantity=1,
+            payment_method="CARD",
+            _save_related=True,
         ),
     ]
     Selling.objects.bulk_create(sales)
diff --git a/counter/views/click.py b/counter/views/click.py
index 4a1e1c88..eb6f8e28 100644
--- a/counter/views/click.py
+++ b/counter/views/click.py
@@ -142,15 +142,16 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
     """
 
     model = Counter
-    queryset = Counter.objects.annotate_is_open()
+    queryset = (
+        Counter.objects.exclude(type="EBOUTIC")
+        .annotate_is_open()
+        .select_related("club")
+    )
     form_class = BasketForm
     template_name = "counter/counter_click.jinja"
     pk_url_kwarg = "counter_id"
     current_tab = "counter"
 
-    def get_queryset(self):
-        return super().get_queryset().exclude(type="EBOUTIC").annotate_is_open()
-
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
         kwargs["form_kwargs"] = {
@@ -168,9 +169,15 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
             return redirect(obj)  # Redirect to counter
 
         if obj.type == "OFFICE" and (
-            obj.sellers.filter(pk=request.user.pk).exists()
-            or not obj.club.has_rights_in_club(request.user)
+            request.user.is_anonymous
+            or not (
+                obj.sellers.contains(request.user)
+                or obj.club.has_rights_in_club(request.user)
+            )
         ):
+            # To be able to click on an office counter,
+            # a user must either be in the board of the club that own the counter
+            # or a seller of this counter.
             raise PermissionDenied
 
         if obj.type == "BAR" and (
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 07eede14..0cfef902 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-10 14:52+0100\n"
+"POT-Creation-Date: 2025-02-12 15:55+0100\n"
 "PO-Revision-Date: 2016-07-18\n"
 "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
 "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -1447,7 +1447,7 @@ msgid "News admin"
 msgstr "Administration des nouvelles"
 
 #: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja
-#: com/templates/com/news_list.jinja
+#: com/templates/com/news_list.jinja com/views.py
 msgid "News"
 msgstr "Nouvelles"
 
@@ -1525,6 +1525,10 @@ msgstr "Éditer (sera soumise de nouveau à la modération)"
 msgid "Edit news"
 msgstr "Éditer la nouvelle"
 
+#: com/templates/com/news_list.jinja
+msgid "News feed"
+msgstr "Flux d'actualités"
+
 #: com/templates/com/news_list.jinja
 msgid "Events today and the next few days"
 msgstr "Événements aujourd'hui et dans les prochains jours"
@@ -1767,6 +1771,10 @@ msgstr "Message d'alerte"
 msgid "Screens list"
 msgstr "Liste d'écrans"
 
+#: com/views.py
+msgid "All incoming events"
+msgstr "Tous les événements à venir"
+
 #: com/views.py
 msgid "Delete and save to regenerate"
 msgstr "Supprimer et sauver pour régénérer"
@@ -2375,11 +2383,10 @@ msgstr "Confirmation"
 msgid "Cancel"
 msgstr "Annuler"
 
-#: core/templates/core/edit.jinja core/templates/core/file_edit.jinja
-#: counter/templates/counter/cash_register_summary.jinja
+#: core/templates/core/edit.jinja
 #, python-format
-msgid "Edit %(obj)s"
-msgstr "Éditer %(obj)s"
+msgid "Edit %(name)s"
+msgstr "Éditer %(name)s"
 
 #: core/templates/core/file.jinja core/templates/core/file_list.jinja
 msgid "File list"
@@ -2449,6 +2456,12 @@ msgstr "octets"
 msgid "Download"
 msgstr "Télécharger"
 
+#: core/templates/core/file_edit.jinja
+#: counter/templates/counter/cash_register_summary.jinja
+#, python-format
+msgid "Edit %(obj)s"
+msgstr "Éditer %(obj)s"
+
 #: core/templates/core/file_list.jinja
 msgid "There is no file in this website."
 msgstr "Il n'y a pas de fichier sur ce site web."
@@ -2906,7 +2919,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"
@@ -3138,15 +3151,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"
@@ -3219,6 +3223,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"
@@ -3347,6 +3364,10 @@ msgstr "Utilisateurs à ajouter au groupe"
 msgid "Users to remove from group"
 msgstr "Utilisateurs à retirer du groupe"
 
+#: core/views/group.py
+msgid "Groups have been successfully updated."
+msgstr "Les groupes ont été mis à jour avec succès."
+
 #: core/views/user.py
 msgid "We couldn't verify that this email actually exists"
 msgstr "Nous n'avons pas réussi à vérifier que cette adresse mail existe."
@@ -5668,6 +5689,10 @@ msgstr "Cotisations par type"
 msgid "Existing member"
 msgstr "Membre existant"
 
+#: subscription/views.py
+msgid "the groups that can create subscriptions"
+msgstr "les groupes pouvant créer des cotisations"
+
 #: trombi/models.py
 msgid "subscription deadline"
 msgstr "fin des inscriptions"
diff --git a/pedagogy/api.py b/pedagogy/api.py
index 68e3d5e2..e8d34351 100644
--- a/pedagogy/api.py
+++ b/pedagogy/api.py
@@ -1,13 +1,13 @@
+import operator
 from typing import Annotated
 
 from annotated_types import Ge
-from django.conf import settings
 from ninja import Query
 from ninja_extra import ControllerBase, api_controller, paginate, route
 from ninja_extra.exceptions import NotFound
 from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema
 
-from core.auth.api_permissions import IsInGroup, IsRoot, IsSubscriber
+from core.auth.api_permissions import HasPerm
 from pedagogy.models import UV
 from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
 from pedagogy.utbm_api import find_uv
@@ -17,7 +17,11 @@ from pedagogy.utbm_api import find_uv
 class UvController(ControllerBase):
     @route.get(
         "/{year}/{code}",
-        permissions=[IsRoot | IsInGroup(settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)],
+        permissions=[
+            # this route will almost always be called in the context
+            # of a UV creation/edition
+            HasPerm(["pedagogy.add_uv", "pedagogy.change_uv"], op=operator.or_)
+        ],
         url_name="fetch_uv_from_utbm",
         response=UvSchema,
     )
@@ -34,8 +38,8 @@ class UvController(ControllerBase):
         "",
         response=PaginatedResponseSchema[SimpleUvSchema],
         url_name="fetch_uvs",
-        permissions=[IsSubscriber | IsInGroup(settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)],
+        permissions=[HasPerm("pedagogy.view_uv")],
     )
     @paginate(PageNumberPaginationExtra, page_size=100)
     def fetch_uv_list(self, search: Query[UvFilterSchema]):
-        return search.filter(UV.objects.all())
+        return search.filter(UV.objects.values())
diff --git a/pedagogy/models.py b/pedagogy/models.py
index 956e6791..892f7028 100644
--- a/pedagogy/models.py
+++ b/pedagogy/models.py
@@ -20,10 +20,12 @@
 # Place - Suite 330, Boston, MA 02111-1307, USA.
 #
 #
+from typing import Self
 
 from django.conf import settings
 from django.core import validators
 from django.db import models
+from django.db.models import Exists, OuterRef
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.functional import cached_property
@@ -145,14 +147,6 @@ class UV(models.Model):
     def get_absolute_url(self):
         return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.id})
 
-    def is_owned_by(self, user):
-        """Can be created by superuser, root or pedagogy admin user."""
-        return user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
-
-    def can_be_viewed_by(self, user):
-        """Only visible by subscribers."""
-        return user.is_subscribed
-
     def __grade_average_generic(self, field):
         comments = self.comments.filter(**{field + "__gte": 0})
         if not comments.exists():
@@ -191,6 +185,22 @@ class UV(models.Model):
         return self.__grade_average_generic("grade_work_load")
 
 
+class UVCommentQuerySet(models.QuerySet):
+    def viewable_by(self, user: User) -> Self:
+        if user.has_perms(["pedagogy.view_uvcomment", "pedagogy.view_uvcommentreport"]):
+            # the user can view uv comment reports,
+            # so he can view non-moderated comments
+            return self
+        if user.has_perm("pedagogy.view_uvcomment"):
+            return self.filter(reports=None)
+        return self.filter(author=user)
+
+    def annotate_is_reported(self) -> Self:
+        return self.annotate(
+            is_reported=Exists(UVCommentReport.objects.filter(comment=OuterRef("pk")))
+        )
+
+
 class UVComment(models.Model):
     """A comment about an UV."""
 
@@ -243,6 +253,8 @@ class UVComment(models.Model):
     )
     publish_date = models.DateTimeField(_("publish date"), blank=True)
 
+    objects = UVCommentQuerySet.as_manager()
+
     def __str__(self):
         return f"{self.uv} - {self.author}"
 
@@ -251,15 +263,6 @@ class UVComment(models.Model):
             self.publish_date = timezone.now()
         super().save(*args, **kwargs)
 
-    def is_owned_by(self, user):
-        """Is owned by a pedagogy admin, a superuser or the author himself."""
-        return self.author == user or user.is_owner(self.uv)
-
-    @cached_property
-    def is_reported(self):
-        """Return True if someone reported this UV."""
-        return self.reports.exists()
-
 
 # TODO : it seems that some views were meant to be implemented
 #        to use this model.
@@ -323,7 +326,3 @@ class UVCommentReport(models.Model):
     @cached_property
     def uv(self):
         return self.comment.uv
-
-    def is_owned_by(self, user):
-        """Can be created by a pedagogy admin, a superuser or a subscriber."""
-        return user.is_subscribed or user.is_owner(self.comment.uv)
diff --git a/pedagogy/templates/pedagogy/guide.jinja b/pedagogy/templates/pedagogy/guide.jinja
index 112ff1a5..79b66c24 100644
--- a/pedagogy/templates/pedagogy/guide.jinja
+++ b/pedagogy/templates/pedagogy/guide.jinja
@@ -19,7 +19,7 @@
 {% endblock head %}
 
 {% block content %}
-  {% if can_create_uv %}
+  {% if user.has_perm("pedagogy.add_uv") %}
     <div class="action-bar">
       <p>
         <a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a>
@@ -94,8 +94,10 @@
           <td>{% trans %}Credit type{% endtrans %}</td>
           <td><i class="fa fa-leaf"></i></td>
           <td><i class="fa-regular fa-sun"></i></td>
-          {% if can_create_uv %}
+          {%- if user.has_perm("pedagogy.change_uv") -%}
             <td>{% trans %}Edit{% endtrans %}</td>
+          {%- endif -%}
+          {%- if user.has_perm("pedagogy.delete_uv") -%}
             <td>{% trans %}Delete{% endtrans %}</td>
           {% endif %}
         </tr>
@@ -109,8 +111,10 @@
             <td x-text="uv.credit_type"></td>
             <td><i :class="uv.semester.includes('AUTUMN') && 'fa fa-leaf'"></i></td>
             <td><i :class="uv.semester.includes('SPRING') && 'fa-regular fa-sun'"></i></td>
-            {% if can_create_uv -%}
+            {%- if user.has_perm("pedagogy.change_uv") -%}
               <td><a :href="`/pedagogy/uv/${uv.id}/edit`">{% trans %}Edit{% endtrans %}</a></td>
+            {%- endif -%}
+            {%- if user.has_perm("pedagogy.delete_uv") -%}
               <td><a :href="`/pedagogy/uv/${uv.id}/delete`">{% trans %}Delete{% endtrans %}</a></td>
             {%- endif -%}
           </tr>
diff --git a/pedagogy/templates/pedagogy/uv_detail.jinja b/pedagogy/templates/pedagogy/uv_detail.jinja
index c2133eae..a5b07f68 100644
--- a/pedagogy/templates/pedagogy/uv_detail.jinja
+++ b/pedagogy/templates/pedagogy/uv_detail.jinja
@@ -89,7 +89,7 @@
         <div id="leave_comment_not_allowed">
           <p>{% trans %}You already posted a comment on this UV. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
         </div>
-      {% else %}
+      {% elif user.has_perm("pedagogy.add_uvcomment") %}
         <div id="leave_comment">
           <h2>{% trans %}Leave comment{% endtrans %}</h2>
           <div>
@@ -146,9 +146,9 @@
       {% endif %}
       <br>
 
-      {% if object.comments.exists() %}
+      {% if comments %}
         <h2>{% trans %}Comments{% endtrans %}</h2>
-        {% for comment in object.comments.order_by("-publish_date").all() %}
+        {% for comment in comments %}
           <div id="{{ comment.id }}" class="comment-container">
 
             <div class="grade-block">
@@ -183,16 +183,28 @@
                 </p>
               {% endif %}
 
-              {% if user.is_owner(comment) %}
+              {% if comment.author_id == user.id or user.has_perm("pedagogy.change_comment") %}
                 <p class="actions">
-                  <a href="{{ url('pedagogy:comment_update', comment_id=comment.id) }}">{% trans %}Edit{% endtrans %}</a>
-                  <a href="{{ url('pedagogy:comment_delete', comment_id=comment.id) }}">{% trans %}Delete{% endtrans %}</a>
+                  <a href="{{ url('pedagogy:comment_update', comment_id=comment.id) }}">
+                    {% trans %}Edit{% endtrans %}
+                  </a>
+              {% endif %}
+              {% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %}
+                <a href="{{ url('pedagogy:comment_delete', comment_id=comment.id) }}">
+                  {% trans %}Delete{% endtrans %}
+                </a>
                 </p>
               {% endif %}
             </div>
 
             <div class="comment-end-bar">
-              <div class="report"><p><a href="{{ url('pedagogy:comment_report', comment_id=comment.id) }}">{% trans %}Report this comment{% endtrans %}</a></p></div>
+              <div class="report">
+                <p>
+                  <a href="{{ url('pedagogy:comment_report', comment_id=comment.id) }}">
+                    {% trans %}Report this comment{% endtrans %}
+                  </a>
+                </p>
+              </div>
 
               <div class="date"><p>{{ comment.publish_date.strftime('%d/%m/%Y') }}</p></div>
 
@@ -209,7 +221,7 @@
   <script type="text/javascript">
     $("#return_noscript").hide();
     $("#return_js").show();
-    var icons = {
+    const icons = {
       header: "fa fa-toggle-right",
       activeHeader: "fa fa-toggle-down"
     };
diff --git a/pedagogy/tests/tests.py b/pedagogy/tests/tests.py
index 6e04b949..643d6d27 100644
--- a/pedagogy/tests/tests.py
+++ b/pedagogy/tests/tests.py
@@ -20,14 +20,18 @@
 # Place - Suite 330, Boston, MA 02111-1307, USA.
 #
 #
+from typing import Callable
 
 import pytest
 from django.conf import settings
+from django.contrib.auth.models import Permission
 from django.test import Client, TestCase
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
+from model_bakery import baker
 from pytest_django.asserts import assertRedirects
 
+from core.baker_recipes import old_subscriber_user, subscriber_user
 from core.models import Notification, User
 from pedagogy.models import UV, UVComment, UVCommentReport
 
@@ -144,17 +148,17 @@ class TestUVCreation(TestCase):
 
 @pytest.mark.django_db
 @pytest.mark.parametrize(
-    ("username", "expected_code"),
+    ("user_factory", "expected_code"),
     [
-        ("root", 200),
-        ("tutu", 200),
-        ("sli", 200),
-        ("old_subscriber", 200),
-        ("public", 403),
+        (subscriber_user.make, 200),
+        (old_subscriber_user.make, 200),
+        (lambda: baker.make(User), 403),
     ],
 )
-def test_guide_permissions(client: Client, username: str, expected_code: int):
-    client.force_login(User.objects.get(username=username))
+def test_guide_permissions(
+    client: Client, user_factory: Callable[[], User], expected_code: int
+):
+    client.force_login(user_factory())
     res = client.get(reverse("pedagogy:guide"))
     assert res.status_code == expected_code
 
@@ -190,20 +194,15 @@ class TestUVDelete(TestCase):
     def test_uv_delete_pedagogy_unauthorized_fail(self):
         # Anonymous user
         response = self.client.post(self.delete_uv_url)
-        assert response.status_code == 403
+        assertRedirects(response, reverse("core:login") + f"?next={self.delete_uv_url}")
         assert UV.objects.filter(pk=self.uv.pk).exists()
 
-        # Not subscribed user
-        self.client.force_login(self.guy)
-        response = self.client.post(self.delete_uv_url)
-        assert response.status_code == 403
-        assert UV.objects.filter(pk=self.uv.pk).exists()
-
-        # Simply subscribed user
-        self.client.force_login(self.sli)
-        response = self.client.post(self.delete_uv_url)
-        assert response.status_code == 403
-        assert UV.objects.filter(pk=self.uv.pk).exists()
+        for user in baker.make(User), subscriber_user.make():
+            with self.subTest():
+                self.client.force_login(user)
+                response = self.client.post(self.delete_uv_url)
+                assert response.status_code == 403
+                assert UV.objects.filter(pk=self.uv.pk).exists()
 
 
 class TestUVUpdate(TestCase):
@@ -249,7 +248,7 @@ class TestUVUpdate(TestCase):
         response = self.client.post(
             self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
         )
-        assert response.status_code == 403
+        assertRedirects(response, reverse("core:login") + f"?next={self.update_uv_url}")
 
         # Not subscribed user
         self.client.force_login(self.guy)
@@ -312,7 +311,7 @@ class TestUVCommentCreationAndDisplay(TestCase):
         response = self.client.post(
             self.uv_url, create_uv_comment_template(self.bibou.id)
         )
-        self.assertRedirects(response, self.uv_url)
+        assertRedirects(response, self.uv_url)
         response = self.client.get(self.uv_url)
         self.assertContains(response, text="Superbe UV")
 
@@ -338,7 +337,7 @@ class TestUVCommentCreationAndDisplay(TestCase):
         nb_comments = self.uv.comments.count()
         # Test with anonymous user
         response = self.client.post(self.uv_url, create_uv_comment_template(0))
-        assert response.status_code == 403
+        assertRedirects(response, reverse("core:login") + f"?next={self.uv_url}")
 
         # Test with non subscribed user
         self.client.force_login(self.guy)
@@ -405,62 +404,35 @@ class TestUVCommentDelete(TestCase):
 
     @classmethod
     def setUpTestData(cls):
-        cls.bibou = User.objects.get(username="root")
-        cls.tutu = User.objects.get(username="tutu")
-        cls.sli = User.objects.get(username="sli")
-        cls.guy = User.objects.get(username="guy")
-        cls.krophil = User.objects.get(username="krophil")
+        cls.comment = baker.make(UVComment)
 
-    def setUp(self):
-        comment_kwargs = create_uv_comment_template(
-            User.objects.get(username="krophil").id
-        )
-        comment_kwargs["author"] = User.objects.get(id=comment_kwargs["author"])
-        comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
-        self.comment = UVComment(**comment_kwargs)
-        self.comment.save()
-
-    def test_uv_comment_delete_root_success(self):
-        self.client.force_login(self.bibou)
-        self.client.post(
-            reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
-        )
-        assert not UVComment.objects.filter(id=self.comment.id).exists()
-
-    def test_uv_comment_delete_pedagogy_admin_success(self):
-        self.client.force_login(self.tutu)
-        self.client.post(
-            reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
-        )
-        assert not UVComment.objects.filter(id=self.comment.id).exists()
-
-    def test_uv_comment_delete_author_success(self):
-        self.client.force_login(self.krophil)
-        self.client.post(
-            reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
-        )
-        assert not UVComment.objects.filter(id=self.comment.id).exists()
+    def test_uv_comment_delete_success(self):
+        url = reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
+        for user in (
+            baker.make(User, is_superuser=True),
+            baker.make(
+                User, user_permissions=[Permission.objects.get(codename="view_uv")]
+            ),
+            self.comment.author,
+        ):
+            with self.subTest():
+                self.client.force_login(user)
+                self.client.post(url)
+                assert not UVComment.objects.filter(id=self.comment.id).exists()
 
     def test_uv_comment_delete_unauthorized_fail(self):
+        url = reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
+
         # Anonymous user
-        response = self.client.post(
-            reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
-        )
-        assert response.status_code == 403
+        response = self.client.post(url)
+        assertRedirects(response, reverse("core:login") + f"?next={url}")
 
         # Unsbscribed user
-        self.client.force_login(self.guy)
-        response = self.client.post(
-            reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
-        )
-        assert response.status_code == 403
-
-        # Subscribed user (not author of the comment)
-        self.client.force_login(self.sli)
-        response = self.client.post(
-            reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
-        )
-        assert response.status_code == 403
+        for user in baker.make(User), subscriber_user.make():
+            with self.subTest():
+                self.client.force_login(user)
+                response = self.client.post(url)
+                assert response.status_code == 403
 
         # Check that the comment still exists
         assert UVComment.objects.filter(id=self.comment.id).exists()
@@ -499,16 +471,6 @@ class TestUVCommentUpdate(TestCase):
         self.comment.refresh_from_db()
         self.assertEqual(self.comment.comment, self.comment_edit["comment"])
 
-    def test_uv_comment_update_pedagogy_admin_success(self):
-        self.client.force_login(self.tutu)
-        response = self.client.post(
-            reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}),
-            self.comment_edit,
-        )
-        assert response.status_code == 302
-        self.comment.refresh_from_db()
-        self.assertEqual(self.comment.comment, self.comment_edit["comment"])
-
     def test_uv_comment_update_author_success(self):
         self.client.force_login(self.krophil)
         response = self.client.post(
@@ -520,25 +482,18 @@ class TestUVCommentUpdate(TestCase):
         self.assertEqual(self.comment.comment, self.comment_edit["comment"])
 
     def test_uv_comment_update_unauthorized_fail(self):
+        url = reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id})
         # Anonymous user
-        response = self.client.post(
-            reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}),
-            self.comment_edit,
-        )
-        assert response.status_code == 403
+        response = self.client.post(url, self.comment_edit)
+        assertRedirects(response, reverse("core:login") + f"?next={url}")
 
         # Unsbscribed user
-        response = self.client.post(
-            reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}),
-            self.comment_edit,
-        )
+        self.client.force_login(baker.make(User))
+        response = self.client.post(url, self.comment_edit)
         assert response.status_code == 403
 
         # Subscribed user (not author of the comment)
-        response = self.client.post(
-            reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}),
-            self.comment_edit,
-        )
+        response = self.client.post(url, self.comment_edit)
         assert response.status_code == 403
 
         # Check that the comment hasn't change
@@ -611,18 +566,19 @@ class TestUVModerationForm(TestCase):
         assert response.status_code == 200
 
     def test_access_unauthorized_fail(self):
+        url = reverse("pedagogy:moderation")
         # Test with anonymous user
-        response = self.client.get(reverse("pedagogy:moderation"))
-        assert response.status_code == 403
+        response = self.client.get(url)
+        assertRedirects(response, reverse("core:login") + f"?next={url}")
 
         # Test with unsubscribed user
         self.client.force_login(self.guy)
-        response = self.client.get(reverse("pedagogy:moderation"))
+        response = self.client.get(url)
         assert response.status_code == 403
 
         # Test with subscribed user
         self.client.force_login(self.sli)
-        response = self.client.get(reverse("pedagogy:moderation"))
+        response = self.client.get(url)
         assert response.status_code == 403
 
     def test_do_nothing(self):
diff --git a/pedagogy/views.py b/pedagogy/views.py
index 88e9c186..c7764e4a 100644
--- a/pedagogy/views.py
+++ b/pedagogy/views.py
@@ -22,8 +22,7 @@
 #
 
 from django.conf import settings
-from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
-from django.core.exceptions import PermissionDenied
+from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.db.models import Exists, OuterRef
 from django.shortcuts import get_object_or_404
 from django.urls import reverse, reverse_lazy
@@ -35,7 +34,7 @@ from django.views.generic import (
     UpdateView,
 )
 
-from core.auth.mixins import CanEditPropMixin, CanViewMixin, FormerSubscriberMixin
+from core.auth.mixins import PermissionOrAuthorRequiredMixin
 from core.models import Notification, User
 from core.views import DetailFormView
 from pedagogy.forms import (
@@ -47,7 +46,7 @@ from pedagogy.forms import (
 from pedagogy.models import UV, UVComment, UVCommentReport
 
 
-class UVDetailFormView(CanViewMixin, DetailFormView):
+class UVDetailFormView(PermissionRequiredMixin, DetailFormView):
     """Display every comment of an UV and detailed infos about it.
 
     Allow to comment the UV.
@@ -57,11 +56,21 @@ class UVDetailFormView(CanViewMixin, DetailFormView):
     pk_url_kwarg = "uv_id"
     template_name = "pedagogy/uv_detail.jinja"
     form_class = UVCommentForm
+    permission_required = "pedagogy.view_uv"
+
+    def has_permission(self):
+        if self.request.method == "POST" and not self.request.user.has_perm(
+            "pedagogy.add_uvcomment"
+        ):
+            # if it's a POST request, the user is trying to add a new UVComment
+            # thus he also needs the "add_uvcomment" permission
+            return False
+        return super().has_permission()
 
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
         kwargs["author_id"] = self.request.user.id
-        kwargs["uv_id"] = self.get_object().id
+        kwargs["uv_id"] = self.object.id
         kwargs["is_creation"] = True
         return kwargs
 
@@ -69,66 +78,61 @@ class UVDetailFormView(CanViewMixin, DetailFormView):
         form.save()
         return super().form_valid(form)
 
-    def get_success_url(self):
-        return reverse_lazy(
-            "pedagogy:uv_detail", kwargs={"uv_id": self.get_object().id}
-        )
-
     def get_context_data(self, **kwargs):
-        user = self.request.user
         return super().get_context_data(**kwargs) | {
-            "can_create_uv": (
-                user.is_root
-                or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
+            "comments": list(
+                self.object.comments.viewable_by(self.request.user)
+                .annotate_is_reported()
+                .select_related("author")
+                .order_by("-publish_date")
             )
         }
 
+    def get_success_url(self):
+        # once the new uv comment has been saved
+        # redirect to the same page we are currently
+        return self.request.path
 
-class UVCommentUpdateView(CanEditPropMixin, UpdateView):
+
+class UVCommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
     """Allow edit of a given comment."""
 
     model = UVComment
     form_class = UVCommentForm
     pk_url_kwarg = "comment_id"
     template_name = "core/edit.jinja"
+    permission_required = "pedagogy.change_uvcomment"
+    author_field = "author"
 
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
-        obj = self.get_object()
-        kwargs["author_id"] = obj.author.id
-        kwargs["uv_id"] = obj.uv.id
+        kwargs["author_id"] = self.object.author_id
+        kwargs["uv_id"] = self.object.uv_id
         kwargs["is_creation"] = False
-
         return kwargs
 
     def get_success_url(self):
-        return reverse_lazy("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv.id})
+        return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv_id})
 
 
-class UVCommentDeleteView(CanEditPropMixin, DeleteView):
+class UVCommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
     """Allow delete of a given comment."""
 
     model = UVComment
     pk_url_kwarg = "comment_id"
     template_name = "core/delete_confirm.jinja"
+    permission_required = "pedagogy.delete_uvcomment"
+    author_field = "author"
 
     def get_success_url(self):
-        return reverse_lazy("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv.id})
+        return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv_id})
 
 
-class UVGuideView(LoginRequiredMixin, FormerSubscriberMixin, TemplateView):
+class UVGuideView(PermissionRequiredMixin, TemplateView):
     """UV guide main page."""
 
     template_name = "pedagogy/guide.jinja"
-
-    def get_context_data(self, **kwargs):
-        user = self.request.user
-        return super().get_context_data(**kwargs) | {
-            "can_create_uv": (
-                user.is_root
-                or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
-            )
-        }
+    permission_required = "pedagogy.view_uv"
 
 
 class UVCommentReportCreateView(PermissionRequiredMixin, CreateView):
@@ -168,21 +172,16 @@ class UVCommentReportCreateView(PermissionRequiredMixin, CreateView):
         return resp
 
     def get_success_url(self):
-        return reverse_lazy(
-            "pedagogy:uv_detail", kwargs={"uv_id": self.uv_comment.uv.id}
-        )
+        return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.uv_comment.uv_id})
 
 
-class UVModerationFormView(FormView):
+class UVModerationFormView(PermissionRequiredMixin, FormView):
     """Moderation interface (Privileged)."""
 
     form_class = UVCommentModerationForm
     template_name = "pedagogy/moderation.jinja"
-
-    def dispatch(self, request, *args, **kwargs):
-        if not request.user.is_owner(UV()):
-            raise PermissionDenied
-        return super().dispatch(request, *args, **kwargs)
+    permission_required = "pedagogy.delete_uvcomment"
+    success_url = reverse_lazy("pedagogy:moderation")
 
     def form_valid(self, form):
         form_clean = form.clean()
@@ -194,9 +193,6 @@ class UVModerationFormView(FormView):
             UVCommentReport.objects.filter(id__in={d.id for d in denied}).delete()
         return super().form_valid(form)
 
-    def get_success_url(self):
-        return reverse_lazy("pedagogy:moderation")
-
 
 class UVCreateView(PermissionRequiredMixin, CreateView):
     """Add a new UV (Privileged)."""
@@ -211,34 +207,28 @@ class UVCreateView(PermissionRequiredMixin, CreateView):
         kwargs["author_id"] = self.request.user.id
         return kwargs
 
-    def get_success_url(self):
-        return reverse_lazy("pedagogy:uv_detail", kwargs={"uv_id": self.object.id})
 
-
-class UVDeleteView(CanEditPropMixin, DeleteView):
+class UVDeleteView(PermissionRequiredMixin, DeleteView):
     """Allow to delete an UV (Privileged)."""
 
     model = UV
     pk_url_kwarg = "uv_id"
     template_name = "core/delete_confirm.jinja"
-
-    def get_success_url(self):
-        return reverse_lazy("pedagogy:guide")
+    permission_required = "pedagogy.delete_uv"
+    success_url = reverse_lazy("pedagogy:guide")
 
 
-class UVUpdateView(CanEditPropMixin, UpdateView):
+class UVUpdateView(PermissionRequiredMixin, UpdateView):
     """Allow to edit an UV (Privilegied)."""
 
     model = UV
     form_class = UVForm
     pk_url_kwarg = "uv_id"
     template_name = "pedagogy/uv_edit.jinja"
+    permission_required = "pedagogy.change_uv"
 
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
         obj = self.get_object()
-        kwargs["author_id"] = obj.author.id
+        kwargs["author_id"] = obj.author_id
         return kwargs
-
-    def get_success_url(self):
-        return reverse_lazy("pedagogy:uv_detail", kwargs={"uv_id": self.object.id})
diff --git a/sith/settings.py b/sith/settings.py
index d3acabec..b7d7e71c 100644
--- a/sith/settings.py
+++ b/sith/settings.py
@@ -517,14 +517,6 @@ SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = 1
 SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = 2
 SITH_PRODUCTTYPE_SUBSCRIPTION = 2
 
-# Defines which club lets its member the ability to make subscriptions
-# Elements of this list are club's id
-SITH_CAN_CREATE_SUBSCRIPTIONS = [1]
-
-# Defines which clubs lets its members the ability to see users subscription history
-# Elements of this list are club's id
-SITH_CAN_READ_SUBSCRIPTION_HISTORY = []
-
 # Number of weeks before the end of a subscription when the subscriber can resubscribe
 SITH_SUBSCRIPTION_END = 10
 
diff --git a/subscription/tests/test_new_susbcription.py b/subscription/tests/test_new_susbcription.py
index ccdff407..8fd5e7c4 100644
--- a/subscription/tests/test_new_susbcription.py
+++ b/subscription/tests/test_new_susbcription.py
@@ -5,6 +5,7 @@ from typing import Callable
 
 import pytest
 from dateutil.relativedelta import relativedelta
+from django.contrib.auth.models import Permission
 from django.test import Client
 from django.urls import reverse
 from django.utils.timezone import localdate
@@ -108,7 +109,12 @@ def test_page_access(
 
 @pytest.mark.django_db
 def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
-    client.force_login(board_user.make())
+    client.force_login(
+        baker.make(
+            User,
+            user_permissions=Permission.objects.filter(codename="add_subscription"),
+        )
+    )
     user = old_subscriber_user.make()
     response = client.post(
         reverse("subscription:fragment-existing-user"),
@@ -133,7 +139,12 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
 
 @pytest.mark.django_db
 def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
-    client.force_login(board_user.make())
+    client.force_login(
+        baker.make(
+            User,
+            user_permissions=Permission.objects.filter(codename="add_subscription"),
+        )
+    )
     response = client.post(
         reverse("subscription:fragment-new-user"),
         {
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 b285a137..d5f9d75d 100644
--- a/subscription/views.py
+++ b/subscription/views.py
@@ -14,13 +14,15 @@
 #
 
 from django.conf import settings
-from django.contrib.auth.mixins import UserPassesTestMixin
+from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.exceptions import PermissionDenied
 from django.urls import reverse, reverse_lazy
 from django.utils.timezone import localdate
+from django.utils.translation import gettext_lazy as _
 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,
@@ -30,13 +32,9 @@ from subscription.forms import (
 from subscription.models import Subscription
 
 
-class CanCreateSubscriptionMixin(UserPassesTestMixin):
-    def test_func(self):
-        return self.request.user.can_create_subscription
-
-
-class NewSubscription(CanCreateSubscriptionMixin, TemplateView):
+class NewSubscription(PermissionRequiredMixin, TemplateView):
     template_name = "subscription/subscription.jinja"
+    permission_required = "subscription.add_subscription"
 
     def get_context_data(self, **kwargs):
         return super().get_context_data(**kwargs) | {
@@ -49,8 +47,9 @@ class NewSubscription(CanCreateSubscriptionMixin, TemplateView):
         }
 
 
-class CreateSubscriptionFragment(CanCreateSubscriptionMixin, CreateView):
+class CreateSubscriptionFragment(PermissionRequiredMixin, CreateView):
     template_name = "subscription/fragments/creation_form.jinja"
+    permission_required = "subscription.add_subscription"
 
     def get_success_url(self):
         return reverse(
@@ -72,13 +71,21 @@ class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
     extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")}
 
 
-class SubscriptionCreatedFragment(CanCreateSubscriptionMixin, DetailView):
+class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView):
     template_name = "subscription/fragments/creation_success.jinja"
+    permission_required = "subscription.add_subscription"
     model = Subscription
     pk_url_kwarg = "subscription_id"
     context_object_name = "subscription"
 
 
+class SubscriptionPermissionView(PermissionGroupsUpdateView):
+    """Manage the groups that have access to the subscription creation page."""
+
+    permission = "subscription.add_subscription"
+    extra_context = {"object_name": _("the groups that can create subscriptions")}
+
+
 class SubscriptionsStatsView(FormView):
     template_name = "subscription/stats.jinja"
     form_class = SelectionDateForm