django-treebeard 5.2.2__tar.gz → 5.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/PKG-INFO +1 -1
  2. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/django_treebeard.egg-info/PKG-INFO +1 -1
  3. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/migrations/0001_initial.py +29 -0
  4. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/models.py +18 -6
  5. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/test_treebeard.py +72 -14
  6. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/__init__.py +1 -1
  7. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/al_tree.py +18 -1
  8. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/ltree/__init__.py +20 -3
  9. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/models.py +10 -5
  10. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/mp_tree.py +41 -17
  11. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/ns_tree.py +20 -3
  12. django_treebeard-5.3.0/treebeard/utils.py +38 -0
  13. django_treebeard-5.2.2/treebeard/utils.py +0 -46
  14. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/AUTHORS +0 -0
  15. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/LICENSE +0 -0
  16. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/LICENSE-THIRD-PARTY +0 -0
  17. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/MANIFEST.in +0 -0
  18. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/README.md +0 -0
  19. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/django_treebeard.egg-info/SOURCES.txt +0 -0
  20. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/django_treebeard.egg-info/dependency_links.txt +0 -0
  21. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/django_treebeard.egg-info/requires.txt +0 -0
  22. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/django_treebeard.egg-info/top_level.txt +0 -0
  23. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/Makefile +0 -0
  24. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/README.md +0 -0
  25. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/make.bat +0 -0
  26. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/_ext/djangodocs.py +0 -0
  27. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/_static/treebeard-admin-advanced.png +0 -0
  28. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/_static/treebeard-admin-basic.png +0 -0
  29. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/admin.rst +0 -0
  30. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/al_tree.rst +0 -0
  31. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/api.rst +0 -0
  32. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/caveats.rst +0 -0
  33. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/changes.rst +0 -0
  34. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/choosing.rst +0 -0
  35. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/conf.py +0 -0
  36. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/exceptions.rst +0 -0
  37. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/forms.rst +0 -0
  38. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/index.rst +0 -0
  39. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/install.rst +0 -0
  40. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/ltree.rst +0 -0
  41. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/mp_tree.rst +0 -0
  42. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/ns_tree.rst +0 -0
  43. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/tests.rst +0 -0
  44. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/docs/source/tutorial.rst +0 -0
  45. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/pyproject.toml +0 -0
  46. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/setup.cfg +0 -0
  47. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/__init__.py +0 -0
  48. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/admin.py +0 -0
  49. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/conftest.py +0 -0
  50. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/manage.py +0 -0
  51. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/migrations/__init__.py +0 -0
  52. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/settings.py +0 -0
  53. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/test_benchmarks.py +0 -0
  54. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/test_ltree_utils.py +0 -0
  55. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/test_migrations.py +0 -0
  56. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/test_numconv.py +0 -0
  57. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/tests/urls.py +0 -0
  58. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/admin.py +0 -0
  59. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/exceptions.py +0 -0
  60. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/forms.py +0 -0
  61. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/de/LC_MESSAGES/django.mo +0 -0
  62. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/de/LC_MESSAGES/django.po +0 -0
  63. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/de/LC_MESSAGES/djangojs.mo +0 -0
  64. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/de/LC_MESSAGES/djangojs.po +0 -0
  65. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/es/LC_MESSAGES/django.mo +0 -0
  66. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/es/LC_MESSAGES/django.po +0 -0
  67. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/es/LC_MESSAGES/djangojs.mo +0 -0
  68. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/es/LC_MESSAGES/djangojs.po +0 -0
  69. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/fr/LC_MESSAGES/django.mo +0 -0
  70. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/fr/LC_MESSAGES/django.po +0 -0
  71. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/fr/LC_MESSAGES/djangojs.mo +0 -0
  72. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/fr/LC_MESSAGES/djangojs.po +0 -0
  73. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/hu/LC_MESSAGES/django.mo +0 -0
  74. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/hu/LC_MESSAGES/django.po +0 -0
  75. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/hu/LC_MESSAGES/djangojs.mo +0 -0
  76. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/hu/LC_MESSAGES/djangojs.po +0 -0
  77. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/nl/LC_MESSAGES/django.mo +0 -0
  78. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/nl/LC_MESSAGES/django.po +0 -0
  79. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/nl/LC_MESSAGES/djangojs.mo +0 -0
  80. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/nl/LC_MESSAGES/djangojs.po +0 -0
  81. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/pl/LC_MESSAGES/django.mo +0 -0
  82. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/pl/LC_MESSAGES/django.po +0 -0
  83. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/ru/LC_MESSAGES/django.mo +0 -0
  84. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/ru/LC_MESSAGES/django.po +0 -0
  85. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/ru/LC_MESSAGES/djangojs.mo +0 -0
  86. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/locale/ru/LC_MESSAGES/djangojs.po +0 -0
  87. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/ltree/fields.py +0 -0
  88. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/numconv.py +0 -0
  89. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/static/treebeard/expand-collapse.png +0 -0
  90. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/static/treebeard/treebeard-admin.css +0 -0
  91. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/static/treebeard/treebeard-admin.js +0 -0
  92. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/templates/admin/tree_change_list.html +0 -0
  93. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/templates/admin/tree_list.html +0 -0
  94. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/templatetags/__init__.py +0 -0
  95. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/templatetags/admin_tree.py +0 -0
  96. {django_treebeard-5.2.2 → django_treebeard-5.3.0}/treebeard/templatetags/admin_tree_list.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-treebeard
