Skip to content

Commit 538b4df

Browse files
authored
Merge pull request #476 from winged/feat_convenience_methods
feat(models): (re)introduce some convenience methods for working with scope trees
2 parents e0c80f2 + b96014f commit 538b4df

File tree

6 files changed

+223
-40
lines changed

6 files changed

+223
-40
lines changed

emeis/core/models.py

+58-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import operator
12
import unicodedata
23
import uuid
4+
from functools import reduce
35

46
from django.conf import settings
57
from django.contrib.auth.models import AbstractBaseUser, UserManager
@@ -158,6 +160,58 @@ def is_authenticated(self):
158160
return True
159161

160162

163+
class ScopeQuerySet(TreeQuerySet):
164+
# django-tree-queries sadly does not (yet?) support ancestors query
165+
# for QS - only for single nodes. So we're providing all_descendants()
166+
# and all_ancestors() queryset methods.
167+
168+
def all_descendants(self, include_self=False):
169+
"""Return a QS that contains all descendants of the given QS.
170+
171+
This is a workaround for django-tree-queries, which currently does
172+
not support this query (it can only do it on single nodes).
173+
174+
This is in contrast to .descendants(), which can only give the descendants
175+
of one model instance.
176+
"""
177+
descendants_q = reduce(
178+
operator.or_,
179+
[
180+
models.Q(
181+
pk__in=entry.descendants(include_self=include_self).values("pk")
182+
)
183+
for entry in self
184+
],
185+
models.Q(),
186+
)
187+
return self.model.objects.filter(descendants_q)
188+
189+
def all_ancestors(self, include_self=False):
190+
"""Return a QS that contains all ancestors of the given QS.
191+
192+
This is a workaround for django-tree-queries, which currently does
193+
not support this query (it can only do it on single nodes).
194+
195+
This is in contrast to .ancestors(), which can only give the ancestors
196+
of one model instance.
197+
"""
198+
199+
descendants_q = reduce(
200+
operator.or_,
201+
[
202+
models.Q(pk__in=entry.ancestors(include_self=include_self).values("pk"))
203+
for entry in self
204+
],
205+
models.Q(),
206+
)
207+
return self.model.objects.filter(descendants_q)
208+
209+
def all_roots(self):
210+
return Scope.objects.all().filter(
211+
pk__in=[scope.ancestors(include_self=True).first().pk for scope in self]
212+
)
213+
214+
161215
class Scope(TreeNode, UUIDModel):
162216
name = LocalizedCharField(_("scope name"), blank=False, null=False, required=False)
163217

@@ -170,7 +224,10 @@ class Scope(TreeNode, UUIDModel):
170224
)
171225
is_active = models.BooleanField(default=True)
172226

173-
objects = TreeQuerySet.as_manager(with_tree_fields=True)
227+
objects = ScopeQuerySet.as_manager(with_tree_fields=True)
228+
229+
def get_root(self):
230+
return self.ancestors(include_self=True).first()
174231

175232
def save(self, *args, **kwargs):
176233
# django-tree-queries does validation in TreeNode.clean(), which is not

emeis/core/serializers.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,13 @@ def get_level(self, obj):
9191
# Sometimes, the model object may come out of a non-django-tree-queries
9292
# QS, and thus would not have the `tree_*` attributes amended. Then we
9393
# need to go the "slow path"
94-
return obj.ancestors().count()
94+
if not obj.pk and obj.parent_id:
95+
# unsaved object, sometimes used in unit tests etc
96+
return self.get_level(obj.parent) + 1
97+
98+
if obj.parent_id:
99+
return obj.ancestors().count()
100+
return 0
95101

96102
class Meta:
97103
model = Scope

emeis/core/tests/snapshots/snap_test_api.py

