From b25120b7f68a3eac2438b7253e81602cb2263079 Mon Sep 17 00:00:00 2001 From: Mythic Date: Sat, 27 Jan 2024 04:40:39 +0200 Subject: [PATCH] Add 'installers' field support to package manifest Add support for a new 'installers' field to the package manifest.json. This is intended to allow packages to declare which install strategies a mod manager should apply to the package when installing. This commit does not know or care what the installers actually do or how the installer system works, it only focuses on validating that the declared installers are known to exist in the ecosystem. What ultimately populates the "known installers" db table is left as a consideration for later. --- django/conftest.py | 15 ++- .../thunderstore/repository/admin/__init__.py | 1 + .../repository/admin/package_installer.py | 21 ++++ .../admin/tests/test_package_installer.py | 39 +++++++ django/thunderstore/repository/factories.py | 8 ++ .../migrations/0049_add_package_installer.py | 104 ++++++++++++++++++ .../repository/models/__init__.py | 1 + .../repository/models/package_installer.py | 27 +++++ .../repository/models/package_version.py | 16 ++- .../repository/package_formats.py | 15 ++- .../repository/package_manifest.py | 37 ++++++- .../thunderstore/repository/package_upload.py | 6 +- .../templates/repository/package_docs.html | 49 ++++++++- .../repository/tests/test_package_formats.py | 1 + .../repository/tests/test_package_manifest.py | 97 ++++++++++++++++ .../repository/tests/test_package_upload.py | 91 ++++++++++----- 16 files changed, 491 insertions(+), 37 deletions(-) create mode 100644 django/thunderstore/repository/admin/package_installer.py create mode 100644 django/thunderstore/repository/admin/tests/test_package_installer.py create mode 100644 django/thunderstore/repository/migrations/0049_add_package_installer.py create mode 100644 django/thunderstore/repository/models/package_installer.py diff --git a/django/conftest.py b/django/conftest.py index 49c0e3b8b..025ac1a52 100644 --- a/django/conftest.py +++ b/django/conftest.py @@ -35,6 +35,7 @@ AsyncPackageSubmissionFactory, NamespaceFactory, PackageFactory, + PackageInstallerFactory, PackageVersionFactory, PackageWikiFactory, TeamFactory, @@ -43,6 +44,7 @@ from thunderstore.repository.models import ( AsyncPackageSubmission, Package, + PackageInstaller, PackageVersion, PackageWiki, Team, @@ -425,11 +427,15 @@ def api_client(community_site) -> APIClient: @pytest.fixture(scope="session") -def manifest_v1_package_bytes() -> bytes: +def package_icon_bytes() -> bytes: icon_raw = io.BytesIO() icon = Image.new("RGB", (256, 256), "#FF0000") icon.save(icon_raw, format="PNG") + return icon_raw.getvalue() + +@pytest.fixture(scope="session") +def manifest_v1_package_bytes(package_icon_bytes: bytes) -> bytes: readme = "# Test readme".encode("utf-8") manifest = json.dumps( { @@ -443,7 +449,7 @@ def manifest_v1_package_bytes() -> bytes: files = [ ("README.md", readme), - ("icon.png", icon_raw.getvalue()), + ("icon.png", package_icon_bytes), ("manifest.json", manifest), ] @@ -500,6 +506,11 @@ def async_package_submission( ) +@pytest.fixture +def package_installer() -> PackageInstaller: + return PackageInstallerFactory() + + def create_test_service_account_user(): team_owner = UserFactory() team = TeamFactory() diff --git a/django/thunderstore/repository/admin/__init__.py b/django/thunderstore/repository/admin/__init__.py index 17773c1fd..3cdcd4fc5 100644 --- a/django/thunderstore/repository/admin/__init__.py +++ b/django/thunderstore/repository/admin/__init__.py @@ -1,6 +1,7 @@ from .discord_bot import DiscordUserBotPermissionAdmin from .namespace import NamespaceAdmin from .package import PackageAdmin +from .package_installer import PackageInstallerAdmin from .package_rating import PackageRatingAdmin from .package_version import PackageVersionAdmin from .submission import AsyncPackageSubmissionAdmin diff --git a/django/thunderstore/repository/admin/package_installer.py b/django/thunderstore/repository/admin/package_installer.py new file mode 100644 index 000000000..ce0ef79a3 --- /dev/null +++ b/django/thunderstore/repository/admin/package_installer.py @@ -0,0 +1,21 @@ +from django.contrib import admin +from django.http import HttpRequest + +from thunderstore.repository.models import PackageInstaller + + +@admin.register(PackageInstaller) +class PackageInstallerAdmin(admin.ModelAdmin): + readonly_fields = ("identifier",) + list_display_links = ("pk", "identifier") + list_display = ("pk", "identifier") + search_fields = ("pk", "identifier") + + def has_add_permission(self, request: HttpRequest, obj=None) -> bool: + return False + + def has_change_permission(self, request: HttpRequest, obj=None) -> bool: + return False + + def has_delete_permission(self, request: HttpRequest, obj=None) -> bool: + return False diff --git a/django/thunderstore/repository/admin/tests/test_package_installer.py b/django/thunderstore/repository/admin/tests/test_package_installer.py new file mode 100644 index 000000000..801214af9 --- /dev/null +++ b/django/thunderstore/repository/admin/tests/test_package_installer.py @@ -0,0 +1,39 @@ +import pytest +from django.conf import settings +from django.test import Client + +from thunderstore.repository.models import PackageInstaller + + +@pytest.mark.django_db +def test_admin_package_installer_search(admin_client: Client) -> None: + resp = admin_client.get( + path="/djangoadmin/repository/packageinstaller/?q=asd", + HTTP_HOST=settings.PRIMARY_HOST, + ) + assert resp.status_code == 200 + + +@pytest.mark.django_db +def test_admin_package_installer_list( + package_installer: PackageInstaller, admin_client: Client +) -> None: + resp = admin_client.get( + path="/djangoadmin/repository/packageinstaller/", + HTTP_HOST=settings.PRIMARY_HOST, + ) + assert resp.status_code == 200 + assert package_installer.identifier in resp.content.decode() + + +@pytest.mark.django_db +def test_admin_package_installer_detail( + package_installer: PackageInstaller, + admin_client: Client, +) -> None: + path = f"/djangoadmin/repository/packageinstaller/{package_installer.pk}/change/" + resp = admin_client.get( + path=path, + HTTP_HOST=settings.PRIMARY_HOST, + ) + assert resp.status_code == 200 diff --git a/django/thunderstore/repository/factories.py b/django/thunderstore/repository/factories.py index 34557ae80..276b97aa4 100644 --- a/django/thunderstore/repository/factories.py +++ b/django/thunderstore/repository/factories.py @@ -10,6 +10,7 @@ AsyncPackageSubmission, Namespace, Package, + PackageInstaller, PackageRating, PackageVersion, PackageWiki, @@ -86,3 +87,10 @@ class Meta: owner = factory.SubFactory(UserFactory) file = factory.SubFactory(UserMediaFactory, owner=factory.SelfAttribute("..owner")) form_data = dict() + + +class PackageInstallerFactory(DjangoModelFactory): + class Meta: + model = PackageInstaller + + identifier = factory.Sequence(lambda n: f"package-installer-{n}") diff --git a/django/thunderstore/repository/migrations/0049_add_package_installer.py b/django/thunderstore/repository/migrations/0049_add_package_installer.py new file mode 100644 index 000000000..92aaaf898 --- /dev/null +++ b/django/thunderstore/repository/migrations/0049_add_package_installer.py @@ -0,0 +1,104 @@ +# Generated by Django 3.1.7 on 2024-01-27 04:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("repository", "0048_populate_visibility"), + ] + + operations = [ + migrations.CreateModel( + name="PackageInstaller", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("identifier", models.SlugField(editable=False, unique=True)), + ], + ), + migrations.CreateModel( + name="PackageInstallerDeclaration", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + ), + migrations.RemoveConstraint( + model_name="packageversion", + name="valid_package_format", + ), + migrations.AlterField( + model_name="packageversion", + name="format_spec", + field=models.TextField( + blank=True, + choices=[ + ("thunderstore.io:v0.0", "V0 0"), + ("thunderstore.io:v0.1", "V0 1"), + ("thunderstore.io:v0.2", "V0 2"), + ("thunderstore.io:v0.3", "V0 3"), + ], + help_text="Used to track the latest package format spec this package is compatible with", + null=True, + ), + ), + migrations.AddConstraint( + model_name="packageversion", + constraint=models.CheckConstraint( + check=models.Q( + ("format_spec", None), + ("format_spec", "thunderstore.io:v0.0"), + ("format_spec", "thunderstore.io:v0.1"), + ("format_spec", "thunderstore.io:v0.2"), + ("format_spec", "thunderstore.io:v0.3"), + _connector="OR", + ), + name="valid_package_format", + ), + ), + migrations.AddField( + model_name="packageinstallerdeclaration", + name="package_installer", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="installer_declarations", + to="repository.packageinstaller", + ), + ), + migrations.AddField( + model_name="packageinstallerdeclaration", + name="package_version", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="installer_declarations", + to="repository.packageversion", + ), + ), + migrations.AddField( + model_name="packageversion", + name="installers", + field=models.ManyToManyField( + blank=True, + related_name="package_versions", + through="repository.PackageInstallerDeclaration", + to="repository.PackageInstaller", + ), + ), + ] diff --git a/django/thunderstore/repository/models/__init__.py b/django/thunderstore/repository/models/__init__.py index 04ad1b97b..f14ff5d62 100644 --- a/django/thunderstore/repository/models/__init__.py +++ b/django/thunderstore/repository/models/__init__.py @@ -3,6 +3,7 @@ from .namespace import * from .package import * from .package_download import * +from .package_installer import * from .package_rating import * from .package_version import * from .submission import * diff --git a/django/thunderstore/repository/models/package_installer.py b/django/thunderstore/repository/models/package_installer.py new file mode 100644 index 000000000..f20e86725 --- /dev/null +++ b/django/thunderstore/repository/models/package_installer.py @@ -0,0 +1,27 @@ +from django.db import models + + +class PackageInstaller(models.Model): + identifier = models.SlugField(unique=True, db_index=True, editable=False) + + def __str__(self): + return self.identifier + + +class PackageInstallerDeclaration(models.Model): + """ + Used for the m2m relation between PackageVersion and PackageInstaller. + A custom model is used in order to support potential extra data in the + installer declarations in the future. + """ + + package_installer = models.ForeignKey( + "repository.PackageInstaller", + on_delete=models.PROTECT, + related_name="installer_declarations", + ) + package_version = models.ForeignKey( + "repository.PackageVersion", + on_delete=models.CASCADE, + related_name="installer_declarations", + ) diff --git a/django/thunderstore/repository/models/package_version.py b/django/thunderstore/repository/models/package_version.py index 8840014f0..927dd9a93 100644 --- a/django/thunderstore/repository/models/package_version.py +++ b/django/thunderstore/repository/models/package_version.py @@ -1,6 +1,6 @@ import re import uuid -from typing import Iterator, Optional +from typing import TYPE_CHECKING, Iterator, Optional from django.conf import settings from django.core.cache import cache @@ -19,6 +19,12 @@ from thunderstore.utils.decorators import run_after_commit from thunderstore.webhooks.models.release import Webhook +if TYPE_CHECKING: + from thunderstore.repository.models.package_installer import ( + PackageInstaller, + PackageInstallerDeclaration, + ) + def get_version_zip_filepath(instance, filename): return f"repository/packages/{instance}.zip" @@ -61,6 +67,8 @@ def listed_in(self, community_identifier: str): class PackageVersion(VisibilityMixin): + installers: "Manager[PackageInstaller]" + installer_declarations: "Manager[PackageInstallerDeclaration]" objects: "Manager[PackageVersion]" = PackageVersionQuerySet.as_manager() id: int @@ -102,6 +110,12 @@ class PackageVersion(VisibilityMixin): symmetrical=False, blank=True, ) + installers = models.ManyToManyField( + "repository.PackageInstaller", + through="repository.PackageInstallerDeclaration", + related_name="package_versions", + blank=True, + ) readme = models.TextField() changelog = models.TextField(blank=True, null=True) diff --git a/django/thunderstore/repository/package_formats.py b/django/thunderstore/repository/package_formats.py index a22222202..7f6c3c063 100644 --- a/django/thunderstore/repository/package_formats.py +++ b/django/thunderstore/repository/package_formats.py @@ -11,10 +11,21 @@ class PackageFormats(models.TextChoices): PackageVersion! """ + # Initial version v0_0 = "thunderstore.io:v0.0" + + # Added `dependencies` field to manifest v0_1 = "thunderstore.io:v0.1" + + # Added support for `CHANGELOG.md` v0_2 = "thunderstore.io:v0.2" + # Added `installers` field to manifest + # - if populated, must have at least a single valid installer + # - if populated, has a maximum of 100 installers defined + # - if not populated, must be emitted entirely + v0_3 = "thunderstore.io:v0.3" + @classmethod def as_query_filter(cls, field_name: str, allow_none: bool) -> Q: result = Q() @@ -26,8 +37,8 @@ def as_query_filter(cls, field_name: str, allow_none: bool) -> Q: @classmethod def get_supported_formats(cls) -> Tuple["PackageFormats"]: - return (cls.v0_1, cls.v0_2) + return (cls.v0_1, cls.v0_2, cls.v0_3) @classmethod def get_active_format(cls) -> "PackageFormats": - return PackageFormats.v0_2 + return PackageFormats.v0_3 diff --git a/django/thunderstore/repository/package_manifest.py b/django/thunderstore/repository/package_manifest.py index 9dc34673c..0eeca6c94 100644 --- a/django/thunderstore/repository/package_manifest.py +++ b/django/thunderstore/repository/package_manifest.py @@ -1,11 +1,14 @@ +from typing import List, TypedDict + from django.core.validators import URLValidator from rest_framework import serializers from rest_framework.exceptions import ValidationError -from thunderstore.repository.models import PackageVersion +from thunderstore.repository.models import PackageInstaller, PackageVersion from thunderstore.repository.package_reference import PackageReference from thunderstore.repository.serializer_fields import ( DependencyField, + ModelChoiceField, PackageNameField, PackageVersionField, StrictCharField, @@ -13,6 +16,29 @@ from thunderstore.repository.utils import does_contain_package, has_duplicate_packages +class PackageInstallerSerializer(serializers.Serializer): + identifier = ModelChoiceField( + queryset=PackageInstaller.objects.all(), + required=True, + to_field="identifier", + error_messages={"not_found": "Matching installer not found."}, + ) + # Potential to expand this in the future with installer-specific arguments. + # Not yet added as to not create backwards incompatibilities if the field + # ends up unused. + + class Type(TypedDict): + identifier: PackageInstaller + + +def validate_unique_installers(installers: List[PackageInstallerSerializer.Type]): + seen = set() + for entry in [x["identifier"].identifier for x in installers]: + if entry in seen: + raise ValidationError(f"Duplicate use of installer {entry}") + seen.add(entry) + + class ManifestV1Serializer(serializers.Serializer): def __init__(self, *args, **kwargs): if "user" not in kwargs: @@ -39,6 +65,15 @@ def __init__(self, *args, **kwargs): max_length=1000, allow_empty=True, ) + installers = serializers.ListField( + child=PackageInstallerSerializer(), + validators=[validate_unique_installers], + max_length=100, + min_length=1, + allow_empty=False, + allow_null=False, + required=False, + ) def validate(self, data): result = super().validate(data) diff --git a/django/thunderstore/repository/package_upload.py b/django/thunderstore/repository/package_upload.py index a22532165..b2249719d 100644 --- a/django/thunderstore/repository/package_upload.py +++ b/django/thunderstore/repository/package_upload.py @@ -173,7 +173,7 @@ def clean_community_categories(self): return clean_community_categories(self.cleaned_data.get("community_categories")) @transaction.atomic - def save(self, *args, **kwargs): + def save(self, *args, **kwargs) -> PackageVersion: self.instance.name = self.manifest["name"] self.instance.version_number = self.manifest["version_number"] self.instance.website_url = self.manifest["website_url"] @@ -215,4 +215,8 @@ def save(self, *args, **kwargs): instance = super().save() for reference in self.manifest["dependencies"]: instance.dependencies.add(reference.instance) + + for installer in self.manifest.get("installers", []): + instance.installers.add(installer["identifier"]) + return instance diff --git a/django/thunderstore/repository/templates/repository/package_docs.html b/django/thunderstore/repository/templates/repository/package_docs.html index 9299f8b80..a6d3912ee 100644 --- a/django/thunderstore/repository/templates/repository/package_docs.html +++ b/django/thunderstore/repository/templates/repository/package_docs.html @@ -47,7 +47,18 @@ name ✔ - Name of the mod, no spaces. Allowed characters: a-z A-Z 0-9 _ + +

Name of the mod, no spaces. Allowed characters: a-z A-Z 0-9 _

+

+ Underscores get replaced with a space for display purposes in some views on the + website & mod manager. +

+

+ Important: This will become a part of the package + ID and can not be changed without creating a new + package. +

+
"Some_Mod"
@@ -77,6 +88,42 @@ URL of the mod's website (e.g. GitHub repo). Can be an empty string.
"https://example.com/"
+ + installers + ❌ + +

+ A list of installer declarations. Installer declarations can be used to explicitly + control how a mod manager should install the package. If omitted, legacy install + rules are automatically used. +

+

+ As of January 2024, the mod managers don't yet use this field for anything. Documentation + will be updated with more details once an implementation exists. +

+

+ Documentation for the default (legacy) behavior is currently maintained as a wiki page on + + the r2modmanPlus wiki + +

+

+ This field should either contain a list of at least one valid installer declarations + or be omitted entirely. +

+

+ This field will become mandatory in the future. +

+ +
[
+    { "identifier": "foo-installer" }
+]
+

+ The installer referred above does not actually exist, + this is for illustrative purposes only. +

+ +

diff --git a/django/thunderstore/repository/tests/test_package_formats.py b/django/thunderstore/repository/tests/test_package_formats.py index a5971d537..b8ed2c99b 100644 --- a/django/thunderstore/repository/tests/test_package_formats.py +++ b/django/thunderstore/repository/tests/test_package_formats.py @@ -21,6 +21,7 @@ "thunderstore.io:v0.0", "thunderstore.io:v0.1", "thunderstore.io:v0.2", + "thunderstore.io:v0.3", ] diff --git a/django/thunderstore/repository/tests/test_package_manifest.py b/django/thunderstore/repository/tests/test_package_manifest.py index 9330c7ded..45beb333f 100644 --- a/django/thunderstore/repository/tests/test_package_manifest.py +++ b/django/thunderstore/repository/tests/test_package_manifest.py @@ -1,4 +1,5 @@ import pytest +from rest_framework.exceptions import ValidationError from thunderstore.repository.factories import ( NamespaceFactory, @@ -546,3 +547,99 @@ def test_manifest_v1_invalid_key_formatting(user): data=data, ) assert deserializer.is_valid() is False + + +@pytest.mark.django_db +def test_manifest_v1_installers_omitted_succeeds(user, manifest_v1_data): + assert "installers" not in manifest_v1_data + team = Team.get_or_create_for_user(user) + serializer = ManifestV1Serializer( + user=user, + team=team, + data=manifest_v1_data, + ) + assert serializer.is_valid() + + +def test_manifest_v1_installers_null_fails(user, manifest_v1_data): + team = Team.get_or_create_for_user(user) + manifest_v1_data["installers"] = None + serializer = ManifestV1Serializer( + user=user, + team=team, + data=manifest_v1_data, + ) + with pytest.raises(ValidationError, match="This field may not be null."): + serializer.is_valid(raise_exception=True) + + +def test_manifest_v1_installers_empty_fails(user, manifest_v1_data): + team = Team.get_or_create_for_user(user) + manifest_v1_data["installers"] = [] + serializer = ManifestV1Serializer( + user=user, + team=team, + data=manifest_v1_data, + ) + with pytest.raises(ValidationError, match="This list may not be empty."): + serializer.is_valid(raise_exception=True) + + +def test_manifest_v1_installers_unknown_fails(user, manifest_v1_data): + team = Team.get_or_create_for_user(user) + manifest_v1_data["installers"] = [{"identifier": "foo"}] + serializer = ManifestV1Serializer( + user=user, + team=team, + data=manifest_v1_data, + ) + with pytest.raises(ValidationError, match="Matching installer not found."): + assert serializer.is_valid(raise_exception=True) + + +def test_manifest_v1_installers_empty_entry_fails(user, manifest_v1_data): + team = Team.get_or_create_for_user(user) + manifest_v1_data["installers"] = [{}] + serializer = ManifestV1Serializer( + user=user, + team=team, + data=manifest_v1_data, + ) + with pytest.raises(ValidationError, match="This field is required."): + assert serializer.is_valid(raise_exception=True) + + +def test_manifest_v1_installers_valid_entry_succeeds( + user, manifest_v1_data, package_installer +): + team = Team.get_or_create_for_user(user) + manifest_v1_data["installers"] = [{"identifier": package_installer.identifier}] + serializer = ManifestV1Serializer( + user=user, + team=team, + data=manifest_v1_data, + ) + assert serializer.is_valid(raise_exception=True) + + +def test_manifest_v1_installers_duplicate_entries_fails( + user, manifest_v1_data, package_installer +): + # TODO: Edit this test to accommodate duplicate calls to the same installer + # if the arguments differ in a meaningful fashion once per-installer + # arguments are supported. + team = Team.get_or_create_for_user(user) + manifest_v1_data["installers"] = [ + {"identifier": package_installer.identifier}, + {"identifier": package_installer.identifier}, + ] + serializer = ManifestV1Serializer( + user=user, + team=team, + data=manifest_v1_data, + ) + with pytest.raises( + ValidationError, + match=f"Duplicate use of installer {package_installer.identifier}", + ): + assert serializer.is_valid(raise_exception=True) diff --git a/django/thunderstore/repository/tests/test_package_upload.py b/django/thunderstore/repository/tests/test_package_upload.py index 842da4744..da5999817 100644 --- a/django/thunderstore/repository/tests/test_package_upload.py +++ b/django/thunderstore/repository/tests/test_package_upload.py @@ -1,10 +1,10 @@ import io import json +from typing import List, Tuple from zipfile import ZIP_DEFLATED, ZipFile import pytest from django.core.files.uploadedfile import SimpleUploadedFile -from PIL import Image from thunderstore.community.models import PackageCategory, PackageListing from thunderstore.repository.models import Team @@ -12,35 +12,36 @@ from thunderstore.repository.package_upload import PackageUploadForm -@pytest.mark.django_db -@pytest.mark.parametrize("changelog", (None, "# Test changelog")) -def test_package_upload(user, manifest_v1_data, community, changelog): +def _build_package( + files: List[Tuple[str, bytes]], +) -> SimpleUploadedFile: + zip_raw = io.BytesIO() + with ZipFile(zip_raw, "a", ZIP_DEFLATED, False) as zip_file: + for name, data in files: + zip_file.writestr(name, data) + return SimpleUploadedFile("mod.zip", zip_raw.getvalue()) - icon_raw = io.BytesIO() - icon = Image.new("RGB", (256, 256), "#FF0000") - icon.save(icon_raw, format="PNG") +@pytest.mark.django_db +@pytest.mark.parametrize("changelog", (None, "# Test changelog")) +def test_package_upload( + user, manifest_v1_data, package_icon_bytes: bytes, community, changelog +): readme = "# Test readme" manifest = json.dumps(manifest_v1_data).encode("utf-8") files = [ ("README.md", readme.encode("utf-8")), - ("icon.png", icon_raw.getvalue()), + ("icon.png", package_icon_bytes), ("manifest.json", manifest), ] if changelog: files.append(("CHANGELOG.md", changelog.encode("utf-8"))) - zip_raw = io.BytesIO() - with ZipFile(zip_raw, "a", ZIP_DEFLATED, False) as zip_file: - for name, data in files: - zip_file.writestr(name, data) - - file_data = {"file": SimpleUploadedFile("mod.zip", zip_raw.getvalue())} team = Team.get_or_create_for_user(user) form = PackageUploadForm( user=user, - files=file_data, + files={"file": _build_package(files)}, community=community, data={ "team": team.name, @@ -58,43 +59,35 @@ def test_package_upload(user, manifest_v1_data, community, changelog): assert version.package.namespace.name == team.name assert version.file_tree is not None assert version.file_tree.entries.count() == 3 if changelog is None else 4 + assert version.installers.count() == 0 @pytest.mark.django_db @pytest.mark.parametrize("changelog", (None, "# Test changelog")) -def test_package_upload_with_extra_data(user, community, manifest_v1_data, changelog): - - icon_raw = io.BytesIO() - icon = Image.new("RGB", (256, 256), "#FF0000") - icon.save(icon_raw, format="PNG") - +def test_package_upload_with_extra_data( + user, community, manifest_v1_data, package_icon_bytes: bytes, changelog +): readme = "# Test readme" manifest = json.dumps(manifest_v1_data).encode("utf-8") files = [ ("README.md", readme.encode("utf-8")), - ("icon.png", icon_raw.getvalue()), + ("icon.png", package_icon_bytes), ("manifest.json", manifest), ] if changelog: files.append(("CHANGELOG.md", changelog.encode("utf-8"))) - zip_raw = io.BytesIO() - with ZipFile(zip_raw, "a", ZIP_DEFLATED, False) as zip_file: - for name, data in files: - zip_file.writestr(name, data) - category = PackageCategory.objects.create( name="Test Category", slug="test-category", community=community, ) - file_data = {"file": SimpleUploadedFile("mod.zip", zip_raw.getvalue())} team = Team.get_or_create_for_user(user) form = PackageUploadForm( user=user, - files=file_data, + files={"file": _build_package(files)}, community=community, data={ "categories": [category.pk], @@ -116,3 +109,43 @@ def test_package_upload_with_extra_data(user, community, manifest_v1_data, chang assert listing.has_nsfw_content is True assert version.file_tree is not None assert version.file_tree.entries.count() == 3 if changelog is None else 4 + assert version.installers.count() == 0 + + +@pytest.mark.django_db +def test_package_upload_with_installers( + user, community, manifest_v1_data, package_icon_bytes, package_installer +): + readme = "# Test readme" + manifest_v1_data["installers"] = [{"identifier": package_installer.identifier}] + manifest = json.dumps(manifest_v1_data).encode("utf-8") + + files = [ + ("README.md", readme.encode("utf-8")), + ("icon.png", package_icon_bytes), + ("manifest.json", manifest), + ] + + team = Team.get_or_create_for_user(user) + form = PackageUploadForm( + user=user, + files={"file": _build_package(files)}, + community=community, + data={ + "team": team.name, + "communities": [community.identifier], + }, + ) + assert form.is_valid() + version = form.save() + assert version.name == manifest_v1_data["name"] + assert version.readme == readme + assert version.changelog is None + assert version.package.owner == team + assert version.format_spec == PackageFormats.get_active_format() + assert version.package.namespace == team.get_namespace() + assert version.package.namespace.name == team.name + assert version.file_tree is not None + assert version.file_tree.entries.count() == 3 + assert version.installers.count() == 1 + assert version.installers.first() == package_installer