diff --git a/shared/templates/wiki/base.html b/shared/templates/wiki/base.html index 4188a8c..ff5d311 100644 --- a/shared/templates/wiki/base.html +++ b/shared/templates/wiki/base.html @@ -6,50 +6,57 @@ {% block wiki_header_branding %} - Wiki  ENS + Wiki  ENS {% endblock %} {% block wiki_header_navlinks %} + + {% else %} + + {% endif %} + {% endblock %} diff --git a/wiki_groups/forms.py b/wiki_groups/forms.py new file mode 100644 index 0000000..8c397fc --- /dev/null +++ b/wiki_groups/forms.py @@ -0,0 +1,52 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group + +from wiki_groups.models import WikiGroup + +User = get_user_model() + + +class SelectUserForm(forms.Form): + user = forms.CharField(max_length=150) + + def clean_user(self): + user = User.objects.filter(username=self.cleaned_data["user"]).first() + + if user is None: + self.add_error( + "user", "Aucune utilisatrice ou utilisateur avec ce login n'existe." + ) + + return user + + +class SelectGroupForm(forms.Form): + group = forms.CharField(max_length=150) + + def clean_group(self): + group = WikiGroup.objects.filter( + django_group__name=self.cleaned_data["group"] + ).first() + + if group is None: + self.add_error("group", "Aucun groupe avec ce nom n'existe.") + + return group + + +class CreateGroupForm(forms.Form): + group = forms.CharField(max_length=150) + + def clean_group(self): + name = self.cleaned_data["group"] + + django_group, created = Group.objects.get_or_create(name=name) + + if hasattr(django_group, "wikigroup"): + self.add_error("group", "Un groupe avec ce nom existe déjà.") + return None + + group = WikiGroup.objects.create(django_group=django_group) + + return group diff --git a/wiki_groups/migrations/0002_wikigroup_managers.py b/wiki_groups/migrations/0002_wikigroup_managers.py new file mode 100644 index 0000000..8ae5dd3 --- /dev/null +++ b/wiki_groups/migrations/0002_wikigroup_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.19 on 2021-07-23 09:01 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('wiki_groups', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='wikigroup', + name='managers', + field=models.ManyToManyField(blank=True, related_name='managed_groups', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/wiki_groups/models.py b/wiki_groups/models.py index 038f303..55ae155 100644 --- a/wiki_groups/models.py +++ b/wiki_groups/models.py @@ -1,12 +1,14 @@ -from django.db import models -from django.contrib.auth.models import Group as DjangoGroup from django.contrib.auth import get_user_model -from django.db.models.signals import post_save, m2m_changed +from django.contrib.auth.models import Group as DjangoGroup +from django.db import models +from django.db.models.signals import m2m_changed, post_save from django.dispatch import receiver +User = get_user_model() + class WikiGroup(models.Model): - """ A structured user group, used to grant permissions on sub-wikis + """A structured user group, used to grant permissions on sub-wikis This model contains a structured group of users, in the sense that a group contains both users and other groups, allowing a DAG group structure. @@ -20,8 +22,8 @@ class WikiGroup(models.Model): """ class CyclicStructureException(Exception): - """ Exception raised when a new edge introduces a cycle in the groups' - structure """ + """Exception raised when a new edge introduces a cycle in the groups' + structure""" def __init__(self, from_group, to_group): self.from_group = from_group @@ -40,22 +42,54 @@ class WikiGroup(models.Model): related_name="included_in_groups", blank=True, ) - users = models.ManyToManyField(get_user_model(), blank=True) + users = models.ManyToManyField(User, blank=True) + managers = models.ManyToManyField(User, related_name="managed_groups", blank=True) def __str__(self): return str(self.django_group) def get_all_users(self): - """ Get the queryset of all the users in this group, including recursively - included users """ + """Get the queryset of all the users in this group, including recursively + included users""" users_set = self.users.all() for subgroup in self.includes_groups.all(): users_set = users_set.union(subgroup.get_all_users()) return users_set + def is_manager(self, user): + """Checks wether the user is a manager of this group or any subgroup""" + + # Base case: the user is an explicit manager + if user in self.managers.all(): + return True + + for subgroup in self.includes_groups.all(): + # If the user is a manager of a subgroup + if subgroup.is_manager(user): + return True + + return False + + def get_all_groups(self, already_notified=None): + """Returns the set of metagroups i.e. self plus the list of groups including + this group recursively""" + + if already_notified is None: + already_notified = set() + elif self.pk in already_notified: + return set() + already_notified.add(self.pk) + + groups = {self} + + for metagroup in self.included_in_groups.all(): + groups |= metagroup.get_all_groups(already_notified=already_notified) + + return groups + def propagate_update(self, already_notified=None): - """ Commits itself to the Django group, and calls this method on every group in - `included_in_groups` """ + """Commits itself to the Django group, and calls this method on every group in + `included_in_groups`""" # Check that we did not already propagate the update signal to this group if already_notified is None: @@ -69,20 +103,20 @@ class WikiGroup(models.Model): metagroup.propagate_update(already_notified=already_notified) def commit_to_django_group(self): - """ Writes this model's data to the related Django group """ + """Writes this model's data to the related Django group""" self.django_group.user_set.set(self.get_all_users()) def group_in_cycle(self, with_children): - """ Checks whether this group would be in a group cycle if it had + """Checks whether this group would be in a group cycle if it had `with_children` as child nodes. This assumes that the graph currently stored is acyclic. Returns `None` if no cycle is found, else retuns a child from `with_children` - causing the cycle to appear. """ + causing the cycle to appear.""" def do_dfs(cur_node, visited_nodes): - """ DFS to check whether we find `self` again """ + """DFS to check whether we find `self` again""" if cur_node.pk in visited_nodes: return False if cur_node.pk == self.pk: @@ -103,7 +137,7 @@ class WikiGroup(models.Model): @receiver(post_save, sender=WikiGroup, dispatch_uid="on_wiki_group_changed") def on_wiki_group_changed(sender, instance, **kwargs): - """ Commit the related WikiGroups to Django Group upon model change """ + """Commit the related WikiGroups to Django Group upon model change""" instance.propagate_update() @@ -113,8 +147,8 @@ def on_wiki_group_changed(sender, instance, **kwargs): dispatch_uid="on_wiki_group_includes_changed", ) def on_wiki_group_includes_changed(sender, instance, action, **kwargs): - """ Commit the related WikiGroups to Django Group upon change of the set of - included other groups """ + """Commit the related WikiGroups to Django Group upon change of the set of + included other groups""" if action in ["post_add", "post_remove", "post_clear"]: instance.propagate_update() @@ -125,7 +159,7 @@ def on_wiki_group_includes_changed(sender, instance, action, **kwargs): dispatch_uid="on_wiki_group_users_changed", ) def on_wiki_group_users_changed(sender, instance, action, **kwargs): - """ Commit the related WikiGroups to Django Group upon change of included users """ + """Commit the related WikiGroups to Django Group upon change of included users""" if action in ["post_add", "post_remove", "post_clear"]: instance.propagate_update() @@ -136,7 +170,7 @@ def on_wiki_group_users_changed(sender, instance, action, **kwargs): dispatch_uid="on_wiki_group_includes_check_acyclic", ) def on_wiki_group_includes_check_acyclic(sender, instance, action, pk_set, **kwargs): - """ Checks the acyclicity of the groups' graph before committing new edges. + """Checks the acyclicity of the groups' graph before committing new edges. PLEASE NOTE that this check is only a fallback, and that forms should validate the acyclicity before committing anything. diff --git a/wiki_groups/templates/wiki_groups/admin.html b/wiki_groups/templates/wiki_groups/admin.html new file mode 100644 index 0000000..590fad7 --- /dev/null +++ b/wiki_groups/templates/wiki_groups/admin.html @@ -0,0 +1,207 @@ +{% extends "wiki/base.html" %} +{% load sekizai_tags %} + +{% block wiki_site_title %}Groupes administrés - WikiENS{% endblock %} + +{% block wiki_contents %} +

Gestion du groupe « {{ wikigroup }} »

+
+ +
+
+ +
+

Liste des membres

+
+ +
+
+
+ {% csrf_token %} +
+ +
+ +
+ {% if errors.user %} + {% for msg in errors.user.msg %} +
{{ msg }}
+ {% endfor %} + {% endif %} +
+ Entrer le login (clipper) de la personne à ajouter +
+
+ + {% for user in wikigroup.users.all %} +
+ {{ user }} +
+ {% endfor %} +
+
+ +
+

Liste des groupes inclus

+
+ +
+
+
+ {% csrf_token %} +
+ +
+ +
+ {% if errors.group_add %} + {% for msg in errors.group_add.msg %} +
{{ msg }}
+ {% endfor %} + {% endif %} +
+ Entrer le nom du groupe à ajouter +
+
+ + {% for group in wikigroup.includes_groups.all %} +
+ {{ group }} +
+ {% endfor %} + +
+
+ {% csrf_token %} +
+ +
+ +
+ {% if errors.group_create %} + {% for msg in errors.group_create.msg %} +
{{ msg }}
+ {% endfor %} + {% endif %} +
+ Entrer le nom du groupe à créer +
+
+
+
+
+
+ +
+
+

Liste des gestionnaires

+
+ +
+
+
+ {% csrf_token %} +
+ +
+ +
+ {% if errors.manager %} + {% for msg in errors.manager.msg %} +
{{ msg }}
+ {% endfor %} + {% endif %} +
+ Entrer le login (clipper) de la personne à ajouter +
+
+ + {% for user in wikigroup.managers.all %} +
+ {{ user }} +
+ {% endfor %} +
+
+ + {% if request.user.is_staff %} +
+

Supprimer ce groupe

+
+ +
+

Attention, cette action est irréversible.

+
+
+ + + {% endif %} +
+
+ +{# Confirmation modal #} + + +{% addtoblock "js" %} + +{% endaddtoblock %} + +{% endblock %} diff --git a/wiki_groups/templates/wiki_groups/managed.html b/wiki_groups/templates/wiki_groups/managed.html new file mode 100644 index 0000000..2a4b3e8 --- /dev/null +++ b/wiki_groups/templates/wiki_groups/managed.html @@ -0,0 +1,18 @@ +{% extends "wiki/base.html" %} + +{% block wiki_site_title %}Groupes administrés - WikiENS{% endblock %} + +{% block wiki_contents %} +

Liste des groupes administrés

+
+ +{% if wikigroup_list %} +
+ {% for group in wikigroup_list %} + {{ group }} + {% endfor %} +
+{% endif %} + + +{% endblock %} diff --git a/wiki_groups/urls.py b/wiki_groups/urls.py index 851a62d..b9c4f44 100644 --- a/wiki_groups/urls.py +++ b/wiki_groups/urls.py @@ -1,9 +1,31 @@ from django.urls import path -from wiki_groups import views +from wiki_groups import views app_name = "wiki_groups" urlpatterns = [ path("dot_graph", views.dot_graph, name="dot_graph"), path("graph", views.graph, name="graph"), + path("managed", views.ManagedGroupsView.as_view(), name="managed-groups"), + path("", views.WikiGroupView.as_view(), name="admin-group"), + path( + "/rm-user/", + views.RemoveUserView.as_view(), + name="remove-user", + ), + path( + "/rm-manager/", + views.RemoveManagerView.as_view(), + name="remove-manager", + ), + path( + "/rm-group/", + views.RemoveGroupView.as_view(), + name="remove-group", + ), + path("/add-user", views.AddUserView.as_view(), name="add-user"), + path("/add-manager", views.AddManagerView.as_view(), name="add-manager"), + path("/add-group", views.AddGroupView.as_view(), name="add-group"), + path("/create-group", views.CreateGroupView.as_view(), name="create-group"), + path("/delete", views.DeleteGroupView.as_view(), name="delete-group"), ] diff --git a/wiki_groups/views.py b/wiki_groups/views.py index c5200cd..f580963 100644 --- a/wiki_groups/views.py +++ b/wiki_groups/views.py @@ -1,8 +1,16 @@ -from django.http import HttpResponse -from django.views.generic import TemplateView +from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.http import HttpResponse, HttpResponseRedirect +from django.urls import reverse, reverse_lazy +from django.views.generic import DetailView, ListView, RedirectView, TemplateView +from django.views.generic.detail import SingleObjectMixin +from django.views.generic.edit import BaseFormView +from wiki_groups.forms import CreateGroupForm, SelectGroupForm, SelectUserForm from wiki_groups.models import WikiGroup +User = get_user_model() + def dot_graph(request): response = HttpResponse(content_type="text/vnd.graphviz") @@ -23,3 +31,206 @@ def dot_graph(request): graph = TemplateView.as_view(template_name="wiki_groups/graph.html") + + +class WikiGroupMixin(SingleObjectMixin, UserPassesTestMixin): + """ + Restricts the view to a manager of the wikigroup, and selects automatically + the required WikiGroup with its Django group + """ + + model = WikiGroup + + def get_queryset(self): + return super().get_queryset().select_related("django_group") + + def test_func(self): + self.object = self.get_object() + return self.request.user.is_staff or self.object.is_manager(self.request.user) + + +class StaffMixin(UserPassesTestMixin): + """Restricts the view to a staff member""" + + def test_func(self): + return self.request.user.is_staff + + +class ManagedGroupsView(LoginRequiredMixin, ListView): + model = WikiGroup + template_name = "wiki_groups/managed.html" + + def get_queryset(self): + if self.request.user.is_staff: + return WikiGroup.objects.select_related("django_group") + return self.request.user.managed_groups.select_related("django_group") + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + wikigroups = set() + + for group in ctx["wikigroup_list"]: + wikigroups |= group.get_all_groups() + + ctx["wikigroup_list"] = wikigroups + ctx["object_list"] = wikigroups + + return ctx + + +class WikiGroupView(WikiGroupMixin, DetailView): + template_name = "wiki_groups/admin.html" + + def get_context_data(self, **kwargs): + kwargs.update({"errors": self.request.session.pop("wiki_form_errors", None)}) + return super().get_context_data(**kwargs) + + +class RemoveUserView(WikiGroupMixin, RedirectView): + def get_redirect_url(self, *args, **kwargs): + return reverse("wiki_groups:admin-group", args=[kwargs["pk"]]) + + def get(self, request, *args, **kwargs): + user = User.objects.filter(pk=kwargs["user_pk"]).first() + + if user is not None: + self.object.users.remove(user) + + return super().get(request, *args, **kwargs) + + +class RemoveManagerView(WikiGroupMixin, RedirectView): + def get_redirect_url(self, *args, **kwargs): + return reverse("wiki_groups:admin-group", args=[kwargs["pk"]]) + + def get(self, request, *args, **kwargs): + user = User.objects.filter(pk=kwargs["user_pk"]).first() + + if user is not None: + self.object.users.remove(user) + + self.object.managers.remove(user) + + return super().get(request, *args, **kwargs) + + +class RemoveGroupView(WikiGroupMixin, RedirectView): + def get_redirect_url(self, *args, **kwargs): + return reverse("wiki_groups:admin-group", args=[kwargs["pk"]]) + + def get(self, request, *args, **kwargs): + group = WikiGroup.objects.filter(pk=kwargs["group_pk"]).first() + + if group is not None: + self.object.includes_groups.remove(group) + + return super().get(request, *args, **kwargs) + + +class AddUserView(WikiGroupMixin, BaseFormView): + form_class = SelectUserForm + + def get_success_url(self): + return reverse("wiki_groups:admin-group", args=[self.object.pk]) + + def form_valid(self, form): + self.object.users.add(form.cleaned_data["user"]) + + return super().form_valid(form) + + def form_invalid(self, form): + self.request.session["wiki_form_errors"] = { + "user": {"value": form["user"].value(), "msg": form.errors["user"]} + } + return HttpResponseRedirect(self.get_success_url()) + + +class AddManagerView(WikiGroupMixin, BaseFormView): + form_class = SelectUserForm + + def get_success_url(self): + return reverse("wiki_groups:admin-group", args=[self.object.pk]) + + def form_valid(self, form): + self.object.managers.add(form.cleaned_data["user"]) + + return super().form_valid(form) + + def form_invalid(self, form): + self.request.session["wiki_form_errors"] = { + "manager": {"value": form["user"].value(), "msg": form.errors["user"]} + } + return HttpResponseRedirect(self.get_success_url()) + + +class AddGroupView(WikiGroupMixin, BaseFormView): + """Adds an existing metagroup to this group""" + + form_class = SelectGroupForm + + def get_success_url(self): + return reverse("wiki_groups:admin-group", args=[self.object.pk]) + + def form_valid(self, form): + subgroup = form.cleaned_data["group"] + group = self.object + + if group.group_in_cycle(list(group.includes_groups.all()) + [subgroup]): + form.add_error( + "group", + ( + "Ajout impossible sous peine de créer un cycle " + f"({group} est inclus dans {subgroup})" + ), + ) + return self.form_invalid(form) + + group.includes_groups.add(subgroup) + return super().form_valid(form) + + def form_invalid(self, form): + self.request.session["wiki_form_errors"] = { + "group_add": {"value": form["group"].value(), "msg": form.errors["group"]} + } + return HttpResponseRedirect(self.get_success_url()) + + +class CreateGroupView(WikiGroupMixin, BaseFormView): + """Creates a metagroup included in this group""" + + form_class = CreateGroupForm + + def get_success_url(self): + return reverse("wiki_groups:admin-group", args=[self.object.pk]) + + def form_valid(self, form): + new_group = form.cleaned_data["group"] + + self.object.includes_groups.add(new_group) + new_group.managers.add(self.request.user) + + return super().form_valid(form) + + def form_invalid(self, form): + self.request.session["wiki_form_errors"] = { + "group_create": { + "value": form["group"].value(), + "msg": form.errors["group"], + } + } + return HttpResponseRedirect(self.get_success_url()) + + +class DeleteGroupView(SingleObjectMixin, StaffMixin, RedirectView): + model = WikiGroup + url = reverse_lazy("wiki_groups:managed-groups") + + def get(self, request, *args, **kwargs): + group = self.get_object() + # On enlève les membres pour répercuter les changements + group.users.clear() + + # On utilise la propagation de la suppression django_group -> wiki_group + group.django_group.delete() + + return super().get(request, *args, **kwargs)