From 08a7bda871053f97f5c5880fe6c0e3c795b73dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Schm=C3=B6cker?= Date: Sat, 14 Mar 2026 17:57:05 +0100 Subject: [PATCH 1/5] feat: allow updating readme --- builder/src/api/api.ts | 2 + .../components/PackageManagement/Modal.tsx | 49 ++++++++++++++++++- .../src/components/PackageManagement/hooks.ts | 13 ++++- .../api/cyberstorm/views/markdown.py | 4 ++ .../community/api/experimental/serializers.py | 10 ++++ .../api/experimental/views/listing.py | 14 ++++++ .../migrations/0065_add_readme_override.py | 23 +++++++++ .../thunderstore/repository/models/package.py | 4 ++ .../repository/models/package_version.py | 2 + 9 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 django/thunderstore/repository/migrations/0065_add_readme_override.py diff --git a/builder/src/api/api.ts b/builder/src/api/api.ts index 56fb57654..8d0860c70 100644 --- a/builder/src/api/api.ts +++ b/builder/src/api/api.ts @@ -75,6 +75,8 @@ class ExperimentalApiImpl extends ThunderstoreApi { packageListingId: string; data: { categories: string[]; + readme?: string; + changelog?: string; }; }) => { const response = await this.post( diff --git a/builder/src/components/PackageManagement/Modal.tsx b/builder/src/components/PackageManagement/Modal.tsx index eb8f22826..e10225c6a 100644 --- a/builder/src/components/PackageManagement/Modal.tsx +++ b/builder/src/components/PackageManagement/Modal.tsx @@ -1,6 +1,11 @@ import React, { CSSProperties } from "react"; +import { useWatch } from "react-hook-form"; import { useManagementContext } from "./Context"; -import { PackageListingUpdateForm, usePackageListingUpdateForm } from "./hooks"; +import { + PackageListingUpdateForm, + PackageListingUpdateFormValues, + usePackageListingUpdateForm, +} from "./hooks"; import { PackageStatus } from "./PackageStatus"; import { CategoriesSelect } from "./CategoriesSelect"; import { DeprecationForm } from "./Deprecation"; @@ -31,6 +36,24 @@ interface BodyProps { const Body: React.FC = (props) => { const context = useManagementContext().props; + const readme = useWatch({ + control: props.form.control, + name: "readme", + }); + const changelog = useWatch({ + control: props.form.control, + name: "changelog", + }); + + const handleFile = async (e: React.ChangeEvent, field: "readme" | "changelog") => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + + const content = await file.text(); + props.form.setValue(field, { fileName: file.name, content: content }); + }; return (
@@ -49,6 +72,30 @@ const Body: React.FC = (props) => {
)} +
+
Update Readme
+ +
+
+
Update Changelog
+ +
{props.form.error && (
diff --git a/builder/src/components/PackageManagement/hooks.ts b/builder/src/components/PackageManagement/hooks.ts index 606226626..ad171d509 100644 --- a/builder/src/components/PackageManagement/hooks.ts +++ b/builder/src/components/PackageManagement/hooks.ts @@ -3,14 +3,18 @@ import { ExperimentalApi, UpdatePackageListingResponse } from "../../api"; import * as Sentry from "@sentry/react"; import { useState } from "react"; import { Control } from "react-hook-form/dist/types"; +import { UseFormSetValue } from "react-hook-form"; type Status = undefined | "SUBMITTING" | "SUCCESS" | "ERROR"; export type PackageListingUpdateFormValues = { categories: { value: string; label: string }[]; + readme?: { fileName: string; content: string }; + changelog?: { fileName: string; content: string }; }; export type PackageListingUpdateForm = { onSubmit: () => Promise; + setValue: UseFormSetValue; control: Control; error?: string; status: Status; @@ -20,7 +24,7 @@ export const usePackageListingUpdateForm = ( packageListingId: string, onSuccess: (result: UpdatePackageListingResponse) => void ): PackageListingUpdateForm => { - const { handleSubmit, control } = useForm(); + const { handleSubmit, control, setValue } = useForm(); const [status, setStatus] = useState(undefined); const [error, setError] = useState(undefined); @@ -31,7 +35,11 @@ export const usePackageListingUpdateForm = ( try { const result = await ExperimentalApi.updatePackageListing({ packageListingId: packageListingId, - data: { categories: data.categories.map((x) => x.value) }, + data: { + categories: data.categories.map((x) => x.value), + readme: data.readme?.content, + changelog: data.changelog?.content, + }, }); onSuccess(result); setStatus("SUCCESS"); @@ -44,6 +52,7 @@ export const usePackageListingUpdateForm = ( return { onSubmit, + setValue, control, error, status, diff --git a/django/thunderstore/api/cyberstorm/views/markdown.py b/django/thunderstore/api/cyberstorm/views/markdown.py index f8f84ae30..7c3579ebc 100644 --- a/django/thunderstore/api/cyberstorm/views/markdown.py +++ b/django/thunderstore/api/cyberstorm/views/markdown.py @@ -31,6 +31,8 @@ def get_object(self): version_number=self.kwargs.get("version_number"), ) + if package_version.readme_override: + return {"html": render_markdown(package_version.readme_override)} return {"html": render_markdown(package_version.readme)} @@ -57,6 +59,8 @@ def get_object(self): if package_version.changelog is None: raise Http404 + if package_version.changelog_override: + return {"html": render_markdown(package_version.changelog_override)} return {"html": render_markdown(package_version.changelog)} diff --git a/django/thunderstore/community/api/experimental/serializers.py b/django/thunderstore/community/api/experimental/serializers.py index 61948dc8b..057fff88c 100644 --- a/django/thunderstore/community/api/experimental/serializers.py +++ b/django/thunderstore/community/api/experimental/serializers.py @@ -20,6 +20,16 @@ class PackageListingUpdateRequestSerializer(serializers.Serializer): ), allow_empty=True, ) + readme = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + ) + changelog = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + ) class PackageCategoryExperimentalSerializer(serializers.Serializer): diff --git a/django/thunderstore/community/api/experimental/views/listing.py b/django/thunderstore/community/api/experimental/views/listing.py index 9af650556..00976beec 100644 --- a/django/thunderstore/community/api/experimental/views/listing.py +++ b/django/thunderstore/community/api/experimental/views/listing.py @@ -3,6 +3,8 @@ from rest_framework.generics import GenericAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from thunderstore.cache.tasks import invalidate_cache_on_commit_async +from thunderstore.cache.enums import CacheBustCondition from thunderstore.api.cyberstorm.services.package_listing import ( approve_package_listing, @@ -45,6 +47,18 @@ def post(self, request, *args, **kwargs): listing=listing, ) + if "readme" in request_serializer.validated_data: + readme = request_serializer.validated_data["readme"] + listing.package.latest.readme_override = readme + listing.package.latest.save() + invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated) + + if "changelog" in request_serializer.validated_data: + changelog = request_serializer.validated_data["changelog"] + listing.package.latest.changelog_override = changelog + listing.package.latest.save() + invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated) + serializer = self.serializer_class(instance=listing) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/django/thunderstore/repository/migrations/0065_add_readme_override.py b/django/thunderstore/repository/migrations/0065_add_readme_override.py new file mode 100644 index 000000000..081dd3874 --- /dev/null +++ b/django/thunderstore/repository/migrations/0065_add_readme_override.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.7 on 2026-03-13 20:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0064_add_namespaces_for_existing_teams'), + ] + + operations = [ + migrations.AddField( + model_name='packageversion', + name='changelog_override', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='packageversion', + name='readme_override', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/django/thunderstore/repository/models/package.py b/django/thunderstore/repository/models/package.py index db1ab52c2..42170a842 100644 --- a/django/thunderstore/repository/models/package.py +++ b/django/thunderstore/repository/models/package.py @@ -241,9 +241,13 @@ def dependants_list(self): return get_package_dependants_list(self.pk) def readme(self): + if self.latest.readme_override: + return self.latest.readme_override return self.latest.readme def changelog(self): + if self.latest.changelog_override: + return self.latest.changelog_override return self.latest.changelog def get_absolute_url(self) -> str: diff --git a/django/thunderstore/repository/models/package_version.py b/django/thunderstore/repository/models/package_version.py index 44cf3b4d6..3665ba5ac 100644 --- a/django/thunderstore/repository/models/package_version.py +++ b/django/thunderstore/repository/models/package_version.py @@ -136,7 +136,9 @@ class PackageVersion(VisibilityMixin, AdminLinkMixin): blank=True, ) readme = models.TextField() + readme_override = models.TextField(blank=True, null=True) changelog = models.TextField(blank=True, null=True) + changelog_override = models.TextField(blank=True, null=True) review_status = models.TextField( default=PackageVersionReviewStatus.unreviewed, From b0e98ab32cbd4402fc719fbec79b6dfb292e9dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Schm=C3=B6cker?= Date: Sat, 14 Mar 2026 18:55:11 +0100 Subject: [PATCH 2/5] chore: test coverage --- .../api/cyberstorm/tests/test_markdown.py | 33 ++++++++++++++ .../tests/test_api_package_listing.py | 43 +++++++++++++++++++ .../repository/tests/test_package.py | 23 ++++++++++ 3 files changed, 99 insertions(+) diff --git a/django/thunderstore/api/cyberstorm/tests/test_markdown.py b/django/thunderstore/api/cyberstorm/tests/test_markdown.py index 04435ca34..aae3fcd40 100644 --- a/django/thunderstore/api/cyberstorm/tests/test_markdown.py +++ b/django/thunderstore/api/cyberstorm/tests/test_markdown.py @@ -71,6 +71,22 @@ def test_readme_api_view__prerenders_markup(api_client: APIClient) -> None: assert actual["html"] == "

Very strong header

\n" +@pytest.mark.django_db +def test_readme_api_view__uses_override_when_present(api_client: APIClient) -> None: + v = PackageVersionFactory( + readme="Original", + readme_override="Override", + ) + + response = api_client.get( + f"/api/cyberstorm/package/{v.package.namespace}/{v.package.name}/latest/readme/", + ) + actual = response.json() + + # Simple markdown paragraph + assert actual["html"] == "

Override

\n" + + @pytest.mark.django_db @pytest.mark.parametrize( ("markdown", "markup"), @@ -94,6 +110,23 @@ def test_changelog_api_view__prerenders_markup( assert actual["html"] == markup +@pytest.mark.django_db +def test_changelog_api_view__uses_override_when_present( + api_client: APIClient, +) -> None: + v = PackageVersionFactory( + changelog="Original changelog", + changelog_override="Override changelog", + ) + + response = api_client.get( + f"/api/cyberstorm/package/{v.package.namespace}/{v.package.name}/latest/changelog/", + ) + actual = response.json() + + assert actual["html"] == "

Override changelog

\n" + + @pytest.mark.django_db def test_changelog_api_view__when_package_has_no_changelog__returns_404( api_client: APIClient, diff --git a/django/thunderstore/community/api/experimental/tests/test_api_package_listing.py b/django/thunderstore/community/api/experimental/tests/test_api_package_listing.py index 02b651571..1845f0dc5 100644 --- a/django/thunderstore/community/api/experimental/tests/test_api_package_listing.py +++ b/django/thunderstore/community/api/experimental/tests/test_api_package_listing.py @@ -6,6 +6,7 @@ from rest_framework.test import APIClient from conftest import TestUserTypes +from thunderstore.cache.enums import CacheBustCondition from thunderstore.community.models import PackageCategory, PackageListing from thunderstore.repository.models import TeamMember @@ -73,3 +74,45 @@ def test_api_experimental_package_listing_update( ] assert active_package_listing.categories.count() == 1 assert package_category in active_package_listing.categories.all() + + +@pytest.mark.django_db +def test_api_experimental_package_listing_update_overrides_readme_and_changelog( + api_client: APIClient, + active_package_listing: PackageListing, + team_owner: TeamMember, + mocker, +): + assert team_owner.team == active_package_listing.package.owner + api_client.force_authenticate(user=team_owner.user) + + mocked_invalidate = mocker.patch( + "thunderstore.community.api.experimental.views.listing.invalidate_cache_on_commit_async" + ) + + readme_markdown = "Override readme" + changelog_markdown = "Override changelog" + + response = api_client.post( + f"/api/experimental/package-listing/{active_package_listing.pk}/update/", + data=json.dumps( + { + "categories": [], + "readme": readme_markdown, + "changelog": changelog_markdown, + } + ), + content_type="application/json", + ) + + assert response.status_code == 200 + + latest = active_package_listing.package.latest + latest.refresh_from_db() + + assert latest.readme_override == readme_markdown + assert latest.changelog_override == changelog_markdown + + # Once for readme, once for changelog + mocked_invalidate.assert_called_with(CacheBustCondition.any_package_updated) + assert mocked_invalidate.call_count == 2 diff --git a/django/thunderstore/repository/tests/test_package.py b/django/thunderstore/repository/tests/test_package.py index a88b28c8d..0bfeca481 100644 --- a/django/thunderstore/repository/tests/test_package.py +++ b/django/thunderstore/repository/tests/test_package.py @@ -46,6 +46,29 @@ def test_package_get_page_url( assert owner_url == f"/c/test/p/Test_Team/{active_package_listing.package.name}/" +@pytest.mark.django_db +def test_package_readme_uses_override_when_present() -> None: + version = PackageVersionFactory(readme="Base", readme_override="Override") + package = version.package + package.latest = version + package.save(update_fields=("latest",)) + + assert package.readme() == "Override" + + +@pytest.mark.django_db +def test_package_changelog_uses_override_when_present() -> None: + version = PackageVersionFactory( + changelog="Base changelog", + changelog_override="Override changelog", + ) + package = version.package + package.latest = version + package.save(update_fields=("latest",)) + + assert package.changelog() == "Override changelog" + + @pytest.mark.django_db @pytest.mark.parametrize( "site_host", ("thunderstore.dev", "test.thunderstore.dev", None) From aa8596e4c5cc274759059e94524d91748e4c5233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Schm=C3=B6cker?= Date: Sat, 14 Mar 2026 21:44:32 +0100 Subject: [PATCH 3/5] fix: allow emtpy string override --- django/thunderstore/api/cyberstorm/views/markdown.py | 4 ++-- django/thunderstore/repository/models/package.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/thunderstore/api/cyberstorm/views/markdown.py b/django/thunderstore/api/cyberstorm/views/markdown.py index 7c3579ebc..28fe8ca46 100644 --- a/django/thunderstore/api/cyberstorm/views/markdown.py +++ b/django/thunderstore/api/cyberstorm/views/markdown.py @@ -31,7 +31,7 @@ def get_object(self): version_number=self.kwargs.get("version_number"), ) - if package_version.readme_override: + if package_version.readme_override is not None: return {"html": render_markdown(package_version.readme_override)} return {"html": render_markdown(package_version.readme)} @@ -59,7 +59,7 @@ def get_object(self): if package_version.changelog is None: raise Http404 - if package_version.changelog_override: + if package_version.changelog_override is not None: return {"html": render_markdown(package_version.changelog_override)} return {"html": render_markdown(package_version.changelog)} diff --git a/django/thunderstore/repository/models/package.py b/django/thunderstore/repository/models/package.py index 42170a842..07461dd1a 100644 --- a/django/thunderstore/repository/models/package.py +++ b/django/thunderstore/repository/models/package.py @@ -241,12 +241,12 @@ def dependants_list(self): return get_package_dependants_list(self.pk) def readme(self): - if self.latest.readme_override: + if self.latest.readme_override is not None: return self.latest.readme_override return self.latest.readme def changelog(self): - if self.latest.changelog_override: + if self.latest.changelog_override is not None: return self.latest.changelog_override return self.latest.changelog From 2fca400ebfd6d41121736c64be1c05e4ffbfa5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Schm=C3=B6cker?= Date: Sat, 14 Mar 2026 22:12:26 +0100 Subject: [PATCH 4/5] fix: readme override of experimental API --- .../tests/test_package_version.py | 42 +++++++++++++++++++ .../api/experimental/views/package_version.py | 10 ++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/django/thunderstore/repository/api/experimental/tests/test_package_version.py b/django/thunderstore/repository/api/experimental/tests/test_package_version.py index 235b1f710..79b0445dc 100644 --- a/django/thunderstore/repository/api/experimental/tests/test_package_version.py +++ b/django/thunderstore/repository/api/experimental/tests/test_package_version.py @@ -25,6 +25,27 @@ def test_api_experimental_package_version_changelog( assert result["markdown"] == changelog +@pytest.mark.django_db +def test_api_experimental_package_version_changelog_uses_override( + api_client: APIClient, + package_version: PackageVersion, +) -> None: + package_version.changelog = "Base changelog" + package_version.changelog_override = "Override changelog" + package_version.save() + + response = api_client.get( + f"/api/experimental/package/" + f"{package_version.package.owner.name}/" + f"{package_version.package.name}/" + f"{package_version.version_number}/" + "changelog/" + ) + assert response.status_code == 200 + result = response.json() + assert result["markdown"] == "Override changelog" + + @pytest.mark.django_db @pytest.mark.parametrize("readme", ("", "# Test readme")) def test_api_experimental_package_version_readme( @@ -42,3 +63,24 @@ def test_api_experimental_package_version_readme( assert response.status_code == 200 result = response.json() assert result["markdown"] == readme + + +@pytest.mark.django_db +def test_api_experimental_package_version_readme_uses_override( + api_client: APIClient, + package_version: PackageVersion, +) -> None: + package_version.readme = "Base readme" + package_version.readme_override = "Override readme" + package_version.save() + + response = api_client.get( + f"/api/experimental/package/" + f"{package_version.package.owner.name}/" + f"{package_version.package.name}/" + f"{package_version.version_number}/" + "readme/" + ) + assert response.status_code == 200 + result = response.json() + assert result["markdown"] == "Override readme" diff --git a/django/thunderstore/repository/api/experimental/views/package_version.py b/django/thunderstore/repository/api/experimental/views/package_version.py index 5fd4bc66f..0e5f6c2c6 100644 --- a/django/thunderstore/repository/api/experimental/views/package_version.py +++ b/django/thunderstore/repository/api/experimental/views/package_version.py @@ -85,7 +85,10 @@ def get(self, *args, **kwargs): def retrieve(self, request, *args, **kwargs): instance = self.get_object() - serializer = self.get_serializer({"markdown": instance.changelog}) + if instance.changelog_override is not None: + serializer = self.get_serializer({"markdown": instance.changelog_override}) + else: + serializer = self.get_serializer({"markdown": instance.changelog}) return Response(serializer.data) @@ -105,5 +108,8 @@ def get(self, *args, **kwargs): def retrieve(self, request, *args, **kwargs): instance = self.get_object() - serializer = self.get_serializer({"markdown": instance.readme}) + if instance.readme_override is not None: + serializer = self.get_serializer({"markdown": instance.readme_override}) + else: + serializer = self.get_serializer({"markdown": instance.readme}) return Response(serializer.data) From 2d6ab7058889fe5e3a520f21cd9ab07df6f907f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Schm=C3=B6cker?= Date: Sat, 14 Mar 2026 22:30:09 +0100 Subject: [PATCH 5/5] fix: use changelog_override even when the original changelog is missing --- .../api/cyberstorm/tests/test_markdown.py | 15 +++++++++++++++ .../thunderstore/api/cyberstorm/views/markdown.py | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/django/thunderstore/api/cyberstorm/tests/test_markdown.py b/django/thunderstore/api/cyberstorm/tests/test_markdown.py index aae3fcd40..87e0b0028 100644 --- a/django/thunderstore/api/cyberstorm/tests/test_markdown.py +++ b/django/thunderstore/api/cyberstorm/tests/test_markdown.py @@ -140,3 +140,18 @@ def test_changelog_api_view__when_package_has_no_changelog__returns_404( assert response.status_code == 404 assert actual["detail"] == "Not found." + + +@pytest.mark.django_db +def test_changelog_api_view__when_package_has_override_but_no_changelog( + api_client: APIClient, +) -> None: + v = PackageVersionFactory(changelog=None, changelog_override="Override changelog") + + response = api_client.get( + f"/api/cyberstorm/package/{v.package.namespace}/{v.package.name}/latest/changelog/", + ) + actual = response.json() + + assert response.status_code == 200 + assert actual["html"] == "

Override changelog

\n" diff --git a/django/thunderstore/api/cyberstorm/views/markdown.py b/django/thunderstore/api/cyberstorm/views/markdown.py index 28fe8ca46..1c01e0494 100644 --- a/django/thunderstore/api/cyberstorm/views/markdown.py +++ b/django/thunderstore/api/cyberstorm/views/markdown.py @@ -56,7 +56,7 @@ def get_object(self): version_number=self.kwargs.get("version_number"), ) - if package_version.changelog is None: + if package_version.changelog is None and package_version.changelog_override is None: raise Http404 if package_version.changelog_override is not None: