change release section put video and more

This commit is contained in:
mohamad
2026-06-08 17:08:43 +03:30
parent 33b3477176
commit 7cddc03a57
33 changed files with 1657 additions and 93 deletions
+96 -7
View File
@@ -1,6 +1,9 @@
from django.contrib import admin
from .models import Article, ArticleCitation, ArticleSection, MainProduct, SubProduct, SubProductVersion
from apps.core.admin_video import video_admin_preview
from apps.core.video import VIDEO_ADMIN_FIELDSET
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
class ArticleCitationInline(admin.TabularInline):
@@ -37,7 +40,10 @@ class MainProductArticleInline(admin.StackedInline):
RELEASE_VERSION_INLINE_FIELDS = (
"version",
"is_featured_stable",
"release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
"windows_download_url",
"macos_download_url",
"linux_download_url",
@@ -67,6 +73,28 @@ class MainProductVersionInline(admin.TabularInline):
show_change_link = True
class ProductVideoInline(admin.StackedInline):
model = ProductVideo
fk_name = "main_product"
extra = 0
fields = (
"badge",
"title",
"description_format",
"description",
"video_source",
"video_url",
"video_file",
"video_poster",
"video_size",
"video_aspect_ratio",
"video_styled_background",
"order",
"is_active",
)
ordering = ("order",)
class SubProductInline(admin.StackedInline):
model = SubProduct
extra = 0
@@ -83,7 +111,7 @@ class MainProductAdmin(admin.ModelAdmin):
search_fields = ("name", "description")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
inlines = [MainProductArticleInline, MainProductVersionInline, SubProductInline]
inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline]
fieldsets = (
(
None,
@@ -107,6 +135,28 @@ class MainProductAdmin(admin.ModelAdmin):
)
class SubProductVideoInline(admin.StackedInline):
model = ProductVideo
fk_name = "sub_product"
extra = 0
fields = (
"badge",
"title",
"description_format",
"description",
"video_source",
"video_url",
"video_file",
"video_poster",
"video_size",
"video_aspect_ratio",
"video_styled_background",
"order",
"is_active",
)
ordering = ("order",)
@admin.register(SubProduct)
class SubProductAdmin(admin.ModelAdmin):
list_display = (
@@ -124,7 +174,7 @@ class SubProductAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
raw_id_fields = ("main_product",)
inlines = [SubProductArticleInline, SubProductVersionInline]
inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline]
fieldsets = (
(
None,
@@ -173,6 +223,33 @@ class ArticleAdmin(admin.ModelAdmin):
return ""
@admin.register(ProductVideo)
class ProductVideoAdmin(admin.ModelAdmin):
list_display = ("title", "parent", "video_source", "video_size", "order", "is_active")
list_filter = ("video_source", "is_active", "main_product", "sub_product__main_product")
list_editable = ("order", "is_active")
search_fields = ("title", "description", "video_url", "main_product__name", "sub_product__name")
raw_id_fields = ("main_product", "sub_product")
readonly_fields = ("video_preview",)
fieldsets = (
(None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description")}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
)
@admin.display(description="Parent")
def parent(self, obj):
if obj.main_product_id:
return obj.main_product
if obj.sub_product_id:
return obj.sub_product
return ""
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
@admin.register(ArticleSection)
class ArticleSectionAdmin(admin.ModelAdmin):
list_display = ("title", "article", "value_format", "order")
@@ -191,11 +268,14 @@ class SubProductVersionAdmin(admin.ModelAdmin):
list_display = (
"parent",
"version",
"is_featured_stable",
"release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
"is_active",
"order",
)
list_filter = ("is_active", "is_featured_stable", "main_product", "sub_product__main_product")
list_filter = ("is_active", "release_channel", "is_featured", "show_on_product_page", "main_product", "sub_product__main_product")
search_fields = ("main_product__name", "sub_product__name", "version")
list_editable = ("is_active", "order")
raw_id_fields = ("main_product", "sub_product")
@@ -203,11 +283,20 @@ class SubProductVersionAdmin(admin.ModelAdmin):
(
None,
{
"description": (
"Set a preset channel badge (Stable, Beta, Previous, etc.) or write a custom "
"label. Mark one release as Primary (featured) for the main download block. "
"Enable “Show on product page” for additional inline channels such as beta or "
"previous versions."
),
"fields": (
"main_product",
"sub_product",
"version",
"is_featured_stable",
"release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
)
},
),
@@ -0,0 +1,39 @@
# Generated by Django 5.2.13 on 2026-06-08 12:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0017_alter_subproductversion_linux_download_file_and_more'),
]
operations = [
migrations.CreateModel(
name='ProductVideo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)),
('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)),
('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')),
('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')),
('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)),
('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('description', models.TextField(blank=True)),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('main_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.mainproduct')),
('sub_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.subproduct')),
],
options={
'verbose_name': 'Product Video',
'verbose_name_plural': 'Product Videos',
'ordering': ['order', 'pk'],
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='product_video_exactly_one_parent')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.13 on 2026-06-08 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0018_video_support'),
]
operations = [
migrations.AddField(
model_name='productvideo',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.13 on 2026-06-08 13:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0019_video_styled_background'),
]
operations = [
migrations.AddField(
model_name='productvideo',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
]
@@ -0,0 +1,70 @@
from django.db import migrations, models
def migrate_featured_stable_to_channels(apps, schema_editor):
Version = apps.get_model("products", "SubProductVersion")
for version in Version.objects.filter(is_featured_stable=True):
version.is_featured = True
if not version.release_channel:
version.release_channel = "stable"
version.save(update_fields=["is_featured", "release_channel"])
class Migration(migrations.Migration):
dependencies = [
("products", "0020_video_description_format"),
]
operations = [
migrations.AddField(
model_name="subproductversion",
name="channel_label",
field=models.CharField(
blank=True,
help_text="Optional custom badge text. Overrides the preset channel label when set.",
max_length=50,
),
),
migrations.AddField(
model_name="subproductversion",
name="is_featured",
field=models.BooleanField(
default=False,
help_text="Primary release shown at the top of the downloads section.",
),
),
migrations.AddField(
model_name="subproductversion",
name="release_channel",
field=models.CharField(
blank=True,
choices=[
("", "None"),
("stable", "Stable"),
("beta", "Beta"),
("rc", "Release candidate"),
("preview", "Preview"),
("nightly", "Nightly"),
("current", "Current"),
("previous", "Previous"),
],
default="",
help_text="Preset badge for this release (Stable, Beta, Previous, etc.).",
max_length=20,
),
),
migrations.AddField(
model_name="subproductversion",
name="show_on_product_page",
field=models.BooleanField(
default=False,
help_text="Also show this release on the product page (e.g. beta or previous version).",
),
),
migrations.RunPython(migrate_featured_stable_to_channels, migrations.RunPython.noop),
migrations.RemoveField(
model_name="subproductversion",
name="is_featured_stable",
),
]
+124 -2
View File
@@ -6,6 +6,7 @@ from django.urls import reverse
from django.utils.text import slugify
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content
from apps.core.video import VideoBlockMixin
from .release_assets import (
FILE_FIELD_BY_ASSET_KEY,
@@ -36,6 +37,35 @@ DISTRIBUTION_CHOICES = [
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
]
RELEASE_CHANNEL_STABLE = "stable"
RELEASE_CHANNEL_BETA = "beta"
RELEASE_CHANNEL_RC = "rc"
RELEASE_CHANNEL_PREVIEW = "preview"
RELEASE_CHANNEL_NIGHTLY = "nightly"
RELEASE_CHANNEL_CURRENT = "current"
RELEASE_CHANNEL_PREVIOUS = "previous"
RELEASE_CHANNEL_CHOICES = [
("", "None"),
(RELEASE_CHANNEL_STABLE, "Stable"),
(RELEASE_CHANNEL_BETA, "Beta"),
(RELEASE_CHANNEL_RC, "Release candidate"),
(RELEASE_CHANNEL_PREVIEW, "Preview"),
(RELEASE_CHANNEL_NIGHTLY, "Nightly"),
(RELEASE_CHANNEL_CURRENT, "Current"),
(RELEASE_CHANNEL_PREVIOUS, "Previous"),
]
RELEASE_CHANNEL_LABELS = {
RELEASE_CHANNEL_STABLE: "Stable",
RELEASE_CHANNEL_BETA: "Beta",
RELEASE_CHANNEL_RC: "Release candidate",
RELEASE_CHANNEL_PREVIEW: "Preview",
RELEASE_CHANNEL_NIGHTLY: "Nightly",
RELEASE_CHANNEL_CURRENT: "Current",
RELEASE_CHANNEL_PREVIOUS: "Previous",
}
class MainProduct(models.Model):
name = models.CharField(max_length=200)
@@ -280,9 +310,25 @@ class SubProductVersion(models.Model):
blank=True,
)
version = models.CharField(max_length=80)
is_featured_stable = models.BooleanField(
release_channel = models.CharField(
max_length=20,
choices=RELEASE_CHANNEL_CHOICES,
blank=True,
default="",
help_text="Preset badge for this release (Stable, Beta, Previous, etc.).",
)
channel_label = models.CharField(
max_length=50,
blank=True,
help_text="Optional custom badge text. Overrides the preset channel label when set.",
)
is_featured = models.BooleanField(
default=False,
help_text="Highlighted as the main release on the product page.",
help_text="Primary release shown at the top of the downloads section.",
)
show_on_product_page = models.BooleanField(
default=False,
help_text="Also show this release on the product page (e.g. beta or previous version).",
)
windows_download_url = models.URLField(blank=True)
macos_download_url = models.URLField(blank=True)
@@ -361,6 +407,31 @@ class SubProductVersion(models.Model):
parent = self.sub_product or self.main_product
return f"{parent.name} v{self.version}"
@property
def display_channel_label(self):
custom = (self.channel_label or "").strip()
if custom:
return custom
if self.release_channel:
return RELEASE_CHANNEL_LABELS.get(
self.release_channel,
self.release_channel.replace("_", " ").title(),
)
return ""
@property
def display_channel_css_modifier(self):
if (self.channel_label or "").strip():
return "custom"
return self.release_channel or "none"
@property
def is_previous_channel(self):
if self.release_channel == RELEASE_CHANNEL_PREVIOUS:
return True
label = (self.channel_label or "").strip().lower()
return label == "previous"
def save(self, *args, **kwargs):
for file_field, name_field in RELEASE_ORIGINAL_FILENAME_FIELDS.items():
field_file = getattr(self, file_field)
@@ -476,3 +547,54 @@ class ArticleCitation(models.Model):
def __str__(self):
return f"{self.article.title} — citation {self.order or self.pk}"
class ProductVideo(VideoBlockMixin, models.Model):
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="videos",
null=True,
blank=True,
)
sub_product = models.ForeignKey(
SubProduct,
on_delete=models.CASCADE,
related_name="videos",
null=True,
blank=True,
)
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
description = models.TextField(blank=True)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["order", "pk"]
verbose_name = "Product Video"
verbose_name_plural = "Product Videos"
constraints = [
models.CheckConstraint(
check=(
models.Q(sub_product__isnull=False, main_product__isnull=True)
| models.Q(sub_product__isnull=True, main_product__isnull=False)
),
name="product_video_exactly_one_parent",
),
]
def clean(self):
super().clean()
has_main = self.main_product_id is not None
has_sub = self.sub_product_id is not None
if has_main == has_sub:
raise ValidationError(
"A product video must belong to exactly one main product or sub-product."
)
self.clean_video_fields(require=True)
def __str__(self):
parent = self.sub_product or self.main_product
label = self.title or self.badge or "Video"
return f"{parent} {label}"
+56 -23
View File
@@ -23,10 +23,14 @@ def downloads_section_link(product):
def featured_version(qs):
ordered = qs.order_by("order", "pk")
cand = ordered.filter(is_featured_stable=True).first()
cand = ordered.filter(is_featured=True).first()
return cand if cand else ordered.first()
def version_has_public_assets(version):
return version.has_any_install_asset() or bool((version.package_resource_url or "").strip())
def install_specs_from_versions(version_list):
present = set()
for ver in version_list:
@@ -36,13 +40,29 @@ def install_specs_from_versions(version_list):
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
def build_installable_channel_block(version):
if not version_has_public_assets(version):
return None
block = {
"version": version,
"install_cells": [],
"is_previous": version.is_previous_channel,
}
if version.has_any_install_asset():
specs = install_specs_from_versions([version])
block["install_cells"] = version.install_urls_for_specs(specs)
return block
def build_release_context(distribution, active_versions, product):
context = {
"distribution_installable": distribution == DISTRIBUTION_INSTALLABLE,
"distribution_package": distribution == DISTRIBUTION_PACKAGE,
"show_releases_section": False,
"featured_version": None,
"featured_channel": None,
"featured_install_cells": [],
"inline_download_channels": [],
"show_older_versions_link": False,
"package_versions": [],
**package_button_labels(product),
@@ -51,30 +71,36 @@ def build_release_context(distribution, active_versions, product):
if distribution == DISTRIBUTION_INSTALLABLE:
featured = featured_version(active_versions)
if (
featured
and not featured.has_any_install_asset()
and not (featured.package_resource_url or "").strip()
):
if featured and not version_has_public_assets(featured):
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():
if version_has_public_assets(cand):
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
if featured and version_has_public_assets(featured):
featured_block = build_installable_channel_block(featured)
if featured_block:
context["featured_channel"] = featured_block
context["featured_install_cells"] = featured_block["install_cells"]
context["show_releases_section"] = True
inline_blocks = []
for ver in active_versions.order_by("order", "pk"):
if featured and ver.pk == featured.pk:
continue
if not ver.show_on_product_page:
continue
block = build_installable_channel_block(ver)
if block:
inline_blocks.append(block)
context["inline_download_channels"] = inline_blocks
shown_pks = {featured.pk} if featured else set()
shown_pks.update(block["version"].pk for block in inline_blocks)
older_qs = active_versions.order_by("order", "pk")
if shown_pks:
older_qs = older_qs.exclude(pk__in=shown_pks)
context["show_older_versions_link"] = older_qs.exists()
elif distribution == DISTRIBUTION_PACKAGE:
pkg_versions = list(active_versions.order_by("order", "pk"))
@@ -86,9 +112,16 @@ def build_release_context(distribution, active_versions, product):
def build_archive_context(distribution, active_versions, product):
featured = featured_version(active_versions)
inline_pks = set(
active_versions.filter(show_on_product_page=True).values_list("pk", flat=True)
)
exclude_pks = set()
if featured:
exclude_pks.add(featured.pk)
exclude_pks.update(inline_pks)
archive = list(
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
if featured
active_versions.exclude(pk__in=exclude_pks).order_by("order", "pk")
if exclude_pks
else active_versions.order_by("order", "pk")
)
context = {
+23
View File
@@ -213,6 +213,29 @@ class SubProductVersionModelTest(TestCase):
with self.assertRaises(ValidationError):
version.full_clean()
def test_display_channel_label_uses_custom_text(self):
from apps.products.models import RELEASE_CHANNEL_BETA
version = SubProductVersion.objects.create(
main_product=self.main_product,
version="2.0",
release_channel=RELEASE_CHANNEL_BETA,
channel_label="Preview build",
)
self.assertEqual(version.display_channel_label, "Preview build")
self.assertEqual(version.display_channel_css_modifier, "custom")
def test_display_channel_label_uses_preset(self):
from apps.products.models import RELEASE_CHANNEL_PREVIOUS
version = SubProductVersion.objects.create(
sub_product=self.sub_product,
version="1.0",
release_channel=RELEASE_CHANNEL_PREVIOUS,
)
self.assertEqual(version.display_channel_label, "Previous")
self.assertTrue(version.is_previous_channel)
class ArticleSectionModelTest(TestCase):
def setUp(self):
+47 -3
View File
@@ -160,14 +160,15 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
SubProductVersion.objects.create(
sub_product=self.sub_product,
version="9.9",
is_featured_stable=True,
is_featured=True,
release_channel="stable",
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,
is_featured=False,
windows_download_url="https://example.com/w2",
is_active=True,
)
@@ -185,7 +186,8 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
SubProductVersion.objects.create(
main_product=self.main_product,
version="9.9",
is_featured_stable=True,
is_featured=True,
release_channel="stable",
windows_download_url="https://example.com/win.exe",
is_active=True,
)
@@ -215,6 +217,48 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
response = self.client.get(url)
self.assertTrue(response.context["show_releases_section"])
def test_installable_shows_release_channel_badges(self):
from apps.products.models import RELEASE_CHANNEL_BETA, RELEASE_CHANNEL_PREVIOUS, SubProductVersion
SubProductVersion.objects.create(
main_product=self.main_product,
version="2.0",
release_channel=RELEASE_CHANNEL_BETA,
is_featured=True,
windows_download_url="https://example.com/beta.exe",
is_active=True,
)
SubProductVersion.objects.create(
main_product=self.main_product,
version="1.9",
release_channel=RELEASE_CHANNEL_PREVIOUS,
show_on_product_page=True,
windows_download_url="https://example.com/prev.exe",
is_active=True,
)
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
response = self.client.get(url)
self.assertContains(response, "Beta")
self.assertContains(response, "Previous")
self.assertEqual(len(response.context["inline_download_channels"]), 1)
def test_custom_channel_label_overrides_preset(self):
from apps.products.models import RELEASE_CHANNEL_STABLE, SubProductVersion
SubProductVersion.objects.create(
main_product=self.main_product,
version="3.0",
release_channel=RELEASE_CHANNEL_STABLE,
channel_label="Early Access",
is_featured=True,
windows_download_url="https://example.com/ea.exe",
is_active=True,
)
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
response = self.client.get(url)
self.assertContains(response, "Early Access")
self.assertNotContains(response, ">Stable<")
@override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases")
class ReleaseAssetDownloadViewTest(ProductViewsSetup):
+3 -1
View File
@@ -5,7 +5,7 @@ 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, SubProductVersion
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, 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
@@ -58,6 +58,7 @@ class MainProductDetailView(DetailView):
context["articles"] = self.object.articles.prefetch_related(
"sections", "citations"
).all()
context["product_videos"] = self.object.videos.filter(is_active=True).order_by("order")
release_context = build_release_context(
self.object.distribution,
self.object.versions.filter(is_active=True),
@@ -111,6 +112,7 @@ class SubProductDetailView(TemplateView):
context["articles"] = sub_product.articles.prefetch_related(
"sections", "citations"
).all()
context["product_videos"] = sub_product.videos.filter(is_active=True).order_by("order")
context["siblings"] = (
SubProduct.objects.filter(main_product=main_product, is_active=True)
.exclude(pk=sub_product.pk)