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 }} icon + {{ object.package }} icon

{{ object.package.display_name }}

-

{{ object.package.description }}

+

{{ version.description }}

By {{ object.package.owner.name }}
- {% if object.package.website_url %} - + {% if version.website_url %} + - {{ object.package.website_url }} + {{ version.website_url }} {% endif %}
diff --git a/django/thunderstore/community/templates/community/includes/package_management_panel.html b/django/thunderstore/community/templates/community/includes/package_management_panel.html new file mode 100644 index 000000000..aea84ef64 --- /dev/null +++ b/django/thunderstore/community/templates/community/includes/package_management_panel.html @@ -0,0 +1,28 @@ +{% load encode_props %} + +{% if show_management_panel %} +
+ {% if review_panel_props %} +
+ + {% endif %} +
+ {% if show_listing_admin_link %} +   Listing admin + {% endif %} + {% if show_package_admin_link %} +   Package admin + {% endif %} +
+ +{% endif %} diff --git a/django/thunderstore/community/templates/community/includes/package_status.html b/django/thunderstore/community/templates/community/includes/package_status.html new file mode 100644 index 000000000..001cdc5cd --- /dev/null +++ b/django/thunderstore/community/templates/community/includes/package_status.html @@ -0,0 +1,56 @@ +{% if object.package.is_deprecated %} + +{% endif %} + +{% if show_review_status and object.package.unavailable_versions %} + +{% endif %} + +{% if show_review_status and object.is_rejected %} +
+
+

+ Package rejected +

+

+ 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 %} +
+
+{% endif %} + +{% if show_review_status and object.is_waiting_for_approval %} +
+
+

+ Waiting for approval +

+

+ This package is waiting for approval by site or community moderators +

+
+
+{% endif %} + +{% if show_internal_notes and object.notes %} +
+
+

+ Internal notes +

+

{{ object.notes }}

+
+
+{% endif %} diff --git a/django/thunderstore/community/templates/community/includes/version_table.html b/django/thunderstore/community/templates/community/includes/version_table.html index 0d2ca1c38..d4a6f019f 100644 --- a/django/thunderstore/community/templates/community/includes/version_table.html +++ b/django/thunderstore/community/templates/community/includes/version_table.html @@ -4,16 +4,87 @@ Version number Downloads Actions + {% if can_moderate %} + Moderation + {% endif %} {% for version in versions %} {{ version.date_created|date:"Y-n-j" }} {{ version.version_number }} {{ version.downloads }} - - Download - Install + + + {% if can_moderate %} + +
+ + +
+ + {% endif %} {% endfor %} + +{% if can_moderate %} + +{% endif %} diff --git a/django/thunderstore/community/templates/community/packagelisting_changelog.html b/django/thunderstore/community/templates/community/packagelisting_changelog.html index 8df5b476e..2a2df0d16 100644 --- a/django/thunderstore/community/templates/community/packagelisting_changelog.html +++ b/django/thunderstore/community/templates/community/packagelisting_changelog.html @@ -11,7 +11,7 @@ - + @@ -20,7 +20,10 @@ {% endblock %} {% block content %} -{% cache_until "any_package_updated" "mod-detail-changelog-tab" 300 object.package.pk object.package.latest.pk community_identifier %} + +{% include "community/includes/package_management_panel.html" %} + +{% cache_until "any_package_updated" "mod-detail-changelog-header" 300 object.package.pk version.pk community_identifier %} +{% endcache %} + +{% include "community/includes/package_status.html" %} + +{% cache_until "any_package_updated" "mod-detail-changelog" 300 object.package.pk version.pk community_identifier %} +
{% include "community/includes/package_tabs.html" with tabs=tabs %} {% include "community/includes/package_header.html" with object=object %} @@ -38,9 +47,9 @@

CHANGELOG

- {% if object.package.changelog %} + {% if version.changelog %}
- {{ object.package.changelog|markdownify }} + {{ version.changelog|markdownify }}
{% else %}
diff --git a/django/thunderstore/community/templates/community/packagelisting_detail.html b/django/thunderstore/community/templates/community/packagelisting_detail.html index ef854a324..408524bd7 100644 --- a/django/thunderstore/community/templates/community/packagelisting_detail.html +++ b/django/thunderstore/community/templates/community/packagelisting_detail.html @@ -3,53 +3,28 @@ {% load arrow %} {% load markdownify %} {% load cache_until %} -{% load encode_props %} {% load dynamic_html community_url %} {% block title %}{{ object.package.display_name }}{% endblock %} -{% block description %}{{ object.package.description }}{% endblock %} +{% block description %}{{ version.description }}{% endblock %} {% block opengraph %} - + - + - + {% endblock %} {% block content %} -{% if show_management_panel %} -
- {% if review_panel_props %} -
- - {% endif %} -
- {% if show_listing_admin_link %} -   Listing admin - {% endif %} - {% if show_package_admin_link %} -   Package admin - {% endif %} -
- -{% endif %} -{% cache_until "any_package_updated" "mod-detail-header" 300 object.package.pk community_identifier %} +{% include "community/includes/package_management_panel.html" %} + +{% cache_until "any_package_updated" "mod-detail-header" 300 version.pk community_identifier %} -{% if object.package.is_deprecated %} - -{% endif %} - {% endcache %} -{% if show_review_status and object.is_rejected %} -
-
-

- Package rejected -

-

- 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 %} -
-
-{% endif %} - -{% if show_review_status and object.is_waiting_for_approval %} -
-
-

- Waiting for approval -

-

- This package is waiting for approval by site or community moderators -

-
-
-{% endif %} - -{% if show_internal_notes and object.notes %} -
-
-

- Internal notes -

-

{{ object.notes }}

-
-
-{% endif %} +{% include "community/includes/package_status.html" %}
{% include "community/includes/package_tabs.html" with tabs=tabs %} - {% cache_until "any_package_updated" "mod-detail-content" 300 object.package.pk community_identifier %} + {% cache_until "any_package_updated" "mod-detail-content" 300 version.pk community_identifier %} {% include "community/includes/package_header.html" with object=object %}
@@ -140,7 +66,7 @@

