From af90190fe4138f51a30e565813d5a167c43267b4 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 29 Mar 2021 17:51:37 +0200
Subject: [PATCH] Add support for managing permissions in the frontend

This includes views, forms, filters and tables.
---
 aleksis/core/filters.py                       |  94 ++++++++++++++
 aleksis/core/forms.py                         | 117 ++++++++++++++++++
 aleksis/core/menus.py                         |  11 ++
 aleksis/core/mixins.py                        |  10 ++
 aleksis/core/models.py                        |   6 +
 aleksis/core/rules.py                         |   4 +
 aleksis/core/tables.py                        |  45 +++++++
 aleksis/core/templates/core/perms/assign.html |  32 +++++
 aleksis/core/templates/core/perms/list.html   |  79 ++++++++++++
 aleksis/core/urls.py                          |  30 +++++
 aleksis/core/views.py                         | 114 ++++++++++++++++-
 11 files changed, 539 insertions(+), 3 deletions(-)
 create mode 100644 aleksis/core/templates/core/perms/assign.html
 create mode 100644 aleksis/core/templates/core/perms/list.html

diff --git a/aleksis/core/filters.py b/aleksis/core/filters.py
index aa287690b..92d01464f 100644
--- a/aleksis/core/filters.py
+++ b/aleksis/core/filters.py
@@ -1,9 +1,14 @@
 from typing import Sequence
 
+from django.contrib.auth.models import Group as DjangoGroup
+from django.contrib.auth.models import Permission, User
+from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
 from django_filters import CharFilter, FilterSet, ModelChoiceFilter, ModelMultipleChoiceFilter
+from django_select2.forms import ModelSelect2Widget
+from guardian.models import GroupObjectPermission, UserObjectPermission
 from material import Layout, Row
 
 from aleksis.core.models import Group, GroupType, Person, SchoolTerm
@@ -72,3 +77,92 @@ class PersonFilter(FilterSet):
     class Meta:
         model = Person
         fields = ["sex", "is_active", "primary_group"]
