Merge branch 'structured_groups' into 'master'
Structured groups Closes #5 See merge request klub-dev-ens/WikiENS!8
This commit is contained in:
commit
087b5a871e
9 changed files with 344 additions and 0 deletions
|
@ -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
1
wiki_groups/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
default_app_config = "wiki_groups.apps.WikiGroupsConfig"
|
4
wiki_groups/admin.py
Normal file
4
wiki_groups/admin.py
Normal 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
6
wiki_groups/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WikiGroupsConfig(AppConfig):
|
||||
name = "wiki_groups"
|
||||
verbose_name = "Wiki Groups"
|
27
wiki_groups/migrations/0001_initial.py
Normal file
27
wiki_groups/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
0
wiki_groups/migrations/__init__.py
Normal file
0
wiki_groups/migrations/__init__.py
Normal file
148
wiki_groups/models.py
Normal file
148
wiki_groups/models.py
Normal 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)
|
154
wiki_groups/tests.py
Normal file
154
wiki_groups/tests.py
Normal file
|
@ -0,0 +1,154 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.test import TestCase
|
||||
|
||||
from wiki_groups.models import WikiGroup
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def create_user(username):
|
||||
return User.objects.create_user(username=username)
|
||||
|
||||
|
||||
def create_group(name, with_users=None):
|
||||
"""Create a new WikiGroup, initialised with some new Users."""
|
||||
django_group = Group.objects.create(name=name)
|
||||
wiki_group = WikiGroup.objects.create(django_group=django_group)
|
||||
if with_users is not None:
|
||||
for username in with_users:
|
||||
u = create_user(username)
|
||||
wiki_group.users.add(u)
|
||||
return wiki_group
|
||||
|
||||
|
||||
class TestPropagation(TestCase):
|
||||
"""Test that WikiGroup changes are correctly propagated to normal Django groups."""
|
||||
|
||||
def test_tree(self):
|
||||
"""Simple case: tree-shaped group structure."""
|
||||
a = create_group("group_a", with_users=["a1", "a2"])
|
||||
b = create_group("group_b", with_users=["b1", "b2"])
|
||||
c = create_group("group_c", with_users=["c1", "c2"])
|
||||
|
||||
a.includes_groups.set([b, c])
|
||||
|
||||
self.assertQuerysetEqual(
|
||||
a.django_group.user_set.all(), map(repr, User.objects.all()), ordered=False,
|
||||
)
|
||||
self.assertQuerysetEqual(
|
||||
b.django_group.user_set.all(),
|
||||
["b1", "b2"],
|
||||
ordered=False,
|
||||
transform=lambda u: u.username,
|
||||
)
|
||||
self.assertQuerysetEqual(
|
||||
c.django_group.user_set.all(),
|
||||
["c1", "c2"],
|
||||
ordered=False,
|
||||
transform=lambda u: u.username,
|
||||
)
|
||||
|
||||
def test_diamond(self):
|
||||
"""Simple case: diamond-shaped group structure."""
|
||||
a = create_group("group_a", with_users=["a1", "a2"])
|
||||
b = create_group("group_b", with_users=["b1", "b2"])
|
||||
c = create_group("group_c", with_users=["c1", "c2"])
|
||||
d = create_group("group_d", with_users=["d1", "d2"])
|
||||
|
||||
a.includes_groups.set([b, c])
|
||||
b.includes_groups.add(d)
|
||||
c.includes_groups.add(d)
|
||||
|
||||
self.assertQuerysetEqual(
|
||||
a.django_group.user_set.all(), map(repr, User.objects.all()), ordered=False,
|
||||
)
|
||||
self.assertQuerysetEqual(
|
||||
b.django_group.user_set.all(),
|
||||
["b1", "b2", "d1", "d2"],
|
||||
ordered=False,
|
||||
transform=lambda u: u.username,
|
||||
)
|
||||
self.assertQuerysetEqual(
|
||||
c.django_group.user_set.all(),
|
||||
["c1", "c2", "d1", "d2"],
|
||||
ordered=False,
|
||||
transform=lambda u: u.username,
|
||||
)
|
||||
self.assertQuerysetEqual(
|
||||
d.django_group.user_set.all(),
|
||||
["d1", "d2"],
|
||||
ordered=False,
|
||||
transform=lambda u: u.username,
|
||||
)
|
||||
|
||||
def test_removal(self):
|
||||
"""Test propagation of user removal."""
|
||||
|
||||
a = create_group("group_a", with_users=["a1", "a2"])
|
||||
b = create_group("group_b", with_users=["b1", "b2"])
|
||||
a.includes_groups.add(b)
|
||||
|
||||
self.assertQuerysetEqual(
|
||||
a.django_group.user_set.all(),
|
||||
["a1", "a2", "b1", "b2"],
|
||||
ordered=False,
|
||||
transform=lambda u: u.username,
|
||||
)
|
||||
|
||||
b.users.remove(User.objects.get(username="b1"))
|
||||
|
||||
self.assertQuerysetEqual(
|
||||
a.django_group.user_set.all(),
|
||||
["a1", "a2", "b2"],
|
||||
ordered=False,
|
||||
transform=lambda u: u.username,
|
||||
)
|
||||
self.assertQuerysetEqual(
|
||||
b.django_group.user_set.all(), ["b2"], transform=lambda u: u.username
|
||||
)
|
||||
|
||||
def test_update(self):
|
||||
"""Test structure updates propagation."""
|
||||
|
||||
a = create_group("group_a")
|
||||
b = create_group("group_b")
|
||||
c = create_group("group_c")
|
||||
a.includes_groups.add(b)
|
||||
b.includes_groups.add(c)
|
||||
|
||||
# Update: set a's children to [c]
|
||||
# Before update: a --> b --> c
|
||||
# After update: b --> c <-- a
|
||||
a.includes_groups.set([c])
|
||||
|
||||
self.assertQuerysetEqual(a.includes_groups.all(), [repr(c)])
|
||||
self.assertQuerysetEqual(b.includes_groups.all(), [repr(c)])
|
||||
self.assertQuerysetEqual(c.includes_groups.all(), [])
|
||||
|
||||
|
||||
class TestCycleDetection(TestCase):
|
||||
"""Test the cycle detection procedure."""
|
||||
|
||||
def test_loop(self):
|
||||
"""Test loops (a --> a) detection."""
|
||||
|
||||
a = create_group("group_a")
|
||||
|
||||
in_cycle = a.group_in_cycle(with_children=[a])
|
||||
self.assertEqual(in_cycle, a)
|
||||
|
||||
in_cycle = a.group_in_cycle(with_children=[a, a, a])
|
||||
self.assertEqual(in_cycle, a)
|
||||
|
||||
def test_trivial_cycle(self):
|
||||
"""Test trivial cycle detection (a --> b --> c --> a)."""
|
||||
|
||||
a = create_group("group_a")
|
||||
b = create_group("group_b")
|
||||
c = create_group("group_c")
|
||||
a.includes_groups.add(b)
|
||||
b.includes_groups.add(c)
|
||||
|
||||
in_cycle = c.group_in_cycle(with_children=[a])
|
||||
self.assertIsNotNone(in_cycle)
|
3
wiki_groups/views.py
Normal file
3
wiki_groups/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
Loading…
Reference in a new issue