d3f80f2339
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.
148 lines
5.5 KiB
Python
148 lines
5.5 KiB
Python
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)
|