From 76185c7434fdb023c7e04c1f3ebf318e0274f905 Mon Sep 17 00:00:00 2001 From: mohamad Date: Sun, 31 May 2026 13:50:55 +0330 Subject: [PATCH] feat: add upload and delete option for releases --- apps/products/admin.py | 13 +- apps/products/apps.py | 3 + .../migrations/0014_release_download_files.py | 34 ++++++ .../0015_release_original_filenames.py | 54 +++++++++ apps/products/models.py | 112 ++++++++++++++++-- apps/products/release_assets.py | 84 +++++++++++++ apps/products/release_context.py | 11 +- apps/products/signals.py | 37 ++++++ apps/products/tests/test_views.py | 99 +++++++++++++++- apps/products/urls.py | 5 + apps/products/views.py | 32 ++++- templates/products/_releases_section.html | 6 +- .../products/_versions_archive_body.html | 4 +- 13 files changed, 475 insertions(+), 19 deletions(-) create mode 100644 apps/products/migrations/0014_release_download_files.py create mode 100644 apps/products/migrations/0015_release_original_filenames.py create mode 100644 apps/products/release_assets.py create mode 100644 apps/products/signals.py diff --git a/apps/products/admin.py b/apps/products/admin.py index 103d98f..ce7cbbb 100644 --- a/apps/products/admin.py +++ b/apps/products/admin.py @@ -55,6 +55,7 @@ class SubProductVersionInline(admin.TabularInline): extra = 0 ordering = ("order", "version") fields = RELEASE_VERSION_INLINE_FIELDS + show_change_link = True class MainProductVersionInline(admin.TabularInline): @@ -63,6 +64,7 @@ class MainProductVersionInline(admin.TabularInline): extra = 0 ordering = ("order", "version") fields = RELEASE_VERSION_INLINE_FIELDS + show_change_link = True class SubProductInline(admin.StackedInline): @@ -208,12 +210,21 @@ class SubProductVersionAdmin(admin.ModelAdmin): ( "Installable downloads", { + "description": ( + "For each platform, set an external URL, upload a file, both, or neither. " + "When a URL is set it is used on the site; otherwise an uploaded file is served. " + "To delete an uploaded file from the server, open this release, check Clear next to the file, and Save." + ), "fields": ( "windows_download_url", + "windows_download_file", "macos_download_url", + "macos_download_file", "linux_download_url", + "linux_download_file", "source_code_url", - ) + "source_code_file", + ), }, ), ("Package link", {"fields": ("package_resource_url",)}), diff --git a/apps/products/apps.py b/apps/products/apps.py index 6185e2f..417a2ba 100644 --- a/apps/products/apps.py +++ b/apps/products/apps.py @@ -5,3 +5,6 @@ class ProductsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.products" verbose_name = "Products" + + def ready(self): + from . import signals # noqa: F401 diff --git a/apps/products/migrations/0014_release_download_files.py b/apps/products/migrations/0014_release_download_files.py new file mode 100644 index 0000000..e447e16 --- /dev/null +++ b/apps/products/migrations/0014_release_download_files.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.2 on 2026-05-31 09:42 + +import apps.products.release_assets +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0013_package_release_button_labels'), + ] + + operations = [ + migrations.AddField( + model_name='subproductversion', + name='linux_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to), + ), + migrations.AddField( + model_name='subproductversion', + name='macos_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to), + ), + migrations.AddField( + model_name='subproductversion', + name='source_code_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to), + ), + migrations.AddField( + model_name='subproductversion', + name='windows_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to), + ), + ] diff --git a/apps/products/migrations/0015_release_original_filenames.py b/apps/products/migrations/0015_release_original_filenames.py new file mode 100644 index 0000000..952edd3 --- /dev/null +++ b/apps/products/migrations/0015_release_original_filenames.py @@ -0,0 +1,54 @@ +# Generated by Django 5.0.2 on 2026-05-31 09:52 + +import apps.products.release_assets +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0014_release_download_files'), + ] + + operations = [ + migrations.AddField( + model_name='subproductversion', + name='linux_download_filename', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='subproductversion', + name='macos_download_filename', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='subproductversion', + name='source_code_filename', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='subproductversion', + name='windows_download_filename', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AlterField( + model_name='subproductversion', + name='linux_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_linux_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='macos_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_macos_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='source_code_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_source_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='windows_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_windows_file_upload_to), + ), + ] diff --git a/apps/products/models.py b/apps/products/models.py index 1924b7a..2c6c7fd 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -1,3 +1,5 @@ +import os + from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -5,6 +7,20 @@ from django.utils.text import slugify from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content +from .release_assets import ( + FILE_FIELD_BY_ASSET_KEY, + RELEASE_ASSET_KEY_BY_URL_FIELD, + RELEASE_FILE_BY_URL_FIELD, + RELEASE_FILE_HELP_TEXT, + RELEASE_ORIGINAL_FILENAME_BY_ASSET, + RELEASE_ORIGINAL_FILENAME_FIELDS, + release_file_storage, + release_linux_file_upload_to, + release_macos_file_upload_to, + release_source_file_upload_to, + release_windows_file_upload_to, +) + INSTALLABLE_PLATFORM_SPECS = ( ("windows_download_url", "Windows", "images/icon-windows.svg"), @@ -254,6 +270,34 @@ class SubProductVersion(models.Model): macos_download_url = models.URLField(blank=True) linux_download_url = models.URLField(blank=True) source_code_url = models.URLField(blank=True) + windows_download_file = models.FileField( + upload_to=release_windows_file_upload_to, + storage=release_file_storage, + blank=True, + help_text=RELEASE_FILE_HELP_TEXT, + ) + macos_download_file = models.FileField( + upload_to=release_macos_file_upload_to, + storage=release_file_storage, + blank=True, + help_text=RELEASE_FILE_HELP_TEXT, + ) + linux_download_file = models.FileField( + upload_to=release_linux_file_upload_to, + storage=release_file_storage, + blank=True, + help_text=RELEASE_FILE_HELP_TEXT, + ) + source_code_file = models.FileField( + upload_to=release_source_file_upload_to, + storage=release_file_storage, + blank=True, + help_text=RELEASE_FILE_HELP_TEXT, + ) + windows_download_filename = models.CharField(max_length=255, blank=True) + macos_download_filename = models.CharField(max_length=255, blank=True) + linux_download_filename = models.CharField(max_length=255, blank=True) + source_code_filename = models.CharField(max_length=255, blank=True) package_resource_url = models.URLField( blank=True, help_text="Shown on the site when set. For Resources it is the main Open link; for Installable it appears under the platform grid.", @@ -299,19 +343,73 @@ class SubProductVersion(models.Model): parent = self.sub_product or self.main_product return f"{parent.name} v{self.version}" + def save(self, *args, **kwargs): + for file_field, name_field in RELEASE_ORIGINAL_FILENAME_FIELDS.items(): + field_file = getattr(self, file_field) + if field_file and field_file._file is not None: + setattr(self, name_field, os.path.basename(field_file.name)) + elif not field_file: + setattr(self, name_field, "") + super().save(*args, **kwargs) + + def release_download_filename(self, asset): + name_field = RELEASE_ORIGINAL_FILENAME_BY_ASSET.get(asset) + if name_field: + stored = (getattr(self, name_field, "") or "").strip() + if stored: + return stored + file_field = FILE_FIELD_BY_ASSET_KEY.get(asset) + if file_field: + release_file = getattr(self, file_field) + if release_file: + return os.path.basename(release_file.name) + return "download" + + def has_platform_asset(self, url_field_name): + if (getattr(self, url_field_name) or "").strip(): + return True + file_field = RELEASE_FILE_BY_URL_FIELD.get(url_field_name) + if not file_field: + return False + return bool(getattr(self, file_field)) + + def resolve_asset_url(self, url_field_name): + url = (getattr(self, url_field_name) or "").strip() + if url: + return url + file_field = RELEASE_FILE_BY_URL_FIELD.get(url_field_name) + if not file_field or not getattr(self, file_field): + return "" + if not self.pk: + return "" + asset = RELEASE_ASSET_KEY_BY_URL_FIELD[url_field_name] + return reverse( + "products:release_asset_download", + kwargs={"version_id": self.pk, "asset": asset}, + ) + def install_urls_for_specs(self, specs): out = [] for field_name, label, icon in specs: - url = getattr(self, field_name, "") or "" - if url.strip(): - out.append({"field": field_name, "label": label, "icon": icon, "url": url}) + url = self.resolve_asset_url(field_name) + if url: + out.append( + { + "field": field_name, + "label": label, + "icon": icon, + "url": url, + "external": bool((getattr(self, field_name) or "").strip()), + } + ) return out def has_any_install_asset(self): - return any( - (getattr(self, f[0]) or "").strip() - for f in INSTALLABLE_PLATFORM_SPECS - ) + return any(self.has_platform_asset(f[0]) for f in INSTALLABLE_PLATFORM_SPECS) + + @property + def resolved_source_code_url(self): + return self.resolve_asset_url("source_code_url") def has_package_link(self): return bool((self.package_resource_url or "").strip()) diff --git a/apps/products/release_assets.py b/apps/products/release_assets.py new file mode 100644 index 0000000..145438d --- /dev/null +++ b/apps/products/release_assets.py @@ -0,0 +1,84 @@ +import os + +from django.core.files.storage import FileSystemStorage +from django.utils.text import get_valid_filename + + +class ReleaseFileStorage(FileSystemStorage): + def get_available_name(self, name, max_length=None): + return name + + +release_file_storage = ReleaseFileStorage() + +RELEASE_FILE_BY_URL_FIELD = { + "windows_download_url": "windows_download_file", + "macos_download_url": "macos_download_file", + "linux_download_url": "linux_download_file", + "source_code_url": "source_code_file", +} + +RELEASE_ASSET_KEY_BY_URL_FIELD = { + "windows_download_url": "windows", + "macos_download_url": "macos", + "linux_download_url": "linux", + "source_code_url": "source", +} + +URL_FIELD_BY_ASSET_KEY = { + v: k for k, v in RELEASE_ASSET_KEY_BY_URL_FIELD.items() +} + +FILE_FIELD_BY_ASSET_KEY = { + asset: RELEASE_FILE_BY_URL_FIELD[url_field] + for url_field, asset in RELEASE_ASSET_KEY_BY_URL_FIELD.items() +} + +RELEASE_ASSET_KEYS = frozenset(FILE_FIELD_BY_ASSET_KEY.keys()) + +RELEASE_FILE_HELP_TEXT = ( + "Optional. Served from this site when the matching URL above is empty. " + "To remove an uploaded file, check Clear, then Save — the file is deleted from the server. " + "Any file type and size are allowed; large uploads may require web server limits." +) + +RELEASE_ORIGINAL_FILENAME_FIELDS = { + "windows_download_file": "windows_download_filename", + "macos_download_file": "macos_download_filename", + "linux_download_file": "linux_download_filename", + "source_code_file": "source_code_filename", +} + +RELEASE_ORIGINAL_FILENAME_BY_ASSET = { + RELEASE_ASSET_KEY_BY_URL_FIELD[url_field]: RELEASE_ORIGINAL_FILENAME_FIELDS[file_field] + for url_field, file_field in RELEASE_FILE_BY_URL_FIELD.items() +} + + +def _release_file_path(instance, filename, platform): + if instance.sub_product_id: + base = f"{instance.sub_product.main_product.slug}/{instance.sub_product.slug}" + else: + base = instance.main_product.slug + version_part = (instance.version or "release").replace("/", "-") + safe_name = get_valid_filename(os.path.basename(filename)) + return f"releases/{base}/{version_part}/{platform}/{safe_name}" + + +def release_windows_file_upload_to(instance, filename): + return _release_file_path(instance, filename, "windows") + + +def release_macos_file_upload_to(instance, filename): + return _release_file_path(instance, filename, "macos") + + +def release_linux_file_upload_to(instance, filename): + return _release_file_path(instance, filename, "linux") + + +def release_source_file_upload_to(instance, filename): + return _release_file_path(instance, filename, "source") + + +release_file_upload_to = release_windows_file_upload_to diff --git a/apps/products/release_context.py b/apps/products/release_context.py index ffada6e..4c8637a 100644 --- a/apps/products/release_context.py +++ b/apps/products/release_context.py @@ -23,7 +23,7 @@ def install_specs_from_versions(version_list): present = set() for ver in version_list: for field_name, *_ in INSTALLABLE_PLATFORM_SPECS: - if (getattr(ver, field_name) or "").strip(): + if ver.has_platform_asset(field_name): present.add(field_name) return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present) @@ -95,9 +95,14 @@ def build_archive_context(distribution, active_versions, product): for ver in archive: cells = [] for field_name, label, icon in specs: - u = (getattr(ver, field_name) or "").strip() cells.append( - {"field": field_name, "label": label, "icon": icon, "url": u} + { + "field": field_name, + "label": label, + "icon": icon, + "url": ver.resolve_asset_url(field_name), + "external": bool((getattr(ver, field_name) or "").strip()), + } ) archive_rows_installable.append({"version_obj": ver, "cells": cells}) context["archive_rows_installable"] = archive_rows_installable diff --git a/apps/products/signals.py b/apps/products/signals.py new file mode 100644 index 0000000..0ac231d --- /dev/null +++ b/apps/products/signals.py @@ -0,0 +1,37 @@ +from django.db.models.signals import post_delete, pre_save +from django.dispatch import receiver + +from .models import SubProductVersion +from .release_assets import RELEASE_FILE_BY_URL_FIELD + + +def _stored_release_file_name(version, file_field): + release_file = getattr(version, file_field) + return release_file.name if release_file else "" + + +def _delete_release_file_from_disk(version, file_field): + release_file = getattr(version, file_field) + if release_file: + release_file.delete(save=False) + + +@receiver(pre_save, sender=SubProductVersion) +def delete_replaced_or_cleared_release_files(sender, instance, **kwargs): + if not instance.pk: + return + try: + previous = SubProductVersion.objects.get(pk=instance.pk) + except SubProductVersion.DoesNotExist: + return + for file_field in RELEASE_FILE_BY_URL_FIELD.values(): + old_name = _stored_release_file_name(previous, file_field) + new_name = _stored_release_file_name(instance, file_field) + if old_name and old_name != new_name: + _delete_release_file_from_disk(previous, file_field) + + +@receiver(post_delete, sender=SubProductVersion) +def delete_release_files_on_version_delete(sender, instance, **kwargs): + for file_field in RELEASE_FILE_BY_URL_FIELD.values(): + _delete_release_file_from_disk(instance, file_field) diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py index f6490a3..275b169 100644 --- a/apps/products/tests/test_views.py +++ b/apps/products/tests/test_views.py @@ -1,7 +1,11 @@ -from django.test import TestCase +import os + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings from django.urls import reverse -from apps.products.models import Article, ArticleSection, MainProduct, SubProduct +from apps.products.models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion class ProductViewsSetup(TestCase): @@ -210,3 +214,94 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup): url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) response = self.client.get(url) self.assertTrue(response.context["show_releases_section"]) + + +class ReleaseAssetDownloadViewTest(ProductViewsSetup): + def test_uploaded_file_download(self): + release_file = SimpleUploadedFile("setup.exe", b"binary-payload") + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="2.0", + windows_download_file=release_file, + is_active=True, + ) + url = reverse( + "products:release_asset_download", + kwargs={"version_id": version.pk, "asset": "windows"}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(b"".join(response.streaming_content), b"binary-payload") + self.assertIn("attachment", response["Content-Disposition"]) + self.assertIn("setup.exe", response["Content-Disposition"]) + + def test_external_url_blocks_file_download(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="2.1", + windows_download_url="https://example.com/win.exe", + windows_download_file=SimpleUploadedFile("local.exe", b"x"), + is_active=True, + ) + url = reverse( + "products:release_asset_download", + kwargs={"version_id": version.pk, "asset": "windows"}, + ) + self.assertEqual(self.client.get(url).status_code, 404) + + def test_product_page_uses_file_when_no_url(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="3.0", + windows_download_file=SimpleUploadedFile("win.tar.gz", b"gz"), + is_active=True, + ) + detail_url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) + response = self.client.get(detail_url) + download_url = reverse( + "products:release_asset_download", + kwargs={"version_id": version.pk, "asset": "windows"}, + ) + self.assertContains(response, download_url) + + def test_inactive_version_returns_404(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="0.1", + windows_download_file=SimpleUploadedFile("a.exe", b"a"), + is_active=False, + ) + url = reverse( + "products:release_asset_download", + kwargs={"version_id": version.pk, "asset": "windows"}, + ) + self.assertEqual(self.client.get(url).status_code, 404) + + @override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases") + def test_clearing_file_removes_from_disk(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="4.0", + windows_download_file=SimpleUploadedFile("remove-me.exe", b"data"), + is_active=True, + ) + stored_path = version.windows_download_file.path + self.assertTrue(stored_path) + version.windows_download_file = "" + version.save() + version.refresh_from_db() + self.assertFalse(version.windows_download_file) + self.assertEqual(version.windows_download_filename, "") + self.assertFalse(os.path.exists(stored_path)) + + @override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases") + def test_deleting_version_removes_files_from_disk(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="5.0", + windows_download_file=SimpleUploadedFile("gone.exe", b"data"), + is_active=True, + ) + stored_path = version.windows_download_file.path + version.delete() + self.assertFalse(os.path.exists(stored_path)) diff --git a/apps/products/urls.py b/apps/products/urls.py index ad6be2a..e94b4c2 100644 --- a/apps/products/urls.py +++ b/apps/products/urls.py @@ -6,6 +6,11 @@ app_name = "products" urlpatterns = [ path("", views.ProductOverviewView.as_view(), name="overview"), + path( + "releases///download/", + views.ReleaseAssetDownloadView.as_view(), + name="release_asset_download", + ), path( "/", views.MainProductDetailView.as_view(), diff --git a/apps/products/views.py b/apps/products/views.py index 752ca7a..38aedd6 100644 --- a/apps/products/views.py +++ b/apps/products/views.py @@ -1,10 +1,40 @@ +import mimetypes + +from django.http import FileResponse, Http404 from django.shortcuts import get_object_or_404 +from django.views import View from django.views.generic import DetailView, ListView, TemplateView -from .models import MainProduct, SubProduct +from .models import MainProduct, SubProduct, SubProductVersion +from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY from .release_context import build_archive_context, build_release_context +class ReleaseAssetDownloadView(View): + def get(self, request, version_id, asset): + if asset not in RELEASE_ASSET_KEYS: + raise Http404 + version = get_object_or_404(SubProductVersion, pk=version_id, is_active=True) + parent = version.sub_product or version.main_product + if parent is None or not parent.is_active: + raise Http404 + url_field = URL_FIELD_BY_ASSET_KEY[asset] + if (getattr(version, url_field) or "").strip(): + raise Http404 + file_field = FILE_FIELD_BY_ASSET_KEY[asset] + release_file = getattr(version, file_field) + if not release_file: + raise Http404 + filename = version.release_download_filename(asset) + content_type, _ = mimetypes.guess_type(filename) + return FileResponse( + release_file.open("rb"), + as_attachment=True, + filename=filename, + content_type=content_type or "application/octet-stream", + ) + + class ProductOverviewView(ListView): model = MainProduct template_name = "products/overview.html" diff --git a/templates/products/_releases_section.html b/templates/products/_releases_section.html index 9b1e701..fa196d1 100644 --- a/templates/products/_releases_section.html +++ b/templates/products/_releases_section.html @@ -35,7 +35,7 @@ {{ cell.label }} {{ featured_version.version }} - {% if cell.label == "Source code" %}Source{% else %}Download{% endif %} + {% if cell.label == "Source code" %}Source{% else %}Download{% endif %} {% endfor %} @@ -86,8 +86,8 @@ {% if ver.package_resource_url %} {{ package_resource_button_text }} {% endif %} - {% if ver.source_code_url %} - {{ package_source_button_text }} + {% if ver.resolved_source_code_url %} + {{ package_source_button_text }} {% endif %} {% if ver.release_notes %} diff --git a/templates/products/_versions_archive_body.html b/templates/products/_versions_archive_body.html index f530a5f..098902c 100644 --- a/templates/products/_versions_archive_body.html +++ b/templates/products/_versions_archive_body.html @@ -20,12 +20,12 @@ {% if cell.url %} {% if cell.icon %} - + {{ cell.label }} {% else %} - Source + Source {% endif %} {% else %}