diff --git a/django/thunderstore/community/admin/package_listing.py b/django/thunderstore/community/admin/package_listing.py index bc49bb6d3..07bdf3651 100644 --- a/django/thunderstore/community/admin/package_listing.py +++ b/django/thunderstore/community/admin/package_listing.py @@ -1,8 +1,10 @@ +from html import escape from typing import Optional from django.contrib import admin from django.db import transaction from django.db.models import QuerySet +from django.utils.safestring import mark_safe from ..consts import PackageListingReviewStatus from ..forms import PackageListingAdminForm @@ -62,6 +64,7 @@ class PackageListingAdmin(admin.ModelAdmin): "package__namespace__name", "package__owner__name", "package__name", + "package__versions__file_tree__entries__blob__checksum_sha256", ) list_select_related = ( "package", @@ -70,11 +73,19 @@ class PackageListingAdmin(admin.ModelAdmin): "community", ) readonly_fields = ( - "package", + "package_link", "community", "datetime_created", "datetime_updated", ) + exclude = ("package",) + + def package_link(self, obj): + return mark_safe( + f'{escape(str(obj.package))}' + ) + + package_link.short_description = "Package" def get_readonly_fields(self, request, obj=None): if obj: @@ -82,6 +93,12 @@ def get_readonly_fields(self, request, obj=None): else: return [] + def get_exclude(self, request, obj=None): + if obj: + return self.exclude + else: + return [] + def get_view_on_site_url( self, obj: Optional[PackageListing] = None ) -> Optional[str]: diff --git a/django/thunderstore/community/models/package_listing.py b/django/thunderstore/community/models/package_listing.py index 86ee191e4..03c97f29b 100644 --- a/django/thunderstore/community/models/package_listing.py +++ b/django/thunderstore/community/models/package_listing.py @@ -11,7 +11,7 @@ from thunderstore.cache.enums import CacheBustCondition from thunderstore.cache.tasks import invalidate_cache_on_commit_async from thunderstore.community.consts import PackageListingReviewStatus -from thunderstore.core.mixins import TimestampMixin +from thunderstore.core.mixins import AdminLinkMixin, TimestampMixin from thunderstore.core.types import UserType from thunderstore.core.utils import check_validity from thunderstore.frontend.url_reverse import get_community_url_reverse_args @@ -46,7 +46,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, models.Model): +class PackageListing(TimestampMixin, AdminLinkMixin, models.Model): """ Represents a package's relation to how it's displayed on the site and APIs """ diff --git a/django/thunderstore/core/mixins.py b/django/thunderstore/core/mixins.py index 127b4b1e6..846581331 100644 --- a/django/thunderstore/core/mixins.py +++ b/django/thunderstore/core/mixins.py @@ -4,6 +4,7 @@ from django.db import DEFAULT_DB_ALIAS, connections, models from django.db.models import Q from django.shortcuts import redirect +from django.urls import reverse from thunderstore.cache.storage import CACHE_STORAGE @@ -106,3 +107,14 @@ class Meta: indexes = [ models.Index(fields=["last_modified"]), ] + + +class AdminLinkMixin(models.Model): + def get_admin_url(self): + return reverse( + f"admin:{self._meta.app_label}_{self._meta.model_name}_change", + args=[self.pk], + ) + + class Meta: + abstract = True diff --git a/django/thunderstore/repository/admin/package.py b/django/thunderstore/repository/admin/package.py index 06c2eabc0..81e3a9523 100644 --- a/django/thunderstore/repository/admin/package.py +++ b/django/thunderstore/repository/admin/package.py @@ -1,9 +1,11 @@ +from html import escape from typing import Optional from django.contrib import admin from django.db import transaction from django.db.models import QuerySet from django.http import HttpRequest +from django.utils.safestring import mark_safe from thunderstore.repository.admin.actions import activate, deactivate from thunderstore.repository.models import Package, PackageVersion @@ -12,6 +14,7 @@ class PackageVersionInline(admin.StackedInline): model = PackageVersion readonly_fields = ( + "version_link", "date_created", "description", "downloads", @@ -19,12 +22,18 @@ class PackageVersionInline(admin.StackedInline): "file_size", "format_spec", "icon", - "version_number", + "file_tree_link", + "visibility", "website_url", + ) + exclude = ( + "version_number", "file_tree", - "visibility", + "dependencies", + "name", + "readme", + "changelog", ) - exclude = ("readme", "changelog", "dependencies", "name") extra = 0 def has_add_permission(self, request: HttpRequest, obj) -> bool: @@ -33,6 +42,20 @@ def has_add_permission(self, request: HttpRequest, obj) -> bool: def has_delete_permission(self, request: HttpRequest, obj=None) -> bool: return False + def version_link(self, obj): + return mark_safe(f'{escape(str(obj))}') + + version_link.short_description = "Version" + + def file_tree_link(self, obj): + if not obj.file_tree: + return None + return mark_safe( + f'{escape(str(obj.file_tree))}' + ) + + file_tree_link.short_description = "File Tree" + @transaction.atomic def deprecate_package(modeladmin, request, queryset: QuerySet[Package]): @@ -93,6 +116,7 @@ class PackageAdmin(admin.ModelAdmin): "name", "namespace__name", "owner__name", + "versions__file_tree__entries__blob__checksum_sha256", ) list_select_related = ( "latest", diff --git a/django/thunderstore/repository/admin/package_version.py b/django/thunderstore/repository/admin/package_version.py index 73e274cd1..54a29c09a 100644 --- a/django/thunderstore/repository/admin/package_version.py +++ b/django/thunderstore/repository/admin/package_version.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils.safestring import mark_safe +from thunderstore.community.models import PackageListing from thunderstore.repository.models import PackageVersion from thunderstore.repository.tasks.files import extract_package_version_file_tree @@ -41,10 +42,15 @@ class PackageVersionAdmin(admin.ModelAdmin): "package__owner__name", "package__namespace__name", "version_number", + "file_tree__entries__blob__checksum_sha256", ) date_hierarchy = "date_created" readonly_fields = [x.name for x in PackageVersion._meta.fields] + [ "file_tree_link", + "listings", + ] + exclude = [ + "file_tree", ] def get_queryset(self, request: HttpRequest) -> QuerySet: @@ -58,6 +64,9 @@ def get_queryset(self, request: HttpRequest) -> QuerySet: ) ) + def get_readonly_fields(self, request, obj=None): + return [x for x in self.readonly_fields if x not in self.exclude] + def has_file_tree(self, obj): return obj.has_file_tree @@ -73,6 +82,16 @@ def file_tree_link(self, obj): ) return mark_safe(f'{obj.file_tree}') + file_tree_link.short_description = "File tree" + + def listings(self, obj): + url = reverse( + f"admin:{PackageListing._meta.app_label}_{PackageListing._meta.model_name}_changelist", + ) + return mark_safe( + f'View package listings' + ) + def has_add_permission(self, request: HttpRequest) -> bool: return False diff --git a/django/thunderstore/repository/models/package.py b/django/thunderstore/repository/models/package.py index 29eb74d3c..5abe5bf58 100644 --- a/django/thunderstore/repository/models/package.py +++ b/django/thunderstore/repository/models/package.py @@ -16,6 +16,7 @@ from thunderstore.cache.enums import CacheBustCondition from thunderstore.cache.tasks import invalidate_cache_on_commit_async from thunderstore.core.enums import OptionalBoolChoice +from thunderstore.core.mixins import AdminLinkMixin from thunderstore.core.types import UserType from thunderstore.core.utils import check_validity from thunderstore.permissions.utils import validate_user @@ -43,7 +44,7 @@ def get_package_dependants_list(package_pk: int): return list(get_package_dependants(package_pk)) -class Package(models.Model): +class Package(AdminLinkMixin, models.Model): objects = PackageQueryset.as_manager() wiki: Optional["PackageWiki"] diff --git a/django/thunderstore/repository/models/package_version.py b/django/thunderstore/repository/models/package_version.py index fa0931593..23a434172 100644 --- a/django/thunderstore/repository/models/package_version.py +++ b/django/thunderstore/repository/models/package_version.py @@ -12,6 +12,7 @@ from django.utils import timezone from django.utils.functional import cached_property +from thunderstore.core.mixins import AdminLinkMixin from thunderstore.permissions.mixins import VisibilityMixin, VisibilityQuerySet from thunderstore.repository.consts import PACKAGE_NAME_REGEX from thunderstore.repository.models import Package @@ -66,7 +67,7 @@ def listed_in(self, community_identifier: str): ) -class PackageVersion(VisibilityMixin): +class PackageVersion(VisibilityMixin, AdminLinkMixin): installers: "Manager[PackageInstaller]" installer_declarations: "Manager[PackageInstallerDeclaration]" objects: "Manager[PackageVersion]" = PackageVersionQuerySet.as_manager() diff --git a/django/thunderstore/repository/tests/test_tabs_mixin.py b/django/thunderstore/repository/tests/test_tabs_mixin.py index 37b060ed8..6dbe7d836 100644 --- a/django/thunderstore/repository/tests/test_tabs_mixin.py +++ b/django/thunderstore/repository/tests/test_tabs_mixin.py @@ -98,3 +98,28 @@ def assert_disabled(user: UserType, expected: bool) -> None: active_package_listing.package.latest.changelog = "# Foo bar" active_package_listing.package.latest.save() assert_disabled(user, False) + + +@pytest.mark.django_db +def test_active_tabs_are_visible( + user: UserType, + active_package_listing: PackageListing, +) -> None: + tabs_mixin = PackageTabsMixin() + + tabs1 = tabs_mixin.get_tab_context( + user, + active_package_listing, + "details", + )["tabs"] + + for tab1 in tabs1: + tabs2 = tabs_mixin.get_tab_context( + user, + active_package_listing, + tab1.name, + )["tabs"] + + for tab2 in tabs2: + if tab2.is_active: + assert tab2.is_visible diff --git a/django/thunderstore/repository/views/mixins.py b/django/thunderstore/repository/views/mixins.py index baa2aa0c9..96741ac51 100644 --- a/django/thunderstore/repository/views/mixins.py +++ b/django/thunderstore/repository/views/mixins.py @@ -16,6 +16,7 @@ class PartialTab: url: str title: str is_disabled: bool = False + is_visible: bool = True @dataclasses.dataclass @@ -24,6 +25,7 @@ class Tab: name: str url: str is_disabled: bool + is_visible: bool is_active: bool @@ -67,9 +69,11 @@ def get_tab_context( name=k, url=v.url, is_disabled=v.is_disabled and k != active_tab, + is_visible=v.is_visible or k == active_tab, is_active=k == active_tab, ) for k, v in tabs.items() + if (v.is_visible or k == active_tab) ], } diff --git a/django/thunderstore/storage/admin/group.py b/django/thunderstore/storage/admin/group.py index 9450b6254..75388ec16 100644 --- a/django/thunderstore/storage/admin/group.py +++ b/django/thunderstore/storage/admin/group.py @@ -1,3 +1,5 @@ +from html import escape + from django.contrib import admin from django.http import HttpRequest from django.utils.safestring import mark_safe @@ -9,17 +11,23 @@ class DataBlobReferenceInline(ReadOnlyInline, admin.TabularInline): model = DataBlobReference fields = ( - "name", + "link", "data_size", "file", ) readonly_fields = ( + "link", "data_size", "file", ) def file(self, obj: DataBlobReference): - return mark_safe(f'{obj.blob.data}') + return mark_safe(f'{escape(str(obj.blob.data))}') + + def link(self, obj: DataBlobReference): + return mark_safe(f'{escape(str(obj))}') + + link.short_description = "Name" @admin.register(DataBlobGroup) diff --git a/django/thunderstore/storage/admin/reference.py b/django/thunderstore/storage/admin/reference.py index 914cde174..88eb73fad 100644 --- a/django/thunderstore/storage/admin/reference.py +++ b/django/thunderstore/storage/admin/reference.py @@ -1,3 +1,5 @@ +from html import escape + from django.contrib import admin from django.http import HttpRequest from django.utils.safestring import mark_safe @@ -9,7 +11,7 @@ class DataBlobReferenceAdmin(admin.ModelAdmin): list_display = ( "name", - "group", + "linked_group", "data_size", "content_type", "content_encoding", @@ -32,7 +34,14 @@ class DataBlobReferenceAdmin(admin.ModelAdmin): date_hierarchy = "datetime_created" def file(self, obj: DataBlobReference): - return mark_safe(f'{obj.blob.data}') + return mark_safe(f'{escape(str(obj.blob.data))}') + + def linked_group(self, obj: DataBlobReference): + return mark_safe( + f'{escape(str(obj.group))}' + ) + + linked_group.short_description = "group" def has_add_permission(self, request: HttpRequest) -> bool: return False diff --git a/django/thunderstore/storage/models/blob.py b/django/thunderstore/storage/models/blob.py index 4c4720469..77a9c4740 100644 --- a/django/thunderstore/storage/models/blob.py +++ b/django/thunderstore/storage/models/blob.py @@ -5,14 +5,14 @@ from django.core.files.storage import get_storage_class from django.db import models -from thunderstore.core.mixins import SafeDeleteMixin +from thunderstore.core.mixins import AdminLinkMixin, SafeDeleteMixin def get_object_file_path(_, filename: str) -> str: return f"blob-storage/sha256/{filename}.sha256.blob" -class DataBlob(SafeDeleteMixin): +class DataBlob(SafeDeleteMixin, AdminLinkMixin): """ The DataBlob class is responsible for storing arbitrary blobs of data with automatic deduplication by blob sha256 checksum. It is not interested in diff --git a/django/thunderstore/storage/models/group.py b/django/thunderstore/storage/models/group.py index fc0600715..9b3e44d65 100644 --- a/django/thunderstore/storage/models/group.py +++ b/django/thunderstore/storage/models/group.py @@ -2,11 +2,11 @@ from django.db import models -from thunderstore.core.mixins import TimestampMixin +from thunderstore.core.mixins import AdminLinkMixin, TimestampMixin from thunderstore.storage.models.reference import DataBlobReference -class DataBlobGroup(TimestampMixin): +class DataBlobGroup(TimestampMixin, AdminLinkMixin): """ The DataBlobGroup class is intended to support grouping of multiple data blobs into logical groups, e.g. file trees. It does not hold much diff --git a/django/thunderstore/storage/models/reference.py b/django/thunderstore/storage/models/reference.py index 78b766a74..2e8d05061 100644 --- a/django/thunderstore/storage/models/reference.py +++ b/django/thunderstore/storage/models/reference.py @@ -2,7 +2,7 @@ from django.db import models -from thunderstore.core.mixins import TimestampMixin +from thunderstore.core.mixins import AdminLinkMixin, TimestampMixin from thunderstore.storage.models.blob import DataBlob if TYPE_CHECKING: @@ -19,7 +19,7 @@ def get_queryset(self): return super().get_queryset().select_related("blob") -class DataBlobReference(TimestampMixin): +class DataBlobReference(TimestampMixin, AdminLinkMixin): """ Acts as a middle man between DataBlob and the consumers of DataBlob, and is responsible for storing contextual use-case dependant information (such diff --git a/django/thunderstore/webhooks/audit.py b/django/thunderstore/webhooks/audit.py index 0fe8b6d2f..c77667339 100644 --- a/django/thunderstore/webhooks/audit.py +++ b/django/thunderstore/webhooks/audit.py @@ -9,6 +9,7 @@ class AuditAction(str, Enum): PACKAGE_REJECTED = "PACKAGE_REJECTED" PACKAGE_APPROVED = "PACKAGE_APPROVED" + PACKAGE_WARNING = "PACKAGE_WARNING" class AuditEventField(BaseModel): diff --git a/django/thunderstore/webhooks/models/audit.py b/django/thunderstore/webhooks/models/audit.py index bfb2a1b82..569b85d14 100644 --- a/django/thunderstore/webhooks/models/audit.py +++ b/django/thunderstore/webhooks/models/audit.py @@ -44,6 +44,8 @@ def get_event_color(action: AuditAction) -> int: return 5763719 if action == AuditAction.PACKAGE_REJECTED: return 15548997 + if action == AuditAction.PACKAGE_WARNING: + return 16705372 return 9807270 @staticmethod diff --git a/django/thunderstore/webhooks/tasks/tests/test_audit.py b/django/thunderstore/webhooks/tasks/tests/test_audit.py index c383af9a5..8b4eac65e 100644 --- a/django/thunderstore/webhooks/tasks/tests/test_audit.py +++ b/django/thunderstore/webhooks/tasks/tests/test_audit.py @@ -15,6 +15,7 @@ ( AuditAction.PACKAGE_APPROVED, AuditAction.PACKAGE_REJECTED, + AuditAction.PACKAGE_WARNING, ), ) @pytest.mark.parametrize("message", (None, "Test message"))