diff --git a/django/thunderstore/api/cyberstorm/tests/test_package_listing.py b/django/thunderstore/api/cyberstorm/tests/test_package_listing.py index 376dca6df..5cf2644d7 100644 --- a/django/thunderstore/api/cyberstorm/tests/test_package_listing.py +++ b/django/thunderstore/api/cyberstorm/tests/test_package_listing.py @@ -1,8 +1,10 @@ from datetime import datetime from typing import Optional -from unittest.mock import PropertyMock, patch +from unittest.mock import Mock, PropertyMock, patch import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext from rest_framework.test import APIClient from thunderstore.api.cyberstorm.views.package_listing import ( @@ -302,9 +304,8 @@ def test_dependency_serializer__reads_is_active_from_correct_field( dependency.package.save() dependant.dependencies.set([dependency]) - # community_identifier is normally added using annotations, but - # it's irrelavant for this test case. dependency.community_identifier = "greendale" + dependency.version_is_unavailable = False actual = DependencySerializer(dependency).data @@ -313,10 +314,9 @@ def test_dependency_serializer__reads_is_active_from_correct_field( @pytest.mark.django_db def test_dependency_serializer__when_dependency_is_not_active__censors_icon_and_description() -> None: - # community_identifier is normally added using annotations, but - # it's irrelavant for this test case. dependency = PackageVersionFactory() dependency.community_identifier = "greendale" + dependency.version_is_unavailable = False actual = DependencySerializer(dependency).data @@ -369,3 +369,72 @@ def test_package_listing_is_removed( assert "is_removed" in response_dependencies assert response_dependencies["is_removed"] == return_val + + +@pytest.mark.django_db +@pytest.mark.parametrize("return_val", [True, False]) +@patch("thunderstore.repository.models.package_version.PackageVersion.is_unavailable") +def test_package_listing_is_unavailable( + is_unavailable_func: Mock, + return_val: bool, + api_client: APIClient, + community: Community, +) -> None: + is_unavailable_func.return_value = return_val + + package = "Mod" + target_ns = NamespaceFactory() + + target_dependency = PackageListingFactory( + community_=community, + package_kwargs={"name": package, "namespace": target_ns}, + ) + + target_package = PackageListingFactory(community_=community) + target_package.package.latest.dependencies.set( + [target_dependency.package.latest.id], + ) + + community_id = target_package.community.identifier + namespace = target_package.package.namespace.name + package_name = target_package.package.name + + url = f"/api/cyberstorm/listing/{community_id}/{namespace}/{package_name}/" + response = api_client.get(url) + response_dependencies = response.json()["dependencies"][0] + + assert "is_unavailable" in response_dependencies + assert response_dependencies["is_unavailable"] == return_val + + +@pytest.mark.django_db +def test_package_listing_query_count( + api_client: APIClient, community: Community +) -> None: + package = "Mod" + target_ns = NamespaceFactory() + + target_dependencies = [ + PackageListingFactory( + community_=community, + package_kwargs={"name": f"{package}_{i}", "namespace": target_ns}, + ) + for i in range(10) + ] + + target_package = PackageListingFactory(community_=community) + target_package.package.latest.dependencies.set( + [dep.package.latest.id for dep in target_dependencies], + ) + + community_id = target_package.community.identifier + namespace = target_package.package.namespace.name + package_name = target_package.package.name + + url = f"/api/cyberstorm/listing/{community_id}/{namespace}/{package_name}/" + + with CaptureQueriesContext(connection) as ctx: + response = api_client.get(url) + + assert response.status_code == 200 + assert len(ctx.captured_queries) < 20 diff --git a/django/thunderstore/api/cyberstorm/views/package_listing.py b/django/thunderstore/api/cyberstorm/views/package_listing.py index aa3d18b0d..22e3a505d 100644 --- a/django/thunderstore/api/cyberstorm/views/package_listing.py +++ b/django/thunderstore/api/cyberstorm/views/package_listing.py @@ -20,6 +20,7 @@ CyberstormTeamMemberSerializer, ) from thunderstore.api.utils import CyberstormAutoSchemaMixin +from thunderstore.community.models.community import Community from thunderstore.community.models.package_listing import PackageListing from thunderstore.repository.models.package import get_package_dependants from thunderstore.repository.models.package_version import PackageVersion @@ -45,6 +46,7 @@ class DependencySerializer(serializers.Serializer): namespace = serializers.CharField(source="package.namespace.name") version_number = serializers.CharField() is_removed = serializers.BooleanField() + is_unavailable = serializers.SerializerMethodField() def get_description(self, obj: PackageVersion) -> str: return ( @@ -56,6 +58,11 @@ def get_description(self, obj: PackageVersion) -> str: def get_icon_url(self, obj: PackageVersion) -> Optional[str]: return obj.icon.url if obj.is_effectively_active else None + def get_is_unavailable(self, obj: PackageVersion) -> bool: + # Annotated result of PackageVersion.is_unavailable + # See get_custom_package_listing() + return obj.version_is_unavailable + class TeamSerializer(serializers.Serializer): """ @@ -182,10 +189,14 @@ def get_custom_package_listing( package__name__iexact=package_name, ) + community = listing.community + version_is_unavailable = listing.package.latest.is_unavailable(community) + dependencies = ( listing.package.latest.dependencies.listed_in(community_id) .annotate( community_identifier=Value(community_id, CharField()), + version_is_unavailable=Value(version_is_unavailable, BooleanField()), ) .select_related("package", "package__namespace") .order_by("package__namespace__name", "package__name") diff --git a/django/thunderstore/community/models/package_listing.py b/django/thunderstore/community/models/package_listing.py index 83ac671e0..9568eb32d 100644 --- a/django/thunderstore/community/models/package_listing.py +++ b/django/thunderstore/community/models/package_listing.py @@ -320,6 +320,10 @@ def update_categories(self, agent: UserType, categories: List["PackageCategory"] ) self.categories.set(categories) + @property + def is_unavailable(self): + return self.is_rejected or self.is_waiting_for_approval + def can_be_moderated_by_user(self, user: Optional[UserType]) -> bool: return self.community.can_user_manage_packages(user) diff --git a/django/thunderstore/community/tests/test_package_listing.py b/django/thunderstore/community/tests/test_package_listing.py index 0d4cbce87..da6a7c130 100644 --- a/django/thunderstore/community/tests/test_package_listing.py +++ b/django/thunderstore/community/tests/test_package_listing.py @@ -1,3 +1,5 @@ +from unittest.mock import PropertyMock, patch + import pytest from django.core.exceptions import ValidationError from django.db import IntegrityError @@ -416,3 +418,27 @@ def test_package_listing_filter_with_multiple_community_packages() -> None: count = community.package_listings.filter_with_single_community().count() assert count == 1 assert community.aggregated.package_count == count + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("is_rejected", "is_waiting_for_approval", "expected"), + [(True, False, True), (False, True, True), (False, False, False)], +) +def test_package_listing_is_unavailable( + is_rejected: bool, + is_waiting_for_approval: bool, + expected: bool, +) -> None: + with patch( + "thunderstore.community.models.PackageListing.is_rejected", + new_callable=PropertyMock, + return_value=is_rejected, + ), patch( + "thunderstore.community.models.PackageListing.is_waiting_for_approval", + new_callable=PropertyMock, + return_value=is_waiting_for_approval, + ): + + listing = PackageListingFactory() + assert listing.is_unavailable == expected diff --git a/django/thunderstore/repository/models/package.py b/django/thunderstore/repository/models/package.py index 597f7e789..0853fa9e7 100644 --- a/django/thunderstore/repository/models/package.py +++ b/django/thunderstore/repository/models/package.py @@ -135,6 +135,13 @@ def update_listing(self, has_nsfw_content, categories, community): listing.categories.add(*categories) listing.save(update_fields=("has_nsfw_content",)) + def is_unavailable(self, community) -> bool: + if not self.is_effectively_active: + return True + + listing = self.get_package_listing(community) + return listing is None or listing.is_unavailable + @cached_property def has_wiki(self) -> bool: try: diff --git a/django/thunderstore/repository/models/package_version.py b/django/thunderstore/repository/models/package_version.py index 337949063..1850275c7 100644 --- a/django/thunderstore/repository/models/package_version.py +++ b/django/thunderstore/repository/models/package_version.py @@ -197,6 +197,9 @@ def get_page_url(self, community_identifier: str) -> str: }, ) + def is_unavailable(self, community) -> bool: + return self.package.is_unavailable(community) or not self.is_active + @cached_property def is_removed(self): if self.package.is_removed: diff --git a/django/thunderstore/repository/tests/test_package.py b/django/thunderstore/repository/tests/test_package.py index cd99d07bc..c49bcf75b 100644 --- a/django/thunderstore/repository/tests/test_package.py +++ b/django/thunderstore/repository/tests/test_package.py @@ -7,7 +7,13 @@ from django.core.exceptions import ValidationError from conftest import TestUserTypes -from thunderstore.community.factories import PackageCategoryFactory, SiteFactory +from thunderstore.community.consts import PackageListingReviewStatus +from thunderstore.community.factories import ( + CommunityFactory, + PackageCategoryFactory, + PackageListingFactory, + SiteFactory, +) from thunderstore.community.models.package_listing import PackageListing from thunderstore.core.types import UserType from thunderstore.repository.factories import PackageFactory, PackageVersionFactory @@ -245,3 +251,67 @@ def test_package_is_removed( PackageVersionFactory(package=package, is_active=version_is_active) assert package.is_removed == expected_is_removed + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ( + "review_status", + "package_is_active", + "require_package_listing_approval", + "expected_is_unavailable_result", + ), + [ + (PackageListingReviewStatus.approved, True, False, False), + (PackageListingReviewStatus.approved, False, False, True), + (PackageListingReviewStatus.rejected, True, False, True), + (PackageListingReviewStatus.rejected, False, False, True), + (PackageListingReviewStatus.unreviewed, True, True, True), + (PackageListingReviewStatus.unreviewed, True, False, False), + (PackageListingReviewStatus.unreviewed, False, True, True), + (PackageListingReviewStatus.unreviewed, False, False, True), + ], +) +def test_package_is_unavailable( + review_status: PackageListingReviewStatus, + package_is_active: bool, + require_package_listing_approval: bool, + expected_is_unavailable_result: bool, +) -> None: + community = CommunityFactory( + require_package_listing_approval=require_package_listing_approval + ) + + package = PackageFactory(is_active=package_is_active) + if package_is_active: + PackageVersionFactory(package=package, version_number="1.0.0") + + PackageListingFactory( + package_=package, + community_=community, + review_status=review_status, + ) + + assert package.is_unavailable(community) == expected_is_unavailable_result + + +@pytest.mark.django_db +def test_package_is_unavailable_no_listing() -> None: + community = CommunityFactory() + package = PackageFactory(is_active=True) + PackageVersionFactory(package=package, version_number="1.0.0") + + assert package.is_unavailable(community) is True + + +@pytest.mark.django_db +def test_package_is_unavailable_no_version() -> None: + community = CommunityFactory() + package = PackageFactory(is_active=True) + PackageListingFactory( + package_=package, + community_=community, + review_status=PackageListingReviewStatus.approved, + ) + + assert package.is_unavailable(community) is True diff --git a/django/thunderstore/repository/tests/test_package_version.py b/django/thunderstore/repository/tests/test_package_version.py index a5846f8f6..d16d88ef3 100644 --- a/django/thunderstore/repository/tests/test_package_version.py +++ b/django/thunderstore/repository/tests/test_package_version.py @@ -3,7 +3,7 @@ import pytest from django.db import IntegrityError -from thunderstore.community.factories import PackageListingFactory +from thunderstore.community.factories import CommunityFactory, PackageListingFactory from thunderstore.community.models.package_listing import PackageListing from thunderstore.repository.factories import PackageFactory, PackageVersionFactory from thunderstore.repository.models import PackageVersion @@ -150,3 +150,26 @@ def test_package_version_is_removed( version = PackageVersionFactory(package=package, is_active=version_is_active) assert version.is_removed == expected_is_removed + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("package_is_unavailable", "version_is_active", "expected_is_unavailable"), + [ + (True, True, True), + (True, False, True), + (False, True, False), + (False, False, True), + ], +) +def test_package_version_is_unavailable( + package_is_unavailable: bool, + version_is_active: bool, + expected_is_unavailable: bool, +) -> None: + community = CommunityFactory() + package = PackageFactory() + package.is_unavailable = lambda _: package_is_unavailable + version = PackageVersionFactory(package=package, is_active=version_is_active) + + assert version.is_unavailable(community) == expected_is_unavailable