From 9d4a38193fd969cb6918a91e1da077022894ac08 Mon Sep 17 00:00:00 2001 From: Roffenlund Date: Thu, 8 May 2025 18:39:15 +0300 Subject: [PATCH 1/4] Implement team service for updating teams Implement service layer function for updating teams. Support only dontation_link at the moment. Implement tests. Refs. TS-2314 --- .../api/cyberstorm/services/team.py | 11 ++++++ .../tests/services/test_team_services.py | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/django/thunderstore/api/cyberstorm/services/team.py b/django/thunderstore/api/cyberstorm/services/team.py index bcdf030a2..4ea78033c 100644 --- a/django/thunderstore/api/cyberstorm/services/team.py +++ b/django/thunderstore/api/cyberstorm/services/team.py @@ -31,3 +31,14 @@ def create_team(user: UserType, team_name: str) -> Team: team = Team.objects.create(name=team_name) team.add_member(user=user, role=TeamMemberRole.owner) return team + + +@transaction.atomic +def update_team(agent: UserType, team: Team, donation_link: str) -> Team: + team.ensure_user_can_access(agent) + team.ensure_user_can_edit_info(agent) + + team.donation_link = donation_link + team.save() + + return team diff --git a/django/thunderstore/api/cyberstorm/tests/services/test_team_services.py b/django/thunderstore/api/cyberstorm/tests/services/test_team_services.py index da8e11a14..fd210d37d 100644 --- a/django/thunderstore/api/cyberstorm/tests/services/test_team_services.py +++ b/django/thunderstore/api/cyberstorm/tests/services/test_team_services.py @@ -3,6 +3,7 @@ from django.http import Http404 from thunderstore.api.cyberstorm.services import team as team_services +from thunderstore.core.exceptions import PermissionValidationError from thunderstore.repository.models import Namespace, Team, TeamMemberRole @@ -67,3 +68,38 @@ def test_create_team_success(user): assert Team.objects.filter(name=team_name).exists() assert team.name == team_name assert team.members.filter(user=user, role=TeamMemberRole.owner).exists() + + +@pytest.mark.django_db +def test_update_team_success(team_owner): + team = team_owner.team + new_donation_link = "https://example.com/donate" + updated_team = team_services.update_team( + agent=team_owner.user, team=team, donation_link=new_donation_link + ) + + assert updated_team.donation_link == new_donation_link + + +@pytest.mark.django_db +def test_update_team_user_cannot_access(user, team): + new_donation_link = "https://example.com/donate" + + error_msg = "Must be a member to access team" + with pytest.raises(PermissionValidationError, match=error_msg): + team_services.update_team( + agent=user, team=team, donation_link=new_donation_link + ) + + +@pytest.mark.django_db +def test_update_team_user_cannot_edit_info(team_member): + new_donation_link = "https://example.com/donate" + + error_msg = "Must be an owner to edit team info" + with pytest.raises(PermissionValidationError, match=error_msg): + team_services.update_team( + agent=team_member.user, + team=team_member.team, + donation_link=new_donation_link, + ) From 02a25473d2505f1b558422cdafc9e42b2745f3ea Mon Sep 17 00:00:00 2001 From: Roffenlund Date: Thu, 8 May 2025 18:40:15 +0300 Subject: [PATCH 2/4] Implement serializer for updating teams Refs. TS-2314 --- .../thunderstore/api/cyberstorm/serializers/__init__.py | 2 ++ django/thunderstore/api/cyberstorm/serializers/team.py | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/django/thunderstore/api/cyberstorm/serializers/__init__.py b/django/thunderstore/api/cyberstorm/serializers/__init__.py index 31b7a4d38..f09d2bab5 100644 --- a/django/thunderstore/api/cyberstorm/serializers/__init__.py +++ b/django/thunderstore/api/cyberstorm/serializers/__init__.py @@ -11,6 +11,7 @@ CyberstormTeamAddMemberResponseSerializer, CyberstormTeamMemberSerializer, CyberstormTeamSerializer, + CyberstormTeamUpdateSerializer, ) __all__ = [ @@ -25,4 +26,5 @@ "CyberstormTeamMemberSerializer", "CyberstormTeamSerializer", "PackagePermissionsSerializer", + "CyberstormTeamUpdateSerializer", ] diff --git a/django/thunderstore/api/cyberstorm/serializers/team.py b/django/thunderstore/api/cyberstorm/serializers/team.py index 3e89c9bf9..9035dadbf 100644 --- a/django/thunderstore/api/cyberstorm/serializers/team.py +++ b/django/thunderstore/api/cyberstorm/serializers/team.py @@ -1,10 +1,9 @@ from typing import Optional +from django.core.validators import URLValidator from rest_framework import serializers -from rest_framework.exceptions import ValidationError from thunderstore.repository.forms import AddTeamMemberForm -from thunderstore.repository.models import Namespace, Team from thunderstore.repository.validators import PackageReferenceComponentValidator from thunderstore.social.utils import get_user_avatar_url @@ -53,3 +52,9 @@ class CyberstormCreateTeamSerializer(serializers.Serializer): name = serializers.CharField( max_length=64, validators=[PackageReferenceComponentValidator("Author name")] ) + + +class CyberstormTeamUpdateSerializer(serializers.Serializer): + donation_link = serializers.CharField( + max_length=1024, validators=[URLValidator(["https"])] + ) From 918e7814c1473eade876d675f77eb089c4293066 Mon Sep 17 00:00:00 2001 From: Roffenlund Date: Thu, 8 May 2025 18:40:57 +0300 Subject: [PATCH 3/4] Implement cyberstorm view for updating teams Implement APIView for updating teams. Currently support only dontation_link over PATCH request. Add URl and update tests. Refs. TS-2314 --- .../api/cyberstorm/tests/test_team.py | 134 ++++++++++++++++++ .../api/cyberstorm/views/__init__.py | 2 + .../thunderstore/api/cyberstorm/views/team.py | 39 ++++- django/thunderstore/api/urls.py | 6 + 4 files changed, 177 insertions(+), 4 deletions(-) diff --git a/django/thunderstore/api/cyberstorm/tests/test_team.py b/django/thunderstore/api/cyberstorm/tests/test_team.py index 009c113de..d07dc2fb1 100644 --- a/django/thunderstore/api/cyberstorm/tests/test_team.py +++ b/django/thunderstore/api/cyberstorm/tests/test_team.py @@ -322,3 +322,137 @@ def test_team_member_add_api_view__when_adding_a_member__fails_because_user_is_n .count() == 0 ) + + +@pytest.mark.django_db +def test_team_update_succeeds( + api_client: APIClient, + user: UserType, + team: Team, +): + TeamMemberFactory(team=team, user=user, role="owner") + api_client.force_authenticate(user) + + new_donation_link = "https://example.com" + + response = api_client.patch( + f"/api/cyberstorm/team/{team.name}/update/", + json.dumps({"donation_link": new_donation_link}), + content_type="application/json", + ) + + expected_response = {"donation_link": new_donation_link} + assert response.status_code == 200 + + assert response.json() == expected_response + assert Team.objects.get(pk=team.pk).donation_link == new_donation_link + + +@pytest.mark.django_db +def test_team_update_fails_user_not_authenticated( + api_client: APIClient, + team: Team, +): + new_donation_link = "https://example.com" + + response = api_client.patch( + f"/api/cyberstorm/team/{team.name}/update/", + json.dumps({"donation_link": new_donation_link}), + content_type="application/json", + ) + + expected_response = {"detail": "Authentication credentials were not provided."} + + assert response.status_code == 401 + assert response.json() == expected_response + assert Team.objects.get(pk=team.pk).donation_link is None + + +@pytest.mark.django_db +def test_team_update_fails_validation( + api_client: APIClient, + user: UserType, + team: Team, +): + TeamMemberFactory(team=team, user=user, role="owner") + api_client.force_authenticate(user) + + new_bad_donation_link = "example.com" + + response = api_client.patch( + f"/api/cyberstorm/team/{team.name}/update/", + json.dumps({"donation_link": new_bad_donation_link}), + content_type="application/json", + ) + + expected_response = {"donation_link": ["Enter a valid URL."]} + + assert response.status_code == 400 + assert response.json() == expected_response + + +@pytest.mark.django_db +def test_team_update_fail_user_not_owner( + api_client: APIClient, + user: UserType, + team: Team, +): + TeamMemberFactory(team=team, user=user, role="member") + api_client.force_authenticate(user) + + new_donation_link = "https://example.com" + + response = api_client.patch( + f"/api/cyberstorm/team/{team.name}/update/", + json.dumps({"donation_link": new_donation_link}), + content_type="application/json", + ) + + expected_response = {"non_field_errors": ["Must be an owner to edit team info"]} + + assert response.status_code == 403 + assert response.json() == expected_response + assert Team.objects.get(pk=team.pk).donation_link is None + + +@pytest.mark.django_db +def test_team_update_fail_team_does_not_exist( + api_client: APIClient, + user: UserType, +): + api_client.force_authenticate(user) + + new_donation_link = "https://example.com" + + response = api_client.patch( + "/api/cyberstorm/team/FakeTeam/update/", + json.dumps({"donation_link": new_donation_link}), + content_type="application/json", + ) + + expected_response = {"detail": "Not found."} + + assert response.status_code == 404 + assert response.json() == expected_response + + +@pytest.mark.django_db +def test_team_update_fail_user_not_team_member( + api_client: APIClient, + user: UserType, + team: Team, +): + api_client.force_authenticate(user) + + new_donation_link = "https://example.com" + + response = api_client.patch( + f"/api/cyberstorm/team/{team.name}/update/", + json.dumps({"donation_link": new_donation_link}), + content_type="application/json", + ) + + expected_response = {"non_field_errors": ["Must be a member to access team"]} + + assert response.status_code == 403 + assert response.json() == expected_response diff --git a/django/thunderstore/api/cyberstorm/views/__init__.py b/django/thunderstore/api/cyberstorm/views/__init__.py index 4fd279bc3..86324482b 100644 --- a/django/thunderstore/api/cyberstorm/views/__init__.py +++ b/django/thunderstore/api/cyberstorm/views/__init__.py @@ -24,6 +24,7 @@ TeamMemberAddAPIView, TeamMemberListAPIView, TeamServiceAccountListAPIView, + UpdateTeamAPIView, ) __all__ = [ @@ -49,4 +50,5 @@ "UpdatePackageListingCategoriesAPIView", "RejectPackageListingAPIView", "ApprovePackageListingAPIView", + "UpdateTeamAPIView", ] diff --git a/django/thunderstore/api/cyberstorm/views/team.py b/django/thunderstore/api/cyberstorm/views/team.py index 1b9a93d03..e360b58cd 100644 --- a/django/thunderstore/api/cyberstorm/views/team.py +++ b/django/thunderstore/api/cyberstorm/views/team.py @@ -1,4 +1,3 @@ -from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import Q, QuerySet from rest_framework import status from rest_framework.exceptions import PermissionDenied, ValidationError @@ -16,8 +15,13 @@ CyberstormTeamAddMemberResponseSerializer, CyberstormTeamMemberSerializer, CyberstormTeamSerializer, + CyberstormTeamUpdateSerializer, +) +from thunderstore.api.cyberstorm.services.team import ( + create_team, + disband_team, + update_team, ) -from thunderstore.api.cyberstorm.services import team as team_services from thunderstore.api.ordering import StrictOrderingFilter from thunderstore.api.utils import ( CyberstormAutoSchemaMixin, @@ -66,7 +70,7 @@ def post(self, request, *args, **kwargs): serializer = CyberstormCreateTeamSerializer(data=request.data) serializer.is_valid(raise_exception=True) team_name = serializer.validated_data["name"] - team = team_services.create_team(user=request.user, team_name=team_name) + team = create_team(user=request.user, team_name=team_name) return_data = CyberstormTeamSerializer(team).data return Response(return_data, status=status.HTTP_201_CREATED) @@ -135,5 +139,32 @@ class DisbandTeamAPIView(APIView): ) def delete(self, request, *args, **kwargs): team_name = kwargs["team_name"] - team_services.disband_team(user=request.user, team_name=team_name) + disband_team(user=request.user, team_name=team_name) return Response(status=status.HTTP_204_NO_CONTENT) + + +class UpdateTeamAPIView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = CyberstormTeamUpdateSerializer + http_method_names = ["patch"] + + @conditional_swagger_auto_schema( + operation_id="cyberstorm.team.update", + tags=["cyberstorm"], + request_body=CyberstormTeamUpdateSerializer, + responses={status.HTTP_200_OK: serializer_class}, + ) + def patch(self, request, team_name, *args, **kwargs): + team = get_object_or_404(Team.objects.exclude(is_active=False), name=team_name) + + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + updated_team = update_team( + agent=request.user, + team=team, + donation_link=serializer.validated_data["donation_link"], + ) + + return_data = self.serializer_class(instance=updated_team).data + return Response(return_data, status=status.HTTP_200_OK) diff --git a/django/thunderstore/api/urls.py b/django/thunderstore/api/urls.py index 5392b898e..dc35c1471 100644 --- a/django/thunderstore/api/urls.py +++ b/django/thunderstore/api/urls.py @@ -23,6 +23,7 @@ TeamMemberListAPIView, TeamServiceAccountListAPIView, UpdatePackageListingCategoriesAPIView, + UpdateTeamAPIView, ) cyberstorm_urls = [ @@ -126,6 +127,11 @@ TeamAPIView.as_view(), name="cyberstorm.team", ), + path( + "team//update/", + UpdateTeamAPIView.as_view(), + name="cyberstorm.team.update", + ), path( "team//disband/", DisbandTeamAPIView.as_view(), From 595edfde2f1a3033ef48db122b70c3dfafe012e2 Mon Sep 17 00:00:00 2001 From: Roffenlund Date: Fri, 9 May 2025 17:22:10 +0300 Subject: [PATCH 4/4] User service layer in team update form view Use the service layer function in the form responsible for updating team's donation_links. Utilize the service layer function in the form's save function and update the view accordingly. In order to invoke the post-validation errors added to the form in the form tests, call form.save() in the tests as well. Implement tests for the view in a similar manner as they are implemented for the forms to assert functionality. Refs. TS-2314 --- django/thunderstore/repository/forms/team.py | 17 +++++- .../repository/tests/test_team_forms.py | 4 ++ .../repository/tests/test_views.py | 56 +++++++++++++++++++ .../repository/views/team_settings.py | 7 ++- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/django/thunderstore/repository/forms/team.py b/django/thunderstore/repository/forms/team.py index e81e6509d..bf4923858 100644 --- a/django/thunderstore/repository/forms/team.py +++ b/django/thunderstore/repository/forms/team.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, ValidationError +from thunderstore.api.cyberstorm.services.team import update_team from thunderstore.core.exceptions import PermissionValidationError from thunderstore.core.types import UserType from thunderstore.repository.models import ( @@ -183,10 +184,20 @@ def __init__(self, user: UserType, *args, **kwargs): def clean(self): if not self.instance.pk: raise ValidationError("Missing team instance") - self.instance.ensure_user_can_edit_info(self.user) return super().clean() @transaction.atomic def save(self, **kwargs): - self.instance.ensure_user_can_edit_info(self.user) - return super().save(**kwargs) + if self.errors: + raise ValidationError(self.errors) + + try: + update_team( + agent=self.user, + team=self.instance, + donation_link=self.cleaned_data["donation_link"], + ) + except ValidationError as e: + self.add_error(None, e) + + return self.instance diff --git a/django/thunderstore/repository/tests/test_team_forms.py b/django/thunderstore/repository/tests/test_team_forms.py index c4f0a973c..02308a7f9 100644 --- a/django/thunderstore/repository/tests/test_team_forms.py +++ b/django/thunderstore/repository/tests/test_team_forms.py @@ -1,6 +1,7 @@ from typing import Optional import pytest +from django.core.exceptions import ValidationError from conftest import TestUserTypes from thunderstore.core.factories import UserFactory @@ -552,8 +553,11 @@ def test_form_donation_link_team_form_permissions( team.refresh_from_db() assert team.donation_link == link else: + form.save() assert form.is_valid() is False assert form.errors + team.refresh_from_db() + assert team.donation_link is None @pytest.mark.django_db diff --git a/django/thunderstore/repository/tests/test_views.py b/django/thunderstore/repository/tests/test_views.py index b434e722d..1662315ff 100644 --- a/django/thunderstore/repository/tests/test_views.py +++ b/django/thunderstore/repository/tests/test_views.py @@ -478,6 +478,62 @@ def test_team_settings_donation_link_view( assert b"Donation link saved" in response.content +@pytest.mark.django_db +@pytest.mark.parametrize("user_type", TestUserTypes.options()) +@pytest.mark.parametrize("role", TeamMemberRole.options() + [None]) +def test_team_settings_donation_link_view_permissions( + client: APIClient, + community_site: CommunitySite, + team: Team, + user_type: str, + role: Optional[str], +) -> None: + valid_user_type_map = { + TestUserTypes.no_user: False, + TestUserTypes.unauthenticated: False, + TestUserTypes.regular_user: True, + TestUserTypes.deactivated_user: False, + TestUserTypes.service_account: False, + TestUserTypes.site_admin: True, + TestUserTypes.superuser: True, + } + + valid_role_map = { + None: False, + TeamMemberRole.member: False, + TeamMemberRole.owner: True, + } + + user = TestUserTypes.get_user_by_type(user_type) + if role is not None and user_type not in TestUserTypes.fake_users(): + TeamMember.objects.create(user=user, team=team, role=role) + client.force_login(user) + + should_succeed = all( + ( + valid_user_type_map[user_type], + valid_role_map[role], + ) + ) + + kwargs = {"name": team.name} + response = client.post( + reverse("settings.teams.detail.donation_link", kwargs=kwargs), + {"donation_link": "https://example.org/"}, + HTTP_HOST=community_site.site.domain, + follow=True, + ) + + if should_succeed: + assert b"Donation link saved" in response.content + team.refresh_from_db() + assert team.donation_link == "https://example.org/" + else: + assert b"Donation link saved" not in response.content + team.refresh_from_db() + assert team.donation_link is None + + @pytest.mark.django_db @pytest.mark.parametrize("user_type", TestUserTypes.options()) def test_view_package_detail_management_option_visibility_without_team( diff --git a/django/thunderstore/repository/views/team_settings.py b/django/thunderstore/repository/views/team_settings.py index 9d70ba92c..f4f33b86f 100644 --- a/django/thunderstore/repository/views/team_settings.py +++ b/django/thunderstore/repository/views/team_settings.py @@ -16,6 +16,7 @@ CreateServiceAccountForm, DeleteServiceAccountForm, ) +from thunderstore.api.cyberstorm.services.team import update_team from thunderstore.core.mixins import RequireAuthenticationMixin from thunderstore.core.utils import capture_exception from thunderstore.frontend.views import SettingsViewMixin @@ -26,7 +27,6 @@ DonationLinkTeamForm, EditTeamMemberForm, RemoveTeamMemberForm, - TeamMemberRole, ) from thunderstore.repository.models import Team, TeamMember, reverse @@ -276,5 +276,8 @@ def get_success_url(self): return self.object.donation_link_url def form_valid(self, form: DonationLinkTeamForm): + self.object = form.save() + if form.errors: # Check if service layer raised an error + return super().form_invalid(form) messages.success(self.request, "Donation link saved") - return super().form_valid(form) + return redirect(self.get_success_url())