feat: a link in download section

This commit is contained in:
mohamad
2026-06-07 17:41:41 +03:30
parent cbc49cd8ae
commit 33b3477176
17 changed files with 215 additions and 17 deletions
@@ -0,0 +1,23 @@
# Generated by Django 5.2.13 on 2026-06-07 13:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0013_contact_submission_attachments'),
]
operations = [
migrations.AlterField(
model_name='aboutsection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='aboutsectionitem',
name='image',
field=models.ImageField(blank=True, help_text='Logo or image (used for Supporters cards).', null=True, upload_to='about/items/'),
),
]
+4
View File
@@ -95,6 +95,8 @@ class MainProductAdmin(admin.ModelAdmin):
"distribution", "distribution",
"package_resource_button_text", "package_resource_button_text",
"package_source_button_text", "package_source_button_text",
"downloads_section_link_text",
"downloads_section_link_url",
) )
}, },
), ),
@@ -135,6 +137,8 @@ class SubProductAdmin(admin.ModelAdmin):
"short_description", "short_description",
"package_resource_button_text", "package_resource_button_text",
"package_source_button_text", "package_source_button_text",
"downloads_section_link_text",
"downloads_section_link_url",
) )
}, },
), ),
@@ -0,0 +1,45 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("products", "0015_release_original_filenames"),
]
operations = [
migrations.AddField(
model_name="mainproduct",
name="downloads_section_link_text",
field=models.CharField(
blank=True,
help_text="Label for the optional downloads section link.",
max_length=100,
),
),
migrations.AddField(
model_name="mainproduct",
name="downloads_section_link_url",
field=models.URLField(
blank=True,
help_text="Optional link shown in the top-right corner of the downloads section.",
),
),
migrations.AddField(
model_name="subproduct",
name="downloads_section_link_text",
field=models.CharField(
blank=True,
help_text="Label for the optional downloads section link.",
max_length=100,
),
),
migrations.AddField(
model_name="subproduct",
name="downloads_section_link_url",
field=models.URLField(
blank=True,
help_text="Optional link shown in the top-right corner of the downloads section.",
),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.2.13 on 2026-06-07 13:49
import apps.products.release_assets
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0016_downloads_section_link'),
]
operations = [
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. 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.', 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. 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.', 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. 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.', 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. 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.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_windows_file_upload_to),
),
]
+18
View File
@@ -62,6 +62,15 @@ class MainProduct(models.Model):
default="Source", default="Source",
help_text="Label for the source code button on package releases.", help_text="Label for the source code button on package releases.",
) )
downloads_section_link_url = models.URLField(
blank=True,
help_text="Optional link shown in the top-right corner of the downloads section.",
)
downloads_section_link_text = models.CharField(
max_length=100,
blank=True,
help_text="Label for the optional downloads section link.",
)
order = models.PositiveIntegerField(default=0) order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
show_on_homepage = models.BooleanField(default=False, help_text="Display this product in the homepage Products section.") show_on_homepage = models.BooleanField(default=False, help_text="Display this product in the homepage Products section.")
@@ -135,6 +144,15 @@ class SubProduct(models.Model):
default="Source", default="Source",
help_text="Label for the source code button on package releases.", help_text="Label for the source code button on package releases.",
) )
downloads_section_link_url = models.URLField(
blank=True,
help_text="Optional link shown in the top-right corner of the downloads section.",
)
downloads_section_link_text = models.CharField(
max_length=100,
blank=True,
help_text="Label for the optional downloads section link.",
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
+4
View File
@@ -5,6 +5,10 @@ from django.utils.text import get_valid_filename
class ReleaseFileStorage(FileSystemStorage): class ReleaseFileStorage(FileSystemStorage):
def __init__(self, **kwargs):
kwargs.setdefault("allow_overwrite", True)
super().__init__(**kwargs)
def get_available_name(self, name, max_length=None): def get_available_name(self, name, max_length=None):
return name return name
+9
View File
@@ -13,6 +13,14 @@ def package_button_labels(product):
} }
def downloads_section_link(product):
url = (getattr(product, "downloads_section_link_url", "") or "").strip()
text = (getattr(product, "downloads_section_link_text", "") or "").strip()
if url and text:
return {"url": url, "text": text}
return None
def featured_version(qs): def featured_version(qs):
ordered = qs.order_by("order", "pk") ordered = qs.order_by("order", "pk")
cand = ordered.filter(is_featured_stable=True).first() cand = ordered.filter(is_featured_stable=True).first()
@@ -38,6 +46,7 @@ def build_release_context(distribution, active_versions, product):
"show_older_versions_link": False, "show_older_versions_link": False,
"package_versions": [], "package_versions": [],
**package_button_labels(product), **package_button_labels(product),
"downloads_section_link": downloads_section_link(product),
} }
if distribution == DISTRIBUTION_INSTALLABLE: if distribution == DISTRIBUTION_INSTALLABLE:
+27
View File
@@ -155,6 +155,33 @@ class ArticleModelTest(TestCase):
article.full_clean() article.full_clean()
class ReleaseFileStorageTest(TestCase):
def test_save_overwrites_existing_release_file(self):
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.products.models import MainProduct, SubProductVersion
main = MainProduct.objects.create(
name="Storage Test",
short_description="s",
description="d",
)
version = SubProductVersion.objects.create(
main_product=main,
version="1.0",
windows_download_file=SimpleUploadedFile("setup.exe", b"v1"),
is_active=True,
)
version.windows_download_file.save(
"setup.exe",
SimpleUploadedFile("setup.exe", b"v2"),
save=True,
)
version.refresh_from_db()
with version.windows_download_file.open("rb") as handle:
self.assertEqual(handle.read(), b"v2")
class SubProductVersionModelTest(TestCase): class SubProductVersionModelTest(TestCase):
def setUp(self): def setUp(self):
self.main_product = MainProduct.objects.create( self.main_product = MainProduct.objects.create(
+1 -2
View File
@@ -216,6 +216,7 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
self.assertTrue(response.context["show_releases_section"]) self.assertTrue(response.context["show_releases_section"])
@override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases")
class ReleaseAssetDownloadViewTest(ProductViewsSetup): class ReleaseAssetDownloadViewTest(ProductViewsSetup):
def test_uploaded_file_download(self): def test_uploaded_file_download(self):
release_file = SimpleUploadedFile("setup.exe", b"binary-payload") release_file = SimpleUploadedFile("setup.exe", b"binary-payload")
@@ -277,7 +278,6 @@ class ReleaseAssetDownloadViewTest(ProductViewsSetup):
) )
self.assertEqual(self.client.get(url).status_code, 404) 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): def test_clearing_file_removes_from_disk(self):
version = SubProductVersion.objects.create( version = SubProductVersion.objects.create(
main_product=self.main_product, main_product=self.main_product,
@@ -294,7 +294,6 @@ class ReleaseAssetDownloadViewTest(ProductViewsSetup):
self.assertEqual(version.windows_download_filename, "") self.assertEqual(version.windows_download_filename, "")
self.assertFalse(os.path.exists(stored_path)) 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): def test_deleting_version_removes_files_from_disk(self):
version = SubProductVersion.objects.create( version = SubProductVersion.objects.create(
main_product=self.main_product, main_product=self.main_product,
+3
View File
@@ -0,0 +1,3 @@
from .development import * # noqa: F401, F403
DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405
+3
View File
@@ -4,6 +4,9 @@ import sys
def main(): def main():
if len(sys.argv) > 1 and sys.argv[1] == "test":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.test")
else:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
+16 -1
View File
@@ -2124,16 +2124,31 @@ a.citation-count-badge:hover {
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
} }
.sub-downloads-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.sub-downloads-title { .sub-downloads-title {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1.25rem; margin-bottom: 0;
color: var(--text-primary); color: var(--text-primary);
} }
.sub-downloads-corner-link {
flex-shrink: 0;
font-size: 0.88rem;
white-space: nowrap;
}
.sub-downloads-channel { .sub-downloads-channel {
margin-bottom: 1.1rem; margin-bottom: 1.1rem;
} }
+10
View File
@@ -2,12 +2,17 @@
{% if show_releases_section %} {% if show_releases_section %}
{% if distribution_installable %} {% if distribution_installable %}
<section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading"> <section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading">
<div class="sub-downloads-header">
<h2 class="sub-downloads-title" id="sub-dl-heading"> <h2 class="sub-downloads-title" id="sub-dl-heading">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg> </svg>
Downloads Downloads
</h2> </h2>
{% if downloads_section_link %}
<a href="{{ downloads_section_link.url }}" class="sub-downloads-corner-link link-arrow" target="_blank" rel="noopener noreferrer">{{ downloads_section_link.text }}</a>
{% endif %}
</div>
<div class="sub-downloads-channel"> <div class="sub-downloads-channel">
{% if featured_version.is_featured_stable %} {% if featured_version.is_featured_stable %}
@@ -61,6 +66,7 @@
{% elif distribution_package %} {% elif distribution_package %}
<section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading"> <section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading">
<div class="sub-downloads-header">
<h2 class="sub-downloads-title" id="sub-resources-heading"> <h2 class="sub-downloads-title" id="sub-resources-heading">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
@@ -68,6 +74,10 @@
</svg> </svg>
Resources Resources
</h2> </h2>
{% if downloads_section_link %}
<a href="{{ downloads_section_link.url }}" class="sub-downloads-corner-link link-arrow" target="_blank" rel="noopener noreferrer">{{ downloads_section_link.text }}</a>
{% endif %}
</div>
<ul class="pkg-version-list" role="list"> <ul class="pkg-version-list" role="list">
{% for ver in package_versions %} {% for ver in package_versions %}
<li class="pkg-version-row"> <li class="pkg-version-row">
@@ -0,0 +1 @@
a
@@ -0,0 +1 @@
binary-payload
@@ -0,0 +1 @@
x
@@ -0,0 +1 @@
gz