3
- Version: 5.2.2
3
+ Version: 5.3.0
4
4
  Summary: Efficient tree implementations for Django
5
5
  Author-email: Gustavo Picon <tabo@tabo.pe>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-treebeard
3
- Version: 5.2.2
3
+ Version: 5.3.0
4
4
  Summary: Efficient tree implementations for Django
5
5
  Author-email: Gustavo Picon <tabo@tabo.pe>
6
6
  License-Expression: Apache-2.0
@@ -291,6 +291,7 @@ class Migration(migrations.Migration):
291
291
  ("depth", models.PositiveIntegerField(db_index=True)),
292
292
  ("desc", models.CharField(max_length=255)),
293
293
  ("related", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tests.relatedmodel")),
294
+ ("related_m2m", models.ManyToManyField(to="tests.relatedmodel", related_name="+")),
294
295
  ],
295
296
  options={
296
297
  "abstract": False,
@@ -312,6 +313,7 @@ class Migration(migrations.Migration):
312
313
  ("numchild", models.PositiveIntegerField(default=0)),
313
314
  ("desc", models.CharField(max_length=255)),
314
315
  ("related", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tests.relatedmodel")),
316
+ ("related_m2m", models.ManyToManyField(to="tests.relatedmodel", related_name="+")),
315
317
  ],
316
318
  options={
317
319
  "abstract": False,
@@ -361,6 +363,7 @@ class Migration(migrations.Migration):
361
363
  ),
362
364
  ),
363
365
  ("related", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tests.relatedmodel")),
366
+ ("related_m2m", models.ManyToManyField(to="tests.relatedmodel", related_name="+")),
364
367
  ],
365
368
  options={
366
369
  "abstract": False,
@@ -454,6 +457,32 @@ if os.environ.get("DATABASE_ENGINE") == "psql":
454
457
  ("node", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tests.lt_testnode")),
455
458
  ],
456
459
  ),
460
+ migrations.CreateModel(
461
+ name="LT_TestNodeRelated",
462
+ fields=[
463
+ (
464
+ "id",
465
+ models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
466
+ ),
467
+ ("path", treebeard.ltree.fields.PathField()),
468
+ ("desc", models.CharField(max_length=255)),
469
+ (
470
+ "related",
471
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tests.relatedmodel"),
472
+ ),
473
+ ("related_m2m", models.ManyToManyField(to="tests.relatedmodel", related_name="+")),
474
+ ],
475
+ options={
476
+ "abstract": False,
477
+ "constraints": [
478
+ models.UniqueConstraint(
479
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
480
+ fields=("path",),
481
+ name="tests_lt_testnoderelated_deferred_unique_path",
482
+ )
483
+ ],
484
+ },
485
+ ),
457
486
  migrations.CreateModel(
458
487
  name="LT_TestNode_Proxy",
459
488
  fields=[],
@@ -26,6 +26,18 @@ class DescMixin(models.Model):
26
26
  class RelatedModel(DescMixin): ...
27
27
 
28
28
 
29
+ class RelatedNodeMixin(models.Model):
30
+ """
31
+ Mixin for nodes with relationships (foreign key, m2m)
32
+ """
33
+
34
+ related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE)
35
+ related_m2m = models.ManyToManyField(RelatedModel, related_name="+")
36
+
37
+ class Meta:
38
+ abstract = True
39
+
40
+
29
41
  class MP_TestNode(MP_Node, DescMixin):
30
42
  steplen = 3
31
43
 
@@ -34,9 +46,8 @@ class MP_TestNodeSomeDep(models.Model):
34
46
  node = models.ForeignKey(MP_TestNode, on_delete=models.CASCADE)
35
47
 
36
48
 
37
- class MP_TestNodeRelated(MP_Node, DescMixin):
49
+ class MP_TestNodeRelated(MP_Node, DescMixin, RelatedNodeMixin):
38
50
  steplen = 3
