diff --git a/django/conftest.py b/django/conftest.py index e35949b2e..9f7dc21a3 100644 --- a/django/conftest.py +++ b/django/conftest.py @@ -4,7 +4,7 @@ from copy import copy, deepcopy from http.server import BaseHTTPRequestHandler from http.server import HTTPServer as SuperHTTPServer -from typing import Any +from typing import Any, List from zipfile import ZIP_DEFLATED, ZipFile import pytest @@ -276,6 +276,18 @@ def package_category(community): ) +@pytest.fixture() +def package_categories(community) -> List[PackageCategory]: + return [ + PackageCategory.objects.create(community=community, slug=slug, name=name) + for slug, name in [ + ("cat-1", "Category One"), + ("cat-2", "Category Two"), + ("cat-3", "Category Three"), + ] + ] + + @pytest.fixture() def package_listing_section(community): return PackageListingSection.objects.create( diff --git a/django/thunderstore/api/cyberstorm/tests/test_package_listing.py b/django/thunderstore/api/cyberstorm/tests/test_package_listing.py index 6918795a5..1eef877c9 100644 --- a/django/thunderstore/api/cyberstorm/tests/test_package_listing.py +++ b/django/thunderstore/api/cyberstorm/tests/test_package_listing.py @@ -1,5 +1,6 @@ +import json from datetime import datetime -from typing import Optional +from typing import List, Optional import pytest from rest_framework.test import APIClient @@ -13,11 +14,14 @@ PackageCategoryFactory, PackageListingFactory, ) +from thunderstore.community.models.package_category import PackageCategory +from thunderstore.community.models.package_listing import PackageListing from thunderstore.repository.factories import ( PackageRatingFactory, PackageVersionFactory, TeamMemberFactory, ) +from thunderstore.repository.models.team import TeamMember @pytest.mark.django_db @@ -332,5 +336,271 @@ def test_dependency_serializer__when_dependency_is_not_active__censors_icon_and_ assert actual["icon_url"] is None +@pytest.mark.django_db +def test_package_listing_edit_categories_view__returns_error_for_non_existent_package_listing( + api_client: APIClient, + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + + api_client.force_authenticate(team_member.user) + response = api_client.post( + f"/api/cyberstorm/listing/BAD/{active_package_listing.package.namespace.name}/{active_package_listing.package.name}/edit/categories/", + json.dumps( + { + "current_categories": [x.slug for x in apl_categories], + "new_categories": [ + x.slug for x in [apl_categories[0], apl_categories[1]] + ], + } + ), + content_type="application/json", + ) + actual = response.json() + + assert actual["detail"] == "Not found." + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 0 + ) + + response = api_client.post( + f"/api/cyberstorm/listing/{active_package_listing.community.identifier}/BAD/{active_package_listing.package.name}/edit/categories/", + json.dumps( + { + "current_categories": [x.slug for x in apl_categories], + "new_categories": [ + x.slug for x in [apl_categories[0], apl_categories[1]] + ], + } + ), + content_type="application/json", + ) + actual = response.json() + + assert actual["detail"] == "Not found." + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 0 + ) + + response = api_client.post( + f"/api/cyberstorm/listing/{active_package_listing.community.identifier}/{active_package_listing.package.namespace.name}/BAD/edit/categories/", + json.dumps( + { + "current_categories": [x.slug for x in apl_categories], + "new_categories": [ + x.slug for x in [apl_categories[0], apl_categories[1]] + ], + } + ), + content_type="application/json", + ) + actual = response.json() + + assert actual["detail"] == "Not found." + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 0 + ) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_view__correct_values__remove_one_category__succeeds( + api_client: APIClient, + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + + api_client.force_authenticate(team_member.user) + response = api_client.post( + f"/api/cyberstorm/listing/{active_package_listing.community.identifier}/{active_package_listing.package.namespace.name}/{active_package_listing.package.name}/edit/categories/", + json.dumps( + { + "current_categories": [x.slug for x in apl_categories], + "new_categories": [ + x.slug for x in [apl_categories[0], apl_categories[1]] + ], + } + ), + content_type="application/json", + ) + actual = response.json() + + assert actual["categories"] == [ + {"id": str(x.id), "name": x.name, "slug": x.slug} + for x in [apl_categories[0], apl_categories[1]] + ] + + +@pytest.mark.django_db +def test_package_listing_edit_categories_view__correct_values__no_user__fails( + api_client: APIClient, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + + response = api_client.post( + f"/api/cyberstorm/listing/{active_package_listing.community.identifier}/{active_package_listing.package.namespace.name}/{active_package_listing.package.name}/edit/categories/", + json.dumps( + { + "current_categories": [x.slug for x in apl_categories], + "new_categories": [ + x.slug for x in [apl_categories[0], apl_categories[1]] + ], + } + ), + content_type="application/json", + ) + actual = response.json() + + assert actual["detail"] == "Authentication credentials were not provided." + + +@pytest.mark.django_db +def test_package_listing_edit_categories_view__wrong_current_categories__fails( + api_client: APIClient, + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + + api_client.force_authenticate(team_member.user) + response = api_client.post( + f"/api/cyberstorm/listing/{active_package_listing.community.identifier}/{active_package_listing.package.namespace.name}/{active_package_listing.package.name}/edit/categories/", + json.dumps( + {"current_categories": [apl_categories[0].slug], "new_categories": []} + ), + content_type="application/json", + ) + actual = response.json() + + assert actual["__all__"] == [ + "Listings current categories do not match provided ones" + ] + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 0 + ) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_view__correct_values__remove_all_categories__succeeds( + api_client: APIClient, + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + + api_client.force_authenticate(team_member.user) + response = api_client.post( + f"/api/cyberstorm/listing/{active_package_listing.community.identifier}/{active_package_listing.package.namespace.name}/{active_package_listing.package.name}/edit/categories/", + json.dumps( + { + "current_categories": [x.slug for x in apl_categories], + "new_categories": [], + } + ), + content_type="application/json", + ) + actual = response.json() + + assert len(actual["categories"]) == 0 + + +@pytest.mark.django_db +def test_package_listing_edit_categories_view__bad_values__fails( + api_client: APIClient, + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + + api_client.force_authenticate(team_member.user) + response = api_client.post( + f"/api/cyberstorm/listing/{active_package_listing.community.identifier}/{active_package_listing.package.namespace.name}/{active_package_listing.package.name}/edit/categories/", + json.dumps( + { + "current_categories": [x.slug for x in apl_categories], + "new_categories": "bad", + } + ), + content_type="application/json", + ) + actual = response.json() + + assert 'Expected a list of items but got type "str".' in str( + actual["new_categories"] + ) + + response = api_client.post( + f"/api/cyberstorm/listing/{active_package_listing.community.identifier}/{active_package_listing.package.namespace.name}/{active_package_listing.package.name}/edit/categories/", + json.dumps( + { + "current_categories": "bad", + "new_categories": [ + x.slug for x in [apl_categories[0], apl_categories[1]] + ], + } + ), + content_type="application/json", + ) + actual = response.json() + + assert 'Expected a list of items but got type "str".' in str( + actual["current_categories"] + ) + + def _date_to_z(value: datetime) -> str: return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ") diff --git a/django/thunderstore/api/cyberstorm/views/__init__.py b/django/thunderstore/api/cyberstorm/views/__init__.py index 37d6b46ce..f0a887007 100644 --- a/django/thunderstore/api/cyberstorm/views/__init__.py +++ b/django/thunderstore/api/cyberstorm/views/__init__.py @@ -3,7 +3,7 @@ from .community_list import CommunityListAPIView from .markdown import PackageVersionChangelogAPIView, PackageVersionReadmeAPIView from .package import PackageDeprecateAPIView -from .package_listing import PackageListingAPIView +from .package_listing import PackageListingAPIView, PackageListingEditCategoriesAPIView from .package_listing_list import ( PackageListingByCommunityListAPIView, PackageListingByDependencyListAPIView, @@ -35,4 +35,5 @@ "TeamServiceAccountListAPIView", "PackageRatingRateAPIView", "PackageDeprecateAPIView", + "PackageListingEditCategoriesAPIView", ] diff --git a/django/thunderstore/api/cyberstorm/views/package_listing.py b/django/thunderstore/api/cyberstorm/views/package_listing.py index 35c738959..a6e4690d1 100644 --- a/django/thunderstore/api/cyberstorm/views/package_listing.py +++ b/django/thunderstore/api/cyberstorm/views/package_listing.py @@ -12,16 +12,25 @@ Sum, Value, ) +from django.http import HttpRequest from rest_framework import serializers +from rest_framework.exceptions import ValidationError from rest_framework.generics import RetrieveAPIView, get_object_or_404 from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView from thunderstore.api.cyberstorm.serializers import ( CyberstormPackageCategorySerializer, CyberstormTeamMemberSerializer, ) -from thunderstore.api.utils import CyberstormAutoSchemaMixin +from thunderstore.api.utils import ( + CyberstormAutoSchemaMixin, + conditional_swagger_auto_schema, +) +from thunderstore.community.models.package_category import PackageCategory from thunderstore.community.models.package_listing import PackageListing +from thunderstore.repository.forms import PackageListingEditCategoriesForm from thunderstore.repository.models.package import get_package_dependants from thunderstore.repository.models.package_version import PackageVersion @@ -199,3 +208,66 @@ def get_custom_package_listing( listing.dependant_count = get_package_dependants(listing.package.pk).count() return listing + + +class CyberstormListingEditCategoriesRequestSerialiazer(serializers.Serializer): + current_categories = serializers.ListSerializer(child=serializers.SlugField()) + new_categories = serializers.ListSerializer(child=serializers.SlugField()) + + +class CyberstormListingEditCategoriesResponseSerialiazer(serializers.Serializer): + categories = serializers.ListSerializer(child=CyberstormPackageCategorySerializer()) + + +class PackageListingEditCategoriesAPIView(APIView): + @conditional_swagger_auto_schema( + request_body=CyberstormListingEditCategoriesRequestSerialiazer, + responses={200: CyberstormListingEditCategoriesResponseSerialiazer}, + operation_id="cyberstorm.listing.edit.categories", + tags=["cyberstorm"], + ) + def post( + self, + request: HttpRequest, + community_id: str, + namespace_id: str, + package_name: str, + ): + serializer = CyberstormListingEditCategoriesRequestSerialiazer( + data=request.data + ) + serializer.is_valid(raise_exception=True) + listing = get_object_or_404( + PackageListing, + community__identifier=community_id, + package__namespace__name=namespace_id, + package__name__iexact=package_name, + ) + + current_categories = [ + get_object_or_404( + PackageCategory, community__identifier=community_id, slug=cat_slug + ) + for cat_slug in serializer.validated_data["current_categories"] + ] + + new_categories = [ + get_object_or_404( + PackageCategory, community__identifier=community_id, slug=cat_slug + ) + for cat_slug in serializer.validated_data["new_categories"] + ] + + form = PackageListingEditCategoriesForm( + user=request.user, + instance=listing, + initial={"categories": current_categories}, + data={"categories": new_categories}, + ) + if form.is_valid(): + listing = form.save() + return Response( + CyberstormListingEditCategoriesResponseSerialiazer(listing).data + ) + else: + raise ValidationError(form.errors) diff --git a/django/thunderstore/api/urls.py b/django/thunderstore/api/urls.py index abf9c04ec..9322a0def 100644 --- a/django/thunderstore/api/urls.py +++ b/django/thunderstore/api/urls.py @@ -9,6 +9,7 @@ PackageListingByCommunityListAPIView, PackageListingByDependencyListAPIView, PackageListingByNamespaceListAPIView, + PackageListingEditCategoriesAPIView, PackageRatingRateAPIView, PackageVersionChangelogAPIView, PackageVersionListAPIView, @@ -50,6 +51,11 @@ PackageListingAPIView.as_view(), name="cyberstorm.listing", ), + path( + "listing////edit/categories/", + PackageListingEditCategoriesAPIView.as_view(), + name="cyberstorm.listing.edit.categories", + ), path( "listing////dependants/", PackageListingByDependencyListAPIView.as_view(), diff --git a/django/thunderstore/repository/forms/__init__.py b/django/thunderstore/repository/forms/__init__.py index 556a3d7d5..85a0bbfd0 100644 --- a/django/thunderstore/repository/forms/__init__.py +++ b/django/thunderstore/repository/forms/__init__.py @@ -1,3 +1,4 @@ from .package import * +from .package_listing import * from .package_rating import * from .team import * diff --git a/django/thunderstore/repository/forms/package_listing.py b/django/thunderstore/repository/forms/package_listing.py new file mode 100644 index 000000000..ed184d991 --- /dev/null +++ b/django/thunderstore/repository/forms/package_listing.py @@ -0,0 +1,45 @@ +from typing import Optional + +from django import forms +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError + +from thunderstore.community.models.package_category import PackageCategory +from thunderstore.community.models.package_listing import PackageListing +from thunderstore.core.types import UserType + +User = get_user_model() + + +class PackageListingEditCategoriesForm(forms.ModelForm): + instance: PackageListing + categories = forms.ModelMultipleChoiceField( + queryset=None, show_hidden_initial=True, required=False + ) + + class Meta: + model = PackageListing + fields = ["categories"] + + def __init__(self, user: Optional[UserType], *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.fields["categories"].queryset = PackageCategory.objects.filter( + community__identifier=self.instance.community.identifier + ) + + def clean(self): + self.instance.ensure_update_categories_permission(self.user) + if ( + len( + set(self.instance.categories.all()).symmetric_difference( + self.initial["categories"] + ) + ) + != 0 + ): + raise ValidationError( + "Listings current categories do not match provided ones" + ) + else: + return super().clean() diff --git a/django/thunderstore/repository/tests/test_package_listing_forms.py b/django/thunderstore/repository/tests/test_package_listing_forms.py new file mode 100644 index 000000000..21f1f45e6 --- /dev/null +++ b/django/thunderstore/repository/tests/test_package_listing_forms.py @@ -0,0 +1,271 @@ +from typing import List + +import pytest + +from thunderstore.account.models.service_account import ServiceAccount +from thunderstore.community.models.package_category import PackageCategory +from thunderstore.community.models.package_listing import PackageListing +from thunderstore.core.types import UserType +from thunderstore.repository.forms.package_listing import ( + PackageListingEditCategoriesForm, +) +from thunderstore.repository.models.team import TeamMember + + +@pytest.mark.django_db +def test_package_listing_edit_categories_form__correct_values__add_categories_to_empty__succeeds( + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 0 + form = PackageListingEditCategoriesForm( + user=team_member.user, + instance=active_package_listing, + initial={"categories": apl_categories}, + data={"categories": package_categories}, + ) + assert form.is_valid() is True + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 0 + ) + returned_listing = form.save() + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 3 + ) + after_action_db_state = PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + assert len(after_action_db_state) == 3 + assert ( + len( + set(returned_listing.categories.all()).symmetric_difference( + after_action_db_state + ) + ) + == 0 + ) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_form__correct_values__remove_one_category__succeeds( + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + form = PackageListingEditCategoriesForm( + user=team_member.user, + instance=active_package_listing, + initial={"categories": apl_categories}, + data={"categories": package_categories[:2]}, + ) + assert form.is_valid() is True + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 0 + ) + returned_listing = form.save() + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 1 + ) + after_action_db_state = PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + assert len(after_action_db_state) == 2 + assert ( + len( + set(returned_listing.categories.all()).symmetric_difference( + after_action_db_state + ) + ) + == 0 + ) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_form__correct_values__remove_all_categories__succeeds( + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + form = PackageListingEditCategoriesForm( + user=team_member.user, + instance=active_package_listing, + initial={"categories": apl_categories}, + data={"categories": []}, + ) + assert form.is_valid() is True + assert ( + len( + set(apl_categories).symmetric_difference( + PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + ) + ) + == 0 + ) + returned_listing = form.save() + after_action_db_state = PackageListing.objects.get( + pk=active_package_listing.pk + ).categories.all() + assert len(after_action_db_state) == 0 + assert len(set(apl_categories).symmetric_difference(after_action_db_state)) == 3 + assert ( + len( + set(returned_listing.categories.all()).symmetric_difference( + after_action_db_state + ) + ) + == 0 + ) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_form__bad_initial_value__fails( + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + form = PackageListingEditCategoriesForm( + user=team_member.user, + instance=active_package_listing, + initial={"categories": {"bad": "bad"}}, + data={"categories": package_categories}, + ) + assert form.is_valid() is False + assert "Listings current categories do not match provided ones" in str( + repr(form.errors) + ) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_form__bad_data_value__fails( + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + form = PackageListingEditCategoriesForm( + user=team_member.user, + instance=active_package_listing, + initial={"categories": apl_categories}, + data={"categories": {"bad": "bad"}}, + ) + assert form.is_valid() is False + assert "“bad” is not a valid value." in str(repr(form.errors)) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_form__user_is_not_in_team__fails( + user: UserType, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + assert ( + len( + TeamMember.objects.filter( + team=active_package_listing.package.namespace.team, user=user + ) + ) + == 0 + ) + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + form = PackageListingEditCategoriesForm( + user=user, + instance=active_package_listing, + initial={"categories": apl_categories}, + data={"categories": package_categories}, + ) + assert form.is_valid() is False + assert "Must have listing management permission" in str(repr(form.errors)) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_form__user_is_deactivated__fails( + team_member: TeamMember, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + team_member.user.is_active = False + team_member.user.save() + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + form = PackageListingEditCategoriesForm( + user=team_member.user, + instance=active_package_listing, + initial={"categories": apl_categories}, + data={"categories": package_categories}, + ) + assert form.is_valid() is False + assert "User has been deactivated" in str(repr(form.errors)) + + +@pytest.mark.django_db +def test_package_listing_edit_categories_form__user_is_service_account__fails( + service_account: ServiceAccount, + active_package_listing: PackageListing, + package_categories: List[PackageCategory], +) -> None: + active_package_listing.categories.set(package_categories) + active_package_listing.save() + apl_categories = active_package_listing.categories.all() + assert len(apl_categories) == 3 + form = PackageListingEditCategoriesForm( + user=service_account.user, + instance=active_package_listing, + initial={"categories": apl_categories}, + data={"categories": package_categories}, + ) + assert form.is_valid() is False + assert "Service accounts are unable to perform this action" in str( + repr(form.errors) + )