(teams) return parent teams in API

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.
This commit is contained in:
Quentin BEY
2024-12-12 18:08:15 +01:00
committed by BEY Quentin
parent 182f9c1d17
commit 201864db3a
6 changed files with 418 additions and 4 deletions

View File

@@ -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,
@@ -299,13 +303,21 @@ 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]
@@ -313,9 +325,32 @@ class TeamViewSet(
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):

View File

@@ -113,3 +113,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 = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(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 = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(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

View File

@@ -125,3 +125,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 = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
third_team = factories.TeamFactory(name="Third", parent_id=second_team.pk)
_fourth_team = factories.TeamFactory(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 = factories.TeamFactory(name="Root", organization=other_organization)
first_team = factories.TeamFactory(
name="First", parent_id=root_team.pk, organization=other_organization
)
second_team = factories.TeamFactory(
name="Second", parent_id=first_team.pk, organization=other_organization
)
third_team = factories.TeamFactory(
name="Third", parent_id=second_team.pk, organization=other_organization
)
_fourth_team = factories.TeamFactory(
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"),
},
]

View File

@@ -74,3 +74,67 @@ def test_api_teams_retrieve_authenticated_related():
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
"service_providers": [],
}
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_retrieve_authenticated_related_parent(client, role):
"""
Authenticated users should be allowed to retrieve a parent team
to which they are related through the child team whatever the role.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(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.get(f"/api/v1.0/teams/{first_team.pk!s}/")
# the abilities enforces the "get" via the queryset
abilities = first_team.get_abilities(user)
abilities["get"] = True
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(first_team.pk),
"name": first_team.name,
"abilities": abilities,
"accesses": [],
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": first_team.updated_at.isoformat().replace("+00:00", "Z"),
"service_providers": [],
}
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_retrieve_authenticated_related_children(client, role):
"""
Authenticated users should NOT be allowed to retrieve a child team
to which they are related through the parent team whatever the role.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(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.get(f"/api/v1.0/teams/{second_team.pk!s}/")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}

View File

@@ -219,3 +219,75 @@ def test_api_teams_update_authenticated_owners_add_service_providers():
team.refresh_from_db()
assert team.service_providers.count() == 2
assert set(team.service_providers.all()) == {service_provider_1, service_provider_2}
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_update_whatever_access_of_child_team(client, role):
"""
Being member, administrator or owner of a team should not grant
authorization to update a parent team.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(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.patch(
f"/api/v1.0/teams/{first_team.pk}/",
{
"name": "New name",
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
first_team.refresh_from_db()
assert first_team.name == "First"
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_update_whatever_access_of_parent_team(client, role):
"""
Being member, administrator or owner of a team should not grant
authorization to update a child team.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(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.patch(
f"/api/v1.0/teams/{second_team.pk}/",
{
"name": "New name",
},
format="json",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
second_team.refresh_from_db()
assert second_team.name == "Second"

View File

@@ -138,6 +138,7 @@ def test_models_teams_get_abilities_preset_role(django_assert_num_queries):
# test trees
def test_models_teams_create_root_team():
"""Create a root team."""
team = models.Team.add_root(name="Root Team")