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