Skip to content

Commit

Permalink
Make team-related Cyberstorm endpoints support required functionality
Browse files Browse the repository at this point in the history
Instead of one endpoint for all team information, use separate
endpoints for team's basic information, members, and service accounts.
On the client side these are used in separate tabs, so this works
better with Suspenses, reduces passing props around and avoids
overfetching.

The basic information ("details") is public information, while the
members and service accounts are intended only for team members.

The actual information returned was changed to reflect what's actually
needed on the client.

When checking if the requested team exists, the name is compared case
insensitively. I assume this is the correct approach since creation of
teams with names that differ only in case is prohibited.

Refs TS-1856
  • Loading branch information
anttimaki committed Oct 23, 2023
1 parent 762d535 commit 8637060
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 50 deletions.
14 changes: 12 additions & 2 deletions django/thunderstore/api/cyberstorm/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
from .community import CyberstormCommunitySerializer
from .team import CyberstormTeamMemberSerializer, CyberstormTeamSerializer
from .user import CyberstormUserSerializer
from .team import (
CyberstormServiceAccountSerializer,
CyberstormTeamMemberSerializer,
CyberstormTeamSerializer,
)

__all__ = [
"CyberstormCommunitySerializer",
"CyberstormServiceAccountSerializer",
"CyberstormTeamMemberSerializer",
"CyberstormTeamSerializer",
]
31 changes: 24 additions & 7 deletions django/thunderstore/api/cyberstorm/serializers/team.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
from typing import Optional

from rest_framework import serializers

from .user import CyberstormUserSerializer
from thunderstore.social.utils import get_avatar_url


class CyberstormTeamSerializer(serializers.Serializer):
"""
This is for team's public profile and readably by anyone. Don't add
any sensitive information here.
"""

identifier = serializers.IntegerField(source="id")
name = serializers.CharField()
donation_link = serializers.CharField(required=False)


class CyberstormTeamMemberSerializer(serializers.Serializer):
user = CyberstormUserSerializer()
identifier = serializers.IntegerField(source="user.id")
username = serializers.CharField(source="user.username")
avatar = serializers.SerializerMethodField()
role = serializers.CharField()

def get_avatar(self, obj) -> Optional[str]:
return get_avatar_url(obj.user)

class CyberstormTeamSerializer(serializers.Serializer):
identifier = serializers.CharField(source="id")
name = serializers.CharField()
members = CyberstormTeamMemberSerializer(many=True)
donation_link = serializers.CharField(required=False)

class CyberstormServiceAccountSerializer(serializers.Serializer):
identifier = serializers.CharField(source="uuid")
name = serializers.CharField(source="user.first_name")
last_used = serializers.DateTimeField()
6 changes: 0 additions & 6 deletions django/thunderstore/api/cyberstorm/serializers/user.py

This file was deleted.

173 changes: 150 additions & 23 deletions django/thunderstore/api/cyberstorm/tests/test_team.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,169 @@
import pytest
from rest_framework.test import APIClient

from thunderstore.community.models import CommunitySite
from thunderstore.repository.models.team import Team, TeamMember
from thunderstore.account.factories import ServiceAccountFactory
from thunderstore.core.types import UserType
from thunderstore.repository.factories import TeamFactory, TeamMemberFactory
from thunderstore.repository.models.team import Team


