diff --git a/WikiENS/settings.default.py b/WikiENS/settings.default.py index 21512f7..cffb20f 100644 --- a/WikiENS/settings.default.py +++ b/WikiENS/settings.default.py @@ -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", diff --git a/wiki_groups/__init__.py b/wiki_groups/__init__.py new file mode 100644 index 0000000..8588003 --- /dev/null +++ b/wiki_groups/__init__.py @@ -0,0 +1 @@ +default_app_config = "wiki_groups.apps.WikiGroupsConfig" diff --git a/wiki_groups/admin.py b/wiki_groups/admin.py new file mode 100644 index 0000000..cb732f9 --- /dev/null +++ b/wiki_groups/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import WikiGroup + +admin.site.register(WikiGroup) diff --git a/wiki_groups/apps.py b/wiki_groups/apps.py new file mode 100644 index 0000000..7ed79d1 --- /dev/null +++ b/wiki_groups/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WikiGroupsConfig(AppConfig): + name = "wiki_groups" + verbose_name = "Wiki Groups" diff --git a/wiki_groups/migrations/0001_initial.py b/wiki_groups/migrations/0001_initial.py new file mode 100644 index 0000000..02b0fdd --- /dev/null +++ b/wiki_groups/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/wiki_groups/migrations/__init__.py b/wiki_groups/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wiki_groups/models.py b/wiki_groups/models.py new file mode 100644 index 0000000..038f303 --- /dev/null +++ b/wiki_groups/models.py @@ -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) diff --git a/wiki_groups/tests.py b/wiki_groups/tests.py new file mode 100644 index 0000000..662c669 --- /dev/null +++ b/wiki_groups/tests.py @@ -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) diff --git a/wiki_groups/views.py b/wiki_groups/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/wiki_groups/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.