39
- related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE)
40
51
 
41
52
 
42
53
  class MP_TestNodeInherited(MP_TestNode):
@@ -55,8 +66,7 @@ class NS_TestNodeSomeDep(models.Model):
55
66
  node = models.ForeignKey(NS_TestNode, on_delete=models.CASCADE)
56
67
 
57
68
 
58
- class NS_TestNodeRelated(NS_Node, DescMixin):
59
- related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE)
69
+ class NS_TestNodeRelated(NS_Node, DescMixin, RelatedNodeMixin): ...
60
70
 
61
71
 
62
72
  class NS_TestNodeInherited(NS_TestNode):
@@ -78,7 +88,7 @@ class AL_TestNodeSomeDep(models.Model):
78
88
  node = models.ForeignKey(AL_TestNode, on_delete=models.CASCADE)
79
89
 
80
90
 
81
- class AL_TestNodeRelated(AL_Node, DescMixin):
91
+ class AL_TestNodeRelated(AL_Node, DescMixin, RelatedNodeMixin):
82
92
  parent = models.ForeignKey(
83
93
  "self",
84
94
  related_name="children_set",
@@ -87,7 +97,6 @@ class AL_TestNodeRelated(AL_Node, DescMixin):
87
97
  on_delete=models.CASCADE,
88
98
  )
89
99
  sib_order = models.PositiveIntegerField()
90
- related = models.ForeignKey(RelatedModel, on_delete=models.CASCADE)
91
100
 
92
101
 
93
102
  class AL_TestNodeInherited(AL_TestNode):
@@ -232,6 +241,8 @@ if os.environ.get("DATABASE_ENGINE", "") == "psql":
232
241
  class Meta:
233
242
  constraints = [] # Override parent class constraints
234
243
 
244
+ class LT_TestNodeRelated(LT_Node, DescMixin, RelatedNodeMixin): ...
245
+
235
246
  class LT_TestNodeSomeDep(models.Model):
236
247
  node = models.ForeignKey(LT_TestNode, on_delete=models.CASCADE)
237
248
 
@@ -240,6 +251,7 @@ if os.environ.get("DATABASE_ENGINE", "") == "psql":
240
251
  DEP_MODELS.append((LT_TestNode, LT_TestNodeSomeDep))
241
252
  INHERITED_MODELS.append((LT_TestNode, LT_TestNodeInherited))
242
253
  SORTED_MODELS.append(LT_TestNodeSorted)
254
+ RELATED_MODELS.append(LT_TestNodeRelated)
243
255
  INHERITED_MODELS_WITH_SORT.append((LT_TestNodeSorted, LT_TestNodeInheritedSorted))
244
256
  LT_BASE_MODELS.append(LT_TestNode)
245
257
  BENCHMARK_MODELS.append(LT_TestNode)
@@ -7,12 +7,15 @@ from unittest import mock
7
7
  from unittest.mock import patch
8
8
 
9
9
  import pytest
10
+ from django.apps import apps
10
11
  from django.contrib.admin.options import TO_FIELD_VAR
11
12
  from django.contrib.admin.sites import AdminSite
12
13
  from django.contrib.admin.views.main import ChangeList
13
14
  from django.contrib.auth.models import AnonymousUser, User
14
15
  from django.contrib.messages.storage.fallback import FallbackStorage
16
+ from django.core.checks.model_checks import check_all_models
15
17
  from django.core.exceptions import PermissionDenied
18
+ from django.db.models import Manager
16
19
  from django.db.models.signals import post_save
17
20
  from django.dispatch import receiver
18
21
  from django.forms import ValidationError
@@ -118,6 +121,11 @@ def mp_model(request):
118
121
  return request.param
119
122
 
120
123
 
124
+ @pytest.fixture(scope="function", params=[models.MP_TestNodeRelated])
125
+ def mp_relatedmodel(request):
126
+ return request.param
127
+
128
+
121
129
  @pytest.fixture(scope="function", params=models.MP_SHORTPATH_MODELS)
122
130
  def mpshort_model(request):
123
131
  return request.param
@@ -463,32 +471,30 @@ class TestClassMethods(TestNonEmptyTree):
463
471
  got = [(o.desc, o.get_depth(), o.get_children_count()) for o in model.get_tree()]
464
472
  assert got == UNCHANGED
465
473
 
