feat: add upload and delete option for releases
This commit is contained in:
+12
-1
@@ -55,6 +55,7 @@ class SubProductVersionInline(admin.TabularInline):
|
|||||||
extra = 0
|
extra = 0
|
||||||
ordering = ("order", "version")
|
ordering = ("order", "version")
|
||||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
class MainProductVersionInline(admin.TabularInline):
|
class MainProductVersionInline(admin.TabularInline):
|
||||||
@@ -63,6 +64,7 @@ class MainProductVersionInline(admin.TabularInline):
|
|||||||
extra = 0
|
extra = 0
|
||||||
ordering = ("order", "version")
|
ordering = ("order", "version")
|
||||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
class SubProductInline(admin.StackedInline):
|
class SubProductInline(admin.StackedInline):
|
||||||
@@ -208,12 +210,21 @@ class SubProductVersionAdmin(admin.ModelAdmin):
|
|||||||
(
|
(
|
||||||
"Installable downloads",
|
"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": (
|
"fields": (
|
||||||
"windows_download_url",
|
"windows_download_url",
|
||||||
|
"windows_download_file",
|
||||||
"macos_download_url",
|
"macos_download_url",
|
||||||
|
"macos_download_file",
|
||||||
"linux_download_url",
|
"linux_download_url",
|
||||||
|
"linux_download_file",
|
||||||
"source_code_url",
|
"source_code_url",
|
||||||
)
|
"source_code_file",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("Package link", {"fields": ("package_resource_url",)}),
|
("Package link", {"fields": ("package_resource_url",)}),
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ class ProductsConfig(AppConfig):
|
|||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "apps.products"
|
name = "apps.products"
|
||||||
verbose_name = "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
@@ -1,3 +1,5 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
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 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 = (
|
INSTALLABLE_PLATFORM_SPECS = (
|
||||||
("windows_download_url", "Windows", "images/icon-windows.svg"),
|
("windows_download_url", "Windows", "images/icon-windows.svg"),
|
||||||
@@ -254,6 +270,34 @@ class SubProductVersion(models.Model):
|
|||||||
macos_download_url = models.URLField(blank=True)
|
macos_download_url = models.URLField(blank=True)
|
||||||
linux_download_url = models.URLField(blank=True)
|
linux_download_url = models.URLField(blank=True)
|
||||||
source_code_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(
|
package_resource_url = models.URLField(
|
||||||
blank=True,
|
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.",
|
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
|
parent = self.sub_product or self.main_product
|
||||||
return f"{parent.name} v{self.version}"
|
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):
|
def install_urls_for_specs(self, specs):
|
||||||
out = []
|
out = []
|
||||||
for field_name, label, icon in specs:
|
for field_name, label, icon in specs:
|
||||||
url = getattr(self, field_name, "") or ""
|
url = self.resolve_asset_url(field_name)
|
||||||
if url.strip():
|
if url:
|
||||||
out.append({"field": field_name, "label": label, "icon": icon, "url": url})
|
out.append(
|
||||||
|
{
|
||||||
|
"field": field_name,
|
||||||
|
"label": label,
|
||||||
|
"icon": icon,
|
||||||
|
"url": url,
|
||||||
|
"external": bool((getattr(self, field_name) or "").strip()),
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def has_any_install_asset(self):
|
def has_any_install_asset(self):
|
||||||
return any(
|
return any(self.has_platform_asset(f[0]) for f in INSTALLABLE_PLATFORM_SPECS)
|
||||||
(getattr(self, f[0]) or "").strip()
|
|
||||||
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):
|
def has_package_link(self):
|
||||||
return bool((self.package_resource_url or "").strip())
|
return bool((self.package_resource_url or "").strip())
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,7 +23,7 @@ def install_specs_from_versions(version_list):
|
|||||||
present = set()
|
present = set()
|
||||||
for ver in version_list:
|
for ver in version_list:
|
||||||
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
|
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)
|
present.add(field_name)
|
||||||
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
|
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:
|
for ver in archive:
|
||||||
cells = []
|
cells = []
|
||||||
for field_name, label, icon in specs:
|
for field_name, label, icon in specs:
|
||||||
u = (getattr(ver, field_name) or "").strip()
|
|
||||||
cells.append(
|
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})
|
archive_rows_installable.append({"version_obj": ver, "cells": cells})
|
||||||
context["archive_rows_installable"] = archive_rows_installable
|
context["archive_rows_installable"] = archive_rows_installable
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 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):
|
class ProductViewsSetup(TestCase):
|
||||||
@@ -210,3 +214,94 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
|
|||||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
|
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertTrue(response.context["show_releases_section"])
|
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))
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ app_name = "products"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.ProductOverviewView.as_view(), name="overview"),
|
path("", views.ProductOverviewView.as_view(), name="overview"),
|
||||||
|
path(
|
||||||
|
"releases/<int:version_id>/<slug:asset>/download/",
|
||||||
|
views.ReleaseAssetDownloadView.as_view(),
|
||||||
|
name="release_asset_download",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"<slug:main_slug>/",
|
"<slug:main_slug>/",
|
||||||
views.MainProductDetailView.as_view(),
|
views.MainProductDetailView.as_view(),
|
||||||
|
|||||||
+31
-1
@@ -1,10 +1,40 @@
|
|||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from django.http import FileResponse, Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.views import View
|
||||||
from django.views.generic import DetailView, ListView, TemplateView
|
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
|
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):
|
class ProductOverviewView(ListView):
|
||||||
model = MainProduct
|
model = MainProduct
|
||||||
template_name = "products/overview.html"
|
template_name = "products/overview.html"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="sub-dl-platform">{{ cell.label }}</span>
|
<span class="sub-dl-platform">{{ cell.label }}</span>
|
||||||
<span class="download-version">{{ featured_version.version }}</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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
{% if ver.package_resource_url %}
|
{% 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>
|
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ver.source_code_url %}
|
{% if ver.resolved_source_code_url %}
|
||||||
<a href="{{ ver.source_code_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">{{ package_source_button_text }}</a>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if ver.release_notes %}
|
{% if ver.release_notes %}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if cell.url %}
|
{% if cell.url %}
|
||||||
{% if cell.icon %}
|
{% 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" />
|
<img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
|
||||||
<span class="sr-only">{{ cell.label }}</span>
|
<span class="sr-only">{{ cell.label }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="versions-na">—</span>
|
<span class="versions-na">—</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user