diff --git a/Dockerfile b/Dockerfile index 5a2faa54a..d30a35f76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ RUN apt-get update && apt-get install -y \ curl build-essential git \ && rm -rf /var/lib/apt/lists/* +COPY ./flake8-thunderstore/ /flake8-thunderstore COPY ./python-packages/ /python-packages COPY ./django/pyproject.toml ./django/poetry.lock /app/ diff --git a/builder/src/scss/package-list.scss b/builder/src/scss/package-list.scss index 88b0a6ec4..397872b10 100644 --- a/builder/src/scss/package-list.scss +++ b/builder/src/scss/package-list.scss @@ -16,6 +16,13 @@ transform-origin: center; } +.unavailable { + position: absolute; + z-index: 1; + pointer-events: none; + text-align: right; +} + .search-options { .checkbox { display: flex; diff --git a/django/poetry.lock b/django/poetry.lock index c7f972332..3c77ad308 100644 --- a/django/poetry.lock +++ b/django/poetry.lock @@ -1199,6 +1199,20 @@ files = [ [package.dependencies] flake8-plugin-utils = ">=1.3.1,<2.0.0" +[[package]] +name = "flake8-thunderstore" +version = "1.0.2" +description = "Custom Flake8 plugins for Thunderstore" +category = "dev" +optional = false +python-versions = "*" +files = [] +develop = true + +[package.source] +type = "directory" +url = "../flake8-thunderstore" + [[package]] name = "freezegun" version = "1.2.1" @@ -3136,4 +3150,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "623250fdf603ca415bb23c6a46c57bfa86499fb51c77b1ef0a6c6f1d74ef6e29" +content-hash = "74f60c439cb2cb3633591a4e7f5a573b80664da66a52d89db894d07e0eab1a69" diff --git a/django/pyproject.toml b/django/pyproject.toml index 0d43fcfbe..714274ff6 100644 --- a/django/pyproject.toml +++ b/django/pyproject.toml @@ -70,6 +70,7 @@ flake8-comprehensions = "^3.3.1" flake8-pie = "^0.6.1" flake8-printf-formatting = "^1.1.0" flake8-pytest-style = "^1.3.0" +flake8-thunderstore = { path = "../flake8-thunderstore", develop = true } pep8-naming = "^0.11.1" watchdog = {extras = ["watchmedo"], version = "^1.0.2"} mypy-boto3-s3 = "^1.17.47" diff --git a/django/thunderstore/api/cyberstorm/tests/test_package_version_list.py b/django/thunderstore/api/cyberstorm/tests/test_package_version_list.py index e8bd57d9f..32583fdb1 100644 --- a/django/thunderstore/api/cyberstorm/tests/test_package_version_list.py +++ b/django/thunderstore/api/cyberstorm/tests/test_package_version_list.py @@ -1,6 +1,8 @@ import pytest from rest_framework.test import APIClient +from thunderstore.permissions.factories import VisibilityFlagsFactory +from thunderstore.repository.consts import PackageVersionReviewStatus from thunderstore.repository.factories import PackageVersionFactory from thunderstore.repository.models import Package @@ -48,3 +50,27 @@ def test_package_version_list_api_view__returns_versions( assert len(actual) == 1 assert actual[0]["version_number"] == expected.version_number + + +@pytest.mark.django_db +def test_only_visible_versions_are_returned( + api_client: APIClient, +) -> None: + version1 = PackageVersionFactory(version_number="1.0.0") + version1.review_status = PackageVersionReviewStatus.approved + version1.save() + + version2 = PackageVersionFactory(package=version1.package, version_number="2.0.0") + version2.review_status = PackageVersionReviewStatus.rejected + version2.save() + + assert version1.visibility.public_list is True + assert version2.visibility.public_list is False + + response = api_client.get( + f"/api/cyberstorm/package/{version1.package.namespace}/{version1.package.name}/versions/", + ) + actual = response.json() + + assert len(actual) == 1 + assert actual[0]["version_number"] == version1.version_number diff --git a/django/thunderstore/api/cyberstorm/views/package_listing.py b/django/thunderstore/api/cyberstorm/views/package_listing.py index 5a8722cde..dfaad4a47 100644 --- a/django/thunderstore/api/cyberstorm/views/package_listing.py +++ b/django/thunderstore/api/cyberstorm/views/package_listing.py @@ -141,11 +141,10 @@ def get_custom_package_listing( namespace_id: str, package_name: str, ) -> CustomListing: - listing_ref = PackageListing.objects.filter(pk=OuterRef("pk")) + listing_ref = PackageListing.objects.public_list().filter(pk=OuterRef("pk")) qs = ( - PackageListing.objects.active() - .filter_by_community_approval_rule() + PackageListing.objects.public_list() .select_related( "community", "package__latest", diff --git a/django/thunderstore/api/cyberstorm/views/package_listing_list.py b/django/thunderstore/api/cyberstorm/views/package_listing_list.py index eec1f8e4e..8debb049c 100644 --- a/django/thunderstore/api/cyberstorm/views/package_listing_list.py +++ b/django/thunderstore/api/cyberstorm/views/package_listing_list.py @@ -334,7 +334,7 @@ def get_queryset(self) -> QuerySet[Package]: community_id = self.kwargs["community_id"] namespace_id = self.kwargs["namespace_id"] package_name = self.kwargs["package_name"] - listings = PackageListing.objects.active() # type: ignore + listings = PackageListing.objects.public_list() listing = get_object_or_404( listings, community__identifier=community_id, diff --git a/django/thunderstore/api/cyberstorm/views/package_version_list.py b/django/thunderstore/api/cyberstorm/views/package_version_list.py index 0dc69c957..4b18379e2 100644 --- a/django/thunderstore/api/cyberstorm/views/package_version_list.py +++ b/django/thunderstore/api/cyberstorm/views/package_version_list.py @@ -27,4 +27,4 @@ def get_queryset(self): name__iexact=self.kwargs["package_name"], ) - return package.versions.active() + return package.versions.public_list() diff --git a/django/thunderstore/community/api/experimental/views/listing.py b/django/thunderstore/community/api/experimental/views/listing.py index 2dcd03291..3eb517dba 100644 --- a/django/thunderstore/community/api/experimental/views/listing.py +++ b/django/thunderstore/community/api/experimental/views/listing.py @@ -13,9 +13,13 @@ class PackageListingUpdateApiView(GenericAPIView): - queryset = PackageListing.objects.active().select_related( - "community", - "package", + queryset = ( + PackageListing.objects.system() + .active() + .select_related( + "community", + "package", + ) ) serializer_class = PackageListingUpdateResponseSerializer @@ -57,7 +61,7 @@ class PackageListingRejectRequestSerializer(serializers.Serializer): class PackageListingRejectApiView(GenericAPIView): - queryset = PackageListing.objects.select_related("community", "package") + queryset = PackageListing.objects.system().select_related("community", "package") @swagger_auto_schema( operation_id="experimental.package_listing.reject", @@ -97,7 +101,7 @@ class PackageListingApproveRequestSerializer(serializers.Serializer): class PackageListingApproveApiView(GenericAPIView): - queryset = PackageListing.objects.select_related("community", "package") + queryset = PackageListing.objects.system().select_related("community", "package") @swagger_auto_schema( operation_id="experimental.package_listing.approve", diff --git a/django/thunderstore/community/migrations/0030_packagelisting_visibility.py b/django/thunderstore/community/migrations/0030_packagelisting_visibility.py new file mode 100644 index 000000000..d1ed2e079 --- /dev/null +++ b/django/thunderstore/community/migrations/0030_packagelisting_visibility.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2024-10-03 19:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("permissions", "0001_initial"), + ("community", "0029_packagelisting_is_auto_imported"), + ] + + operations = [ + migrations.AddField( + model_name="packagelisting", + name="visibility", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="permissions.visibilityflags", + ), + ), + ] diff --git a/django/thunderstore/community/migrations/0031_create_default_visibility_for_existing_listings.py b/django/thunderstore/community/migrations/0031_create_default_visibility_for_existing_listings.py new file mode 100644 index 000000000..c12d2068a --- /dev/null +++ b/django/thunderstore/community/migrations/0031_create_default_visibility_for_existing_listings.py @@ -0,0 +1,80 @@ +from django.db import migrations + +from thunderstore.community.consts import PackageListingReviewStatus + + +def create_default_visibility_for_existing_records(apps, schema_editor): + PackageListing = apps.get_model("community", "PackageListing") + VisibilityFlags = apps.get_model("permissions", "VisibilityFlags") + + for instance in PackageListing.objects.filter(visibility__isnull=True): + visibility_flags = VisibilityFlags.objects.create( + public_list=False, + public_detail=False, + owner_list=True, + owner_detail=True, + moderator_list=True, + moderator_detail=True, + admin_list=True, + admin_detail=True, + ) + instance.visibility = visibility_flags + instance.save() + update_visibility(instance) + + +def update_visibility(listing): + listing.visibility.public_detail = True + listing.visibility.public_list = True + listing.visibility.owner_detail = True + listing.visibility.owner_list = True + listing.visibility.moderator_detail = True + listing.visibility.moderator_list = True + + if not listing.package.is_active: + listing.visibility.public_detail = False + listing.visibility.public_list = False + listing.visibility.owner_detail = False + listing.visibility.owner_list = False + listing.visibility.moderator_detail = False + listing.visibility.moderator_list = False + + if listing.review_status == PackageListingReviewStatus.rejected: + listing.visibility.public_detail = False + listing.visibility.public_list = False + + if ( + listing.community.require_package_listing_approval + and listing.review_status == PackageListingReviewStatus.unreviewed + ): + listing.visibility.public_detail = False + listing.visibility.public_list = False + + versions = listing.package.versions.filter(is_active=True).all() + if versions.exclude(visibility__public_detail=False).count() == 0: + listing.visibility.public_detail = False + if versions.exclude(visibility__public_list=False).count() == 0: + listing.visibility.public_list = False + if versions.exclude(visibility__owner_detail=False).count() == 0: + listing.visibility.owner_detail = False + if versions.exclude(visibility__owner_list=False).count() == 0: + listing.visibility.owner_list = False + if versions.exclude(visibility__moderator_detail=False).count() == 0: + listing.visibility.moderator_detail = False + if versions.exclude(visibility__moderator_list=False).count() == 0: + listing.visibility.moderator_list = False + + listing.visibility.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("community", "0030_packagelisting_visibility"), + ] + + operations = [ + migrations.RunPython( + create_default_visibility_for_existing_records, migrations.RunPython.noop + ), + ] diff --git a/django/thunderstore/community/migrations/0032_auto_20241112_1930.py b/django/thunderstore/community/migrations/0032_auto_20241112_1930.py new file mode 100644 index 000000000..8e52c56bf --- /dev/null +++ b/django/thunderstore/community/migrations/0032_auto_20241112_1930.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2024-11-12 19:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("community", "0031_create_default_visibility_for_existing_listings"), + ] + + operations = [ + migrations.AlterField( + model_name="packagelisting", + name="review_status", + field=models.TextField( + choices=[ + ("unreviewed", "unreviewed"), + ("approved", "approved"), + ("rejected", "rejected"), + ], + default="unreviewed", + ), + ), + ] diff --git a/django/thunderstore/community/migrations/0033_merge_20241217_2045.py b/django/thunderstore/community/migrations/0033_merge_20241217_2045.py new file mode 100644 index 000000000..21e4df3eb --- /dev/null +++ b/django/thunderstore/community/migrations/0033_merge_20241217_2045.py @@ -0,0 +1,13 @@ +# Generated by Django 3.1.7 on 2024-12-17 20:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("community", "0031_community_short_description"), + ("community", "0032_auto_20241112_1930"), + ] + + operations = [] diff --git a/django/thunderstore/community/models/community.py b/django/thunderstore/community/models/community.py index 3727ce36d..50fc65d3d 100644 --- a/django/thunderstore/community/models/community.py +++ b/django/thunderstore/community/models/community.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.db import models, transaction -from django.db.models import Manager, QuerySet +from django.db.models import Count, Manager, QuerySet from django.urls import reverse from django.utils.functional import cached_property @@ -311,10 +311,11 @@ def update_for_community(cls, community: Community) -> None: Assumes the CommunityAggregatedFields objects has been created previously, e.g. by calling .create_missing() """ - listings = community.package_listings.active() - - if community.require_package_listing_approval: - listings = listings.approved() + listings = ( + community.package_listings.public_list() + .annotate(listing_count=Count("package__community_listings")) + .exclude(listing_count__gt=1) + ) community.aggregated_fields.package_count = listings.count() community.aggregated_fields.download_count = sum( diff --git a/django/thunderstore/community/models/package_listing.py b/django/thunderstore/community/models/package_listing.py index e0b7a3263..fc26dd5ed 100644 --- a/django/thunderstore/community/models/package_listing.py +++ b/django/thunderstore/community/models/package_listing.py @@ -15,6 +15,7 @@ from thunderstore.core.types import UserType from thunderstore.core.utils import check_validity from thunderstore.frontend.url_reverse import get_community_url_reverse_args +from thunderstore.permissions.mixins import VisibilityMixin, VisibilityQuerySet from thunderstore.permissions.utils import validate_user from thunderstore.webhooks.audit import ( AuditAction, @@ -27,7 +28,7 @@ from thunderstore.community.models import PackageCategory -class PackageListingQueryset(models.QuerySet): +class PackageListingQueryset(VisibilityQuerySet): def active(self): return self.exclude(package__is_active=False).exclude( ~Q(package__versions__is_active=True) @@ -46,7 +47,7 @@ def filter_by_community_approval_rule(self): # TODO: Add a db constraint that ensures a package listing and it's categories # belong to the same community. This might require actually specifying # the intermediate model in code rather than letting Django handle it -class PackageListing(TimestampMixin, AdminLinkMixin, models.Model): +class PackageListing(TimestampMixin, VisibilityMixin, AdminLinkMixin, models.Model): """ Represents a package's relation to how it's displayed on the site and APIs """ @@ -71,10 +72,9 @@ class PackageListing(TimestampMixin, AdminLinkMixin, models.Model): is_review_requested = models.BooleanField( default=False, ) - review_status = models.CharField( + review_status = models.TextField( default=PackageListingReviewStatus.unreviewed, choices=PackageListingReviewStatus.as_choices(), - max_length=512, ) rejection_reason = models.TextField( null=True, @@ -212,7 +212,7 @@ def reject( message = "\n\n".join(filter(bool, (rejection_reason, internal_notes))) fire_audit_event( self.build_audit_event( - action=AuditAction.PACKAGE_REJECTED, + action=AuditAction.LISTING_REJECTED, user_id=agent.pk if agent else None, message=message, ) @@ -238,7 +238,7 @@ def approve( ) fire_audit_event( self.build_audit_event( - action=AuditAction.PACKAGE_APPROVED, + action=AuditAction.LISTING_APPROVED, user_id=agent.pk if agent else None, message=internal_notes, ) @@ -331,32 +331,74 @@ def check_update_categories_permission(self, user: Optional[UserType]) -> bool: def can_user_manage_approval_status(self, user: Optional[UserType]) -> bool: return self.can_be_moderated_by_user(user) - def ensure_can_be_viewed_by_user(self, user: Optional[UserType]) -> None: - def get_has_perms() -> bool: - return ( - user is not None - and user.is_authenticated - and ( - self.community.can_user_manage_packages(user) - or self.package.owner.can_user_access(user) - ) - ) + def is_visible_to_user(self, user: UserType) -> bool: + if not self.visibility: + return False - if self.community.require_package_listing_approval: - if ( - self.review_status != PackageListingReviewStatus.approved - and not get_has_perms() - ): - raise ValidationError("Insufficient permissions to view") - else: - if ( - self.review_status == PackageListingReviewStatus.rejected - and not get_has_perms() - ): - raise ValidationError("Insufficient permissions to view") - - def can_be_viewed_by_user(self, user: Optional[UserType]) -> bool: - return check_validity(lambda: self.ensure_can_be_viewed_by_user(user)) + if self.visibility.public_detail: + return True + + if user is None: + return False + + if self.visibility.owner_detail: + if self.package.owner.can_user_access(user): + return True + + if self.visibility.moderator_detail: + for listing in self.package.community_listings.all(): + if listing.community.can_user_manage_packages(user): + return True + + if self.visibility.admin_detail: + if user.is_superuser: + return True + + return False + + @transaction.atomic + def update_visibility(self): + self.visibility.public_detail = True + self.visibility.public_list = True + self.visibility.owner_detail = True + self.visibility.owner_list = True + self.visibility.moderator_detail = True + self.visibility.moderator_list = True + + if not self.package.is_active: + self.visibility.public_detail = False + self.visibility.public_list = False + self.visibility.owner_detail = False + self.visibility.owner_list = False + self.visibility.moderator_detail = False + self.visibility.moderator_list = False + + if self.review_status == PackageListingReviewStatus.rejected: + self.visibility.public_detail = False + self.visibility.public_list = False + + if ( + self.community.require_package_listing_approval + and self.review_status == PackageListingReviewStatus.unreviewed + ): + self.visibility.public_detail = False + self.visibility.public_list = False + + versions = self.package.versions.filter(is_active=True).all() + if versions.exclude(visibility__public_detail=False).count() == 0: + self.visibility.public_detail = False + if versions.exclude(visibility__public_list=False).count() == 0: + self.visibility.public_list = False + if versions.exclude(visibility__owner_detail=False).count() == 0: + self.visibility.owner_detail = False + if versions.exclude(visibility__owner_list=False).count() == 0: + self.visibility.owner_list = False + if versions.exclude(visibility__moderator_detail=False).count() == 0: + self.visibility.moderator_detail = False + if versions.exclude(visibility__moderator_list=False).count() == 0: + self.visibility.moderator_list = False + + self.visibility.save() signals.post_save.connect(PackageListing.post_save, sender=PackageListing) diff --git a/django/thunderstore/community/templates/community/includes/package_header.html b/django/thunderstore/community/templates/community/includes/package_header.html index 371f35c81..ed4a50dc8 100644 --- a/django/thunderstore/community/templates/community/includes/package_header.html +++ b/django/thunderstore/community/templates/community/includes/package_header.html @@ -2,16 +2,16 @@
{{ object.package.description }}
+{{ version.description }}
+ This package has been rejected by site or community moderators. + If you think this is a mistake, please reach out to the moderators in + our Discord server +
+ {% if object.rejection_reason %} ++ Reason: {{ object.rejection_reason }} +
+ {% endif %} ++ This package is waiting for approval by site or community moderators +
+{{ object.notes }}
+- This package has been rejected by site or community moderators. - If you think this is a mistake, please reach out to the moderators in - our Discord server -
- {% if object.rejection_reason %} -- Reason: {{ object.rejection_reason }} -
- {% endif %} -- This package is waiting for approval by site or community moderators -
-{{ object.notes }}
-Dependency string | -{{ object.package.latest.full_version_name }} | +{{ version.full_version_name }} |
Dependants | @@ -152,12 +78,12 @@