- + @@ -152,12 +78,12 @@

@@ -172,16 +98,14 @@

{% dynamic_html "package_page_actions" %} -{% with object.package.dependencies as dependencies %} {% if dependencies %} {% include "repository/includes/dependencies.html" with dependencies=dependencies %} {% endif %} -{% endwith %}

README

- {{ object.package.readme|markdownify }} + {{ version.readme|markdownify }}
diff --git a/django/thunderstore/community/templates/community/packagelisting_list.html b/django/thunderstore/community/templates/community/packagelisting_list.html index d4393ec34..1b4cb74a3 100644 --- a/django/thunderstore/community/templates/community/packagelisting_list.html +++ b/django/thunderstore/community/templates/community/packagelisting_list.html @@ -152,6 +152,13 @@

{{ page_title }}

+ {% if not object.visibility.public_detail %} +
+
+ Unavailable +
+
+ {% endif %} {% if object.package.is_pinned %}
@@ -160,7 +167,13 @@

{{ page_title }}

{% endif %} - {{ object.package }} icon + {{ object.package }} icon
@@ -176,7 +189,11 @@
By {{ object.package.owner.name }}
- {{ object.package.description }} + {% if object.package.latest %} + {{ object.package.description }} + {% else %} + {{ object.package.versions.first.description }} + {% endif %}
{% for category in object.categories.all %} diff --git a/django/thunderstore/community/templates/community/packagelisting_versions.html b/django/thunderstore/community/templates/community/packagelisting_versions.html index 835e4dfe8..755abe237 100644 --- a/django/thunderstore/community/templates/community/packagelisting_versions.html +++ b/django/thunderstore/community/templates/community/packagelisting_versions.html @@ -10,7 +10,7 @@ - + @@ -19,7 +19,10 @@ {% endblock %} {% block content %} -{% cache_until "any_package_updated" "mod-detail-version-history" 300 object.package.pk community_identifier %} + +{% include "community/includes/package_management_panel.html" %} + +{% cache_until "any_package_updated" "mod-detail-versions-header" 300 object.package.pk community_identifier %} +{% endcache %} + +{% include "community/includes/package_status.html" %} + +{% cache_until "any_package_updated" "mod-detail-versions" 300 object.package.pk community_identifier can_moderate show_management_panel object.package.available_versions %} +
{% include "community/includes/package_tabs.html" with tabs=tabs %} {% include "community/includes/package_header.html" with object=object %} @@ -51,4 +60,20 @@

Available versions

{% endcache %} + +{% if show_management_panel and object.package.unavailable_versions %} +
+
+

Unavailable versions

+
+
+

+ The versions below are awaiting approval. If this takes more than 24 hours, please reach out to the moderators in + our Discord server. +

