diff --git a/CHANGELOG.rst b/CHANGELOG.rst index eb05f36f914cc6484332d4b8b5938e8f22825f7a..80a42efc21786e83bf03419dfed68e7295861df1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,11 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Added +~~~~~ + +* Recursive helper methods for group hierarchies + Fixed ~~~~~ diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py index aed4cab72d4b0cd53d3544b20f0180a7087dfd2c..a3feacc6cf17731a6d6f38ca5ace42677ed230f1 100644 --- a/aleksis/core/managers.py +++ b/aleksis/core/managers.py @@ -7,6 +7,7 @@ from django.db.models import QuerySet from django.db.models.manager import Manager from calendarweek import CalendarWeek +from django_cte import CTEManager, CTEQuerySet from polymorphic.managers import PolymorphicManager @@ -84,7 +85,7 @@ class SchoolTermRelatedQuerySet(QuerySet): return None -class GroupManager(CurrentSiteManagerWithoutMigrations): +class GroupManager(CurrentSiteManagerWithoutMigrations, CTEManager): """Manager adding specific methods to groups.""" def get_queryset(self): @@ -92,7 +93,7 @@ class GroupManager(CurrentSiteManagerWithoutMigrations): return super().get_queryset().select_related("school_term") -class GroupQuerySet(SchoolTermRelatedQuerySet): +class GroupQuerySet(SchoolTermRelatedQuerySet, CTEQuerySet): pass diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 73ffab18ab217e1698ccc4fbdea9b25c1de97dfb..133af202290421dd215b568cd6a1133adc239571 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -14,7 +14,7 @@ from django.contrib.sites.models import Site from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator from django.db import models, transaction -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from django.db.models.signals import m2m_changed from django.dispatch import receiver from django.forms.widgets import Media @@ -28,6 +28,7 @@ import jsonstore from cachalot.api import cachalot_disabled from cache_memoize import cache_memoize from django_celery_results.models import TaskResult +from django_cte import CTEQuerySet, With from dynamic_preferences.models import PerInstancePreferenceModel from invitations import signals from invitations.adapters import get_invitations_adapter @@ -299,6 +300,22 @@ class Person(ExtensibleModel): user_info_tracker = FieldTracker(fields=("first_name", "last_name", "email")) + @property + def member_of_recursive(self) -> QuerySet: + """Get all groups this person is a member of, recursively.""" + q = self.member_of + for group in q.all(): + q = q.union(group.parent_groups_recursive) + return q + + @property + def owner_of_recursive(self) -> QuerySet: + """Get all groups this person is a member of, recursively.""" + q = self.owner_of + for group in q.all(): + q = q.union(group.child_groups_recursive) + return q + def save(self, *args, **kwargs): # Determine all fields that were changed since last load dirty = self.pk is None or bool(self.user_info_tracker.changed()) @@ -486,6 +503,50 @@ class Group(SchoolTermRelatedExtensibleModel): return stats + @property + def parent_groups_recursive(self) -> CTEQuerySet: + """Get all parent groups recursively.""" + + def _make_cte(cte): + Through = self.parent_groups.through + return ( + Through.objects.values("to_group_id") + .filter(from_group=self) + .union(cte.join(Through, from_group=cte.col.to_group_id), all=True) + ) + + cte = With.recursive(_make_cte) + return cte.join(Group, id=cte.col.to_group_id).with_cte(cte) + + @property + def child_groups_recursive(self) -> CTEQuerySet: + """Get all child groups recursively.""" + + def _make_cte(cte): + Through = self.child_groups.through + return ( + Through.objects.values("from_group_id") + .filter(to_group=self) + .union(cte.join(Through, to_group=cte.col.from_group_id), all=True) + ) + + cte = With.recursive(_make_cte) + return cte.join(Group, id=cte.col.from_group_id).with_cte(cte) + + @property + def members_recursive(self) -> QuerySet: + """Get all members of this group and its child groups.""" + return Person.objects.filter( + Q(member_of=self) | Q(member_of__in=self.child_groups_recursive) + ) + + @property + def owners_recursive(self) -> QuerySet: + """Get all ownerss of this group and its parent groups.""" + return Person.objects.filter( + Q(owner_of=self) | Q(owner_of__in=self.parent_groups_recursive) + ) + def __str__(self) -> str: if self.school_term: return f"{self.name} ({self.short_name}) ({self.school_term})" diff --git a/aleksis/core/tests/models/test_group.py b/aleksis/core/tests/models/test_group.py new file mode 100644 index 0000000000000000000000000000000000000000..8849a842d043b6bd3c91d04393cf5ccc695d2cf3 --- /dev/null +++ b/aleksis/core/tests/models/test_group.py @@ -0,0 +1,163 @@ +import pytest + +from aleksis.core.models import Group, Person + +pytestmark = pytest.mark.django_db + + +def test_child_groups_recursive(): + g_1st_grade = Group.objects.create(name="1st grade") + g_1a = Group.objects.create(name="1a") + g_1b = Group.objects.create(name="1b") + g_2nd_grade = Group.objects.create(name="2nd grade") + g_2a = Group.objects.create(name="2a") + g_2b = Group.objects.create(name="2b") + g_2c = Group.objects.create(name="2c") + g_2nd_grade_french = Group.objects.create(name="2nd grade French") + + g_1a.parent_groups.set([g_1st_grade]) + g_1b.parent_groups.set([g_1st_grade]) + g_2a.parent_groups.set([g_2nd_grade]) + g_2b.parent_groups.set([g_2nd_grade]) + g_2c.parent_groups.set([g_2nd_grade]) + g_2nd_grade_french.parent_groups.set([g_2b, g_2c]) + + assert g_2nd_grade_french in g_2nd_grade.child_groups_recursive + assert g_2nd_grade_french in g_2b.child_groups_recursive + assert g_2nd_grade_french in g_2c.child_groups_recursive + assert g_2nd_grade_french not in g_2a.child_groups_recursive + assert g_2nd_grade_french not in g_1st_grade.child_groups_recursive + + +def test_parent_groups_recursive(): + g_1st_grade = Group.objects.create(name="1st grade") + g_1a = Group.objects.create(name="1a") + g_1b = Group.objects.create(name="1b") + g_2nd_grade = Group.objects.create(name="2nd grade") + g_2a = Group.objects.create(name="2a") + g_2b = Group.objects.create(name="2b") + g_2c = Group.objects.create(name="2c") + g_2nd_grade_french = Group.objects.create(name="2nd grade French") + + g_1a.parent_groups.set([g_1st_grade]) + g_1b.parent_groups.set([g_1st_grade]) + g_2a.parent_groups.set([g_2nd_grade]) + g_2b.parent_groups.set([g_2nd_grade]) + g_2c.parent_groups.set([g_2nd_grade]) + g_2nd_grade_french.parent_groups.set([g_2b, g_2c]) + + assert g_1st_grade in g_1a.parent_groups_recursive + assert g_2nd_grade in g_2a.parent_groups_recursive + assert g_2nd_grade in g_2nd_grade_french.parent_groups_recursive + assert g_1st_grade not in g_2nd_grade_french.parent_groups_recursive + + +def test_members_recursive(): + g_2nd_grade = Group.objects.create(name="2nd grade") + g_2a = Group.objects.create(name="2a") + g_2b = Group.objects.create(name="2b") + g_2c = Group.objects.create(name="2c") + g_2nd_grade_french = Group.objects.create(name="2nd grade French") + + g_2a.parent_groups.set([g_2nd_grade]) + g_2b.parent_groups.set([g_2nd_grade]) + g_2c.parent_groups.set([g_2nd_grade]) + g_2nd_grade_french.parent_groups.set([g_2b, g_2c]) + + p_2a_1 = Person.objects.create(first_name="A", last_name="B") + p_2a_2 = Person.objects.create(first_name="A", last_name="B") + p_2b_1 = Person.objects.create(first_name="A", last_name="B") + p_2b_2 = Person.objects.create(first_name="A", last_name="B") + p_2c_1 = Person.objects.create(first_name="A", last_name="B") + p_2c_2 = Person.objects.create(first_name="A", last_name="B") + p_french_only = Person.objects.create(first_name="A", last_name="B") + + g_2a.members.set([p_2a_1, p_2a_2]) + g_2b.members.set([p_2b_1, p_2b_2]) + g_2c.members.set([p_2c_1, p_2c_2]) + g_2nd_grade_french.members.set([p_2b_1, p_2c_1, p_french_only]) + + assert p_2a_1 in g_2nd_grade.members_recursive + assert p_2a_2 in g_2nd_grade.members_recursive + assert p_2b_1 in g_2nd_grade.members_recursive + assert p_2b_2 in g_2nd_grade.members_recursive + assert p_2c_1 in g_2nd_grade.members_recursive + assert p_2c_2 in g_2nd_grade.members_recursive + assert p_french_only in g_2nd_grade.members_recursive + assert p_french_only in g_2b.members_recursive + assert p_french_only in g_2c.members_recursive + assert p_french_only not in g_2a.members_recursive + + +def test_member_of_recursive(): + g_2nd_grade = Group.objects.create(name="2nd grade") + g_2a = Group.objects.create(name="2a") + g_2b = Group.objects.create(name="2b") + g_2c = Group.objects.create(name="2c") + g_2nd_grade_french = Group.objects.create(name="2nd grade French") + + g_2a.parent_groups.set([g_2nd_grade]) + g_2b.parent_groups.set([g_2nd_grade]) + g_2c.parent_groups.set([g_2nd_grade]) + g_2nd_grade_french.parent_groups.set([g_2b, g_2c]) + + p_2a_1 = Person.objects.create(first_name="A", last_name="B") + p_2a_2 = Person.objects.create(first_name="A", last_name="B") + p_2b_1 = Person.objects.create(first_name="A", last_name="B") + p_2b_2 = Person.objects.create(first_name="A", last_name="B") + p_2c_1 = Person.objects.create(first_name="A", last_name="B") + p_2c_2 = Person.objects.create(first_name="A", last_name="B") + p_french_only = Person.objects.create(first_name="A", last_name="B") + + g_2a.members.set([p_2a_1, p_2a_2]) + g_2b.members.set([p_2b_1, p_2b_2]) + g_2c.members.set([p_2c_1, p_2c_2]) + g_2nd_grade_french.members.set([p_2b_1, p_2c_1, p_french_only]) + + assert g_2nd_grade in p_2a_1.member_of_recursive + assert g_2nd_grade in p_2a_2.member_of_recursive + assert g_2nd_grade in p_2b_1.member_of_recursive + assert g_2nd_grade in p_2b_2.member_of_recursive + assert g_2nd_grade in p_2c_1.member_of_recursive + assert g_2nd_grade in p_2c_2.member_of_recursive + assert g_2nd_grade in p_french_only.member_of_recursive + assert g_2b in p_french_only.member_of_recursive + assert g_2c in p_french_only.member_of_recursive + + +def test_owners_recursive(): + g_2nd_grade = Group.objects.create(name="2nd grade") + g_2a = Group.objects.create(name="2a") + g_2b = Group.objects.create(name="2b") + + g_2a.parent_groups.set([g_2nd_grade]) + g_2b.parent_groups.set([g_2nd_grade]) + + p_1 = Person.objects.create(first_name="A", last_name="B") + p_2 = Person.objects.create(first_name="A", last_name="B") + + g_2nd_grade.owners.set([p_1]) + + assert p_1 in g_2a.owners_recursive + assert p_1 in g_2b.owners_recursive + assert p_2 not in g_2a.owners_recursive + assert p_2 not in g_2b.owners_recursive + + +def test_owner_of_recursive(): + g_2nd_grade = Group.objects.create(name="2nd grade") + g_2a = Group.objects.create(name="2a") + g_2b = Group.objects.create(name="2b") + + g_2a.parent_groups.set([g_2nd_grade]) + g_2b.parent_groups.set([g_2nd_grade]) + + p_1 = Person.objects.create(first_name="A", last_name="B") + p_2 = Person.objects.create(first_name="A", last_name="B") + + g_2nd_grade.owners.set([p_1]) + + assert g_2a in p_1.owner_of_recursive.all() + assert g_2b in p_1.owner_of_recursive.all() + assert g_2a not in p_2.owner_of_recursive.all() + assert g_2b not in p_2.owner_of_recursive.all() diff --git a/pyproject.toml b/pyproject.toml index 357eff863631c377b33777678a28dba88fb81f0d..fd960153b97c4b078931eb9890d38af9cb746152 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,7 @@ django-titofisto = "^0.2.0" haystack-redis = "^0.0.1" python-gnupg = "^0.4.7" sentry-sdk = {version = "^1.4.3", optional = true} +django-cte = "^1.1.5" [tool.poetry.extras] ldap = ["django-auth-ldap"]