466
- def test_load_and_dump_bulk_with_fk(self, related_model):
467
- # https://bitbucket.org/tabo/django-treebeard/issue/48/
468
- related_model.objects.all().delete()
469
- related, _ = models.RelatedModel.objects.get_or_create(desc=f"Test {related_model.__name__}")
474
+ def test_load_and_dump_bulk_with_related_models(self, related_model):
475
+ related = models.RelatedModel.objects.create(desc=f"Test {related_model.__name__}")
470
476
 
471
477
  related_data = [
472
- {"data": {"desc": "1", "related": related.pk}},
478
+ {"data": {"desc": "1", "related": related.pk, "related_m2m": [related.pk]}},
473
479
  {
474
- "data": {"desc": "2", "related": related.pk},
480
+ "data": {"desc": "2", "related": related.pk, "related_m2m": []},
475
481
  "children": [
476
- {"data": {"desc": "21", "related": related.pk}},
477
- {"data": {"desc": "22", "related": related.pk}},
482
+ {"data": {"desc": "21", "related": related.pk, "related_m2m": [related.pk]}},
483
+ {"data": {"desc": "22", "related": related.pk, "related_m2m": [related.pk]}},
478
484
  {
479
- "data": {"desc": "23", "related": related.pk},
485
+ "data": {"desc": "23", "related": related.pk, "related_m2m": []},
480
486
  "children": [
481
- {"data": {"desc": "231", "related": related.pk}},
487
+ {"data": {"desc": "231", "related": related.pk, "related_m2m": [related.pk]}},
482
488
  ],
483
489
  },
484
- {"data": {"desc": "24", "related": related.pk}},
490
+ {"data": {"desc": "24", "related": related.pk, "related_m2m": []}},
485
491
  ],
486
492
  },
487
- {"data": {"desc": "3", "related": related.pk}},
493
+ {"data": {"desc": "3", "related": related.pk, "related_m2m": []}},
488
494
  {
489
- "data": {"desc": "4", "related": related.pk},
495
+ "data": {"desc": "4", "related": related.pk, "related_m2m": []},
490
496
  "children": [
491
- {"data": {"desc": "41", "related": related.pk}},
497
+ {"data": {"desc": "41", "related": related.pk, "related_m2m": []}},
492
498
  ],
493
499
  },
494
500
  ]
@@ -1752,6 +1758,15 @@ class TestDelete(TestTreeBase):
1752
1758
  ("nodes_deleted", delete_model, ["A", "B", "C", "D"], "default"),
1753
1759
  ]
1754
1760
 
1761
+ def test_delete_with_prefetch_related(self, related_model):
1762
+ # Regression test for https://github.com/django-treebeard/django-treebeard/issues/405
1763
+ # If `delete()` is run on a queryset with `prefetch_related()` set, then Treebeard's use
1764
+ # of `iterator()` will throw an exception unless the prefetch is cleared.
1765
+ related = models.RelatedModel.objects.create(desc=f"Test {related_model.__name__}")
1766
+ related_model.add_root(desc="A", related=related)
1767
+ num_deleted, _ = related_model.objects.prefetch_related("related_m2m").all().delete()
1768
+ assert num_deleted == 1
1769
+
1755
1770
 
1756
1771
  @pytest.mark.django_db
1757
1772
  class TestMoveErrors(TestNonEmptyTree):
@@ -3567,6 +3582,37 @@ class TestMP_TreeLoadBulk(TestTreeBase):
3567
3582
  got = [(o.desc, o.get_depth(), o.get_children_count()) for o in mp_model.get_tree()]
3568
3583
  assert got == UNCHANGED
3569
3584
 
3585
+ def test_load_bulk_keeping_ids_with_bulk_create_and_many_to_many(self, mp_relatedmodel):
3586
+ related = models.RelatedModel.objects.create(desc=f"Test {related_model.__name__}")
3587
+
3588
+ related_data = [
3589
+ {"data": {"desc": "1", "related": related.pk, "related_m2m": [related.pk]}},
3590
+ {
3591
+ "data": {"desc": "2", "related": related.pk, "related_m2m": []},
3592
+ "children": [
3593
+ {"data": {"desc": "21", "related": related.pk, "related_m2m": [related.pk]}},
3594
+ {"data": {"desc": "22", "related": related.pk, "related_m2m": [related.pk]}},
3595
+ {
3596
+ "data": {"desc": "23", "related": related.pk, "related_m2m": []},
3597
+ "children": [
3598
+ {"data": {"desc": "231", "related": related.pk, "related_m2m": []}},
3599
+ ],
3600
+ },
3601
+ {"data": {"desc": "24", "related": related.pk, "related_m2m": []}},
3602
+ ],
3603
+ },
3604
+ {"data": {"desc": "3", "related": related.pk, "related_m2m": []}},
3605
+ {
3606
+ "data": {"desc": "4", "related": related.pk, "related_m2m": []},
3607
+ "children": [
3608
+ {"data": {"desc": "41", "related": related.pk, "related_m2m": []}},
3609
+ ],
3610
+ },
3611
+ ]
3612
+ mp_relatedmodel.load_bulk(related_data, bulk_create=True)
3613
+ got = mp_relatedmodel.dump_bulk(keep_ids=False)
3614
+ assert got == related_data
3615
+
3570
3616
 