+ {% include "community/includes/version_table.html" with versions=object.package.unavailable_versions %} +
+
+{% endif %} + {% endblock %} diff --git a/django/thunderstore/community/tests/test_community_aggregated_fields.py b/django/thunderstore/community/tests/test_community_aggregated_fields.py index 347a2181c..155ef0f6e 100644 --- a/django/thunderstore/community/tests/test_community_aggregated_fields.py +++ b/django/thunderstore/community/tests/test_community_aggregated_fields.py @@ -75,16 +75,6 @@ def test_community_aggregated_fields__update_for_community__calculates_packages( assert community1.aggregated.package_count == 5 assert community2.aggregated.package_count == 0 - for _ in range(10): - pl = PackageListingFactory(community_=community1) - PackageListingFactory(community_=community2, package=pl.package) - - CommunityAggregatedFields.update_for_community(community1) - CommunityAggregatedFields.update_for_community(community2) - - assert community1.aggregated.package_count == 15 - assert community2.aggregated.package_count == 10 - @pytest.mark.django_db def test_community_aggregated_fields__update_for_community__calculates_downloads(): @@ -115,19 +105,42 @@ def test_community_aggregated_fields__update_for_community__calculates_downloads assert community2.aggregated.download_count == 0 assert community3.aggregated.download_count == 1 - listing = PackageListingFactory( - community_=community2, - package_version_kwargs={"downloads": 2}, + +@pytest.mark.django_db +def test_community_aggregated_fields__update_for_community__excludes_cross_community(): + community1 = CommunityFactory( + aggregated_fields=CommunityAggregatedFields.objects.create(), + ) + community2 = CommunityFactory( + aggregated_fields=CommunityAggregatedFields.objects.create(), ) - PackageListingFactory(community_=community3, package=listing.package) + + PackageListingFactory(community_=community1) + PackageListingFactory(community_=community2) CommunityAggregatedFields.update_for_community(community1) CommunityAggregatedFields.update_for_community(community2) - CommunityAggregatedFields.update_for_community(community3) - assert community1.aggregated.download_count == 0 - assert community2.aggregated.download_count == 2 - assert community3.aggregated.download_count == 3 + assert community1.aggregated.package_count == 1 + assert community2.aggregated.package_count == 1 + assert community1.package_listings.count() == 1 + assert community2.package_listings.count() == 1 + + for _ in range(10): + pl = PackageListingFactory(community_=community1) + PackageListingFactory( + community_=community2, + package=pl.package, + ) + PackageListingFactory(community_=community1) + + CommunityAggregatedFields.update_for_community(community1) + CommunityAggregatedFields.update_for_community(community2) + + assert community1.package_listings.count() == 12 + assert community2.package_listings.count() == 11 + assert community1.aggregated.package_count == 2 + assert community2.aggregated.package_count == 1 @pytest.mark.django_db @@ -148,30 +161,33 @@ def test_community_aggregated_fields__update_for_community__skips_inactive_packa @pytest.mark.django_db -def test_community_aggregated_fields__update_for_community__skips_unapproved_packages(): +def test_community_aggregated_fields__update_for_community__only_includes_public_list_listings(): community = CommunityFactory( aggregated_fields=CommunityAggregatedFields.objects.create(), - require_package_listing_approval=True, - ) - listing1 = PackageListingFactory( - community_=community, - review_status=PackageListingReviewStatus.unreviewed, - ) - listing2 = PackageListingFactory( - community_=community, - review_status=PackageListingReviewStatus.rejected, ) + listing1 = PackageListingFactory(community_=community) + listing2 = PackageListingFactory(community_=community) + + listing1.visibility.public_list = True + listing1.visibility.save() + listing2.visibility.public_list = False + listing2.visibility.save() CommunityAggregatedFields.update_for_community(community) - assert community.aggregated.package_count == 0 + assert listing1.visibility.public_list is True + assert listing2.visibility.public_list is False + assert community.aggregated.package_count == 1 + + listing1.visibility.public_list = True + listing1.visibility.save() + listing2.visibility.public_list = True + listing2.visibility.save() - listing1.review_status = PackageListingReviewStatus.approved - listing1.save() - listing2.review_status = PackageListingReviewStatus.approved - listing2.save() CommunityAggregatedFields.update_for_community(community) + assert listing1.visibility.public_list is True + assert listing2.visibility.public_list is True assert community.aggregated.package_count == 2 diff --git a/django/thunderstore/community/tests/test_package_listing.py b/django/thunderstore/community/tests/test_package_listing.py index 10ebcd70b..31ccae02b 100644 --- a/django/thunderstore/community/tests/test_package_listing.py +++ b/django/thunderstore/community/tests/test_package_listing.py @@ -181,12 +181,14 @@ def test_package_listing_ensure_can_be_viewed_by_user( team_role: str, ): listing = active_package_listing - listing.review_status = review_status - listing.save() community = listing.community community.require_package_listing_approval = require_approval community.save() + + listing.review_status = review_status + listing.save() + user = TestUserTypes.get_user_by_type(user_type) if community_role is not None and user_type not in TestUserTypes.fake_users(): CommunityMembership.objects.create( @@ -201,46 +203,30 @@ def test_package_listing_ensure_can_be_viewed_by_user( role=team_role, ) - result = listing.can_be_viewed_by_user(user) - errors = [] - expected_error = "Insufficient permissions to view" - try: - listing.ensure_can_be_viewed_by_user(user) - except ValidationError as e: - errors = e.messages + result = listing.is_visible_to_user(user) if require_approval: if review_status == PackageListingReviewStatus.approved: assert result is True - assert not errors elif user is None: assert result is False - assert expected_error in errors elif not user.is_authenticated: assert result is False - assert expected_error in errors elif community.can_user_manage_packages(user): assert result is True - assert not errors elif listing.package.owner.can_user_access(user): assert result is True - assert not errors else: if review_status != PackageListingReviewStatus.rejected: assert result is True - assert not errors elif user is None: assert result is False - assert expected_error in errors elif not user.is_authenticated: assert result is False - assert expected_error in errors elif community.can_user_manage_packages(user): assert result is True - assert not errors elif listing.package.owner.can_user_access(user): assert result is True - assert not errors @pytest.mark.django_db diff --git a/django/thunderstore/core/tasks.py b/django/thunderstore/core/tasks.py index 11376aed8..1a30dd920 100644 --- a/django/thunderstore/core/tasks.py +++ b/django/thunderstore/core/tasks.py @@ -6,7 +6,7 @@ from thunderstore.core.settings import CeleryQueues -@shared_task(queue=CeleryQueues.Default) +@shared_task(queue=CeleryQueues.Default, rate_limit="2/s") def celery_post( webhook_url: str, data: Optional[str] = None, diff --git a/django/thunderstore/frontend/api/experimental/tests/test_community_package_list.py b/django/thunderstore/frontend/api/experimental/tests/test_community_package_list.py index 0d46a464e..2bbb120a9 100644 --- a/django/thunderstore/frontend/api/experimental/tests/test_community_package_list.py +++ b/django/thunderstore/frontend/api/experimental/tests/test_community_package_list.py @@ -11,6 +11,8 @@ from thunderstore.community.models import PackageCategory, PackageListingSection from thunderstore.community.models.package_listing import PackageListing from thunderstore.frontend.api.experimental.views import CommunityPackageListApiView +from thunderstore.permissions.factories import VisibilityFlagsFactory +from thunderstore.repository.consts import PackageVersionReviewStatus from thunderstore.repository.factories import ( PackageRatingFactory, PackageVersionFactory, @@ -109,6 +111,69 @@ def test_only_approved_packages_are_returned_when_approval_is_required( __assert_packages_by_listings(data, listing2) +@pytest.mark.django_db +def test_only_visible_packages_are_returned(api_client: APIClient) -> None: + listing1 = PackageListingFactory() + listing1.review_status = PackageListingReviewStatus.approved + listing1.save() + + listing2 = PackageListingFactory( + community_=listing1.community, + ) + listing2.review_status = PackageListingReviewStatus.approved + listing2.save() + + listing3 = PackageListingFactory( + community_=listing1.community, + ) + listing3.review_status = PackageListingReviewStatus.rejected + listing3.save() + + data = __query_api(api_client, listing1.community.identifier) + + assert listing1.visibility.public_list is True + assert listing2.visibility.public_list is True + assert listing3.visibility.public_list is False + + assert len(data["packages"]) == 2 + __assert_packages_by_listings(data, [listing2, listing1]) + + +@pytest.mark.django_db +def test_packages_with_only_rejected_versions_are_not_returned( + api_client: APIClient, +) -> None: + listing1 = PackageListingFactory() + listing1.review_status = PackageListingReviewStatus.approved + listing1.save() + + listing2 = PackageListingFactory(community_=listing1.community) + listing1.review_status = PackageListingReviewStatus.approved + listing2.save() + PackageVersionFactory( + package=listing2.package, + version_number="2.0.0", + ) + + listing3 = PackageListingFactory(community_=listing1.community) + listing1.review_status = PackageListingReviewStatus.approved + listing3.save() + + listing1.package.latest.review_status = PackageVersionReviewStatus.rejected + listing1.package.latest.save() + + listing2.package.latest.review_status = PackageVersionReviewStatus.rejected + listing2.package.latest.save() + + listing2.package.recache_latest() # can't wait for post_save() here + listing2.package.latest.review_status = PackageVersionReviewStatus.rejected + listing2.package.latest.save() + + data = __query_api(api_client, listing1.community.identifier) + + assert len(data["packages"]) == 1 + + @pytest.mark.django_db def test_deprecated_packages_are_returned_only_when_requested( api_client: APIClient, diff --git a/django/thunderstore/frontend/api/experimental/views/community_package_list.py b/django/thunderstore/frontend/api/experimental/views/community_package_list.py index e1682dbe6..4e8b1d555 100644 --- a/django/thunderstore/frontend/api/experimental/views/community_package_list.py +++ b/django/thunderstore/frontend/api/experimental/views/community_package_list.py @@ -91,6 +91,7 @@ def get_queryset(self, community: Community) -> QuerySet[Package]: .filter( community_listings__community__pk=community.pk, community_listings__review_status__in=review_statuses, + community_listings__visibility__public_list=True, ) .prefetch_related( community_listings, diff --git a/django/thunderstore/frontend/api/experimental/views/package_details.py b/django/thunderstore/frontend/api/experimental/views/package_details.py index dbf5bcec1..1bb6e6819 100644 --- a/django/thunderstore/frontend/api/experimental/views/package_details.py +++ b/django/thunderstore/frontend/api/experimental/views/package_details.py @@ -44,7 +44,7 @@ def get( package__name=package_name, ) - if not listing.can_be_viewed_by_user(request.user): + if not listing.is_visible_to_user(request.user): raise Http404() serializer = self.serialize_results(listing) @@ -58,7 +58,8 @@ def get_listing_queryset(self) -> QuerySet[PackageListing]: results by version number. """ return ( - PackageListing.objects.active() + PackageListing.objects.system() + .active() .select_related( "package", "package__latest", diff --git a/django/thunderstore/permissions/factories.py b/django/thunderstore/permissions/factories.py new file mode 100644 index 000000000..52ec4a6eb --- /dev/null +++ b/django/thunderstore/permissions/factories.py @@ -0,0 +1,17 @@ +from factory.django import DjangoModelFactory + +from thunderstore.permissions.models import VisibilityFlags + + +class VisibilityFlagsFactory(DjangoModelFactory): + class Meta: + model = VisibilityFlags + + public_list = True + public_detail = True + owner_list = True + owner_detail = True + moderator_list = True + moderator_detail = True + admin_list = True + admin_detail = True diff --git a/django/thunderstore/permissions/mixins.py b/django/thunderstore/permissions/mixins.py index 477f3bc58..cf12bc98c 100644 --- a/django/thunderstore/permissions/mixins.py +++ b/django/thunderstore/permissions/mixins.py @@ -1,15 +1,19 @@ from django.db import models, transaction from django.db.models import Q +from thunderstore.core.types import UserType from thunderstore.permissions.models import VisibilityFlags class VisibilityQuerySet(models.QuerySet): + def active(self): + return self + def public_list(self): - return self.exclude(visibility__public_list=False) + return self.active().exclude(visibility__public_list=False) def public_detail(self): - return self.exclude(visibility__public_detail=False) + return self.active().exclude(visibility__public_detail=False) def visible_list(self, is_owner: bool, is_moderator: bool, is_admin: bool): filter = Q(visibility__public_list=True) @@ -19,7 +23,7 @@ def visible_list(self, is_owner: bool, is_moderator: bool, is_admin: bool): filter |= Q(visibility__moderator_list=True) if is_admin: filter |= Q(visibility__admin_list=True) - return self.exclude(~filter) + return self.active().exclude(~filter) def visible_detail(self, is_owner: bool, is_moderator: bool, is_admin: bool): filter = Q(visibility__public_detail=True) @@ -29,7 +33,10 @@ def visible_detail(self, is_owner: bool, is_moderator: bool, is_admin: bool): filter |= Q(visibility__moderator_detail=True) if is_admin: filter |= Q(visibility__admin_detail=True) - return self.exclude(~filter) + return self.active().exclude(~filter) + + def system(self): + return self class VisibilityMixin(models.Model): @@ -41,11 +48,21 @@ class VisibilityMixin(models.Model): on_delete=models.PROTECT, ) + @transaction.atomic + def update_visibility(self): + pass + @transaction.atomic def save(self, *args, **kwargs): if not self.pk and not self.visibility: self.visibility = VisibilityFlags.objects.create_public() + + self.update_visibility() + super().save() class Meta: abstract = True + + def is_visible_to_user(self, user: UserType) -> bool: + return False diff --git a/django/thunderstore/permissions/models/visibility.py b/django/thunderstore/permissions/models/visibility.py index 83116055d..9d01e7464 100644 --- a/django/thunderstore/permissions/models/visibility.py +++ b/django/thunderstore/permissions/models/visibility.py @@ -14,6 +14,18 @@ def create_public(self): admin_detail=True, ) + def create_unpublished(self): + return self.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, + ) + class VisibilityFlags(models.Model): objects = VisibilityFlagsQuerySet.as_manager() diff --git a/django/thunderstore/repository/admin/package_version.py b/django/thunderstore/repository/admin/package_version.py index 603fcbea1..dfb9bfb06 100644 --- a/django/thunderstore/repository/admin/package_version.py +++ b/django/thunderstore/repository/admin/package_version.py @@ -1,10 +1,15 @@ from django.contrib import admin +from django.core.cache import cache +from django.db import transaction from django.db.models import BooleanField, ExpressionWrapper, Q, QuerySet from django.http import HttpRequest from django.urls import reverse from django.utils.safestring import mark_safe +from thunderstore.cache.enums import CacheBustCondition +from thunderstore.cache.tasks import invalidate_cache from thunderstore.community.models import PackageListing +from thunderstore.repository.consts import PackageVersionReviewStatus from thunderstore.repository.models import PackageVersion from thunderstore.repository.tasks.files import extract_package_version_file_tree @@ -17,11 +22,33 @@ def extract_file_list(modeladmin, request, queryset: QuerySet): extract_file_list.short_description = "Queue file list extraction" +@transaction.atomic +def reject_version(modeladmin, request, queryset: QuerySet[PackageVersion]): + for version in queryset: + version.review_status = PackageVersionReviewStatus.rejected + version.save(update_fields=("review_status",)) + + +reject_version.short_description = "Reject" + + +@transaction.atomic +def approve_version(modeladmin, request, queryset: QuerySet[PackageVersion]): + for version in queryset: + version.review_status = PackageVersionReviewStatus.approved + version.save(update_fields=("review_status",)) + + +approve_version.short_description = "Approve" + + @admin.register(PackageVersion) class PackageVersionAdmin(admin.ModelAdmin): model = PackageVersion actions = [ extract_file_list, + reject_version, + approve_version, ] list_select_related = ( "package", @@ -33,6 +60,7 @@ class PackageVersionAdmin(admin.ModelAdmin): "package", "version_number", "is_active", + "review_status", "file_size", "downloads", "date_created", diff --git a/django/thunderstore/repository/api/experimental/urls.py b/django/thunderstore/repository/api/experimental/urls.py index 20535fe13..c1059abe2 100644 --- a/django/thunderstore/repository/api/experimental/urls.py +++ b/django/thunderstore/repository/api/experimental/urls.py @@ -11,6 +11,10 @@ from thunderstore.repository.api.experimental.views.package_index import ( PackageIndexApiView, ) +from thunderstore.repository.api.experimental.views.package_version import ( + PackageVersionApproveApiView, + PackageVersionRejectApiView, +) from thunderstore.repository.api.experimental.views.submit import SubmitPackageApiView from thunderstore.repository.api.experimental.views.submit_async import ( CreateAsyncPackageSubmissionApiView, @@ -98,4 +102,14 @@ IconValidatorApiView.as_view(), name="submission.validate.icon", ), + path( + "package-version//approve/", + PackageVersionApproveApiView.as_view(), + name="package-version.approve", + ), + path( + "package-version//reject/", + PackageVersionRejectApiView.as_view(), + name="package-version.reject", + ), ] diff --git a/django/thunderstore/repository/api/experimental/views/package_index.py b/django/thunderstore/repository/api/experimental/views/package_index.py index 803a6b4d9..eb0322aba 100644 --- a/django/thunderstore/repository/api/experimental/views/package_index.py +++ b/django/thunderstore/repository/api/experimental/views/package_index.py @@ -43,7 +43,8 @@ def get_dependencies(self, instance: PackageVersion) -> List[str]: def serialize_package_index() -> bytes: versions: PackageVersionQuerySet = ( - PackageVersion.objects.active() + PackageVersion.objects.system() + .active() .annotate(namespace=F("package__namespace")) .prefetch_related("dependencies", "dependencies__package") ) diff --git a/django/thunderstore/repository/api/experimental/views/package_version.py b/django/thunderstore/repository/api/experimental/views/package_version.py index 5fd4bc66f..1a926b269 100644 --- a/django/thunderstore/repository/api/experimental/views/package_version.py +++ b/django/thunderstore/repository/api/experimental/views/package_version.py @@ -1,6 +1,8 @@ from drf_yasg.utils import swagger_auto_schema -from rest_framework.exceptions import ValidationError -from rest_framework.generics import RetrieveAPIView, get_object_or_404 +from rest_framework import permissions, status +from rest_framework.authentication import BasicAuthentication, SessionAuthentication +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import GenericAPIView, RetrieveAPIView, get_object_or_404 from rest_framework.response import Response from thunderstore.cache.cache import ManualCacheCommunityMixin @@ -36,7 +38,8 @@ def get_object(self): def get_queryset(self): return ( - PackageVersion.objects.active() + PackageVersion.objects.system() + .active() .select_related( "package", "package__owner", @@ -107,3 +110,41 @@ def retrieve(self, request, *args, **kwargs): instance = self.get_object() serializer = self.get_serializer({"markdown": instance.readme}) return Response(serializer.data) + + +class PackageVersionRejectApiView(GenericAPIView): + queryset = PackageVersion.objects + + @swagger_auto_schema( + operation_id="experimental.package_version.reject", + tags=["experimental"], + ) + def post(self, request, *args, **kwargs): + version: PackageVersion = self.get_object() + + try: + version.reject( + agent=request.user, + ) + return Response(status=status.HTTP_200_OK) + except PermissionError: + raise PermissionDenied() + + +class PackageVersionApproveApiView(GenericAPIView): + queryset = PackageVersion.objects + + @swagger_auto_schema( + operation_id="experimental.package_version.approve", + tags=["experimental"], + ) + def post(self, request, *args, **kwargs): + version: PackageVersion = self.get_object() + + try: + version.approve( + agent=request.user, + ) + return Response(status=status.HTTP_200_OK) + except PermissionError: + raise PermissionDenied() diff --git a/django/thunderstore/repository/api/v1/views/metrics.py b/django/thunderstore/repository/api/v1/views/metrics.py index 31351571a..78c81b960 100644 --- a/django/thunderstore/repository/api/v1/views/metrics.py +++ b/django/thunderstore/repository/api/v1/views/metrics.py @@ -45,7 +45,7 @@ class PackageVersionMetricsApiView(APIView): ) def get(self, request: Request, namespace: str, name: str, version: str): obj = get_object_or_404( - PackageVersion.objects.active(), + PackageVersion.objects.system().active(), package__is_active=True, package__namespace__name=namespace, name=name, diff --git a/django/thunderstore/repository/api/v1/viewsets.py b/django/thunderstore/repository/api/v1/viewsets.py index 503ec0683..e0f77fb37 100644 --- a/django/thunderstore/repository/api/v1/viewsets.py +++ b/django/thunderstore/repository/api/v1/viewsets.py @@ -41,7 +41,7 @@ def serialize_package_list_for_community(community: Community) -> bytes: result.write(b"[") for index, ids in enumerate(batch(batch_size, listing_ids)): queryset = order_package_listing_queryset( - PackageListing.objects.filter(id__in=ids) + PackageListing.objects.system().filter(id__in=ids) ) serializer = PACKAGE_SERIALIZER( queryset, diff --git a/django/thunderstore/repository/cache.py b/django/thunderstore/repository/cache.py index f3ea879bf..ea61c8640 100644 --- a/django/thunderstore/repository/cache.py +++ b/django/thunderstore/repository/cache.py @@ -29,10 +29,8 @@ def order_package_listing_queryset( def get_package_listing_base_queryset( community_identifier: str, ) -> QuerySet[PackageListing]: - return ( - PackageListing.objects.active() - .filter_by_community_approval_rule() - .exclude(~Q(community__identifier=community_identifier)) + return PackageListing.objects.public_list().exclude( + ~Q(community__identifier=community_identifier) ) diff --git a/django/thunderstore/repository/consts.py b/django/thunderstore/repository/consts.py index b0ae54ee4..32dc76a3b 100644 --- a/django/thunderstore/repository/consts.py +++ b/django/thunderstore/repository/consts.py @@ -1,9 +1,18 @@ import re +from thunderstore.core.utils import ChoiceEnum + PACKAGE_NAME_REGEX = re.compile(r"^[a-zA-Z0-9\_]+$") PACKAGE_VERSION_REGEX = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") - PACKAGE_REFERENCE_COMPONENT_REGEX = re.compile( r"^[a-zA-Z0-9]+([a-zA-Z0-9\_]+[a-zA-Z0-9])?$" ) + + +class PackageVersionReviewStatus(ChoiceEnum): + pending = "pending" + approved = "approved" + rejected = "rejected" + skipped = "skipped" + immune = "immune" diff --git a/django/thunderstore/repository/migrations/0055_packageversion_review_status.py b/django/thunderstore/repository/migrations/0055_packageversion_review_status.py new file mode 100644 index 000000000..413aab4a8 --- /dev/null +++ b/django/thunderstore/repository/migrations/0055_packageversion_review_status.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.7 on 2024-09-30 15:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("repository", "0054_alter_chunked_package_cache_index"), + ] + + operations = [ + migrations.AddField( + model_name="packageversion", + name="review_status", + field=models.CharField( + choices=[ + ("pending", "pending"), + ("approved", "approved"), + ("rejected", "rejected"), + ("skipped", "skipped"), + ("immune", "immune"), + ], + default="skipped", + max_length=512, + ), + ), + ] diff --git a/django/thunderstore/repository/migrations/0056_create_default_visibility_for_existing_versions.py b/django/thunderstore/repository/migrations/0056_create_default_visibility_for_existing_versions.py new file mode 100644 index 000000000..ca1a2cc08 --- /dev/null +++ b/django/thunderstore/repository/migrations/0056_create_default_visibility_for_existing_versions.py @@ -0,0 +1,62 @@ +from django.db import migrations + +from thunderstore.repository.consts import PackageVersionReviewStatus + + +def create_default_visibility_for_existing_records(apps, schema_editor): + PackageVersion = apps.get_model("repository", "PackageVersion") + VisibilityFlags = apps.get_model("permissions", "VisibilityFlags") + + for instance in PackageVersion.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(version): + version.visibility.public_detail = True + version.visibility.public_list = True + version.visibility.owner_detail = True + version.visibility.owner_list = True + version.visibility.moderator_detail = True + version.visibility.moderator_list = True + + if not version.is_active or not version.package.is_active: + version.visibility.public_detail = False + version.visibility.public_list = False + version.visibility.owner_detail = False + version.visibility.owner_list = False + version.visibility.moderator_detail = False + version.visibility.moderator_list = False + + if ( + version.review_status == PackageVersionReviewStatus.rejected + or version.review_status == PackageVersionReviewStatus.pending + ): + version.visibility.public_detail = False + version.visibility.public_list = False + + version.visibility.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("repository", "0055_packageversion_review_status"), + ] + + operations = [ + migrations.RunPython( + create_default_visibility_for_existing_records, migrations.RunPython.noop + ), + ] diff --git a/django/thunderstore/repository/migrations/0057_auto_20241112_1930.py b/django/thunderstore/repository/migrations/0057_auto_20241112_1930.py new file mode 100644 index 000000000..93417d64b --- /dev/null +++ b/django/thunderstore/repository/migrations/0057_auto_20241112_1930.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.7 on 2024-11-12 19:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("repository", "0056_create_default_visibility_for_existing_versions"), + ] + + operations = [ + migrations.AlterField( + model_name="packageversion", + name="review_status", + field=models.TextField( + choices=[ + ("pending", "pending"), + ("approved", "approved"), + ("rejected", "rejected"), + ("skipped", "skipped"), + ("immune", "immune"), + ], + default="skipped", + ), + ), + ] diff --git a/django/thunderstore/repository/models/cache.py b/django/thunderstore/repository/models/cache.py index 6a0f2947a..92277f27b 100644 --- a/django/thunderstore/repository/models/cache.py +++ b/django/thunderstore/repository/models/cache.py @@ -254,10 +254,11 @@ def get_package_listing_chunk( ordering = models.Case( *[models.When(id=id, then=pos) for pos, id in enumerate(listing_ids)] ) - listing_ref = PackageListing.objects.filter(pk=models.OuterRef("pk")) + listing_ref = PackageListing.objects.system().filter(pk=models.OuterRef("pk")) return ( - PackageListing.objects.filter(id__in=listing_ids) + PackageListing.objects.system() + .filter(id__in=listing_ids) .select_related("community", "package", "package__owner") .prefetch_related("categories", "community__sites", "package__versions") .annotate( diff --git a/django/thunderstore/repository/models/package.py b/django/thunderstore/repository/models/package.py index 5abe5bf58..433734353 100644 --- a/django/thunderstore/repository/models/package.py +++ b/django/thunderstore/repository/models/package.py @@ -109,6 +109,8 @@ def validate(self): def save(self, *args, **kwargs): self.validate() + for listing in self.community_listings.all(): + listing.update_visibility() return super().save(*args, **kwargs) def get_or_create_package_listing(self, community): @@ -123,10 +125,15 @@ def get_or_create_package_listing(self, community): def get_package_listing(self, community): from thunderstore.community.models import PackageListing - return PackageListing.objects.filter( - package=self, - community=community, - ).first() + return ( + PackageListing.objects.system() + .active() + .filter( + package=self, + community=community, + ) + .first() + ) def update_listing(self, has_nsfw_content, categories, community): listing = self.get_or_create_package_listing(community) @@ -162,9 +169,29 @@ def display_name(self): @cached_property def available_versions(self): # TODO: Caching - versions = self.versions.filter(is_active=True).values_list( - "pk", "version_number" + versions = self.versions.public_list().values_list("pk", "version_number") + ordered = sorted(versions, key=lambda version: StrictVersion(version[1])) + pk_list = [version[0] for version in reversed(ordered)] + preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pk_list)]) + return ( + self.versions.filter(pk__in=pk_list) + .order_by(preserved) + .prefetch_related( + "dependencies", + "dependencies__package", + "dependencies__package__owner", + ) + .select_related( + "package", + "package__owner", + ) ) + + @cached_property + def unavailable_versions(self): + versions = self.versions.filter( + is_active=True, visibility__public_list=False, visibility__owner_list=True + ).values_list("pk", "version_number") ordered = sorted(versions, key=lambda version: StrictVersion(version[1])) pk_list = [version[0] for version in reversed(ordered)] preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pk_list)]) @@ -246,7 +273,7 @@ def get_view_on_site_url(self) -> Optional[str]: # TODO: Point this to the main page of a package once that exists as a concept from thunderstore.community.models import PackageListing - listing = PackageListing.objects.active().filter(package=self).first() + listing = PackageListing.objects.system().active().filter(package=self).first() return listing.get_full_url() if listing else None def get_page_url(self, community_identifier: str) -> str: diff --git a/django/thunderstore/repository/models/package_version.py b/django/thunderstore/repository/models/package_version.py index 23a434172..034f7525d 100644 --- a/django/thunderstore/repository/models/package_version.py +++ b/django/thunderstore/repository/models/package_version.py @@ -6,18 +6,32 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.files.storage import get_storage_class -from django.db import models +from django.db import models, transaction from django.db.models import Manager, Q, QuerySet, Sum, signals from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property +from thunderstore.community.consts import PackageListingReviewStatus +from thunderstore.community.models import Community from thunderstore.core.mixins import AdminLinkMixin +from thunderstore.core.types import UserType +from thunderstore.core.utils import check_validity from thunderstore.permissions.mixins import VisibilityMixin, VisibilityQuerySet -from thunderstore.repository.consts import PACKAGE_NAME_REGEX +from thunderstore.permissions.models import VisibilityFlags +from thunderstore.repository.consts import ( + PACKAGE_NAME_REGEX, + PackageVersionReviewStatus, +) from thunderstore.repository.models import Package from thunderstore.repository.package_formats import PackageFormats from thunderstore.utils.decorators import run_after_commit +from thunderstore.webhooks.audit import ( + AuditAction, + AuditEvent, + AuditEventField, + fire_audit_event, +) from thunderstore.webhooks.models.release import Webhook if TYPE_CHECKING: @@ -36,9 +50,12 @@ def get_version_png_filepath(instance, filename): class PackageVersionQuerySet(VisibilityQuerySet): - def active(self) -> "QuerySet[PackageVersion]": # TODO: Generic type + def active(self): return self.exclude(is_active=False) + def filter_by_review_status(self): + return self.exclude(review_status__in=["pending", "rejected"]) + def chunked_enumerate(self, chunk_size=1000) -> Iterator["PackageVersion"]: """ Enumerate over all the results without fetching everything at once. @@ -120,6 +137,12 @@ class PackageVersion(VisibilityMixin, AdminLinkMixin): readme = models.TextField() changelog = models.TextField(blank=True, null=True) + # TODO: Default should be pending once all versions require automated scanning before appearing to users + review_status = models.TextField( + default=PackageVersionReviewStatus.skipped, + choices=PackageVersionReviewStatus.as_choices(), + ) + # .zip file = models.FileField( upload_to=get_version_zip_filepath, @@ -253,7 +276,7 @@ def post_delete(sender, instance, **kwargs): @classmethod def get_total_used_disk_space(cls): - return cls.objects.aggregate(total=Sum("file_size"))["total"] or 0 + return cls.objects.system().aggregate(total=Sum("file_size"))["total"] or 0 @run_after_commit def announce_release(self): @@ -297,6 +320,138 @@ def log_download_event(version_id: int, client_ip: Optional[str]): log_version_download.delay(version_id, timezone.now().isoformat()) + def build_audit_event( + self, + *, + action: AuditAction, + user_id: Optional[int], + message: Optional[str] = None, + ) -> AuditEvent: + return AuditEvent( + timestamp=timezone.now(), + user_id=user_id, + action=action, + message=message, + related_url=self.package.get_view_on_site_url(), + fields=[ + AuditEventField( + name="Package", + value=self.package.full_package_name, + ), + ], + ) + + @transaction.atomic + def reject( + self, + agent: Optional[UserType], + is_system: bool = False, + message: Optional[str] = None, + ): + if self.review_status == PackageVersionReviewStatus.immune: + raise PermissionError() + + if is_system or self.can_user_manage_approval_status(agent): + self.review_status = PackageVersionReviewStatus.rejected + self.save(update_fields=("review_status",)) + + fire_audit_event( + self.build_audit_event( + action=AuditAction.VERSION_REJECTED, + user_id=agent.pk if agent else None, + message=message, + ) + ) + else: + raise PermissionError() + + @transaction.atomic + def approve( + self, + agent: Optional[UserType], + is_system: bool = False, + message: Optional[str] = None, + ): + if self.review_status == PackageVersionReviewStatus.immune: + raise PermissionError() + + if is_system or self.can_user_manage_approval_status(agent): + self.review_status = PackageVersionReviewStatus.approved + self.save(update_fields=("review_status",)) + + fire_audit_event( + self.build_audit_event( + action=AuditAction.VERSION_APPROVED, + user_id=agent.pk if agent else None, + message=message, + ) + ) + else: + raise PermissionError() + + def can_user_manage_approval_status(self, user: Optional[UserType]) -> bool: + if self.review_status == PackageVersionReviewStatus.immune: + return False + + for listing in self.package.community_listings.all(): + if listing.can_user_manage_approval_status(user): + return True + return False + + def is_visible_to_user(self, user: UserType) -> bool: + if not self.visibility: + return False + + 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.is_active or 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 == PackageVersionReviewStatus.rejected + or self.review_status == PackageVersionReviewStatus.pending + ): + self.visibility.public_detail = False + self.visibility.public_list = False + + self.visibility.save() + for listing in self.package.community_listings.all(): + listing.update_visibility() + self.package.recache_latest() + signals.post_save.connect(PackageVersion.post_save, sender=PackageVersion) signals.post_delete.connect(PackageVersion.post_delete, sender=PackageVersion) diff --git a/django/thunderstore/repository/package_reference.py b/django/thunderstore/repository/package_reference.py index 98e75c0b6..e93a6f1a9 100644 --- a/django/thunderstore/repository/package_reference.py +++ b/django/thunderstore/repository/package_reference.py @@ -188,7 +188,7 @@ def queryset(self) -> QuerySet: :rtype: QuerySet of PackageVersion or Package """ if self.version: - return PackageVersion.objects.filter(**self.get_filter_kwargs()) + return PackageVersion.objects.system().filter(**self.get_filter_kwargs()) else: return Package.objects.filter(**self.get_filter_kwargs()) diff --git a/django/thunderstore/repository/package_upload.py b/django/thunderstore/repository/package_upload.py index 0588437e3..f0ef7914d 100644 --- a/django/thunderstore/repository/package_upload.py +++ b/django/thunderstore/repository/package_upload.py @@ -204,6 +204,9 @@ def save(self, *args, **kwargs) -> PackageVersion: zip_data=self.cleaned_data["file"], ) + self.instance.icon.save("icon.png", self.icon) + instance = super().save() + community_categories = self.cleaned_data.get("community_categories", {}) for community in self.cleaned_data.get("communities", []): categories = community_categories.get(community.identifier, []) @@ -219,8 +222,6 @@ def save(self, *args, **kwargs) -> PackageVersion: community=community, ) - self.instance.icon.save("icon.png", self.icon) - instance = super().save() for reference in self.manifest["dependencies"]: instance.dependencies.add(reference.instance) diff --git a/django/thunderstore/repository/tasks/downloads.py b/django/thunderstore/repository/tasks/downloads.py index bbb35e7df..d70713017 100644 --- a/django/thunderstore/repository/tasks/downloads.py +++ b/django/thunderstore/repository/tasks/downloads.py @@ -18,6 +18,6 @@ def log_version_download(version_id: int, timestamp: str): version_id=version_id, timestamp=datetime.fromisoformat(timestamp), ) - PackageVersion.objects.filter(id=version_id).update( + PackageVersion.objects.system().active().filter(id=version_id).update( downloads=F("downloads") + 1 ) diff --git a/django/thunderstore/repository/templates/repository/_wiki_base.html b/django/thunderstore/repository/templates/repository/_wiki_base.html index 38650d0d3..ba97888fc 100644 --- a/django/thunderstore/repository/templates/repository/_wiki_base.html +++ b/django/thunderstore/repository/templates/repository/_wiki_base.html @@ -29,6 +29,8 @@ {% block content %} +{% include "community/includes/package_management_panel.html" %} + -
+{% include "community/includes/package_status.html" %} + +
{% include "community/includes/package_tabs.html" with tabs=tabs%} {% block wiki_content %}{% endblock %}
diff --git a/django/thunderstore/repository/templates/repository/includes/dependencies.html b/django/thunderstore/repository/templates/repository/includes/dependencies.html index 258004caa..76fcf82c8 100644 --- a/django/thunderstore/repository/templates/repository/includes/dependencies.html +++ b/django/thunderstore/repository/templates/repository/includes/dependencies.html @@ -7,7 +7,7 @@

This mod requires the following mods to function

- {% for dependency in object.package.dependencies.all %} + {% for dependency in dependencies %}
{{ dependency }} icon diff --git a/django/thunderstore/repository/templates/repository/packageversion_detail.html b/django/thunderstore/repository/templates/repository/packageversion_detail.html index 6819ce8da..e24f108f4 100644 --- a/django/thunderstore/repository/templates/repository/packageversion_detail.html +++ b/django/thunderstore/repository/templates/repository/packageversion_detail.html @@ -36,6 +36,11 @@ +{% if not object.visibility.public_list %} + +{% endif %} {% if object.is_deprecated %}

Dependency string{{ object.package.latest.full_version_name }}{{ version.full_version_name }}
Dependants