✨(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:
@@ -1,7 +1,11 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
|
|
||||||
|
import operator
|
||||||
|
from functools import reduce
|
||||||
|
|
||||||
from django.conf import settings
|
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 (
|
from rest_framework import (
|
||||||
decorators,
|
decorators,
|
||||||
@@ -299,13 +303,21 @@ class TeamViewSet(
|
|||||||
permission_classes = [permissions.AccessPermission]
|
permission_classes = [permissions.AccessPermission]
|
||||||
serializer_class = serializers.TeamSerializer
|
serializer_class = serializers.TeamSerializer
|
||||||
filter_backends = [filters.OrderingFilter]
|
filter_backends = [filters.OrderingFilter]
|
||||||
ordering_fields = ["created_at"]
|
ordering_fields = ["created_at", "name", "path"]
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
queryset = models.Team.objects.all()
|
queryset = models.Team.objects.all()
|
||||||
pagination_class = None
|
pagination_class = None
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Custom queryset to get user related teams."""
|
"""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_role_query = models.TeamAccess.objects.filter(
|
||||||
user=self.request.user, team=OuterRef("pk")
|
user=self.request.user, team=OuterRef("pk")
|
||||||
).values("role")[:1]
|
).values("role")[:1]
|
||||||
@@ -313,9 +325,32 @@ class TeamViewSet(
|
|||||||
return (
|
return (
|
||||||
models.Team.objects.prefetch_related("accesses", "service_providers")
|
models.Team.objects.prefetch_related("accesses", "service_providers")
|
||||||
.filter(
|
.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):
|
def perform_create(self, serializer):
|
||||||
|
|||||||
@@ -113,3 +113,59 @@ def test_api_teams_delete_authenticated_owner():
|
|||||||
|
|
||||||
assert response.status_code == HTTP_204_NO_CONTENT
|
assert response.status_code == HTTP_204_NO_CONTENT
|
||||||
assert models.Team.objects.exists() is False
|
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
|
||||||
|
|||||||
@@ -125,3 +125,189 @@ def test_api_teams_order_param():
|
|||||||
assert (
|
assert (
|
||||||
response_team_ids == team_ids
|
response_team_ids == team_ids
|
||||||
), "created_at values are not sorted from oldest to newest"
|
), "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"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|||||||
@@ -74,3 +74,67 @@ def test_api_teams_retrieve_authenticated_related():
|
|||||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"service_providers": [],
|
"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."}
|
||||||
|
|||||||
@@ -219,3 +219,75 @@ def test_api_teams_update_authenticated_owners_add_service_providers():
|
|||||||
team.refresh_from_db()
|
team.refresh_from_db()
|
||||||
assert team.service_providers.count() == 2
|
assert team.service_providers.count() == 2
|
||||||
assert set(team.service_providers.all()) == {service_provider_1, service_provider_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"
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ def test_models_teams_get_abilities_preset_role(django_assert_num_queries):
|
|||||||
|
|
||||||
# test trees
|
# test trees
|
||||||
|
|
||||||
|
|
||||||
def test_models_teams_create_root_team():
|
def test_models_teams_create_root_team():
|
||||||
"""Create a root team."""
|
"""Create a root team."""
|
||||||
team = models.Team.add_root(name="Root Team")
|
team = models.Team.add_root(name="Root Team")
|
||||||
|
|||||||
Reference in New Issue
Block a user