feat: change entire downloads

This commit is contained in:
mohamad
2026-05-16 19:13:32 +03:30
parent eb880e3e8b
commit f0a9d5832b
26 changed files with 1283 additions and 213 deletions
+84 -13
View File
@@ -1,12 +1,12 @@
from django.contrib import admin
from .models import Article, ArticleSection, MainProduct, SubProduct
from .models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion
class ArticleSectionInline(admin.TabularInline):
model = ArticleSection
extra = 1
fields = ("title", "value", "order")
fields = ("title", "value_format", "value", "order")
ordering = ("order",)
@@ -18,10 +18,28 @@ class ArticleInline(admin.StackedInline):
show_change_link = True
class SubProductVersionInline(admin.TabularInline):
model = SubProductVersion
extra = 0
ordering = ("order", "version")
fields = (
"version",
"is_featured_stable",
"windows_download_url",
"macos_download_url",
"linux_download_url",
"source_code_url",
"package_resource_url",
"release_notes",
"is_active",
"order",
)
class SubProductInline(admin.StackedInline):
model = SubProduct
extra = 0
fields = ("name", "slug", "short_description", "image", "order", "is_active")
fields = ("name", "slug", "distribution", "short_description", "image", "order", "is_active")
ordering = ("order",)
show_change_link = True
prepopulated_fields = {"slug": ("name",)}
@@ -36,7 +54,8 @@ class MainProductAdmin(admin.ModelAdmin):
list_editable = ("order", "is_active")
inlines = [SubProductInline]
fieldsets = (
(None, {"fields": ("name", "slug", "short_description", "description")}),
(None, {"fields": ("name", "slug", "short_description")}),
("Description", {"fields": ("description_format", "description")}),
("Media", {"fields": ("image",)}),
("Settings", {"fields": ("order", "is_active")}),
)
@@ -44,18 +63,23 @@ class MainProductAdmin(admin.ModelAdmin):
@admin.register(SubProduct)
class SubProductAdmin(admin.ModelAdmin):
list_display = ("name", "main_product", "order", "is_active", "created_at")
list_filter = ("is_active", "main_product")
list_display = (
"name",
"main_product",
"distribution",
"order",
"is_active",
"created_at",
)
list_filter = ("is_active", "distribution", "main_product")
search_fields = ("name", "description", "main_product__name")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active")
raw_id_fields = ("main_product",)
inlines = [ArticleInline]
inlines = [ArticleInline, SubProductVersionInline]
fieldsets = (
(
None,
{"fields": ("main_product", "name", "slug", "short_description", "description")},
),
(None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}),
("Description", {"fields": ("description_format", "description")}),
("Media", {"fields": ("image",)}),
("Settings", {"fields": ("order", "is_active")}),
)
@@ -70,14 +94,61 @@ class ArticleAdmin(admin.ModelAdmin):
raw_id_fields = ("sub_product",)
inlines = [ArticleSectionInline]
fieldsets = (
(None, {"fields": ("sub_product", "title", "description")}),
(None, {"fields": ("sub_product", "title")}),
("Description", {"fields": ("description_format", "description")}),
("Settings", {"fields": ("order",)}),
)
@admin.register(ArticleSection)
class ArticleSectionAdmin(admin.ModelAdmin):
list_display = ("title", "article", "order")
list_display = ("title", "article", "value_format", "order")
search_fields = ("title", "value", "article__title")
list_editable = ("order",)
raw_id_fields = ("article",)
fieldsets = (
(None, {"fields": ("article", "title")}),
("Content", {"fields": ("value_format", "value")}),
("Settings", {"fields": ("order",)}),
)
@admin.register(SubProductVersion)
class SubProductVersionAdmin(admin.ModelAdmin):
list_display = (
"sub_product",
"version",
"is_featured_stable",
"is_active",
"order",
)
list_filter = ("is_active", "is_featured_stable", "sub_product__distribution")
search_fields = ("sub_product__name", "version")
list_editable = ("is_active", "order")
raw_id_fields = ("sub_product",)
fieldsets = (
(
None,
{
"fields": (
"sub_product",
"version",
"is_featured_stable",
)
},
),
(
"Installable downloads",
{
"fields": (
"windows_download_url",
"macos_download_url",
"linux_download_url",
"source_code_url",
)
},
),
("Package link", {"fields": ("package_resource_url",)}),
("Details", {"fields": ("release_notes",)}),
("Settings", {"fields": ("is_active", "order")}),
)
@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2026-05-14 04:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SubProductRelease',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=20)),
('release_type', models.CharField(choices=[('stable', 'Stable'), ('previous', 'Previous')], default='stable', max_length=20)),
('version', models.CharField(help_text='e.g. 2.1.0', max_length=50)),
('download_url', models.URLField()),
('release_notes', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('sub_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='releases', to='products.subproduct')),
],
options={
'verbose_name': 'Release',
'verbose_name_plural': 'Releases',
'ordering': ['release_type', 'platform', 'order'],
'unique_together': {('sub_product', 'platform', 'release_type')},
},
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.0.2 on 2026-05-14 05:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_subproductrelease'),
]
operations = [
migrations.AddField(
model_name='article',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='articlesection',
name='value_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='mainproduct',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='subproduct',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
]
@@ -0,0 +1,145 @@
from django.db import migrations, models
import django.db.models.deletion
def migrate_legacy_releases_to_versions(apps, schema_editor):
OldRelease = apps.get_model("products", "SubProductRelease")
Version = apps.get_model("products", "SubProductVersion")
PLATFORM_MAP = {
"windows": "windows_download_url",
"macos": "macos_download_url",
"linux": "linux_download_url",
}
sub_ids = (
OldRelease.objects.values_list("sub_product_id", flat=True).distinct().order_by()
)
for sub_id in sub_ids:
used_labels = set()
for release_type in ("stable", "previous"):
slab = OldRelease.objects.filter(
sub_product_id=sub_id, release_type=release_type
).order_by("order", "pk")
if not slab.exists():
continue
urls = {}
labels = []
orders = []
notes = []
any_active = False
for r in slab:
orders.append(r.order)
if r.is_active:
any_active = True
f = PLATFORM_MAP.get(r.platform)
if f and r.download_url:
urls[f] = r.download_url
labels.append(r.version or "")
if (r.release_notes or "").strip():
notes.append((r.release_notes or "").strip())
vn = next((x for x in labels if x), None) or "1.0"
if vn in used_labels:
suffix = "older" if release_type == "previous" else "alternate"
candidate = f"{vn} ({suffix})"
n = 2
while candidate in used_labels:
candidate = f"{vn} ({suffix} {n})"
n += 1
vn = candidate
used_labels.add(vn)
Version.objects.create(
sub_product_id=sub_id,
version=vn,
is_featured_stable=(release_type == "stable"),
windows_download_url=urls.get("windows_download_url", ""),
macos_download_url=urls.get("macos_download_url", ""),
linux_download_url=urls.get("linux_download_url", ""),
source_code_url="",
package_resource_url="",
release_notes="\n\n".join(dict.fromkeys(notes)),
is_active=any_active,
order=min(orders) if orders else 0,
)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("products", "0003_article_description_format_and_more"),
]
operations = [
migrations.AddField(
model_name="subproduct",
name="distribution",
field=models.CharField(
choices=[
("installable", "Installable application"),
("package", "Package (external / non-installable)"),
],
default="installable",
help_text="Only affects how releases appear on the public site.",
max_length=20,
),
),
migrations.CreateModel(
name="SubProductVersion",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("version", models.CharField(max_length=80)),
(
"is_featured_stable",
models.BooleanField(
default=False,
help_text="Highlighted as the main release on the product page.",
),
),
("windows_download_url", models.URLField(blank=True)),
("macos_download_url", models.URLField(blank=True)),
("linux_download_url", models.URLField(blank=True)),
("source_code_url", models.URLField(blank=True)),
(
"package_resource_url",
models.URLField(
blank=True,
help_text="For package-type modules: external link for this release.",
),
),
("release_notes", models.TextField(blank=True)),
("is_active", models.BooleanField(default=True)),
("order", models.PositiveIntegerField(default=0)),
(
"sub_product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="products.subproduct",
),
),
],
options={
"verbose_name": "Release version",
"verbose_name_plural": "Release versions",
"ordering": ["order", "version", "pk"],
"unique_together": {("sub_product", "version")},
},
),
migrations.RunPython(migrate_legacy_releases_to_versions, noop_reverse),
migrations.DeleteModel(
name="SubProductRelease",
),
]
+110
View File
@@ -2,12 +2,25 @@ from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content
INSTALLABLE_PLATFORM_SPECS = (
("windows_download_url", "Windows", "images/icon-windows.svg"),
("macos_download_url", "macOS", "images/icon-macos.svg"),
("linux_download_url", "Linux", "images/icon-linux.svg"),
("source_code_url", "Source code", None),
)
class MainProduct(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
short_description = models.CharField(max_length=300)
description = models.TextField()
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
image = models.ImageField(upload_to="products/main/", blank=True, null=True)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
@@ -19,6 +32,10 @@ class MainProduct(models.Model):
verbose_name = "Main Product"
verbose_name_plural = "Main Products"
@property
def rendered_description(self):
return render_content(self.description, self.description_format)
def __str__(self):
return self.name
@@ -32,6 +49,13 @@ class MainProduct(models.Model):
class SubProduct(models.Model):
DISTRIBUTION_INSTALLABLE = "installable"
DISTRIBUTION_PACKAGE = "package"
DISTRIBUTION_CHOICES = [
(DISTRIBUTION_INSTALLABLE, "Installable application"),
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
]
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
@@ -41,9 +65,18 @@ class SubProduct(models.Model):
slug = models.SlugField(blank=True)
short_description = models.CharField(max_length=300)
description = models.TextField()
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
image = models.ImageField(upload_to="products/sub/", blank=True, null=True)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
distribution = models.CharField(
max_length=20,
choices=DISTRIBUTION_CHOICES,
default=DISTRIBUTION_INSTALLABLE,
help_text="Only affects how releases appear on the public site.",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -53,6 +86,10 @@ class SubProduct(models.Model):
verbose_name = "Sub Product"
verbose_name_plural = "Sub Products"
@property
def rendered_description(self):
return render_content(self.description, self.description_format)
def __str__(self):
return f"{self.main_product.name} {self.name}"
@@ -70,6 +107,15 @@ class SubProduct(models.Model):
},
)
def get_versions_archive_url(self):
return reverse(
"products:sub_product_versions",
kwargs={
"main_slug": self.main_product.slug,
"sub_slug": self.slug,
},
)
class Article(models.Model):
sub_product = models.ForeignKey(
@@ -79,6 +125,9 @@ class Article(models.Model):
)
title = models.CharField(max_length=300)
description = models.TextField()
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -88,10 +137,64 @@ class Article(models.Model):
verbose_name = "Article"
verbose_name_plural = "Articles"
@property
def rendered_description(self):
return render_content(self.description, self.description_format)
def __str__(self):
return self.title
class SubProductVersion(models.Model):
sub_product = models.ForeignKey(
SubProduct,
on_delete=models.CASCADE,
related_name="versions",
)
version = models.CharField(max_length=80)
is_featured_stable = models.BooleanField(
default=False,
help_text="Highlighted as the main release on the product page.",
)
windows_download_url = models.URLField(blank=True)
macos_download_url = models.URLField(blank=True)
linux_download_url = models.URLField(blank=True)
source_code_url = models.URLField(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.",
)
release_notes = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order", "version", "pk"]
unique_together = [["sub_product", "version"]]
verbose_name = "Release version"
verbose_name_plural = "Release versions"
def __str__(self):
return f"{self.sub_product.name} v{self.version}"
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})
return out
def has_any_install_asset(self):
return any(
(getattr(self, f[0]) or "").strip()
for f in INSTALLABLE_PLATFORM_SPECS
)
def has_package_link(self):
return bool((self.package_resource_url or "").strip())
class ArticleSection(models.Model):
article = models.ForeignKey(
Article,
@@ -100,6 +203,9 @@ class ArticleSection(models.Model):
)
title = models.CharField(max_length=200)
value = models.TextField()
value_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
order = models.PositiveIntegerField(default=0)
class Meta:
@@ -107,5 +213,9 @@ class ArticleSection(models.Model):
verbose_name = "Article Section"
verbose_name_plural = "Article Sections"
@property
def rendered_value(self):
return render_content(self.value, self.value_format)
def __str__(self):
return f"{self.article.title} {self.title}"
+27
View File
@@ -135,3 +135,30 @@ class SubProductDetailViewTest(ProductViewsSetup):
)
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
class SubProductOlderVersionsViewTest(ProductViewsSetup):
def test_versions_returns_200(self):
from apps.products.models import SubProductVersion
SubProductVersion.objects.create(
sub_product=self.sub_product,
version="9.9",
is_featured_stable=True,
windows_download_url="https://example.com/w",
is_active=True,
)
SubProductVersion.objects.create(
sub_product=self.sub_product,
version="9.8",
is_featured_stable=False,
windows_download_url="https://example.com/w2",
is_active=True,
)
url = reverse(
"products:sub_product_versions",
kwargs={"main_slug": "radiuma", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "products/sub_versions.html")
+5
View File
@@ -11,6 +11,11 @@ urlpatterns = [
views.MainProductDetailView.as_view(),
name="main_product_detail",
),
path(
"<slug:main_slug>/<slug:sub_slug>/versions/",
views.SubProductOlderVersionsView.as_view(),
name="sub_product_versions",
),
path(
"<slug:main_slug>/<slug:sub_slug>/",
views.SubProductDetailView.as_view(),
+119 -5
View File
@@ -1,7 +1,22 @@
from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView, TemplateView
from .models import MainProduct, SubProduct
from .models import INSTALLABLE_PLATFORM_SPECS, MainProduct, SubProduct
def _featured_version(qs):
ordered = qs.order_by("order", "pk")
cand = ordered.filter(is_featured_stable=True).first()
return cand if cand else ordered.first()
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():
present.add(field_name)
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
class ProductOverviewView(ListView):
@@ -43,11 +58,110 @@ class SubProductDetailView(TemplateView):
context["sub_product"] = sub_product
context["articles"] = sub_product.articles.prefetch_related("sections").all()
context["siblings"] = (
SubProduct.objects.filter(
main_product=main_product,
is_active=True,
)
SubProduct.objects.filter(main_product=main_product, is_active=True)
.exclude(pk=sub_product.pk)
.order_by("order", "name")
)
active_versions = sub_product.versions.filter(is_active=True)
context["distribution_installable"] = (
sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE
)
context["distribution_package"] = (
sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE
)
context["show_releases_section"] = False
context["featured_version"] = None
context["featured_install_cells"] = []
context["show_older_versions_link"] = False
context["package_versions"] = []
if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE:
featured = _featured_version(active_versions)
if (
featured
and not featured.has_any_install_asset()
and not (featured.package_resource_url or "").strip()
):
for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"):
if cand.has_any_install_asset() or (cand.package_resource_url or "").strip():
featured = cand
break
context["featured_version"] = featured
if featured and (
featured.has_any_install_asset()
or (featured.package_resource_url or "").strip()
):
if featured.has_any_install_asset():
specs = _install_specs_from_versions([featured])
context["featured_install_cells"] = featured.install_urls_for_specs(specs)
context["show_releases_section"] = True
older_list = list(
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
if featured else active_versions.order_by("order", "pk")
)
context["show_older_versions_link"] = len(older_list) > 0
elif sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE:
pkg_versions = list(active_versions.order_by("order", "pk"))
context["package_versions"] = pkg_versions
context["show_releases_section"] = len(pkg_versions) > 0
return context
class SubProductOlderVersionsView(TemplateView):
template_name = "products/sub_versions.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
main_product = get_object_or_404(
MainProduct,
slug=self.kwargs["main_slug"],
is_active=True,
)
sub_product = get_object_or_404(
SubProduct,
slug=self.kwargs["sub_slug"],
main_product=main_product,
is_active=True,
)
active_versions = sub_product.versions.filter(is_active=True)
featured = _featured_version(active_versions)
archive = list(
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
if featured
else active_versions.order_by("order", "pk")
)
context["main_product"] = main_product
context["sub_product"] = sub_product
context["featured_version"] = featured
context["archive_versions"] = archive
if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE:
specs = _install_specs_from_versions(archive)
context["archive_specs"] = specs
archive_rows_installable = []
for ver in archive:
cells = []
for field_name, label, icon in specs:
u = getattr(ver, field_name, "") or ""
u = u.strip()
cells.append(
{"field": field_name, "label": label, "icon": icon, "url": u}
)
archive_rows_installable.append(
{"version_obj": ver, "cells": cells}
)
context["archive_rows_installable"] = archive_rows_installable
context["archive_rows_package"] = False
context["distribution_installable"] = True
context["distribution_package"] = False
else:
context["archive_specs"] = ()
context["archive_rows_installable"] = []
context["archive_rows_package"] = True
context["distribution_installable"] = False
context["distribution_package"] = True
return context