Implement the structured groups backend

This implements a structured user group model, propagating its changes
to vanilla Django groups. It features cycle detection at save-time,
although this cycle detection can only be seen as a fallback and forms
should check the acyclicity of their changes before committing using the
provided class method.
This commit is contained in:
Théophile Bastian 2019-12-16 01:02:26 +01:00
parent d9da1ef528
commit d3f80f2339
9 changed files with 193 additions and 0 deletions

View file

@ -48,6 +48,7 @@ INSTALLED_APPS = [
"widget_tweaks",
"shared", # Do not move, so templates in `shared` can override
# thoses in `wiki`
"wiki_groups",
"wiki",
"wiki.plugins.attachments",
"wiki.plugins.notifications",

1
wiki_groups/__init__.py Normal file
View file

@ -0,0 +1 @@
default_app_config = "wiki_groups.apps.WikiGroupsConfig"

4
wiki_groups/admin.py Normal file
View file

@ -0,0 +1,4 @@
from django.contrib import admin
from .models import WikiGroup
admin.site.register(WikiGroup)

6
wiki_groups/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class WikiGroupsConfig(AppConfig):
name = "wiki_groups"
verbose_name = "Wiki Groups"

View file

@ -0,0 +1,27 @@
# Generated by Django 2.2.8 on 2019-12-16 00:01
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WikiGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('django_group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='auth.Group')),
('includes_groups', models.ManyToManyField(blank=True, related_name='included_in_groups', to='wiki_groups.WikiGroup')),
('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

148
wiki_groups/models.py Normal file
View file

@ -0,0 +1,148 @@
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.dispatch import receiver
class WikiGroup(models.Model):
""" 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.
To make it work with django-wiki, we then flatten the set of users in the group
into a native Django group. This might seem a bit inelegant, but it has been
discussed in
https://git.eleves.ens.fr/klub-dev-ens/WikiENS/issues/5
and seemed the best way at the time, in the sense that it seems stable and reliable
wrt. internal behaviour and upstream changes in django-wiki.
"""
class CyclicStructureException(Exception):
""" 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
self.to_group = to_group
def __str__(self):
return (
"The new relation {} --> {} introduces a cycle in the groups' graph"
).format(self.from_group, self.to_group)
django_group = models.OneToOneField(DjangoGroup, on_delete=models.CASCADE)
includes_groups = models.ManyToManyField(
"self",
symmetrical=False, # without this Django assumes that (a -> b) => (b -> a) on
# a many-to-many on self
related_name="included_in_groups",
blank=True,
)
users = models.ManyToManyField(get_user_model(), 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 """
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 propagate_update(self, already_notified=None):
""" 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:
already_notified = set()
elif self.pk in already_notified:
return
already_notified.add(self.pk)
self.commit_to_django_group()
for metagroup in self.included_in_groups.all():
metagroup.propagate_update(already_notified=already_notified)
def commit_to_django_group(self):
""" 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
`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. """
def do_dfs(cur_node, visited_nodes):
""" DFS to check whether we find `self` again """
if cur_node.pk in visited_nodes:
return False
if cur_node.pk == self.pk:
return True
visited_nodes.add(cur_node.pk)
for child in cur_node.includes_groups.all():
if do_dfs(child, visited_nodes):
return True
return False
visited = set()
for child in with_children:
if do_dfs(child, visited):
return child
return None
@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 """
instance.propagate_update()
@receiver(
m2m_changed,
sender=WikiGroup.includes_groups.through,
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 """
if action in ["post_add", "post_remove", "post_clear"]:
instance.propagate_update()
@receiver(
m2m_changed,
sender=WikiGroup.users.through,
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 """
if action in ["post_add", "post_remove", "post_clear"]:
instance.propagate_update()
@receiver(
m2m_changed,
sender=WikiGroup.includes_groups.through,
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.
PLEASE NOTE that this check is only a fallback, and that forms should validate
the acyclicity before committing anything.
"""
if action == "pre_add":
children = [WikiGroup.objects.get(pk=cur_pk) for cur_pk in pk_set]
cycle_child = instance.group_in_cycle(children)
if cycle_child:
raise WikiGroup.CyclicStructureException(instance, cycle_child)

3
wiki_groups/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
wiki_groups/views.py Normal file
View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.