-37
Original file line numberDiff line numberDiff line change
@@ -229,43 +229,6 @@
229229
SELECT (__tree.tree_depth) AS "tree_depth", (__tree.tree_path) AS "tree_path", (__tree.tree_ordering) AS "tree_ordering", "emeis_core_scope"."parent_id", "emeis_core_scope"."created_at", "emeis_core_scope"."modified_at", "emeis_core_scope"."created_by_user_id", "emeis_core_scope"."metainfo", "emeis_core_scope"."id", "emeis_core_scope"."name", "emeis_core_scope"."full_name", "emeis_core_scope"."description", "emeis_core_scope"."is_active" FROM "emeis_core_scope" , "__tree" WHERE ("emeis_core_scope"."parent_id" = \'f561aaf6-ef0b-f14d-4208-bb46a4ccb3ad\'::uuid AND (__tree.tree_pk = emeis_core_scope.id)) ORDER BY ("__tree".tree_ordering) ASC""",
230230
"""INSERT INTO "emeis_core_scope" ("parent_id", "created_at", "modified_at", "created_by_user_id", "metainfo", "id", "name", "full_name", "description", "is_active") VALUES (NULL, \'2017-05-21T00:00:00+00:00\'::timestamptz, \'2017-05-21T00:00:00+00:00\'::timestamptz, \'9336ebf2-5087-d91c-818e-e6e9ec29f8c1\'::uuid, \'{}\', \'f561aaf6-ef0b-f14d-4208-bb46a4ccb3ad\'::uuid, hstore(ARRAY[\'en\',\'de\',\'fr\'], ARRAY[\'Pamela Horton\',\'\',\'\']), hstore(ARRAY[\'en\',\'de\',\'fr\'], ARRAY[\'Pamela Horton\',\'Pamela Horton\',\'Pamela Horton\']), hstore(ARRAY[\'en\',\'de\',\'fr\'], ARRAY[\'Effort meet relationship far. Option program interesting station. First where during teach country talk across.
231231
Argue move appear catch toward help wind. Material minute ago get.','','']), true)""",
232-
"""
233-
WITH RECURSIVE __rank_table(
234-
235-
"id",
236-
"parent_id",
237-
"rank_order"
238-
) AS (
239-
SELECT "emeis_core_scope"."id", "emeis_core_scope"."parent_id", ROW_NUMBER() OVER (ORDER BY "emeis_core_scope"."name" ASC) AS "rank_order" FROM "emeis_core_scope"
240-
),
241-
__tree (
242-
243-
"tree_depth",
244-
"tree_path",
245-
"tree_ordering",
246-
"tree_pk"
247-
) AS (
248-
SELECT
249-
250-
0,
251-
array[T.id],
252-
array[T.rank_order],
253-
T."id"
254-
FROM __rank_table T
255-
WHERE T."parent_id" IS NULL
256-
257-
UNION ALL
258-
259-
SELECT
260-
261-
__tree.tree_depth + 1,
262-
__tree.tree_path || T.id,
263-
__tree.tree_ordering || T.rank_order,
264-
T."id"
265-
FROM __rank_table T
266-
JOIN __tree ON T."parent_id" = __tree.tree_pk
267-
)
268-
SELECT (__tree.tree_depth) AS "tree_depth", (__tree.tree_path) AS "tree_path", (__tree.tree_ordering) AS "tree_ordering", "emeis_core_scope"."parent_id", "emeis_core_scope"."created_at", "emeis_core_scope"."modified_at", "emeis_core_scope"."created_by_user_id", "emeis_core_scope"."metainfo", "emeis_core_scope"."id", "emeis_core_scope"."name", "emeis_core_scope"."full_name", "emeis_core_scope"."description", "emeis_core_scope"."is_active" FROM "emeis_core_scope" , "__tree" WHERE ("emeis_core_scope"."id" = \'f561aaf6-ef0b-f14d-4208-bb46a4ccb3ad\'::uuid AND (__tree.tree_pk = emeis_core_scope.id)) ORDER BY ("__tree".tree_ordering) ASC LIMIT 21""",
269232
],
270233
"request": {
271234
"CONTENT_LENGTH": "614",

emeis/core/tests/test_api.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ def test_api_detail(fixture, admin_client, viewset, snapshot):
174174

175175

176176
@pytest.mark.freeze_time("2017-05-21")
177-
def test_api_create(transactional_db, fixture, admin_client, viewset, snapshot):
177+
def test_api_create(
178+
transactional_db, deterministic_uuids, fixture, admin_client, viewset, snapshot
179+
):
178180
url = reverse("{0}-list".format(viewset.base_name))
179181

180182
serializer = viewset.serializer_class(fixture)

emeis/core/tests/test_models.py

+133
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,136 @@ def test_update_full_name_of_child(db, scope_factory):
149149

150150
grandchild.refresh_from_db()
151151
assert str(grandchild.full_name) == "r » s » c » g"
152+
153+
154+
@pytest.fixture
155+
def simple_tree_structure(db, scope_factory):
156+
# root1
157+
# - sub1sub1
158+
# - sub1sub1sub1
159+
# - sub1sub1sub2
160+
# - sub1sub2
161+
# root2
162+
# - sub2sub1
163+
# - sub2sub2
164+
root1 = scope_factory(name="root1")
165+
root2 = scope_factory(name="root2")
166+
sub1sub1 = scope_factory(parent=root1, name="sub1sub1")
167+
sub1sub2 = scope_factory(parent=root1, name="sub1sub2")
168+
sub1sub1sub1 = scope_factory(parent=sub1sub1, name="sub1sub1sub1")
169+
sub1sub1sub2 = scope_factory(parent=sub1sub1, name="sub1sub1sub2")
170+
171+
sub2sub1 = scope_factory(parent=root2, name="sub2sub1")
172+
sub2sub2 = scope_factory(parent=root2, name="sub2sub2")
173+
return {
174+
"root1": root1,
175+
"root2": root2,
176+
"sub1sub1": sub1sub1,
177+
"sub1sub2": sub1sub2,
178+
"sub1sub1sub1": sub1sub1sub1,
179+
"sub1sub1sub2": sub1sub1sub2,
180+
"sub2sub1": sub2sub1,
181+
"sub2sub2": sub2sub2,
182+
}
183+
184+
185+
@pytest.mark.parametrize(
186+
"include_self, expect_count",
187+
[
188+
(True, 5),
189+
(False, 3),
190+
],
191+
)
192+
def test_scope_ancestors(db, simple_tree_structure, include_self, expect_count):
193+
qs = Scope.objects.filter(
194+
pk__in=[
195+
simple_tree_structure["sub2sub2"].pk,
196+
simple_tree_structure["sub1sub1sub2"].pk,
197+
]
198+
)
199+
200+
ancestors_qs = qs.all_ancestors(include_self=include_self)
201+
# the direct and indirect ancestors must be there
202+
assert simple_tree_structure["root2"] in ancestors_qs
203+
assert simple_tree_structure["root1"] in ancestors_qs
204+
assert simple_tree_structure["sub1sub1"] in ancestors_qs
205+
206+
if include_self:
207+
assert simple_tree_structure["sub2sub2"] in ancestors_qs
208+
assert simple_tree_structure["sub1sub1sub2"] in ancestors_qs
209+
else:
210+
assert simple_tree_structure["sub2sub2"] not in ancestors_qs
211+
assert simple_tree_structure["sub1sub1sub2"] not in ancestors_qs
212+
213+
# ... and nothing else
214+
assert ancestors_qs.count() == expect_count
215+
216+
217+
@pytest.mark.parametrize(
218+
"include_self, expect_count",
219+
[
220+
(True, 6),
221+
(False, 4),
222+
],
223+
)
224+
def test_scope_descendants(db, simple_tree_structure, include_self, expect_count):
225+
qs = Scope.objects.filter(
226+
pk__in=[simple_tree_structure["sub1sub1"].pk, simple_tree_structure["root2"].pk]
227+
)
228+
229+
descendants_qs = qs.all_descendants(include_self=include_self)
230+
# the direct and indirect descendants must be there
231+
assert simple_tree_structure["sub1sub1sub1"] in descendants_qs
232+
assert simple_tree_structure["sub1sub1sub2"] in descendants_qs
233+
assert simple_tree_structure["sub2sub1"] in descendants_qs
234+
assert simple_tree_structure["sub2sub2"] in descendants_qs
235+
236+
if include_self:
237+
assert simple_tree_structure["sub1sub1"] in descendants_qs
238+
assert simple_tree_structure["root2"] in descendants_qs
239+
else:
240+
assert simple_tree_structure["sub1sub1"] not in descendants_qs
241+
assert simple_tree_structure["root2"] not in descendants_qs
242+
243+
# ... and nothing else
244+
assert descendants_qs.count() == expect_count
245+
246+
247+
def test_get_root(db, simple_tree_structure):
248+
assert (
249+
simple_tree_structure["sub1sub2"].get_root() == simple_tree_structure["root1"]
250+
)
251+
assert (
252+
simple_tree_structure["sub1sub1"].get_root() == simple_tree_structure["root1"]
253+
)
254+
assert (
255+
simple_tree_structure["sub2sub2"].get_root() == simple_tree_structure["root2"]
256+
)
257+
assert (
258+
simple_tree_structure["sub2sub1"].get_root() == simple_tree_structure["root2"]
259+
)
260+
assert (
261+
simple_tree_structure["sub1sub1sub2"].get_root()
262+
== simple_tree_structure["root1"]
263+
)
264+
265+
266+
def test_all_roots(db, simple_tree_structure):
267+
qs1 = Scope.objects.filter(
268+
pk__in=[
269+
simple_tree_structure["sub1sub1sub1"].pk,
270+
simple_tree_structure["sub1sub2"].pk,
271+
]
272+
).all_roots()
273+
assert qs1.count() == 1
274+
assert qs1.filter(pk=simple_tree_structure["root1"].pk).exists()
275+
276+
qs2 = Scope.objects.filter(
277+
pk__in=[
278+
simple_tree_structure["sub1sub1sub1"].pk,
279+
simple_tree_structure["sub2sub2"].pk,
280+
]
281+
).all_roots()
282+
assert qs2.count() == 2
283+
assert qs2.filter(pk=simple_tree_structure["root1"].pk).exists()
284+
assert qs2.filter(pk=simple_tree_structure["root2"].pk).exists()

emeis/core/tests/test_views.py

+22
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
HTTP_405_METHOD_NOT_ALLOWED,
1313
)
1414

15+
from emeis.core.serializers import ScopeSerializer
16+
1517

1618
def test_me_200(db, acl, client):
1719
client.force_authenticate(user=acl.user)
@@ -332,3 +334,23 @@ def test_sorted_scopes_when_forced_language(
332334
]
333335

334336
assert received_names == ["eins", "zwei"]
337+
338+
339+
@pytest.mark.parametrize(
340+
"has_parent, is_in_db, expect_level",
341+
[
342+
(False, False, 0),
343+
(False, True, 0),
344+
(True, False, 1),
345+
(True, True, 1),
346+
],
347+
)
348+
def test_serializer_level(db, scope_factory, has_parent, is_in_db, expect_level):
349+
scope = scope_factory(parent=scope_factory() if has_parent else None)
350+
if not is_in_db:
351+
scope.pk = None
352+
353+
ser = ScopeSerializer(instance=scope)
354+
355+
level = ser.data["level"]
356+
assert level == expect_level

0 commit comments

Comments
 (0)