Skip to content

Commit

Permalink
✨(teams) return parent teams in API
Browse files Browse the repository at this point in the history
Also return the parent teams in the user's team endpoints.

This is a first implementation which returns a flat list
of teams (not a tree). This is not really helpful, but
it allows to create hierarchical teams manually (via
admin) if an organization needs it.
  • Loading branch information
qbey committed Dec 18, 2024
1 parent e7d505c commit c1a4cdc
Show file tree
Hide file tree
Showing 6 changed files with 423 additions and 5 deletions.
43 changes: 39 additions & 4 deletions src/backend/core/api/client/viewsets.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"""API endpoints"""

import operator
from functools import reduce

from django.conf import settings
from django.db.models import OuterRef, Q, Subquery
from django.db.models import OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce

from rest_framework import (
decorators,
Expand Down Expand Up @@ -299,23 +303,54 @@ class TeamViewSet(
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering_fields = ["created_at", "name", "path"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
pagination_class = None

def get_queryset(self):
"""Custom queryset to get user related teams."""
teams_queryset = models.Team.objects.filter(
accesses__user=self.request.user,
)
depth_path = teams_queryset.values("depth", "path")

if not depth_path:
return models.Team.objects.none()

user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]

return (
models.Team.objects.prefetch_related("accesses", "service_providers")
.filter(
accesses__user=self.request.user,
reduce(
operator.or_,
(
Q(
# The team the user has access to
depth=d["depth"],
path=d["path"],
)
| Q(
# The parent team the user has access to
depth__lt=d["depth"],
path__startswith=d["path"][: models.Team.steplen],
organization_id=self.request.user.organization_id,
)
for d in depth_path
),
),
)
# Abilities are computed based on logged-in user's role for the team
# and if the user does not have access, it's ok to consider them as a member
# because it's a parent team.
.annotate(
user_role=Coalesce(
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
)
)
.annotate(user_role=Subquery(user_role_query))
)

def perform_create(self, serializer):
Expand Down
57 changes: 57 additions & 0 deletions src/backend/core/tests/teams/test_core_api_teams_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from rest_framework.test import APIClient

from core import factories, models
from core.models import Team

pytestmark = pytest.mark.django_db

Expand Down Expand Up @@ -113,3 +114,59 @@ def test_api_teams_delete_authenticated_owner():

assert response.status_code == HTTP_204_NO_CONTENT
assert models.Team.objects.exists() is False


@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_delete_authenticated_owner_parent_team(client, role):
"""
Authenticated users should not be able to delete a parent team they
don't own.
"""
user = factories.UserFactory()

client.force_login(user)

root_team = Team.objects.create(name="Root")
first_team = Team.objects.create(name="First", parent_id=root_team.pk)
second_team = Team.objects.create(name="Second", parent_id=first_team.pk)

# user is a member of the second team
factories.TeamAccessFactory(team=second_team, user=user, role=role)

response = client.delete(f"/api/v1.0/teams/{first_team.pk}/")

assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Team.objects.count() == 3


@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_delete_authenticated_owner_child_team(client, role):
"""
Authenticated users should not be able to delete a children team they
don't own.
"""
user = factories.UserFactory()

client.force_login(user)

root_team = Team.objects.create(name="Root")
first_team = Team.objects.create(name="First", parent_id=root_team.pk)
second_team = Team.objects.create(name="Second", parent_id=first_team.pk)

# user is a member of the first team
factories.TeamAccessFactory(team=first_team, user=user, role=role)

response = client.delete(f"/api/v1.0/teams/{second_team.pk}/")

assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
assert models.Team.objects.count() == 3
187 changes: 187 additions & 0 deletions src/backend/core/tests/teams/test_core_api_teams_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rest_framework.test import APIClient

from core import factories
from core.models import Team

pytestmark = pytest.mark.django_db

Expand Down Expand Up @@ -125,3 +126,189 @@ def test_api_teams_order_param():
assert (
response_team_ids == team_ids
), "created_at values are not sorted from oldest to newest"


@pytest.mark.parametrize(
"role,local_team_abilities",
[
(
"owner",
{
"delete": True,
"get": True,
"manage_accesses": True,
"patch": True,
"put": True,
},
),
(
"administrator",
{
"delete": False,
"get": True,
"manage_accesses": True,
"patch": True,
"put": True,
},
),
(
"member",
{
"delete": False,
"get": True,
"manage_accesses": False,
"patch": False,
"put": False,
},
),
],
)
def test_api_teams_list_authenticated_team_tree(client, role, local_team_abilities):
"""
Authenticated users should be able to list teams
they are an owner/administrator/member of, or any parent teams.
"""
user = factories.UserFactory()

client.force_login(user)

root_team = Team.objects.create(name="Root")
first_team = Team.objects.create(name="First", parent_id=root_team.pk)
second_team = Team.objects.create(name="Second", parent_id=first_team.pk)
third_team = Team.objects.create(name="Third", parent_id=second_team.pk)
_fourth_team = Team.objects.create(name="Fourth", parent_id=third_team.pk)

# user is a member of the second team
user_access = factories.TeamAccessFactory(team=second_team, user=user, role=role)

response = client.get("/api/v1.0/teams/")

assert response.status_code == HTTP_200_OK
# By default, the teams are sorted by 'created_at' descending
assert response.json() == [
{
# I have the abilities only on the team I have a specific role
"abilities": local_team_abilities,
"accesses": [str(user_access.pk)],
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"id": str(second_team.pk),
"name": "Second",
"service_providers": [],
"updated_at": second_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
# For parent teams, I only have the ability to list/retrieve
"abilities": {
"delete": False,
"get": True,
"manage_accesses": False,
"patch": False,
"put": False,
},
"accesses": [],
"created_at": first_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"id": str(first_team.pk),
"name": "First",
"service_providers": [],
"updated_at": first_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
# For parent teams, I only have the ability to list/retrieve
"abilities": {
"delete": False,
"get": True,
"manage_accesses": False,
"patch": False,
"put": False,
},
"accesses": [],
"created_at": root_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"id": str(root_team.pk),
"name": "Root",
"service_providers": [],
"updated_at": root_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
]


@pytest.mark.parametrize(
"role,local_team_abilities",
[
(
"owner",
{
"delete": True,
"get": True,
"manage_accesses": True,
"patch": True,
"put": True,
},
),
(
"administrator",
{
"delete": False,
"get": True,
"manage_accesses": True,
"patch": True,
"put": True,
},
),
(
"member",
{
"delete": False,
"get": True,
"manage_accesses": False,
"patch": False,
"put": False,
},
),
],
)
def test_api_teams_list_authenticated_team_different_organization(
client, role, local_team_abilities
):
"""
Authenticated users should be able to list teams they
are an owner/administrator/member of and any parent teams
only if from the same organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)

other_organization = factories.OrganizationFactory(with_registration_id=True)
root_team = Team.objects.create(name="Root", organization=other_organization)
first_team = Team.objects.create(
name="First", parent_id=root_team.pk, organization=other_organization
)
second_team = Team.objects.create(
name="Second", parent_id=first_team.pk, organization=other_organization
)
third_team = Team.objects.create(
name="Third", parent_id=second_team.pk, organization=other_organization
)
_fourth_team = Team.objects.create(
name="Fourth", parent_id=third_team.pk, organization=other_organization
)

client.force_login(user)

# user is a member of the second team
user_access = factories.TeamAccessFactory(team=second_team, user=user, role=role)

response = client.get("/api/v1.0/teams/")

assert response.status_code == HTTP_200_OK
assert response.json() == [
{
# I have the abilities only on the team I have a specific role
"abilities": local_team_abilities,
"accesses": [str(user_access.pk)],
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"id": str(second_team.pk),
"name": "Second",
"service_providers": [],
"updated_at": second_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
]
Loading

0 comments on commit c1a4cdc

Please sign in to comment.