+
+
+class PermissionFilter(FilterSet):
+    """Common filter for permissions."""
+
+    permission = ModelChoiceFilter(
+        queryset=Permission.objects.all(),
+        widget=ModelSelect2Widget(
+            search_fields=["name__icontains", "codename__icontains"],
+            attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+        ),
+        label=_("Permission"),
+    )
+    permission__content_type = ModelChoiceFilter(
+        queryset=ContentType.objects.all(),
+        widget=ModelSelect2Widget(
+            search_fields=["app_label__icontains", "model__icontains"],
+            attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+        ),
+        label=_("Content type"),
+    )
+
+
+class UserPermissionFilter(PermissionFilter):
+    """Common filter for user permissions."""
+
+    user = ModelChoiceFilter(
+        queryset=User.objects.all(),
+        widget=ModelSelect2Widget(
+            search_fields=["username__icontains", "first_name__icontains", "last_name__icontains"],
+            attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+        ),
+        label=_("User"),
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.form.layout = Layout(Row("user", "permission", "permission__content_type"))
+
+    class Meta:
+        fields = ["user", "permission", "permission__content_type"]
+
+
+class GroupPermissionFilter(PermissionFilter):
+    """Common filter for group permissions."""
+
+    group = ModelChoiceFilter(
+        queryset=DjangoGroup.objects.all(),
+        widget=ModelSelect2Widget(
+            search_fields=["name__icontains",],
+            attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+        ),
+        label=_("Group"),
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.form.layout = Layout(Row("group", "permission", "permission__content_type"))
+
+    class Meta:
+        fields = ["group", "permission", "permission__content_type"]
+
+
+class UserGlobalPermissionFilter(UserPermissionFilter):
+    """Filter for global user permissions."""
+
+    class Meta(UserPermissionFilter.Meta):
+        model = User.user_permissions.through
+
+
+class GroupGlobalPermissionFilter(GroupPermissionFilter):
+    """Filter for global group permissions."""
+
+    class Meta(GroupPermissionFilter.Meta):
+        model = DjangoGroup.permissions.through
+
+
+class UserObjectPermissionFilter(UserPermissionFilter):
+    """Filter for object user permissions."""
+
+    class Meta(UserPermissionFilter.Meta):
+        model = UserObjectPermission
+
+
+class GroupObjectPermissionFilter(GroupPermissionFilter):
+    """Filter for object group permissions."""
+
+    class Meta(GroupPermissionFilter.Meta):
+        model = GroupObjectPermission
diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index f4599d4d0..59034c0ed 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -1,12 +1,15 @@
 from datetime import datetime, time
+from typing import Any, Dict
 
 from django import forms
 from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Permission
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
 from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, Select2Widget
 from dynamic_preferences.forms import PreferenceForm
+from guardian.shortcuts import assign_perm
 from material import Fieldset, Layout, Row
 
 from .mixins import ExtensibleForm, SchoolTermRelatedExtensibleForm
@@ -370,3 +373,117 @@ class DashboardWidgetOrderForm(ExtensibleForm):
 DashboardWidgetOrderFormSet = forms.formset_factory(
     form=DashboardWidgetOrderForm, max_num=0, extra=0
 )
+
+
+class SelectPermissionForm(forms.Form):
+    """Select a permission to assign."""
+
+    selected_permission = forms.ModelChoiceField(
+        queryset=Permission.objects.all(),
+        widget=ModelSelect2Widget(
+            search_fields=["name__icontains", "codename__icontains"],
+            attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+        ),
+    )
+
+
+class AssignPermissionForm(forms.Form):
+    """Assign a permission to user/groups for all/some objects."""
+
+    layout = Layout(
+        Fieldset(_("Who should get the permission?"), "groups", "persons"),
+        Fieldset(_("For what?"), "objects", "all_objects"),
+    )
+    groups = forms.ModelMultipleChoiceField(
+        queryset=Group.objects.all(),
+        widget=ModelSelect2MultipleWidget(
+            search_fields=["name__icontains", "short_name__icontains"],
+            attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+        ),
+        required=False,
+    )
+    persons = forms.ModelMultipleChoiceField(
+        queryset=Person.objects.all(),
+        widget=ModelSelect2MultipleWidget(
+            search_fields=[
+                "first_name__icontains",
+                "last_name__icontains",
+                "short_name__icontains",
+            ],
+            attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+        ),
+        required=False,
+    )
+
+    objects = forms.ModelMultipleChoiceField(
+        queryset=None,
+        required=False,
+        label=_("Select objects which the permission should be granted for:"),
+    )
+    all_objects = forms.BooleanField(
+        required=False, label=_("Grant the permission for all objects")
+    )
+
+    def clean(self) -> Dict[str, Any]:
+        """Clean form to ensure that at least one target and one type is selected."""
+        cleaned_data = super().clean()
+        if not cleaned_data.get("persons") and not cleaned_data.get("groups"):
+            raise ValidationError(
+                _("You must select at least one group or person which should get the permission.")
+            )
+
+        if not cleaned_data.get("objects") and not cleaned_data.get("all_objects"):
+            raise ValidationError(
+                _("You must grant the permission to all objects and/" "or to some objects.")
+            )
+        return cleaned_data
+
+    def __init__(self, *args, permission: Permission, **kwargs):
+        self.permission = permission
+        super().__init__(*args, **kwargs)
+
+        model_class = self.permission.content_type.model_class()
+        queryset = model_class.objects.all()
+        self.fields["objects"].queryset = queryset
+        search_fields = getattr(model_class, "get_search_fields", lambda: [])()
+
+        # Use select2 only if there are any searchable fields as it can't work without
+        if search_fields:
+            self.fields["objects"].widget = ModelSelect2MultipleWidget(
+                search_fields=search_fields,
+                queryset=queryset,
+                attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+            )
+
+    def save_perms(self) -> int:
+        """Save permissions for selected user/groups and selected/all objects.
+
+        :return: The number of newly created permissions
+        """
+        persons = self.cleaned_data["persons"]
+        groups = self.cleaned_data["groups"]
+        all_objects = self.cleaned_data["all_objects"]
+        objects = self.cleaned_data["objects"]
+        permission_name = f"{self.permission.content_type.app_label}.{self.permission.codename}"
+        created = 0
+
+        # Create permissions for users
+        for person in persons:
+            if getattr("person", "user", None):
+                # Global permission
+                if all_objects:
+                    assign_perm(permission_name, person.user)
+                # Object permissions
+                for instance in objects:
+                    assign_perm(permission_name, person.user, instance)
+
+        # Create permissions for users
+        for group in groups:
+            django_group = group.django_group
+            # Global permission
+            if all_objects:
+                assign_perm(permission_name, django_group)
+            # Object permissions
+            for instance in objects:
+                assign_perm(permission_name, django_group, instance)
+        return created
diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index 8b0afd860..01e65b0aa 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -159,6 +159,17 @@ MENUS = {
                     "icon": "done_all",
                     "validators": ["menu_generator.validators.is_superuser"],
                 },
+                {
+                    "name": _("Manage permissions"),
+                    "url": "manage_user_global_permissions",
+                    "icon": "shield",
+                    "validators": [
+                        (
+                            "aleksis.core.util.predicates.permission_validator",
+                            "core.manage_permissions",
+                        ),
+                    ],
+                },
                 {
                     "name": _("Backend Admin"),
                     "url": "admin:index",
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index 2d06ba0dc..acedaaa2b 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -11,6 +11,7 @@ from django.contrib.sites.managers import CurrentSiteManager
 from django.contrib.sites.models import Site
 from django.db import models
 from django.db.models import JSONField, QuerySet
+from django.db.models.fields import CharField, TextField
 from django.forms.forms import BaseForm
 from django.forms.models import ModelForm, ModelFormMetaclass
 from django.http import HttpResponse
@@ -268,6 +269,15 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
 
             to.property_(_virtual_related, related_name)
 
+    @classmethod
+    def get_search_fields(cls) -> List[str]:
+        """Get names of all text-searchable fields of this model."""
+        fields = []
+        for field in cls.syncable_fields():
+            if isinstance(field, CharField) or isinstance(field, TextField):
+                fields.append(field.name)
+        return fields
+
     @classmethod
     def syncable_fields(
         cls, recursive: bool = True, exclude_remotes: List = []
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 7f208aaa8..09f0498e5 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -455,6 +455,12 @@ class Group(SchoolTermRelatedExtensibleModel):
             )
             dj_group.save()
 
+    @property
+    def django_group(self):
+        """Get Django group for this group."""
+        dj_group, _ = DjangoGroup.objects.get_or_create(name=self.name)
+        return dj_group
+
 
 class PersonGroupThrough(ExtensibleModel):
     """Through table for many-to-many relationship of group members.
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index c5902d2a2..ec8c3f3b2 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -1,4 +1,5 @@
 import rules
+from rules import is_superuser
 
 from .models import AdditionalField, Announcement, Group, GroupType, Person
 from .util.predicates import (
@@ -321,3 +322,6 @@ rules.add_perm("core.edit_default_dashboard", edit_default_dashboard_predicate)
 # Upload and browse files via CKEditor
 upload_files_ckeditor_predicate = has_person & has_global_perm("core.upload_files_ckeditor")
 rules.add_perm("core.upload_files_ckeditor", upload_files_ckeditor_predicate)
+
+manage_person_permissions_predicate = has_person & is_superuser
+rules.add_perm("core.manage_permissions", manage_person_permissions_predicate)
diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py
index 54d3f3e5c..1c305d136 100644
--- a/aleksis/core/tables.py
+++ b/aleksis/core/tables.py
@@ -91,3 +91,48 @@ class DashboardWidgetTable(tables.Table):
 
     def render_widget_name(self, value, record):
         return record._meta.verbose_name
+
+
+class PermissionTable(tables.Table):
+    """Table to list permissions."""
+
+    class Meta:
+        attrs = {"class": "responsive-table highlight"}
+
+    permission = tables.Column()
+
+
+class ObjectPermissionTable(PermissionTable):
+    """Table to list object permissions."""
+
+    content_object = tables.Column()
+
+
+class GlobalPermissionTable(PermissionTable):
+    """Table to list global permissions."""
+
+    pass
+
+
+class GroupObjectPermissionTable(ObjectPermissionTable):
+    """Table to list assigned group object permissions."""
+
+    group = tables.Column()
+
+
+class UserObjectPermissionTable(ObjectPermissionTable):
+    """Table to list assigned user object permissions."""
+
+    user = tables.Column()
+
+
+class GroupGlobalPermissionTable(GlobalPermissionTable):
+    """Table to list assigned global user permissions."""
+
+    group = tables.Column()
+
+
+class UserGlobalPermissionTable(GlobalPermissionTable):
+    """Table to list assigned global group permissions."""
+
+    user = tables.Column()
diff --git a/aleksis/core/templates/core/perms/assign.html b/aleksis/core/templates/core/perms/assign.html
new file mode 100644
index 000000000..f163751f7
--- /dev/null
+++ b/aleksis/core/templates/core/perms/assign.html
@@ -0,0 +1,32 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block browser_title %}{% blocktrans %}Assign permission{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Assign permission{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <p class="flow-text">
+    Selected permission: {{ object }}
+  </p>
+
+  <form method="post">
+    {% csrf_token %}
+    <input type="hidden" name="step" value="{{ step }}">
+    {% form form=form %}{% endform %}
+    <button type="submit" class="btn green waves-effect waves-light">
+      <i class="material-icons left">save</i>
+      {% trans "Assign" %}
+    </button>
+  </form>
+
+  {% include_js "select2-materialize" %}
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/core/templates/core/perms/list.html b/aleksis/core/templates/core/perms/list.html
new file mode 100644
index 000000000..81f731a40
--- /dev/null
+++ b/aleksis/core/templates/core/perms/list.html
@@ -0,0 +1,79 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js django_tables2 %}
+
+{% block extra_head %}
+  {{ filter.form.media.css }}
+  {{ assign_form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block browser_title %}{% blocktrans %}Manage permissions{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Manage permissions{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <a class="waves-effect waves-light btn green modal-trigger" href="#assign-modal">
+    <i class="material-icons left">add</i>
+    {% trans "Assign new permission" %}
+  </a>
+
+  <div id="assign-modal" class="modal">
+    <form action="{% url "select_permission_for_assign" %}?next={% url "manage_"|add:tab|add:"_permissions" %}"
+          method="post">
+      <div class="modal-content">
+        <h5>{% trans "Select permission to assign" %}</h5>
+        {% csrf_token %}
+        {% form form=assign_form %}{% endform %}
+      </div>
+      <div class="modal-footer">
+        <button type="submit" class="btn green waves-effect waves-light">
+          {% trans "Select" %}
+        </button>
+      </div>
+    </form>
+  </div>
+
+  <ul class="tabs">
+    <li class="tab">
+      <a target="_self" href="{% url "manage_user_global_permissions" %}"
+         {% if tab == "user_global" %}class="active"{% endif %}>{% trans "Global (user)" %}</a>
+    </li>
+    <li class="tab">
+      <a target="_self" href="{% url "manage_group_global_permissions" %}"
+         {% if tab == "group_global" %}class="active"{% endif %}>{% trans "Global (group)" %}</a>
+    </li>
+    <li class="tab">
+      <a target="_self" href="{% url "manage_user_object_permissions" %}"
+         {% if tab == "user_object" %}class="active"{% endif %}>{% trans "Object (user)" %}</a>
+    </li>
+    <li class="tab">
+      <a target="_self" href="{% url "manage_group_object_permissions" %}"
+         {% if tab == "group_object" %}class="active"{% endif %}>{% trans "Object (group)" %}</a>
+    </li>
+  </ul>
+
+  <div class="card">
+    <div class="card-content">
+      <form method="get" action="">
+        {% csrf_token %}
+        {% form form=filter.form %}{% endform %}
+        <button type="submit" class="btn waves-effect waves-light">
+          <i class="material-icons left">refresh</i>
+          {% trans "Update" %}
+        </button>
+      </form>
+    </div>
+  </div>
+
+  <div class="card">
+    <div class="card-content">
+      {% render_table table %}
+    </div>
+  </div>
+
+  {% include_js "select2-materialize" %}
+  {{ filter.form.media.js }}
+  {{ assign_form.media.js }}
+{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index f966c8dac..46b3732b8 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -196,6 +196,36 @@ urlpatterns = [
         {"default": True},
         name="edit_default_dashboard",
     ),
+    path(
+        "permissions/global/user/",
+        views.UserGlobalPermissionsListBaseView.as_view(),
+        name="manage_user_global_permissions",
+    ),
+    path(
+        "permissions/global/group/",
+        views.GroupGlobalPermissionsListBaseView.as_view(),
+        name="manage_group_global_permissions",
+    ),
+    path(
+        "permissions/object/user/",
+        views.UserObjectPermissionsListBaseView.as_view(),
+        name="manage_user_object_permissions",
+    ),
+    path(
+        "permissions/object/group/",
+        views.GroupObjectPermissionsListBaseView.as_view(),
+        name="manage_group_object_permissions",
+    ),
+    path(
+        "permissions/assign/",
+        views.SelectPermissionForAssignView.as_view(),
+        name="select_permission_for_assign",
+    ),
+    path(
+        "permissions/<int:pk>/assign/",
+        views.AssignPermissionView.as_view(),
+        name="assign_permission",
+    ),
 ]
 
 # Add URLs for optional features
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 0eb64c62c..eb710fc37 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -1,7 +1,10 @@
 from typing import Any, Dict, Optional, Type
+from urllib.parse import urlencode
 
 from django.apps import apps
+from django.contrib.auth.models import Permission
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.humanize.templatetags.humanize import apnumber
 from django.core.exceptions import PermissionDenied
 from django.core.paginator import Paginator
 from django.db.models import QuerySet
@@ -12,13 +15,15 @@ from django.urls import reverse, reverse_lazy
 from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy as _
 from django.views.decorators.cache import never_cache
+from django.views.generic import FormView
 from django.views.generic.base import View
 from django.views.generic.detail import DetailView
 from django.views.generic.list import ListView
 
 import reversion
 from django_celery_results.models import TaskResult
-from django_tables2 import RequestConfig, SingleTableView
+from django_filters.views import FilterView
+from django_tables2 import RequestConfig, SingleTableMixin, SingleTableView
 from dynamic_preferences.forms import preference_form_builder
 from guardian.shortcuts import get_objects_for_user
 from haystack.inputs import AutoQuery
@@ -32,9 +37,17 @@ from rules.contrib.views import PermissionRequiredMixin, permission_required
 from aleksis.core.data_checks import DataCheckRegistry, check_data
 
 from .celery import app
-from .filters import GroupFilter, PersonFilter
+from .filters import (
+    GroupFilter,
+    GroupGlobalPermissionFilter,
+    GroupObjectPermissionFilter,
+    PersonFilter,
+    UserGlobalPermissionFilter,
+    UserObjectPermissionFilter,
+)
 from .forms import (
     AnnouncementForm,
+    AssignPermissionForm,
     ChildGroupsForm,
     DashboardWidgetOrderFormSet,
     EditAdditionalFieldForm,
@@ -45,9 +58,10 @@ from .forms import (
     PersonPreferenceForm,
     PersonsAccountsFormSet,
     SchoolTermForm,
+    SelectPermissionForm,
     SitePreferenceForm,
 )
-from .mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
+from .mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView, SuccessNextMixin
 from .models import (
     AdditionalField,
     Announcement,
@@ -69,10 +83,14 @@ from .registries import (
 from .tables import (
     AdditionalFieldsTable,
     DashboardWidgetTable,
+    GroupGlobalPermissionTable,
+    GroupObjectPermissionTable,
     GroupsTable,
     GroupTypesTable,
     PersonsTable,
     SchoolTermTable,
+    UserGlobalPermissionTable,
+    UserObjectPermissionTable,
 )
 from .util import messages
 from .util.apps import AppConfig
@@ -941,3 +959,93 @@ class EditDashboardView(PermissionRequiredMixin, View):
         context = self.get_context_data(request, **kwargs)
 
         return render(request, "core/edit_dashboard.html", context=context)
+
+
+class PermissionsListBaseView(PermissionRequiredMixin, SingleTableMixin, FilterView):
+    """Base view for list of all permissions."""
+
+    template_name = "core/perms/list.html"
+    permission_required = "core.manage_permissions"
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["assign_form"] = SelectPermissionForm()
+        context["tab"] = self.tab
+
+        return context
+
+
+class UserGlobalPermissionsListBaseView(PermissionsListBaseView):
+    """List all global user permissions."""
+
+    filterset_class = UserGlobalPermissionFilter
+    table_class = UserGlobalPermissionTable
+    tab = "user_global"
+
+
+class GroupGlobalPermissionsListBaseView(PermissionsListBaseView):
+    """List all global group permissions."""
+
+    filterset_class = GroupGlobalPermissionFilter
+    table_class = GroupGlobalPermissionTable
+    tab = "group_global"
+
+
+class UserObjectPermissionsListBaseView(PermissionsListBaseView):
+    """List all object user permissions."""
+
+    filterset_class = UserObjectPermissionFilter
+    table_class = UserObjectPermissionTable
+    tab = "user_object"
+
+
+class GroupObjectPermissionsListBaseView(PermissionsListBaseView):
+    """List all object group permissions."""
+
+    filterset_class = GroupObjectPermissionFilter
+    table_class = GroupObjectPermissionTable
+    tab = "group_object"
+
+
+class SelectPermissionForAssignView(PermissionRequiredMixin, FormView):
+    """View for selecting a permission to assign."""
+
+    permission_required = "core.manage_permissions"
+    form_class = SelectPermissionForm
+
+    def form_valid(self, form: SelectPermissionForm) -> HttpResponse:
+        url = reverse("assign_permission", args=[form.cleaned_data["selected_permission"].pk])
+        params = {"next": self.request.GET["next"]} if "next" in self.request.GET else {}
+        return redirect(f"{url}?{urlencode(params)}")
+
+    def form_invalid(self, form: SelectPermissionForm) -> HttpResponse:
+        return redirect("manage_group_object_permissions")
+
+
+class AssignPermissionView(SuccessNextMixin, PermissionRequiredMixin, DetailView, FormView):
+    """View for assigning a permission to users/groups for all/some objects."""
+
+    permission_required = "core.manage_permissions"
+    queryset = Permission.objects.all()
+    template_name = "core/perms/assign.html"
+    form_class = AssignPermissionForm
+    success_url = "manage_user_global_permissions"
+
+    def get_form_kwargs(self) -> Dict[str, Any]:
+        kwargs = super().get_form_kwargs()
+        kwargs["permission"] = self.get_object()
+        return kwargs
+
+    def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+        # Overwrite get_context_data to ensure correct function call order
+        self.object = self.get_object()
+        context = super().get_context_data(**kwargs)
+        return context
+
+    def form_valid(self, form: AssignPermissionForm) -> HttpResponse:
+        created = form.save_perms()
+        messages.success(
+            self.request,
+            _("We have successfully created {} permissions.").format(apnumber(created)),
+        )
+        return redirect(self.get_success_url())
-- 
GitLab