3571
3617
  @pytest.mark.django_db
3572
3618
  class TestMP_TreeSortedAutoNow(TestTreeBase):
@@ -4709,3 +4755,15 @@ class TestLT_Insertion(TestTreeBase):
4709
4755
  assert signals == [
4710
4756
  ("subtree_moved_right", lt_model, "A.0", "default"),
4711
4757
  ]
4758
+
4759
+
4760
+ @pytest.mark.django_db
4761
+ class TestChecks:
4762
+ def test_checks_warning_if_model_manager_doesnt_subclass_treebeard_manager(self, model, monkeypatch):
4763
+ configs = [apps.get_app_config("tests")]
4764
+ assert not check_all_models(configs)
4765
+ # Monkey-patch default manager
4766
+ monkeypatch.setattr(model._meta, "default_manager", Manager())
4767
+ errors = check_all_models(configs)
4768
+ assert len(errors) == 1
4769
+ assert "does not subclass treebeard" in errors[0].msg
@@ -18,4 +18,4 @@ Release logic:
18
18
  14. git push
19
19
  """
20
20
 
21
- __version__ = "5.2.2"
21
+ __version__ = "5.3.0"
@@ -1,6 +1,6 @@
1
1
  """Adjacency List"""
2
2
 
3
- from django.core import serializers
3
+ from django.core import checks, serializers
4
4
  from django.db import models, transaction
5
5
  from django.db.models import Exists, Max, Min, OuterRef
6
6
  from django.utils.translation import gettext_noop as _
@@ -353,6 +353,23 @@ class AL_Node(Node):
353
353
 
354
354
  self.save()
355
355
 
356
+ @classmethod
357
+ def check(cls, **kwargs):
358
+ errors = super().check(**kwargs)
359
+ manager_cls = cls._default_manager.__class__
360
+ # Raise a warning if the default manager for the model doesn't subclass AL_NodeManager
361
+ # This will allow us to move class-level methods into the manager in future (see issue #44)
362
+ if not issubclass(manager_cls, AL_NodeManager):
363
+ errors.append(
364
+ checks.Warning(
365
+ f"{manager_cls.__module__}.{manager_cls.__name__} does not subclass "
366
+ "treebeard.al_tree.AL_NodeManager. This will cause an error in Treebeard 6.",
367
+ obj=manager_cls,
368
+ id="treebeard.E001",
369
+ )
370
+ )
371
+ return errors
372
+
356
373
  class Meta:
357
374
  """Abstract model."""
358
375
 
@@ -7,7 +7,7 @@ import string
7
7
  from collections.abc import Iterable
8
8
  from typing import Any
9
9
 
10
- from django.core import serializers
10
+ from django.core import checks, serializers
11
11
  from django.db import models, transaction
12
12
  from django.db.models import F, Func, OuterRef, Q, Subquery, Value
13
13
  from django.db.models.functions import Concat
@@ -106,7 +106,7 @@ class LT_NodeQuerySet(models.query.QuerySet):
106
106
  """
107
107
  # Construct the minimal list of nodes that need deleting along with their descendants
108
108
  paths_to_remove = set()
109
- for node in self.order_by("path").only("path").iterator():
109
+ for node in self.order_by("path").only("path").prefetch_related(None).iterator():
110
110
  found = False
111
111
  for depth in range(1, len(node.path)):
112
112
  path = node.path[0:depth]
@@ -136,7 +136,7 @@ class LT_NodeManager(models.Manager):
136
136
 
137
137
  def get_queryset(self):
138
138
  """Sets the custom queryset as the default."""
139
- return LT_NodeQuerySet(self.model).order_by("path")
139
+ return LT_NodeQuerySet(self.model, using=self._db).order_by("path")
140
140
 
141
141
 
142
142
  class LT_ComplexAddMoveHandler:
