Skip to content

Commit

Permalink
🗃️(teams) add Team dependencies as a tree
Browse files Browse the repository at this point in the history
This provides the technical way to create Team trees.
The implementation is quite naive.
  • Loading branch information
qbey committed Dec 5, 2024
1 parent 99b6181 commit 0d2b578
Show file tree
Hide file tree
Showing 8 changed files with 174 additions and 3 deletions.
1 change: 0 additions & 1 deletion src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ class TeamFactory(factory.django.DjangoModelFactory):

class Meta:
model = models.Team
django_get_or_create = ("name",)
skip_postgeneration_save = True

name = factory.Sequence(lambda n: f"team{n}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 5.1.3 on 2024-11-26 13:55

from django.db import migrations, models

from treebeard.mp_tree import MP_Node


def update_team_paths(apps, schema_editor):
Team = apps.get_model("core", "Team")
alphabet = MP_Node.alphabet
steplen = MP_Node.steplen

# for default values, this resolves to: "{:04d}"
path_format = f"{{:{alphabet[0]}{steplen}d}}"

nodes = Team.objects.all().order_by("created_at")
for i, node in enumerate(nodes, 1):
node.path = path_format.format(i)

if nodes:
Team.objects.bulk_update(nodes, ["path"])

class Migration(migrations.Migration):

dependencies = [
('core', '0008_change_user_profile_to_contact'),
]

operations = [
migrations.AddField(
model_name='team',
name='depth',
field=models.PositiveIntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='team',
name='numchild',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='team',
name='path',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
migrations.RunPython(update_team_paths, migrations.RunPython.noop),
migrations.AlterField(
model_name="team",
name="path",
field=models.CharField(max_length=255, unique=True),
),
]
32 changes: 31 additions & 1 deletion src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import jsonschema
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager

from core.enums import WebhookStatusChoices
from core.utils.webhooks import scim_synchronizer
Expand Down Expand Up @@ -602,7 +603,34 @@ def __str__(self):
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"


class Team(BaseModel):
class TeamManager(MP_NodeManager):
"""
Custom manager for the Team model, to manage complexity/automation.
"""

def create(self, parent_id=None, **kwargs):
"""
Replace the default create method to ease the Team creation process.
Notes:
- the `add_*` methods from django-treebeard does not support the "using db".
Which means it will always use the default db.
- the `add_*` methods from django-treebeard does not support the "force_insert".
"""
if parent_id is None:
return self.model.add_root(**kwargs)

# Retrieve parent object, because django-treebeard uses raw queries for most
# write operations, and raw queries don’t update the django objects of the db
# entries they modify. See caveats in the django-treebeard documentation.
# This might be changed later if we never do any operation on the parent object
# before creating the child.
# Beware the N+1 here.
return self.get(pk=parent_id).add_child(**kwargs)


class Team(MP_Node, BaseModel):
"""
Represents the link between teams and users, specifying the role a user has in a team.
Expand Down Expand Up @@ -633,6 +661,8 @@ class Team(BaseModel):
blank=True,
)

objects = TeamManager()

class Meta:
db_table = "people_team"
ordering = ("name",)
Expand Down
64 changes: 64 additions & 0 deletions src/backend/core/tests/test_models_teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,67 @@ def test_models_teams_get_abilities_preset_role(django_assert_num_queries):
"put": False,
"manage_accesses": False,
}


# test trees

def test_models_teams_create_root_team():
"""Create a root team."""
team = models.Team.add_root(name="Root Team")
assert team.is_root()
assert team.name == "Root Team"


def test_models_teams_create_child_team():
"""Create a child team."""
root_team = models.Team.add_root(name="Root Team")
child_team = root_team.add_child(name="Child Team")
assert child_team.is_child_of(root_team)
assert child_team.name == "Child Team"
assert child_team.get_parent() == root_team


def test_models_teams_create_grandchild_team():
"""Create a grandchild team."""
root_team = models.Team.add_root(name="Root Team")
child_team = root_team.add_child(name="Child Team")
grandchild_team = child_team.add_child(name="Grandchild Team")
assert grandchild_team.is_child_of(child_team)
assert grandchild_team.name == "Grandchild Team"
assert grandchild_team.get_parent() == child_team


def test_models_teams_move_team():
"""Move a team to another parent."""
root_team = models.Team.add_root(name="Root Team")
child_team = root_team.add_child(name="Child Team")
new_root_team = models.Team.add_root(name="New Root Team")
child_team.move(new_root_team, pos="first-child")
child_team.refresh_from_db()
assert child_team.get_parent(update=True) == new_root_team


def test_models_teams_delete_team():
"""
Delete a parent team also deletes children.
This might not be what we want, but it's the default behavior of treebeard.
"""
root_team = models.Team.add_root(name="Root Team")
root_team.add_child(name="Child Team")

assert models.Team.objects.all().count() == 2
root_team.delete()

assert models.Team.objects.all().count() == 0


def test_models_teams_manager_create():
"""Create a team using the manager."""
team = models.Team.objects.create(name="Team")
assert team.is_root()
assert team.name == "Team"

child_team = models.Team.objects.create(name="Child Team", parent_id=team.pk)
assert child_team.is_child_of(team)
assert child_team.name == "Child Team"
25 changes: 24 additions & 1 deletion src/backend/demo/management/commands/create_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from django.utils.text import slugify

from faker import Faker
from treebeard.mp_tree import MP_Node

from core import models

Expand Down Expand Up @@ -45,7 +46,29 @@ def _bulk_create(self, objects):
if not objects:
return

objects[0]._meta.model.objects.bulk_create(objects, ignore_conflicts=False) # noqa: SLF001
objects_model = objects[0]._meta.model # noqa: SLF001
if issubclass(objects_model, MP_Node):
# For treebeard models, we need to create the tree structure
# in a specific way. This is not perfect but it works for the
# current use case.
model_fields = objects_model._meta.concrete_fields # noqa: SLF001
bulk_data = [
{
"data": {
field.name: field.value_from_object(obj)
for field in model_fields
if field.value_from_object(obj)
}
}
for obj in objects
]
objects_model.load_bulk(bulk_data)
else:
objects_model.objects.bulk_create(
objects,
ignore_conflicts=False,
)

# In debug mode, Django keeps query cache which creates a memory leak in this case
db.reset_queries()
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
Expand Down
Binary file modified src/backend/locale/fr_FR/LC_MESSAGES/django.mo
Binary file not shown.
1 change: 1 addition & 0 deletions src/backend/people/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ class Base(Configuration):
"dockerflow.django",
"rest_framework",
"parler",
"treebeard",
"easy_thumbnails",
# Django
"django.contrib.auth",
Expand Down
1 change: 1 addition & 0 deletions src/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-parler==2.3",
"django-treebeard==4.7.1",
"redis==5.2.0",
"django-redis==5.4.0",
"django-storages==1.14.4",
Expand Down

0 comments on commit 0d2b578

Please sign in to comment.