diff --git a/django/thunderstore/community/admin/package_listing.py b/django/thunderstore/community/admin/package_listing.py index d8ae1b7a6..4f5d0576f 100644 --- a/django/thunderstore/community/admin/package_listing.py +++ b/django/thunderstore/community/admin/package_listing.py @@ -16,8 +16,9 @@ @transaction.atomic def reject_listing(modeladmin, request, queryset: QuerySet[PackageListing]): for listing in queryset: - listing.review_status = PackageListingReviewStatus.rejected - listing.save(update_fields=("review_status",)) + listing.reject( + agent=request.user, rejection_reason="Invalid submission", is_system=False + ) reject_listing.short_description = "Reject" @@ -26,8 +27,7 @@ def reject_listing(modeladmin, request, queryset: QuerySet[PackageListing]): @transaction.atomic def approve_listing(modeladmin, request, queryset: QuerySet[PackageListing]): for listing in queryset: - listing.review_status = PackageListingReviewStatus.approved - listing.save(update_fields=("review_status",)) + listing.approve(agent=request.user, is_system=False) approve_listing.short_description = "Approve" @@ -52,6 +52,28 @@ class PackageListingAdmin(admin.ModelAdmin): approve_listing, reject_listing, ) + + fields = ( + "categories", + "is_review_requested", + "review_status", + "rejection_reason", + "notes", + "has_nsfw_content", + "is_auto_imported", + "package_link", + "community", + "datetime_created", + "datetime_updated", + "visibility", + ) + readonly_fields = ( + "package_link", + "community", + "datetime_created", + "datetime_updated", + "visibility", + ) filter_horizontal = ("categories",) raw_id_fields = ("package", "community") list_filter = ( @@ -85,12 +107,6 @@ class PackageListingAdmin(admin.ModelAdmin): "package__namespace", "community", ) - readonly_fields = ( - "package_link", - "community", - "datetime_created", - "datetime_updated", - ) exclude = ("package",) def package_link(self, obj): diff --git a/django/thunderstore/community/migrations/0036_packagelisting_visibility.py b/django/thunderstore/community/migrations/0036_packagelisting_visibility.py new file mode 100644 index 000000000..34f9236df --- /dev/null +++ b/django/thunderstore/community/migrations/0036_packagelisting_visibility.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2025-03-06 21:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("permissions", "0001_initial"), + ("community", "0035_add_janitor_role"), + ] + + 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/0037_create_default_visibility_for_existing_listings.py b/django/thunderstore/community/migrations/0037_create_default_visibility_for_existing_listings.py new file mode 100644 index 000000000..fd4ba5b8d --- /dev/null +++ b/django/thunderstore/community/migrations/0037_create_default_visibility_for_existing_listings.py @@ -0,0 +1,63 @@ +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): + package = listing.package + listing.visibility.public_detail = package.visibility.public_detail + listing.visibility.public_list = package.visibility.public_list + listing.visibility.owner_detail = package.visibility.owner_detail + listing.visibility.owner_list = package.visibility.owner_list + listing.visibility.moderator_detail = package.visibility.moderator_detail + listing.visibility.moderator_list = package.visibility.moderator_list + listing.visibility.admin_detail = package.visibility.admin_detail + listing.visibility.admin_list = package.visibility.admin_list + + 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 + + listing.visibility.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("community", "0036_packagelisting_visibility"), + ("repository", "0058_package_visibility"), + ("repository", "0059_create_default_visibility_for_existing_records"), + ] + + operations = [ + migrations.RunPython( + create_default_visibility_for_existing_records, migrations.RunPython.noop + ), + ] diff --git a/django/thunderstore/community/models/package_listing.py b/django/thunderstore/community/models/package_listing.py index c8233334f..8d99bd9df 100644 --- a/django/thunderstore/community/models/package_listing.py +++ b/django/thunderstore/community/models/package_listing.py @@ -15,11 +15,14 @@ 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 +from thunderstore.permissions.models.visibility import VisibilityFlagsQuerySet from thunderstore.permissions.utils import validate_user from thunderstore.webhooks.audit import ( AuditAction, AuditEvent, AuditEventField, + AuditTarget, fire_audit_event, ) @@ -27,7 +30,7 @@ from thunderstore.community.models import PackageCategory -class PackageListingQueryset(models.QuerySet): +class PackageListingQueryset(VisibilityFlagsQuerySet): def active(self): return self.exclude(package__is_active=False).exclude( ~Q(package__versions__is_active=True) @@ -55,7 +58,7 @@ def filter_with_single_community(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, AdminLinkMixin, VisibilityMixin): """ Represents a package's relation to how it's displayed on the site and APIs """ @@ -166,6 +169,7 @@ def get_full_url(self): def build_audit_event( self, *, + target: AuditTarget, action: AuditAction, user_id: Optional[int], message: Optional[str] = None, @@ -174,6 +178,7 @@ def build_audit_event( timestamp=timezone.now(), user_id=user_id, community_id=self.community.pk, + target=target, action=action, message=message, related_url=self.get_full_url(), @@ -221,7 +226,8 @@ def reject( message = "\n\n".join(filter(bool, (rejection_reason, internal_notes))) fire_audit_event( self.build_audit_event( - action=AuditAction.PACKAGE_REJECTED, + target=AuditTarget.LISTING, + action=AuditAction.REJECTED, user_id=agent.pk if agent else None, message=message, ) @@ -247,7 +253,8 @@ def approve( ) fire_audit_event( self.build_audit_event( - action=AuditAction.PACKAGE_APPROVED, + target=AuditTarget.LISTING, + action=AuditAction.APPROVED, user_id=agent.pk if agent else None, message=internal_notes, ) @@ -378,6 +385,51 @@ def get_has_perms() -> bool: def can_be_viewed_by_user(self, user: Optional[UserType]) -> bool: return check_validity(lambda: self.ensure_can_be_viewed_by_user(user)) + def is_visible_to_user(self, user: Optional[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 + + def set_visibility_from_review_status(self): + 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 + + @transaction.atomic + def update_visibility(self): + self.visibility.copy_from(self.package.visibility) + + self.set_visibility_from_review_status() + + self.visibility.save() + signals.post_save.connect(PackageListing.post_save, sender=PackageListing) signals.post_delete.connect(PackageListing.post_delete, sender=PackageListing) diff --git a/django/thunderstore/community/tests/test_admin_package_listing.py b/django/thunderstore/community/tests/test_admin_package_listing.py index c8797bc10..b342a76fc 100644 --- a/django/thunderstore/community/tests/test_admin_package_listing.py +++ b/django/thunderstore/community/tests/test_admin_package_listing.py @@ -1,4 +1,5 @@ import pytest +from django.test import RequestFactory from thunderstore.community.admin.package_listing import ( PackageListingAdmin, @@ -7,6 +8,7 @@ ) from thunderstore.community.consts import PackageListingReviewStatus from thunderstore.community.models import Community, PackageListing +from thunderstore.core.factories import UserFactory from thunderstore.repository.factories import NamespaceFactory from thunderstore.repository.models import Package, Team @@ -28,8 +30,12 @@ def test_admin_package_listing_approve_listing( for i in range(5) ] + request = RequestFactory().get("/") + request.user = UserFactory() + request.user.is_staff = True + modeladmin = PackageListingAdmin(PackageListing, None) - approve_listing(modeladmin, None, PackageListing.objects.all()) + approve_listing(modeladmin, request, PackageListing.objects.all()) for entry in listings: entry.refresh_from_db() @@ -51,8 +57,12 @@ def test_admin_package_listing_reject_listing(team: Team, community: Community) for i in range(5) ] + request = RequestFactory().get("/") + request.user = UserFactory() + request.user.is_staff = True + modeladmin = PackageListingAdmin(PackageListing, None) - reject_listing(modeladmin, None, PackageListing.objects.all()) + reject_listing(modeladmin, request, PackageListing.objects.all()) for entry in listings: entry.refresh_from_db() diff --git a/django/thunderstore/community/tests/test_package_listing.py b/django/thunderstore/community/tests/test_package_listing.py index 1385879fc..e6bc51564 100644 --- a/django/thunderstore/community/tests/test_package_listing.py +++ b/django/thunderstore/community/tests/test_package_listing.py @@ -18,6 +18,14 @@ PackageCategory, PackageListing, ) +from thunderstore.core.factories import UserFactory +from thunderstore.permissions.models.tests._utils import ( + assert_default_visibility, + assert_visibility_is_not_public, + assert_visibility_is_not_visible, + assert_visibility_is_public, +) +from thunderstore.repository.consts import PackageVersionReviewStatus from thunderstore.repository.models import Package, TeamMember, TeamMemberRole @@ -425,3 +433,168 @@ def test_package_listing_has_mod_manager_support(mod_manager_support: bool) -> N community = CommunityFactory(has_mod_manager_support=mod_manager_support) package_listing = PackageListingFactory(community_=community) assert package_listing.has_mod_manager_support == mod_manager_support + + +@pytest.mark.django_db +def test_package_listing_visibility_inherits_package_is_active( + active_package_listing: PackageListing, +) -> None: + assert_visibility_is_public(active_package_listing.visibility) + + package = active_package_listing.package + + package.is_active = False + package.save() + + active_package_listing.refresh_from_db() + assert_visibility_is_not_visible(active_package_listing.visibility) + + package.is_active = True + package.save() + + active_package_listing.refresh_from_db() + assert_visibility_is_public(active_package_listing.visibility) + + +@pytest.mark.django_db +def test_package_listing_visibility_inherits_union_of_package_versions_visibility( + active_package_listing: PackageListing, +) -> None: + assert_visibility_is_public(active_package_listing.visibility) + + package = active_package_listing.package + + for version in package.versions.all(): + version.review_status = PackageVersionReviewStatus.rejected + version.save() + + active_package_listing.refresh_from_db() + assert_visibility_is_not_public(active_package_listing.visibility) + + for version in package.versions.all(): + version.is_active = False + version.save() + + active_package_listing.refresh_from_db() + assert_visibility_is_not_visible(active_package_listing.visibility) + + for version in package.versions.all(): + version.review_status = PackageVersionReviewStatus.approved + version.is_active = True + version.save() + + active_package_listing.refresh_from_db() + assert_visibility_is_public(active_package_listing.visibility) + + +@pytest.mark.django_db +def test_set_visibility_from_review_status(): + listing = PackageListingFactory() + + listing.review_status = PackageListingReviewStatus.rejected + listing.set_visibility_from_review_status() + listing.visibility.save() + + assert_visibility_is_not_public(listing.visibility) + + listing.visibility.copy_from(listing.package.visibility) + + listing.review_status = PackageListingReviewStatus.unreviewed + listing.set_visibility_from_review_status() + listing.visibility.save() + + assert_default_visibility(listing.visibility) + + listing.visibility.copy_from(listing.package.visibility) + + listing.community.require_package_listing_approval = True + listing.set_visibility_from_review_status() + listing.visibility.save() + + assert_visibility_is_not_public(listing.visibility) + + +@pytest.mark.django_db +def test_is_visible_to_user(): + listing = PackageListingFactory() + + user = UserFactory.create() + + owner = UserFactory.create() + TeamMember.objects.create( + user=owner, + team=listing.package.owner, + role=TeamMemberRole.owner, + ) + + moderator = UserFactory.create() + CommunityMembership.objects.create( + user=moderator, + community=listing.community, + role=CommunityMemberRole.moderator, + ) + + admin = UserFactory.create(is_superuser=True) + + agents = { + "anonymous": None, + "user": user, + "owner": owner, + "moderator": moderator, + "admin": admin, + } + + flags = [ + "public_detail", + "owner_detail", + "moderator_detail", + "admin_detail", + ] + + # Admins are also moderators but not owners + expected = { + "public_detail": { + "anonymous": True, + "user": True, + "owner": True, + "moderator": True, + "admin": True, + }, + "owner_detail": { + "anonymous": False, + "user": False, + "owner": True, + "moderator": False, + "admin": False, + }, + "moderator_detail": { + "anonymous": False, + "user": False, + "owner": False, + "moderator": True, + "admin": True, + }, + "admin_detail": { + "anonymous": False, + "user": False, + "owner": False, + "moderator": False, + "admin": True, + }, + } + + for flag in flags: + listing.visibility.public_detail = False + listing.visibility.owner_detail = False + listing.visibility.moderator_detail = False + listing.visibility.admin_detail = False + + setattr(listing.visibility, flag, True) + listing.visibility.save() + + for role, subject in agents.items(): + result = listing.is_visible_to_user(subject) + assert result == expected[flag][role], ( + f"Expected {flag} visibility for {role} to be " + f"{expected[flag][role]}, got {result}" + ) diff --git a/django/thunderstore/permissions/mixins.py b/django/thunderstore/permissions/mixins.py index 477f3bc58..4d345d90d 100644 --- a/django/thunderstore/permissions/mixins.py +++ b/django/thunderstore/permissions/mixins.py @@ -1,15 +1,23 @@ +from abc import abstractmethod +from typing import Optional + 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): + @abstractmethod + 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 +27,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 +37,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 +52,43 @@ class VisibilityMixin(models.Model): on_delete=models.PROTECT, ) + @abstractmethod + @transaction.atomic + def update_visibility(self): + pass + + def set_default_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 + self.visibility.admin_detail = True + self.visibility.admin_list = True + + def set_zero_visibility(self): + 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 + self.visibility.admin_detail = False + self.visibility.admin_list = False + @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 + + @abstractmethod + def is_visible_to_user(self, user: Optional[UserType]) -> bool: + return False diff --git a/django/thunderstore/permissions/models/tests/_utils.py b/django/thunderstore/permissions/models/tests/_utils.py index e3b1f4128..93a8f8157 100644 --- a/django/thunderstore/permissions/models/tests/_utils.py +++ b/django/thunderstore/permissions/models/tests/_utils.py @@ -16,6 +16,44 @@ def assert_all_visible(visibility: VisibilityFlags): assert getattr(visibility, field) is True +def assert_default_visibility(visibility: VisibilityFlags): + assert visibility.public_detail is True + assert visibility.public_list is True + assert visibility.owner_detail is True + assert visibility.owner_list is True + assert visibility.moderator_detail is True + assert visibility.moderator_list is True + assert visibility.admin_detail is True + assert visibility.admin_list is True + + +def assert_visibility_is_public(visibility: VisibilityFlags) -> None: + assert visibility.public_list is True + assert visibility.public_detail is True + assert visibility.owner_list is True + assert visibility.owner_detail is True + assert visibility.moderator_list is True + assert visibility.moderator_detail is True + + +def assert_visibility_is_not_public(visibility: VisibilityFlags) -> None: + assert visibility.public_list is False + assert visibility.public_detail is False + assert visibility.owner_list is True + assert visibility.owner_detail is True + assert visibility.moderator_list is True + assert visibility.moderator_detail is True + + +def assert_visibility_is_not_visible(visibility: VisibilityFlags) -> None: + assert visibility.public_list is False + assert visibility.public_detail is False + assert visibility.owner_list is False + assert visibility.owner_detail is False + assert visibility.moderator_list is False + assert visibility.moderator_detail is False + + def get_flags_cartesian_product(): """ Returns all possible combinations for visibility flag field values to be diff --git a/django/thunderstore/permissions/models/visibility.py b/django/thunderstore/permissions/models/visibility.py index 83116055d..09961a6cd 100644 --- a/django/thunderstore/permissions/models/visibility.py +++ b/django/thunderstore/permissions/models/visibility.py @@ -35,3 +35,25 @@ def __str__(self) -> str: if isinstance(field, models.BooleanField) and getattr(self, field.name) ) return ", ".join(flag_fields) or "None" + + def as_tuple(self): + return ( + self.public_list, + self.public_detail, + self.owner_list, + self.owner_detail, + self.moderator_list, + self.moderator_detail, + self.admin_list, + self.admin_detail, + ) + + def copy_from(self, from_visibility): + self.public_detail = from_visibility.public_detail + self.public_list = from_visibility.public_list + self.owner_detail = from_visibility.owner_detail + self.owner_list = from_visibility.owner_list + self.moderator_detail = from_visibility.moderator_detail + self.moderator_list = from_visibility.moderator_list + self.admin_detail = from_visibility.admin_detail + self.admin_list = from_visibility.admin_list diff --git a/django/thunderstore/repository/admin/package.py b/django/thunderstore/repository/admin/package.py index 6b4c353c3..29ea76793 100644 --- a/django/thunderstore/repository/admin/package.py +++ b/django/thunderstore/repository/admin/package.py @@ -97,6 +97,19 @@ class PackageAdmin(admin.ModelAdmin): activate, ) + fields = ( + "is_active", + "is_deprecated", + "is_pinned", + "show_decompilation_results", + "date_created", + "downloads", + "name", + "namespace", + "owner", + "latest", + "visibility", + ) readonly_fields = ( "date_created", "downloads", @@ -104,6 +117,7 @@ class PackageAdmin(admin.ModelAdmin): "namespace", "owner", "latest", + "visibility", ) list_display = ( "name", diff --git a/django/thunderstore/repository/admin/package_version.py b/django/thunderstore/repository/admin/package_version.py index 603fcbea1..7cf6086ba 100644 --- a/django/thunderstore/repository/admin/package_version.py +++ b/django/thunderstore/repository/admin/package_version.py @@ -1,10 +1,12 @@ from django.contrib import admin +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.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 +19,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.reject( + agent=request.user, rejection_reason="Invalid submission", is_system=False + ) + + +reject_version.short_description = "Reject" + + +@transaction.atomic +def approve_version(modeladmin, request, queryset: QuerySet[PackageVersion]): + for version in queryset: + version.approve(agent=request.user, is_system=False) + + +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 +57,7 @@ class PackageVersionAdmin(admin.ModelAdmin): "package", "version_number", "is_active", + "review_status", "file_size", "downloads", "date_created", diff --git a/django/thunderstore/repository/consts.py b/django/thunderstore/repository/consts.py index b0ae54ee4..33fc1e8b5 100644 --- a/django/thunderstore/repository/consts.py +++ b/django/thunderstore/repository/consts.py @@ -1,5 +1,7 @@ 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]+$") @@ -7,3 +9,11 @@ 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/0057_packageversion_review_status.py b/django/thunderstore/repository/migrations/0057_packageversion_review_status.py new file mode 100644 index 000000000..49bba604b --- /dev/null +++ b/django/thunderstore/repository/migrations/0057_packageversion_review_status.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.7 on 2025-03-07 20:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("repository", "0056_packageversion_uploaded_by"), + ] + + operations = [ + migrations.AddField( + 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/migrations/0058_package_visibility.py b/django/thunderstore/repository/migrations/0058_package_visibility.py new file mode 100644 index 000000000..4ec74e105 --- /dev/null +++ b/django/thunderstore/repository/migrations/0058_package_visibility.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2025-05-09 21:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("permissions", "0001_initial"), + ("repository", "0057_packageversion_review_status"), + ] + + operations = [ + migrations.AddField( + model_name="package", + name="visibility", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="permissions.visibilityflags", + ), + ), + ] diff --git a/django/thunderstore/repository/migrations/0059_create_default_visibility_for_existing_records.py b/django/thunderstore/repository/migrations/0059_create_default_visibility_for_existing_records.py new file mode 100644 index 000000000..8da34d6aa --- /dev/null +++ b/django/thunderstore/repository/migrations/0059_create_default_visibility_for_existing_records.py @@ -0,0 +1,124 @@ +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") + Package = apps.get_model("repository", "Package") + 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_version_visibility(instance) + + for instance in Package.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_package_visibility(instance) + + +def update_version_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() + + +def update_package_visibility(package): + package.visibility.public_detail = True + package.visibility.public_list = True + package.visibility.owner_detail = True + package.visibility.owner_list = True + package.visibility.moderator_detail = True + package.visibility.moderator_list = True + + if not package.is_active: + package.visibility.public_detail = False + package.visibility.public_list = False + package.visibility.owner_detail = False + package.visibility.owner_list = False + package.visibility.moderator_detail = False + package.visibility.moderator_list = False + + visibility_fields = [ + "public_detail", + "public_list", + "owner_detail", + "owner_list", + "moderator_detail", + "moderator_list", + ] + + versions = list( + package.versions.filter(is_active=True).values( + *[f"visibility__{field}" for field in visibility_fields] + ) + ) + + any_version_visible = {field: False for field in visibility_fields} + + for field in visibility_fields: + for version in versions: + if version[f"visibility__{field}"]: + any_version_visible[field] = True + break + + for field, exists in any_version_visible.items(): + if not exists: + setattr(package.visibility, field, False) + + package.visibility.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("repository", "0058_package_visibility"), + ] + + operations = [ + migrations.RunPython( + create_default_visibility_for_existing_records, migrations.RunPython.noop + ), + ] diff --git a/django/thunderstore/repository/models/package.py b/django/thunderstore/repository/models/package.py index 597f7e789..d8f48ae31 100644 --- a/django/thunderstore/repository/models/package.py +++ b/django/thunderstore/repository/models/package.py @@ -6,7 +6,7 @@ from django.conf import settings from django.contrib.sites.models import Site from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.db import models +from django.db import models, transaction from django.db.models import Case, Q, Sum, When, signals from django.urls import reverse from django.utils import timezone @@ -19,6 +19,7 @@ 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 from thunderstore.permissions.utils import validate_user from thunderstore.repository.consts import PACKAGE_NAME_REGEX @@ -44,7 +45,7 @@ def get_package_dependants_list(package_pk: int): return list(get_package_dependants(package_pk)) -class Package(AdminLinkMixin, models.Model): +class Package(VisibilityMixin, AdminLinkMixin): objects = PackageQueryset.as_manager() wiki: Optional["PackageWiki"] @@ -323,6 +324,91 @@ def can_user_manage_wiki(self, user: Optional[UserType]) -> bool: def __str__(self): return self.full_package_name + def is_visible_to_user(self, user: Optional[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.owner.can_user_access(user): + return True + + if self.visibility.moderator_detail: + for listing in self.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 + + def set_visibility_from_active_status(self): + if not self.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 + + def set_visibility_from_versions(self): + visibility_fields = [ + "public_detail", + "public_list", + "owner_detail", + "owner_list", + "moderator_detail", + "moderator_list", + ] + + versions = list( + self.versions.filter(is_active=True).values( + *[f"visibility__{field}" for field in visibility_fields] + ) + ) + + any_version_visible = {field: False for field in visibility_fields} + + for field in visibility_fields: + for version in versions: + if version[f"visibility__{field}"]: + any_version_visible[field] = True + break + + for field, exists in any_version_visible.items(): + if not exists: + setattr(self.visibility, field, False) + + @transaction.atomic + def update_visibility(self): + original = self.visibility.as_tuple() + + self.set_default_visibility() + + self.set_visibility_from_active_status() + + if self.visibility.as_tuple() != original: + for version in self.versions.all(): + version.update_visibility() + + # package visibility levels can't be higher than the union of version visibility levels + self.set_visibility_from_versions() + + if self.visibility.as_tuple() != original: + self.visibility.save() + for listing in self.community_listings.all(): + listing.update_visibility() + + self.recache_latest() # latest available version could potentially change if visibility changes + # TODO: Available versions should be affected by visibility + @staticmethod def post_save(sender, instance, created, **kwargs): invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated) diff --git a/django/thunderstore/repository/models/package_version.py b/django/thunderstore/repository/models/package_version.py index 337949063..79431d832 100644 --- a/django/thunderstore/repository/models/package_version.py +++ b/django/thunderstore/repository/models/package_version.py @@ -6,18 +6,29 @@ 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.core.mixins import AdminLinkMixin +from thunderstore.core.types import UserType from thunderstore.permissions.mixins import VisibilityMixin, VisibilityQuerySet -from thunderstore.repository.consts import PACKAGE_NAME_REGEX +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, + AuditTarget, + fire_audit_event, +) from thunderstore.webhooks.models.release import Webhook if TYPE_CHECKING: @@ -127,6 +138,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, @@ -310,6 +327,145 @@ 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, + *, + target: AuditTarget, + action: AuditAction, + user_id: Optional[int], + message: Optional[str] = None, + ) -> AuditEvent: + return AuditEvent( + timestamp=timezone.now(), + user_id=user_id, + target=target, + 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( + target=AuditTarget.VERSION, + action=AuditAction.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( + target=AuditTarget.VERSION, + action=AuditAction.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: Optional[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 + + def set_visibility_from_active_status(self): + 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 + + def set_visibility_from_review_status(self): + if ( + self.review_status == PackageVersionReviewStatus.rejected + or self.review_status == PackageVersionReviewStatus.pending + ): + self.visibility.public_detail = False + self.visibility.public_list = False + + @transaction.atomic + def update_visibility(self): + original = self.visibility.as_tuple() + + self.set_default_visibility() + + self.set_visibility_from_active_status() + + self.set_visibility_from_review_status() + + self.visibility.save() + + if self.visibility.as_tuple() != original: + self.package.update_visibility() # package's visibility may change because of its versions + signals.post_save.connect(PackageVersion.post_save, sender=PackageVersion) signals.post_delete.connect(PackageVersion.post_delete, sender=PackageVersion) diff --git a/django/thunderstore/repository/tests/test_package.py b/django/thunderstore/repository/tests/test_package.py index cd99d07bc..2225e9643 100644 --- a/django/thunderstore/repository/tests/test_package.py +++ b/django/thunderstore/repository/tests/test_package.py @@ -7,9 +7,20 @@ from django.core.exceptions import ValidationError from conftest import TestUserTypes -from thunderstore.community.factories import PackageCategoryFactory, SiteFactory +from thunderstore.community.factories import ( + PackageCategoryFactory, + PackageListingFactory, + SiteFactory, +) +from thunderstore.community.models import CommunityMemberRole, CommunityMembership from thunderstore.community.models.package_listing import PackageListing +from thunderstore.core.factories import UserFactory from thunderstore.core.types import UserType +from thunderstore.permissions.models.tests._utils import ( + assert_default_visibility, + assert_visibility_is_not_visible, + assert_visibility_is_public, +) from thunderstore.repository.factories import PackageFactory, PackageVersionFactory from thunderstore.repository.models import ( Namespace, @@ -245,3 +256,149 @@ def test_package_is_removed( PackageVersionFactory(package=package, is_active=version_is_active) assert package.is_removed == expected_is_removed + + +@pytest.mark.django_db +def test_set_default_visibility(): + package = PackageFactory() + package.set_default_visibility() + package.visibility.save() + + assert_visibility_is_public( + package.visibility + ) # this will change when default visibility changes + + +@pytest.mark.django_db +def test_set_visibility_from_active_status_inactive_package(): + package = PackageFactory() + package.is_active = False + package.set_visibility_from_active_status() + package.visibility.save() + + assert_visibility_is_not_visible(package.visibility) + + +@pytest.mark.django_db +def test_set_visibility_from_versions(): + version = PackageVersionFactory() + package = version.package + + assert_default_visibility(package.visibility) + + version.set_zero_visibility() + version.visibility.save() + + package.set_visibility_from_versions() + package.visibility.save() + + assert_visibility_is_not_visible(package.visibility) + + +@pytest.mark.django_db +def test_visibility_changes_update_version_and_listing_visibility(): + listing = PackageListingFactory() + package = listing.package + version = package.latest + + assert_default_visibility(listing.visibility) + assert_default_visibility(package.visibility) + assert_default_visibility(version.visibility) + + package.is_active = False + package.save() + + listing.refresh_from_db() + package.refresh_from_db() + version.refresh_from_db() + assert_visibility_is_not_visible(listing.visibility) + assert_visibility_is_not_visible(package.visibility) + assert_visibility_is_not_visible(version.visibility) + + +@pytest.mark.django_db +def test_is_visible_to_user(): + version = PackageVersionFactory() + package = version.package + listing = PackageListingFactory(package=package) + + user = UserFactory.create() + + owner = UserFactory.create() + TeamMember.objects.create( + user=owner, + team=version.package.owner, + role=TeamMemberRole.owner, + ) + + moderator = UserFactory.create() + CommunityMembership.objects.create( + user=moderator, + community=listing.community, + role=CommunityMemberRole.moderator, + ) + + admin = UserFactory.create(is_superuser=True) + + agents = { + "anonymous": None, + "user": user, + "owner": owner, + "moderator": moderator, + "admin": admin, + } + + flags = [ + "public_detail", + "owner_detail", + "moderator_detail", + "admin_detail", + ] + + # Admins are also moderators but not owners + expected = { + "public_detail": { + "anonymous": True, + "user": True, + "owner": True, + "moderator": True, + "admin": True, + }, + "owner_detail": { + "anonymous": False, + "user": False, + "owner": True, + "moderator": False, + "admin": False, + }, + "moderator_detail": { + "anonymous": False, + "user": False, + "owner": False, + "moderator": True, + "admin": True, + }, + "admin_detail": { + "anonymous": False, + "user": False, + "owner": False, + "moderator": False, + "admin": True, + }, + } + + for flag in flags: + package.visibility.public_detail = False + package.visibility.owner_detail = False + package.visibility.moderator_detail = False + package.visibility.admin_detail = False + + setattr(package.visibility, flag, True) + package.visibility.save() + + for role, subject in agents.items(): + result = package.is_visible_to_user(subject) + assert result == expected[flag][role], ( + f"Expected {flag} visibility for {role} to be " + f"{expected[flag][role]}, got {result}" + ) diff --git a/django/thunderstore/repository/tests/test_package_version.py b/django/thunderstore/repository/tests/test_package_version.py index a5846f8f6..b51a0c67e 100644 --- a/django/thunderstore/repository/tests/test_package_version.py +++ b/django/thunderstore/repository/tests/test_package_version.py @@ -4,9 +4,16 @@ from django.db import IntegrityError from thunderstore.community.factories import PackageListingFactory +from thunderstore.community.models import CommunityMemberRole, CommunityMembership from thunderstore.community.models.package_listing import PackageListing +from thunderstore.core.factories import UserFactory +from thunderstore.permissions.models.tests._utils import ( + assert_visibility_is_not_public, + assert_visibility_is_not_visible, +) +from thunderstore.repository.consts import PackageVersionReviewStatus from thunderstore.repository.factories import PackageFactory, PackageVersionFactory -from thunderstore.repository.models import PackageVersion +from thunderstore.repository.models import PackageVersion, TeamMember, TeamMemberRole from thunderstore.repository.package_formats import PackageFormats @@ -150,3 +157,171 @@ def test_package_version_is_removed( version = PackageVersionFactory(package=package, is_active=version_is_active) assert version.is_removed == expected_is_removed + + +@pytest.mark.django_db +def test_set_visibility_from_active_status_inactive_version(): + version = PackageVersionFactory() + version.is_active = False + version.set_visibility_from_active_status() + version.visibility.save() + assert_visibility_is_not_visible(version.visibility) + + +@pytest.mark.django_db +def test_set_visibility_from_active_status_inactive_package(): + version = PackageVersionFactory() + version.package.is_active = False + version.set_visibility_from_active_status() + version.visibility.save() + assert_visibility_is_not_visible(version.visibility) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "status", [PackageVersionReviewStatus.rejected, PackageVersionReviewStatus.pending] +) +def test_set_visibility_from_review_status(status): + version = PackageVersionFactory() + + version.review_status = status + version.set_visibility_from_review_status() + version.visibility.save() + assert_visibility_is_not_public(version.visibility) + + +@pytest.mark.django_db +def test_can_user_manage_approval_status_false_if_immune(): + user = UserFactory.create() + + listing = PackageListingFactory( + package_version_kwargs={"review_status": PackageVersionReviewStatus.immune} + ) + + CommunityMembership.objects.create( + user=user, + community=listing.community, + role=CommunityMemberRole.moderator, + ) + + version = listing.package.latest + + assert version.review_status == PackageVersionReviewStatus.immune + assert not version.can_user_manage_approval_status(user) + + +@pytest.mark.django_db +def test_can_user_manage_approval_status_true_if_one_listing_allows(): + user = UserFactory.create() + + listing1 = PackageListingFactory() + listing2 = PackageListingFactory(package=listing1.package) + + CommunityMembership.objects.create( + user=user, + community=listing2.community, + role=CommunityMemberRole.moderator, + ) + + version = listing1.package.latest + + assert version.can_user_manage_approval_status(user) + + +@pytest.mark.django_db +def test_can_user_manage_approval_status_false_if_none_allow(): + user = UserFactory.create() + + listing1 = PackageListingFactory() + listing2 = PackageListingFactory(package=listing1.package) + + version = listing1.package.latest + + assert not version.can_user_manage_approval_status(user) + + +@pytest.mark.django_db +def test_is_visible_to_user(): + version = PackageVersionFactory() + listing = PackageListingFactory(package=version.package) + + user = UserFactory.create() + + owner = UserFactory.create() + TeamMember.objects.create( + user=owner, + team=version.package.owner, + role=TeamMemberRole.owner, + ) + + moderator = UserFactory.create() + CommunityMembership.objects.create( + user=moderator, + community=listing.community, + role=CommunityMemberRole.moderator, + ) + + admin = UserFactory.create(is_superuser=True) + + agents = { + "anonymous": None, + "user": user, + "owner": owner, + "moderator": moderator, + "admin": admin, + } + + flags = [ + "public_detail", + "owner_detail", + "moderator_detail", + "admin_detail", + ] + + # Admins are also moderators but not owners + expected = { + "public_detail": { + "anonymous": True, + "user": True, + "owner": True, + "moderator": True, + "admin": True, + }, + "owner_detail": { + "anonymous": False, + "user": False, + "owner": True, + "moderator": False, + "admin": False, + }, + "moderator_detail": { + "anonymous": False, + "user": False, + "owner": False, + "moderator": True, + "admin": True, + }, + "admin_detail": { + "anonymous": False, + "user": False, + "owner": False, + "moderator": False, + "admin": True, + }, + } + + for flag in flags: + version.visibility.public_detail = False + version.visibility.owner_detail = False + version.visibility.moderator_detail = False + version.visibility.admin_detail = False + + setattr(version.visibility, flag, True) + version.visibility.save() + + for role, subject in agents.items(): + result = version.is_visible_to_user(subject) + assert result == expected[flag][role], ( + f"Expected {flag} visibility for {role} to be " + f"{expected[flag][role]}, got {result}" + ) diff --git a/django/thunderstore/webhooks/audit.py b/django/thunderstore/webhooks/audit.py index c77667339..8865dafea 100644 --- a/django/thunderstore/webhooks/audit.py +++ b/django/thunderstore/webhooks/audit.py @@ -6,10 +6,16 @@ from pydantic import BaseModel +class AuditTarget(str, Enum): + PACKAGE = "PACKAGE" + LISTING = "LISTING" + VERSION = "VERSION" + + class AuditAction(str, Enum): - PACKAGE_REJECTED = "PACKAGE_REJECTED" - PACKAGE_APPROVED = "PACKAGE_APPROVED" - PACKAGE_WARNING = "PACKAGE_WARNING" + REJECTED = "REJECTED" + APPROVED = "APPROVED" + WARNING = "WARNING" class AuditEventField(BaseModel): @@ -21,6 +27,7 @@ class AuditEvent(BaseModel): timestamp: datetime user_id: Optional[int] community_id: Optional[int] + target: AuditTarget action: AuditAction message: Optional[str] related_url: Optional[str] diff --git a/django/thunderstore/webhooks/models/audit.py b/django/thunderstore/webhooks/models/audit.py index 569b85d14..b381dc256 100644 --- a/django/thunderstore/webhooks/models/audit.py +++ b/django/thunderstore/webhooks/models/audit.py @@ -40,11 +40,11 @@ def get_for_event(cls, event: AuditEvent) -> QuerySet["AuditWebhook"]: @staticmethod def get_event_color(action: AuditAction) -> int: - if action == AuditAction.PACKAGE_APPROVED: + if action == AuditAction.APPROVED: return 5763719 - if action == AuditAction.PACKAGE_REJECTED: + if action == AuditAction.REJECTED: return 15548997 - if action == AuditAction.PACKAGE_WARNING: + if action == AuditAction.WARNING: return 16705372 return 9807270 @@ -63,7 +63,7 @@ def render_event(event: AuditEvent) -> DiscordPayload: return DiscordPayload( embeds=[ DiscordEmbed( - title=event.action, + title=event.target + "_" + event.action, description=event.message, url=event.related_url, color=AuditWebhook.get_event_color(event.action), diff --git a/django/thunderstore/webhooks/tasks/tests/test_audit.py b/django/thunderstore/webhooks/tasks/tests/test_audit.py index 8b4eac65e..56ea18fa6 100644 --- a/django/thunderstore/webhooks/tasks/tests/test_audit.py +++ b/django/thunderstore/webhooks/tasks/tests/test_audit.py @@ -1,27 +1,30 @@ +import itertools from typing import Optional import pytest from thunderstore.community.models import PackageListing from thunderstore.core.types import UserType -from thunderstore.webhooks.audit import AuditAction +from thunderstore.webhooks.audit import AuditAction, AuditTarget from thunderstore.webhooks.models import AuditWebhook from thunderstore.webhooks.tasks import process_audit_event @pytest.mark.django_db @pytest.mark.parametrize( - "action", - ( - AuditAction.PACKAGE_APPROVED, - AuditAction.PACKAGE_REJECTED, - AuditAction.PACKAGE_WARNING, + ("target", "action"), + list( + itertools.product( + [AuditTarget.PACKAGE, AuditTarget.LISTING, AuditTarget.VERSION], + [AuditAction.APPROVED, AuditAction.REJECTED, AuditAction.WARNING], + ) ), ) @pytest.mark.parametrize("message", (None, "Test message")) def test_process_audit_event( active_package_listing: PackageListing, user: UserType, + target: AuditTarget, action: AuditAction, message: Optional[str], mocker, @@ -35,6 +38,7 @@ def test_process_audit_event( ) webhook.match_communities.set([active_package_listing.community]) event = active_package_listing.build_audit_event( + target=target, action=action, user_id=user.pk, message=message,