@@ -657,6 +657,23 @@ class LT_Node(Node):
657
657
  """
658
658
  return LT_MoveHandler(self, target, pos).process()
659
659
 
660
+ @classmethod
661
+ def check(cls, **kwargs):
662
+ errors = super().check(**kwargs)
663
+ manager_cls = cls._default_manager.__class__
664
+ # Raise a warning if the default manager for the model doesn't subclass LT_NodeManager
665
+ # This will allow us to move class-level methods into the manager in future (see issue #44)
666
+ if not issubclass(manager_cls, LT_NodeManager):
667
+ errors.append(
668
+ checks.Warning(
669
+ f"{manager_cls.__module__}.{manager_cls.__name__} does not subclass "
670
+ "treebeard.ltree.LT_NodeManager. This will cause an error in Treebeard 6.",
671
+ obj=manager_cls,
672
+ id="treebeard.E001",
673
+ )
674
+ )
675
+ return errors
676
+
660
677
  class Meta:
661
678
  abstract = True
662
679
  constraints = [
@@ -9,7 +9,7 @@ from django.db import models, transaction
9
9
  from django.db.models import Q
10
10
 
11
11
  from treebeard.exceptions import InvalidPosition, MissingNodeOrderBy
12
- from treebeard.utils import prepare_dumpdata_for_loading
12
+ from treebeard.utils import prepare_dumpdata_for_loading, save_m2m
13
13
 
14
14
 
15
15
  class Node(models.Model):
@@ -80,14 +80,19 @@ class Node(models.Model):
80
80
  added = []
81
81
  bulk_data = prepare_dumpdata_for_loading(cls, data=bulk_data, keep_ids=keep_ids)
82
82
  # stack of nodes to analyze
83
- stack = [(parent, node) for node in bulk_data[::-1]]
83
+ stack = [(parent, deserialized_obj) for deserialized_obj in bulk_data[::-1]]
84
84
 
85
85
  while stack:
86
- parent, node_struct = stack.pop()
87
- node_obj = parent.add_child(**node_struct["data"]) if parent else cls.add_root(**node_struct["data"])
86
+ parent, deserialized_obj = stack.pop()
87
+ node_obj = deserialized_obj.object = (
88
+ parent.add_child(instance=deserialized_obj.object)
89
+ if parent
90
+ else cls.add_root(instance=deserialized_obj.object)
91
+ )
92
+ save_m2m(node_obj, deserialized_obj)
88
93
  added.append(node_obj.pk)
89
94
  # extending the stack with the current node as the parent of the new nodes
90
- stack.extend([(node_obj, node) for node in node_struct["children"][::-1]])
95
+ stack.extend([(node_obj, child) for child in deserialized_obj.children[::-1]])
91
96
  return added
92
97
 
93
98
  @classmethod
@@ -4,7 +4,7 @@ import collections
4
4
  from functools import cache
5
5
  from typing import Any
6
6
 
7
- from django.core import serializers
7
+ from django.core import checks, serializers
8
8
  from django.db import connections, models, router, transaction
9
9
  from django.db.models import F, Func, OuterRef, Q, Subquery, Value
10
10
  from django.db.models.functions import Concat, Greatest, Length, Substr
@@ -14,7 +14,7 @@ from django.utils.translation import gettext_noop as _
14
14
  from treebeard.exceptions import InvalidMoveToDescendant, NodeAlreadySaved, PathOverflow
15
15
  from treebeard.models import Node
16
16
  from treebeard.numconv import NumConv
17
- from treebeard.utils import prepare_dumpdata_for_loading
17
+ from treebeard.utils import prepare_dumpdata_for_loading, save_m2m
18
18
 
19
19
  path_updated = Signal()
20
20
  nodes_deleted = Signal()
@@ -39,7 +39,7 @@ class MP_NodeQuerySet(models.query.QuerySet):
39
39
  # to be deleted and remove nodes from the list if an ancestor is
40
40
  # already getting removed, since that would be redundant
41
41
  removed = {}
42
- for node in self.order_by("depth", "path").only("path", "depth", "numchild").iterator():
42
+ for node in self.order_by("depth", "path").only("path", "depth", "numchild").prefetch_related(None).iterator():
43
43
  found = False
44
44
  for depth in range(1, int(len(node.path) / node.steplen)):
45
45
  path = node._get_basepath(node.path, depth)
@@ -92,7 +92,7 @@ class MP_NodeManager(models.Manager):
92
92
 
93
93
  def get_queryset(self):
94
94
  """Sets the custom queryset as the default."""
95
- return MP_NodeQuerySet(self.model).order_by("path")
95
+ return MP_NodeQuerySet(self.model, using=self._db).order_by("path")
96
96
 
97
97
 
98
98
  class MP_ComplexAddMoveHandler:
@@ -1103,31 +1103,55 @@ class MP_Node(Node):
1103
1103
  child_depth = parent_node.depth + 1
1104
1104
 
1105
1105
  for i, child in enumerate(children):
1106
- child_obj = cls(
1107
- depth=child_depth,
1108
- numchild=len(child["children"]),
1109
- path=cls._get_path(parent_node.path, child_depth, i + 1),
1110
- **child["data"],
1111
- )
1106
+ child.object.depth = child_depth
1107
+ child.object.numchild = len(child.children)
1108
+ child.object.path = cls._get_path(parent_node.path, child_depth, i + 1)
1112
1109
 
1113
- children_to_create.append(child_obj)
1110
+ children_to_create.append(child)
1114
1111
 
1115
1112
  # Recursively process grandchildren
1116
- _build_children(child_obj, child["children"])
1113
+ _build_children(child.object, child.children)
1117
1114
 
1118
1115
  # Create first level of the bulk data using standard operations, since there may be existing siblings
1119
- for node_struct in bulk_data:
1120
- node_struct["data"]["numchild"] = len(node_struct["children"]) # Set numchild manually
1121
- node_obj = parent.add_child(**node_struct["data"]) if parent else cls.add_root(**node_struct["data"])
1116
+ for deserialized_obj in bulk_data:
1117
+ deserialized_obj.object.numchild = len(deserialized_obj.children) # Set numchild manually
1118
+ node_obj = (
1119
+ parent.add_child(instance=deserialized_obj.object)
1120
+ if parent
1121
+ else cls.add_root(instance=deserialized_obj.object)
1122
+ )
1123
+ save_m2m(node_obj, deserialized_obj)
1122
1124
  added.append(node_obj.pk)
1123
- _build_children(node_obj, node_struct["children"])
1125
+ _build_children(node_obj, deserialized_obj.children)
1124
1126
 
1125
1127
  # Bulk create descendants
1126
- created = cls.objects.bulk_create(children_to_create, batch_size=batch_size)
1128
+ created = cls.objects.bulk_create([obj.object for obj in children_to_create], batch_size=batch_size)
1129
+
1130
+ # Save m2m relationships
1131
+ for obj, source in zip(created, children_to_create):
1132
+ save_m2m(obj, source)
1133
+
1127
1134
  added.extend([obj.pk for obj in created])
1128
1135
 
1129
1136
  return added
1130
1137
 
1138
+ @classmethod
1139
+ def check(cls, **kwargs):
1140
+ errors = super().check(**kwargs)
1141
+ manager_cls = cls._default_manager.__class__
1142
+ # Raise a warning if the default manager for the model doesn't subclass MP_NodeManager
1143
+ # This will allow us to move class-level methods into the manager in future (see issue #44)
1144
+ if not issubclass(manager_cls, MP_NodeManager):
1145
+ errors.append(
1146
+ checks.Warning(
1147
+ f"{manager_cls.__module__}.{manager_cls.__name__} does not subclass "
1148
+ "treebeard.mp_tree.MP_NodeManager. This will cause an error in Treebeard 6.",
1149
+ obj=manager_cls,
1150
+ id="treebeard.E001",
1151
+ )
1152
+ )
1153
+ return errors
1154
+
1131
1155
  class Meta:
1132
1156
  """Abstract model."""
1133
1157
 
@@ -4,7 +4,7 @@ import operator
4
4
  from functools import reduce
5
5
  from itertools import groupby
6
6
 
7
- from django.core import serializers
7
+ from django.core import checks, serializers
8
8
  from django.db import models, transaction
9
9
  from django.db.models import Case, F, Q, When
10
10
  from django.dispatch import Signal
@@ -39,7 +39,7 @@ class NS_NodeQuerySet(models.query.QuerySet):
39
39
  last_node = None
40
40
  toremove = []
41
41
  ranges = []
42
- for node in self.order_by("tree_id", "lft").values("tree_id", "lft", "rgt").iterator():
42
+ for node in self.order_by("tree_id", "lft").values("tree_id", "lft", "rgt").prefetch_related(None).iterator():
43
43
  if (
44
44
  last_node
45
45
  and last_node["tree_id"] == node["tree_id"]
@@ -80,7 +80,7 @@ class NS_NodeManager(models.Manager):
80
80
 
81
81
  def get_queryset(self):
82
82
  """Sets the custom queryset as the default."""
83
- return NS_NodeQuerySet(self.model).order_by("tree_id", "lft")
83
+ return NS_NodeQuerySet(self.model, using=self._db).order_by("tree_id", "lft")
84
84
 
85
85
 
86
86
  class NS_Node(Node):
@@ -638,6 +638,23 @@ class NS_Node(Node):
638
638
 
639
639
  return reversed_lft_rgt, overlapping_nodes, duplicate_tree_ids, wrong_depth
640
640
 
641
+ @classmethod
642
+ def check(cls, **kwargs):
643
+ errors = super().check(**kwargs)
644
+ manager_cls = cls._default_manager.__class__
645
+ # Raise a warning if the default manager for the model doesn't subclass NS_NodeManager
646
+ # This will allow us to move class-level methods into the manager in future (see issue #44)
647
+ if not issubclass(manager_cls, NS_NodeManager):
648
+ errors.append(
649
+ checks.Warning(
650
+ f"{manager_cls.__module__}.{manager_cls.__name__} does not subclass "
651
+ "treebeard.ns_tree.NS_NodeManager. This will cause an error in Treebeard 6.",
652
+ obj=manager_cls,
653
+ id="treebeard.E001",
654
+ )
655
+ )
656
+ return errors
657
+
641
658
  class Meta:
642
659
  """Abstract model."""
643
660
 
@@ -0,0 +1,38 @@
1
+ from typing import Any, TypedDict
2
+
3
+ from django.core import serializers
4
+ from django.core.serializers.base import DeserializedObject
5
+ from django.db import models
6
+
7
+
8
+ class DumpData(TypedDict):
9
+ data: dict[str, Any]
10
+ children: list["DumpData"] # TODO: This should really be NotRequired. Add when Python 3.10 support is dropped
11
+
12
+
13
+ def prepare_dumpdata_for_loading(
14
+ cls: type[models.Model], *, data: list[DumpData], keep_ids: bool
15
+ ) -> list[DeserializedObject]:
16
+ """
17
+ Given data previously dumped using dump_data, prepares a DeserializedObject for use with load_data.
18
+ """
19
+ pk_field = cls._meta.pk.attname
20
+ model_identifier = str(cls._meta)
21
+ output = []
22
+ for item in data:
23
+ obj = {"fields": item["data"], "model": model_identifier, "pk": item[pk_field] if keep_ids else None}
24
+ deserialized_obj = next(serializers.deserialize("python", [obj]))
25
+ deserialized_obj.children = prepare_dumpdata_for_loading(cls, data=item.get("children", []), keep_ids=keep_ids)
26
+ output.append(deserialized_obj)
27
+
28
+ return output
29
+
30
+
31
+ def save_m2m(node: models.Model, deserialized_obj: DeserializedObject):
32
+ """
33
+ Saves m2m relationships stored on a DeserializedObject.
34
+ """
35
+ if deserialized_obj.m2m_data:
36
+ for accessor_name, object_list in deserialized_obj.m2m_data.items():
37
+ getattr(node, accessor_name).set(object_list)
38
+ deserialized_obj.m2m_data = None # Avoid accidental reuse of m2m_data if save() is called on the object
@@ -1,46 +0,0 @@
1
- from copy import deepcopy
2
- from functools import cache
3
- from typing import Any, TypedDict
4
-
5
- from django.db import models
6
-
7
-
8
- class DumpData(TypedDict):
9
- data: dict[str, Any]
10
- children: list["DumpData"] # TODO: This should really be NotRequired. Add when Python 3.10 support is dropped
11
-
12
-
13
- class PreparedDumpData(DumpData):
14
- children: list["DumpData"]
15
- pk: Any # TODO: This should really be NotRequired. Add when Python 3.10 support is dropped
16
-
17
-
18
- @cache
19
- def get_foreign_key_fields(cls: type[models.Model]) -> set[str]:
20
- return {field.name for field in cls._meta.fields if (field.one_to_one or field.many_to_one)}
21
-
22
-
23
- def prepare_dumpdata_for_loading(
24
- cls: type[models.Model], *, data: list[DumpData], keep_ids: bool
25
- ) -> list[PreparedDumpData]:
26
- """
27
- Given data previously dumped using dump_data, prepares the data for use with load_data.
28
-
29
- - Modifies foreign key field names to append an `_id` suffix
30
- - Adds a pk field if `keep_ids` is True.
31
- """
32
- foreign_key_fields = get_foreign_key_fields(cls)
33
- pk_field = cls._meta.pk.attname
34
- output = []
35
- for item in data:
36
- prepared = deepcopy(item)
37
- for field in foreign_key_fields:
38
- # Append _id to field name, so that we don't need to load the foreign objects into memory
39
- prepared["data"][f"{field}_id"] = prepared["data"].pop(field, None)
40
- if keep_ids:
41
- prepared["data"]["pk"] = item[pk_field]
42
-
43
- prepared["children"] = prepare_dumpdata_for_loading(cls, data=item.get("children", []), keep_ids=keep_ids)
44
- output.append(prepared)
45
-
46
- return output