✨(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"""
|
||||
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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."}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user