@pytest.mark.django_db
def test_api_cyberstorm_team_detail_success(
client: APIClient,
community_site: CommunitySite,
def test_team_detail_api_view__for_active_team__returns_data(
api_client: APIClient,
team: Team,
team_member: TeamMember,
):
response = client.get(
f"/api/cyberstorm/team/{team.name}/",
HTTP_HOST=community_site.site.domain,
)
response = api_client.get(f"/api/cyberstorm/team/{team.name}/")
result = response.json()

assert response.status_code == 200
assert team.name == result["name"]
assert team.donation_link == result["donation_link"]

members = team.members.all()
assert len(members) == 1
assert len(members) == len(result["members"])

member = members.first()
assert result["members"][0]["role"] == member.role
assert result["members"][0]["user"]["identifier"] == str(member.user.pk)
assert result["members"][0]["user"]["username"] == member.user.username
@pytest.mark.django_db
def test_team_detail_api_view__for_nonexisting_team__returns_404(api_client: APIClient):
response = api_client.get("/api/cyberstorm/team/bad/")

assert response.status_code == 404


@pytest.mark.django_db
def test_api_cyberstorm_team_detail_failure(
client: APIClient, community_site: CommunitySite
def test_team_detail_api_view__when_fetching_team__is_case_insensitive(
api_client: APIClient,
):
response = client.get(
f"/api/cyberstorm/team/bad/",
HTTP_HOST=community_site.site.domain,
)
TeamFactory(name="RaDTeAm")

response = api_client.get("/api/cyberstorm/team/radteam/")

assert response.status_code == 200


@pytest.mark.django_db
def test_team_detail_api_view__for_inactive_team__returns_404(
api_client: APIClient,
team: Team,
):
team.is_active = False
team.save()

response = api_client.get(f"/api/cyberstorm/team/{team.name}/")

assert response.status_code == 404


@pytest.mark.django_db
def test_team_membership_permission__for_unauthenticated_user__returns_401(
api_client: APIClient,
team: Team,
):
response = api_client.get(f"/api/cyberstorm/team/{team.name}/members/")

assert response.status_code == 401


@pytest.mark.django_db
def test_team_membership_permission__for_nonexisting_team__returns_404(
api_client: APIClient,
user: UserType,
):
api_client.force_authenticate(user)

response = api_client.get("/api/cyberstorm/team/bad/members/")

assert response.status_code == 404


@pytest.mark.django_db
def test_team_membership_permission__for_inactive_team__returns_404(
api_client: APIClient,
team: Team,
user: UserType,
):
team.is_active = False
team.save()
api_client.force_authenticate(user)

response = api_client.get(f"/api/cyberstorm/team/{team.name}/members/")

assert response.status_code == 404


@pytest.mark.django_db
def test_team_membership_permission__for_nonmember__returns_403(
api_client: APIClient,
team: Team,
user: UserType,
):
api_client.force_authenticate(user)

response = api_client.get(f"/api/cyberstorm/team/{team.name}/members/")

assert response.status_code == 403


@pytest.mark.django_db
def test_team_membership_permission__for_member__returns_200(
api_client: APIClient,
team: Team,
user: UserType,
):
TeamMemberFactory(team=team, user=user)
api_client.force_authenticate(user)

response = api_client.get(f"/api/cyberstorm/team/{team.name}/members/")

assert response.status_code == 200


@pytest.mark.django_db
def test_team_membership_permission__when_fetching_team__is_case_insensitive(
api_client: APIClient,
team: Team,
user: UserType,
):
team = TeamFactory(name="ThunderGods")
TeamMemberFactory(team=team, user=user)
api_client.force_authenticate(user)

response = api_client.get("/api/cyberstorm/team/thundergods/members/")

assert response.status_code == 200


@pytest.mark.django_db
def test_team_members_api_view__for_member__returns_only_real_users(
api_client: APIClient,
team: Team,
user: UserType,
):
TeamMemberFactory(team=team, user=user, role="member")
ServiceAccountFactory(owner=team)
api_client.force_authenticate(user)

response = api_client.get(f"/api/cyberstorm/team/{team.name}/members/")
result = response.json()

assert len(result) == 1
assert result[0]["identifier"] == user.id
assert result[0]["username"] == user.username
assert result[0]["avatar"] is None
assert result[0]["role"] == "member"


@pytest.mark.django_db
def test_team_service_accounts_api_view__for_member__returns_only_service_accounts(
api_client: APIClient,
team: Team,
user: UserType,
):
TeamMemberFactory(team=team, user=user, role="member")
sa = ServiceAccountFactory(owner=team)
api_client.force_authenticate(user)

response = api_client.get(f"/api/cyberstorm/team/{team.name}/service-accounts/")
result = response.json()

assert len(result) == 1
assert result[0]["identifier"] == str(sa.uuid)
assert result[0]["name"] == sa.user.first_name
assert result[0]["last_used"] is None
10 changes: 9 additions & 1 deletion django/thunderstore/api/cyberstorm/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
from .community_detail import CommunityDetailAPIView
from .community_list import CommunityListAPIView
from .team import TeamDetailAPIView
from .team import TeamDetailAPIView, TeamMembersAPIView, TeamServiceAccountsAPIView

__all__ = [
"CommunityDetailAPIView",
"CommunityListAPIView",
"TeamDetailAPIView",
"TeamMembersAPIView",
"TeamServiceAccountsAPIView",
]
72 changes: 61 additions & 11 deletions django/thunderstore/api/cyberstorm/views/team.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,67 @@
from rest_framework.generics import RetrieveAPIView
from django.db.models import Q, QuerySet
from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.filters import OrderingFilter
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request

from thunderstore.api.cyberstorm.serializers import CyberstormTeamSerializer
from thunderstore.api.utils import conditional_swagger_auto_schema
from thunderstore.repository.models.team import Team
from thunderstore.account.models.service_account import ServiceAccount
from thunderstore.api.cyberstorm.serializers import (
CyberstormServiceAccountSerializer,
CyberstormTeamMemberSerializer,
CyberstormTeamSerializer,
)
from thunderstore.api.utils import CyberstormAutoSchemaMixin
from thunderstore.repository.models.team import Team, TeamMember


class TeamDetailAPIView(RetrieveAPIView):
permission_classes = []
class TeamDetailAPIView(CyberstormAutoSchemaMixin, RetrieveAPIView):
serializer_class = CyberstormTeamSerializer
queryset = Team.objects.exclude(is_active=False).prefetch_related("members")
lookup_field = "name"
queryset = Team.objects.exclude(is_active=False)
lookup_field = "name__iexact"
lookup_url_kwarg = "team_id"

@conditional_swagger_auto_schema(tags=["cyberstorm"])
def get(self, *args, **kwargs):
return super().get(*args, **kwargs)

class TeamRestrictedAPIView(ListAPIView):
"""
Ensure the user is a member of the Team.
"""

permission_classes = [IsAuthenticated]

def check_permissions(self, request: Request) -> None:
super().check_permissions(request)

try:
team = Team.objects.exclude(is_active=False).get(
name__iexact=self.kwargs["team_id"],
)
except Team.DoesNotExist:
raise NotFound()

if not team.can_user_access(request.user):
raise PermissionDenied()


class TeamMembersAPIView(CyberstormAutoSchemaMixin, TeamRestrictedAPIView):
serializer_class = CyberstormTeamMemberSerializer
filter_backends = [OrderingFilter]
ordering = ["-role", "user__username"]

def get_queryset(self) -> QuerySet[TeamMember]:
return (
TeamMember.objects.real_users()
.exclude(~Q(team__name__iexact=self.kwargs["team_id"]))
.prefetch_related("user__social_auth")
)


class TeamServiceAccountsAPIView(CyberstormAutoSchemaMixin, TeamRestrictedAPIView):
serializer_class = CyberstormServiceAccountSerializer
filter_backends = [OrderingFilter]
ordering = ["user__first_name"]

def get_queryset(self) -> QuerySet[ServiceAccount]:
return ServiceAccount.objects.exclude(
~Q(owner__name__iexact=self.kwargs["team_id"]),
).select_related("user")
12 changes: 12 additions & 0 deletions django/thunderstore/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
CommunityDetailAPIView,
CommunityListAPIView,
TeamDetailAPIView,
TeamMembersAPIView,
TeamServiceAccountsAPIView,
)

cyberstorm_urls = [
Expand All @@ -22,4 +24,14 @@
TeamDetailAPIView.as_view(),
name="cyberstorm.team.detail",
),
path(
"team/<str:team_id>/members/",
TeamMembersAPIView.as_view(),
name="cyberstorm.team.members",
),
path(
"team/<str:team_id>/service-accounts/",
TeamServiceAccountsAPIView.as_view(),
name="cyberstorm.team.service-accounts",
),
]

0 comments on commit 8637060

Please sign in to comment.