Skip to content
Snippets Groups Projects
Verified Commit af90190f authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Add support for managing permissions in the frontend

This includes views, forms, filters and tables.
parent 3b5b720d
No related branches found
No related tags found
1 merge request!533Resolve "Manage permissions for persons (users) and groups (Django groups) in frontend"
Pipeline #6753 canceled
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
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
......@@ -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",
......
......@@ -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 = []
......
......@@ -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.
......
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)
......@@ -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()
{# -*- 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 %}
{# -*- 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 %}
......@@ -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
......
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())
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment