feat: add upload and delete option for releases

This commit is contained in:
mohamad
2026-05-31 13:50:55 +03:30
parent de2789bd99
commit 76185c7434
13 changed files with 475 additions and 19 deletions
+12 -1
View File
@@ -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",)}),
+3
View File
@@ -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
@@ -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),
),
]
@@ -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),
),
]
+105 -7
View File
@@ -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())
+84
View File
@@ -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
+8 -3
View File
@@ -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
+37
View File
@@ -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)
+97 -2
View File
@@ -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))
+5
View File
@@ -6,6 +6,11 @@ app_name = "products"
urlpatterns = [
path("", views.ProductOverviewView.as_view(), name="overview"),
path(
"releases/<int:version_id>/<slug:asset>/download/",
views.ReleaseAssetDownloadView.as_view(),
name="release_asset_download",
),
path(
"<slug:main_slug>/",
views.MainProductDetailView.as_view(),
+31 -1
View File
@@ -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"
+3 -3
View File
@@ -35,7 +35,7 @@
</div>
<span class="sub-dl-platform">{{ cell.label }}</span>
<span class="download-version">{{ featured_version.version }}</span>
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.label == "Source code" %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
</div>
{% endfor %}
</div>
@@ -86,8 +86,8 @@
{% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
{% endif %}
{% if ver.source_code_url %}
<a href="{{ ver.source_code_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">{{ package_source_button_text }}</a>
{% if ver.resolved_source_code_url %}
<a href="{{ ver.resolved_source_code_url }}" class="btn-ghost btn--sm"{% if ver.source_code_url %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ package_source_button_text }}</a>
{% endif %}
</div>
{% if ver.release_notes %}
@@ -20,12 +20,12 @@
<td>
{% if cell.url %}
{% if cell.icon %}
<a href="{{ cell.url }}" class="versions-table-link">
<a href="{{ cell.url }}" class="versions-table-link"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>
<img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
<span class="sr-only">{{ cell.label }}</span>
</a>
{% else %}
<a href="{{ cell.url }}" class="versions-table-text-link" rel="noopener noreferrer">Source</a>
<a href="{{ cell.url }}" class="versions-table-text-link"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>Source</a>
{% endif %}
{% else %}
<span class="versions-na"></span>