change release section put video and more
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
from django.utils.html import format_html
|
||||
|
||||
from apps.core.video import VIDEO_SOURCE_UPLOAD, VIDEO_SOURCE_YOUTUBE
|
||||
|
||||
|
||||
def video_admin_preview(obj):
|
||||
if not obj.has_video:
|
||||
return "No video configured yet."
|
||||
if obj.video_source == VIDEO_SOURCE_UPLOAD and obj.video_file:
|
||||
if obj.video_poster:
|
||||
return format_html(
|
||||
'<video controls playsinline preload="metadata" style="max-width:100%;" poster="{}">'
|
||||
'<source src="{}"></video>',
|
||||
obj.video_poster.url,
|
||||
obj.video_file.url,
|
||||
)
|
||||
return format_html(
|
||||
'<video controls playsinline preload="metadata" style="max-width:100%;">'
|
||||
'<source src="{}"></video>',
|
||||
obj.video_file.url,
|
||||
)
|
||||
if obj.video_source == VIDEO_SOURCE_YOUTUBE and obj.youtube_embed_url:
|
||||
return format_html(
|
||||
'<iframe src="{}" title="Preview" width="480" height="270" '
|
||||
'style="max-width:100%;border:0;" allowfullscreen loading="lazy"></iframe>',
|
||||
obj.youtube_embed_url,
|
||||
)
|
||||
return "No video configured yet."
|
||||
|
||||
video_admin_preview.short_description = "Preview"
|
||||
@@ -0,0 +1,172 @@
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content
|
||||
|
||||
VIDEO_SOURCE_YOUTUBE = "youtube"
|
||||
VIDEO_SOURCE_UPLOAD = "upload"
|
||||
|
||||
VIDEO_SOURCE_CHOICES = [
|
||||
(VIDEO_SOURCE_YOUTUBE, "YouTube / external link"),
|
||||
(VIDEO_SOURCE_UPLOAD, "Uploaded file"),
|
||||
]
|
||||
|
||||
VIDEO_SIZE_SMALL = "sm"
|
||||
VIDEO_SIZE_MEDIUM = "md"
|
||||
VIDEO_SIZE_LARGE = "lg"
|
||||
VIDEO_SIZE_FULL = "full"
|
||||
|
||||
VIDEO_SIZE_CHOICES = [
|
||||
(VIDEO_SIZE_SMALL, "Small (480px)"),
|
||||
(VIDEO_SIZE_MEDIUM, "Medium (720px)"),
|
||||
(VIDEO_SIZE_LARGE, "Large (960px)"),
|
||||
(VIDEO_SIZE_FULL, "Full width"),
|
||||
]
|
||||
|
||||
VIDEO_ASPECT_16_9 = "16/9"
|
||||
VIDEO_ASPECT_4_3 = "4/3"
|
||||
VIDEO_ASPECT_1_1 = "1/1"
|
||||
|
||||
VIDEO_ASPECT_CHOICES = [
|
||||
(VIDEO_ASPECT_16_9, "16:9 (widescreen)"),
|
||||
(VIDEO_ASPECT_4_3, "4:3 (standard)"),
|
||||
(VIDEO_ASPECT_1_1, "1:1 (square)"),
|
||||
]
|
||||
|
||||
VIDEO_UPLOAD_EXTENSIONS = frozenset({".mp4", ".webm", ".ogg", ".mov"})
|
||||
|
||||
VIDEO_ADMIN_FIELDS = (
|
||||
"video_source",
|
||||
"video_url",
|
||||
"video_file",
|
||||
"video_poster",
|
||||
"video_size",
|
||||
"video_aspect_ratio",
|
||||
"video_styled_background",
|
||||
)
|
||||
|
||||
VIDEO_ADMIN_FIELDSET = (
|
||||
"Video",
|
||||
{
|
||||
"fields": VIDEO_ADMIN_FIELDS + ("video_preview",),
|
||||
"description": (
|
||||
"Choose YouTube link or uploaded file. Set display size and aspect ratio "
|
||||
"to control how the preview appears on the site."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
YOUTUBE_ID_PATTERNS = (
|
||||
re.compile(r"(?:youtube\.com/watch\?(?:[^&]+&)*v=|youtube\.com/embed/|youtube\.com/shorts/|youtu\.be/)([\w-]{11})"),
|
||||
re.compile(r"^([\w-]{11})$"),
|
||||
)
|
||||
|
||||
|
||||
def parse_youtube_video_id(url):
|
||||
if not url:
|
||||
return ""
|
||||
value = url.strip()
|
||||
for pattern in YOUTUBE_ID_PATTERNS:
|
||||
match = pattern.search(value)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def youtube_embed_url(url):
|
||||
video_id = parse_youtube_video_id(url)
|
||||
if not video_id:
|
||||
return ""
|
||||
return f"https://www.youtube-nocookie.com/embed/{video_id}"
|
||||
|
||||
|
||||
class VideoBlockMixin(models.Model):
|
||||
video_source = models.CharField(
|
||||
max_length=20,
|
||||
choices=VIDEO_SOURCE_CHOICES,
|
||||
default=VIDEO_SOURCE_YOUTUBE,
|
||||
blank=True,
|
||||
)
|
||||
video_url = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="YouTube watch, embed, or youtu.be link.",
|
||||
)
|
||||
video_file = models.FileField(
|
||||
upload_to="videos/",
|
||||
blank=True,
|
||||
help_text="MP4, WebM, OGG, or MOV file.",
|
||||
)
|
||||
video_poster = models.ImageField(
|
||||
upload_to="videos/posters/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Optional thumbnail shown before an uploaded video plays.",
|
||||
)
|
||||
video_size = models.CharField(
|
||||
max_length=10,
|
||||
choices=VIDEO_SIZE_CHOICES,
|
||||
default=VIDEO_SIZE_MEDIUM,
|
||||
)
|
||||
video_aspect_ratio = models.CharField(
|
||||
max_length=10,
|
||||
choices=VIDEO_ASPECT_CHOICES,
|
||||
default=VIDEO_ASPECT_16_9,
|
||||
)
|
||||
video_styled_background = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Glass panel with ambient glow (similar to the Downloads section).",
|
||||
)
|
||||
description_format = models.CharField(
|
||||
max_length=20,
|
||||
choices=CONTENT_FORMAT_CHOICES,
|
||||
default=FORMAT_PLAIN,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def youtube_embed_url(self):
|
||||
return youtube_embed_url(self.video_url)
|
||||
|
||||
@property
|
||||
def has_video(self):
|
||||
if self.video_source == VIDEO_SOURCE_UPLOAD:
|
||||
return bool(self.video_file)
|
||||
return bool(self.youtube_embed_url)
|
||||
|
||||
@property
|
||||
def video_size_class(self):
|
||||
return f"video-block--{self.video_size or VIDEO_SIZE_MEDIUM}"
|
||||
|
||||
@property
|
||||
def video_aspect_class(self):
|
||||
ratio = (self.video_aspect_ratio or VIDEO_ASPECT_16_9).replace("/", "-")
|
||||
return f"video-block--ratio-{ratio}"
|
||||
|
||||
@property
|
||||
def rendered_description(self):
|
||||
description = getattr(self, "description", "") or ""
|
||||
return render_content(description, self.description_format)
|
||||
|
||||
def clean_video_fields(self, require=False):
|
||||
if not require and not self.video_url and not self.video_file:
|
||||
return
|
||||
if self.video_source == VIDEO_SOURCE_YOUTUBE:
|
||||
if not self.video_url.strip():
|
||||
raise ValidationError({"video_url": "Enter a YouTube link."})
|
||||
if not self.youtube_embed_url:
|
||||
raise ValidationError({"video_url": "Enter a valid YouTube link."})
|
||||
elif self.video_source == VIDEO_SOURCE_UPLOAD:
|
||||
if not self.video_file:
|
||||
raise ValidationError({"video_file": "Upload a video file."})
|
||||
extension = self.video_file.name.rsplit(".", 1)[-1].lower() if self.video_file.name else ""
|
||||
if f".{extension}" not in VIDEO_UPLOAD_EXTENSIONS:
|
||||
raise ValidationError(
|
||||
{
|
||||
"video_file": "Unsupported format. Use MP4, WebM, OGG, or MOV.",
|
||||
}
|
||||
)
|
||||
+43
-2
@@ -3,6 +3,9 @@ from django.http import FileResponse, Http404, HttpResponseRedirect
|
||||
from django.urls import path, reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from apps.core.admin_video import video_admin_preview
|
||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||
|
||||
from .models import (
|
||||
AboutSection,
|
||||
AboutSectionItem,
|
||||
@@ -16,6 +19,7 @@ from .models import (
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
HomepageSectionItem,
|
||||
PageVideo,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,12 +62,18 @@ class HomepageSectionAdmin(admin.ModelAdmin):
|
||||
list_filter = ("section_type", "is_active")
|
||||
list_editable = ("order", "is_active")
|
||||
inlines = [HomepageSectionItemInline]
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("section_type", "badge", "title", "description")}),
|
||||
(None, {"fields": ("section_type", "badge", "title", "description_format", "description")}),
|
||||
("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
class AboutSectionItemInline(admin.TabularInline):
|
||||
model = AboutSectionItem
|
||||
@@ -78,12 +88,18 @@ class AboutSectionAdmin(admin.ModelAdmin):
|
||||
list_filter = ("section_type", "is_active")
|
||||
list_editable = ("order", "is_active")
|
||||
inlines = [AboutSectionItemInline]
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("section_type", "badge", "title", "subtitle")}),
|
||||
("Content", {"fields": ("content_format", "content")}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
class ContactSubmissionAttachmentInline(admin.TabularInline):
|
||||
model = ContactSubmissionAttachment
|
||||
@@ -177,6 +193,7 @@ class CustomPageSectionInline(admin.StackedInline):
|
||||
"badge",
|
||||
"title",
|
||||
"subtitle",
|
||||
"description_format",
|
||||
"description",
|
||||
"content_format",
|
||||
"content",
|
||||
@@ -221,13 +238,19 @@ class CustomPageSectionAdmin(admin.ModelAdmin):
|
||||
list_filter = ("section_type", "is_active", "page")
|
||||
list_editable = ("order", "is_active")
|
||||
inlines = [CustomPageSectionItemInline]
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}),
|
||||
("Text", {"fields": ("description", "content_format", "content")}),
|
||||
("Text", {"fields": ("description_format", "description", "content_format", "content")}),
|
||||
("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
def save_formset(self, request, form, formset, change):
|
||||
instances = formset.save(commit=False)
|
||||
for instance in instances:
|
||||
@@ -267,6 +290,24 @@ class CustomPageAdmin(admin.ModelAdmin):
|
||||
admin.site.register(CustomPageSection, CustomPageSectionAdmin)
|
||||
|
||||
|
||||
@admin.register(PageVideo)
|
||||
class PageVideoAdmin(admin.ModelAdmin):
|
||||
list_display = ("page", "title", "video_source", "video_size", "order", "is_active")
|
||||
list_filter = ("page", "video_source", "is_active")
|
||||
list_editable = ("order", "is_active")
|
||||
search_fields = ("title", "description", "video_url")
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("page", "badge", "title", "description_format", "description")}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
@admin.register(DownloadItem)
|
||||
class DownloadItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "platform", "version", "is_active", "order")
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 12:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0014_alter_aboutsection_section_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PageVideo',
|
||||
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)),
|
||||
('page', models.CharField(choices=[('contact', 'Contact'), ('faq', 'FAQ')], max_length=20)),
|
||||
('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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Page Video',
|
||||
'verbose_name_plural': 'Page Videos',
|
||||
'ordering': ['page', 'order'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_aspect_ratio',
|
||||
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_file',
|
||||
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_poster',
|
||||
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_size',
|
||||
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_source',
|
||||
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_url',
|
||||
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_aspect_ratio',
|
||||
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_file',
|
||||
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_poster',
|
||||
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_size',
|
||||
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_source',
|
||||
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_url',
|
||||
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_aspect_ratio',
|
||||
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_file',
|
||||
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_poster',
|
||||
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_size',
|
||||
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_source',
|
||||
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_url',
|
||||
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
|
||||
),
|
||||
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'), ('video', 'Video')], default='custom', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='custompagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion'), ('video', 'Video')], default='custom', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homepagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 13:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0015_video_support'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevideo',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 13:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0016_video_styled_background'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevideo',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
]
|
||||
+55
-3
@@ -3,6 +3,7 @@ from django.db import models
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
|
||||
from apps.core.video import VideoBlockMixin
|
||||
from apps.pages.contact_uploads import (
|
||||
contact_attachment_storage,
|
||||
contact_attachment_upload_to,
|
||||
@@ -41,7 +42,7 @@ class HeroSection(models.Model):
|
||||
return "Hero Section"
|
||||
|
||||
|
||||
class HomepageSection(models.Model):
|
||||
class HomepageSection(VideoBlockMixin, models.Model):
|
||||
TYPE_FEATURES = "features"
|
||||
TYPE_SCREENSHOTS = "screenshots"
|
||||
TYPE_PRODUCTS = "products"
|
||||
@@ -49,6 +50,7 @@ class HomepageSection(models.Model):
|
||||
TYPE_PROBLEMS = "problems"
|
||||
TYPE_ABOUT_STRIP = "about_strip"
|
||||
TYPE_SUPPORTERS = "supporters"
|
||||
TYPE_VIDEO = "video"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_FEATURES, "Features"),
|
||||
@@ -58,6 +60,7 @@ class HomepageSection(models.Model):
|
||||
(TYPE_PROBLEMS, "Problems / Value Proposition"),
|
||||
(TYPE_ABOUT_STRIP, "About Strip"),
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
(TYPE_VIDEO, "Video"),
|
||||
]
|
||||
|
||||
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES)
|
||||
@@ -74,6 +77,11 @@ class HomepageSection(models.Model):
|
||||
verbose_name = "Homepage Section"
|
||||
verbose_name_plural = "Homepage Sections"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.section_type == self.TYPE_VIDEO:
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.get_section_type_display()}] {self.title or self.badge}"
|
||||
|
||||
@@ -96,13 +104,14 @@ class HomepageSectionItem(models.Model):
|
||||
return f"{self.section} › {self.title or self.icon or '(item)'}"
|
||||
|
||||
|
||||
class AboutSection(models.Model):
|
||||
class AboutSection(VideoBlockMixin, models.Model):
|
||||
TYPE_HERO = "hero"
|
||||
TYPE_INTRO = "intro"
|
||||
TYPE_GRID = "grid"
|
||||
TYPE_HISTORY = "history"
|
||||
TYPE_CUSTOM = "custom"
|
||||
TYPE_SUPPORTERS = "supporters"
|
||||
TYPE_VIDEO = "video"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_HERO, "Hero"),
|
||||
@@ -111,6 +120,7 @@ class AboutSection(models.Model):
|
||||
(TYPE_HISTORY, "History Block"),
|
||||
(TYPE_CUSTOM, "Custom Content"),
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
(TYPE_VIDEO, "Video"),
|
||||
]
|
||||
|
||||
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM)
|
||||
@@ -133,6 +143,11 @@ class AboutSection(models.Model):
|
||||
def rendered_content(self):
|
||||
return render_content(self.content, self.content_format)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.section_type == self.TYPE_VIDEO:
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
def __str__(self):
|
||||
label = self.title or self.badge or self.get_section_type_display()
|
||||
return f"[{self.get_section_type_display()}] {label}"
|
||||
@@ -295,7 +310,7 @@ class CustomPage(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CustomPageSection(models.Model):
|
||||
class CustomPageSection(VideoBlockMixin, models.Model):
|
||||
TYPE_HERO = "hero"
|
||||
TYPE_INTRO = "intro"
|
||||
TYPE_GRID = "grid"
|
||||
@@ -309,6 +324,7 @@ class CustomPageSection(models.Model):
|
||||
TYPE_SUPPORTERS = "supporters"
|
||||
TYPE_ABOUT_STRIP = "about_strip"
|
||||
TYPE_FAQ = "faq"
|
||||
TYPE_VIDEO = "video"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_HERO, "Hero"),
|
||||
@@ -324,6 +340,7 @@ class CustomPageSection(models.Model):
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
(TYPE_ABOUT_STRIP, "About Strip"),
|
||||
(TYPE_FAQ, "FAQ Accordion"),
|
||||
(TYPE_VIDEO, "Video"),
|
||||
]
|
||||
|
||||
page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections")
|
||||
@@ -348,11 +365,46 @@ class CustomPageSection(models.Model):
|
||||
def rendered_content(self):
|
||||
return render_content(self.content, self.content_format)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.section_type == self.TYPE_VIDEO:
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
def __str__(self):
|
||||
label = self.title or self.badge or self.get_section_type_display()
|
||||
return f"{self.page} › [{self.get_section_type_display()}] {label}"
|
||||
|
||||
|
||||
class PageVideo(VideoBlockMixin, models.Model):
|
||||
PAGE_CONTACT = "contact"
|
||||
PAGE_FAQ = "faq"
|
||||
|
||||
PAGE_CHOICES = [
|
||||
(PAGE_CONTACT, "Contact"),
|
||||
(PAGE_FAQ, "FAQ"),
|
||||
]
|
||||
|
||||
page = models.CharField(max_length=20, choices=PAGE_CHOICES)
|
||||
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 = ["page", "order"]
|
||||
verbose_name = "Page Video"
|
||||
verbose_name_plural = "Page Videos"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
def __str__(self):
|
||||
label = self.title or self.badge or self.get_page_display()
|
||||
return f"{self.get_page_display()} › {label}"
|
||||
|
||||
|
||||
class CustomPageSectionItem(models.Model):
|
||||
page = models.ForeignKey(
|
||||
CustomPage,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.core.video import parse_youtube_video_id, youtube_embed_url
|
||||
from apps.pages.models import (
|
||||
AboutSection,
|
||||
CustomPage,
|
||||
CustomPageSection,
|
||||
HomepageSection,
|
||||
PageVideo,
|
||||
)
|
||||
from apps.products.models import MainProduct, ProductVideo
|
||||
|
||||
|
||||
class YouTubeParsingTest(TestCase):
|
||||
def test_watch_url(self):
|
||||
self.assertEqual(
|
||||
parse_youtube_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
|
||||
"dQw4w9WgXcQ",
|
||||
)
|
||||
|
||||
def test_short_url(self):
|
||||
self.assertEqual(
|
||||
parse_youtube_video_id("https://youtu.be/dQw4w9WgXcQ"),
|
||||
"dQw4w9WgXcQ",
|
||||
)
|
||||
|
||||
def test_embed_url(self):
|
||||
url = youtube_embed_url("https://youtu.be/dQw4w9WgXcQ")
|
||||
self.assertEqual(url, "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ")
|
||||
|
||||
|
||||
class HomepageVideoSectionTest(TestCase):
|
||||
def test_video_section_renders_youtube_embed(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_VIDEO,
|
||||
title="Demo",
|
||||
video_source="youtube",
|
||||
video_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
|
||||
self.assertContains(response, "video-block--md")
|
||||
|
||||
|
||||
class AboutVideoSectionTest(TestCase):
|
||||
def test_video_section_renders_on_about_page(self):
|
||||
AboutSection.objects.create(
|
||||
section_type=AboutSection.TYPE_VIDEO,
|
||||
title="Overview",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
video_size="lg",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:about"))
|
||||
self.assertContains(response, "video-block--lg")
|
||||
self.assertContains(response, "Overview")
|
||||
|
||||
|
||||
class CustomPageVideoSectionTest(TestCase):
|
||||
def test_video_section_renders_on_custom_page(self):
|
||||
page = CustomPage.objects.create(
|
||||
title="Media",
|
||||
slug="media-page",
|
||||
is_published=True,
|
||||
)
|
||||
CustomPageSection.objects.create(
|
||||
page=page,
|
||||
section_type=CustomPageSection.TYPE_VIDEO,
|
||||
title="Walkthrough",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "media-page"}))
|
||||
self.assertContains(response, "Walkthrough")
|
||||
self.assertContains(response, "iframe")
|
||||
|
||||
|
||||
class PageVideoTest(TestCase):
|
||||
def test_contact_page_video(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
title="Intro",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:contact"))
|
||||
self.assertContains(response, "Intro")
|
||||
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
|
||||
|
||||
def test_faq_page_video(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
title="Tutorial",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertContains(response, "Tutorial")
|
||||
|
||||
|
||||
class ProductVideoTest(TestCase):
|
||||
def setUp(self):
|
||||
self.main_product = MainProduct.objects.create(
|
||||
name="Radiuma",
|
||||
slug="radiuma",
|
||||
short_description="Short",
|
||||
description="Long",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_product_video_renders_on_detail_page(self):
|
||||
ProductVideo.objects.create(
|
||||
main_product=self.main_product,
|
||||
title="Product Demo",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
video_size="full",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
|
||||
)
|
||||
self.assertContains(response, "Product Demo")
|
||||
self.assertContains(response, "video-block--full")
|
||||
|
||||
|
||||
class VideoStyledBackgroundTest(TestCase):
|
||||
def test_styled_background_renders_panel(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_VIDEO,
|
||||
title="Styled Demo",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
video_styled_background=True,
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "video-panel--styled")
|
||||
self.assertContains(response, "video-panel-blob")
|
||||
|
||||
def test_plain_background_without_panel(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_VIDEO,
|
||||
title="Plain Demo",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
video_styled_background=False,
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertNotContains(response, "video-panel--styled")
|
||||
self.assertContains(response, "Plain Demo")
|
||||
|
||||
|
||||
class VideoDescriptionFormatTest(TestCase):
|
||||
def test_plain_description_preserves_line_breaks(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_VIDEO,
|
||||
title="Demo",
|
||||
description="First line\nSecond line",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "First line")
|
||||
self.assertContains(response, "Second line")
|
||||
self.assertContains(response, "<br>")
|
||||
|
||||
def test_markdown_description_renders(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
title="Guide",
|
||||
description="**Bold** intro",
|
||||
description_format="markdown",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertContains(response, "<strong>Bold</strong>")
|
||||
+17
-1
@@ -17,6 +17,7 @@ from .models import (
|
||||
FAQEntry,
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
PageVideo,
|
||||
)
|
||||
|
||||
|
||||
@@ -71,6 +72,14 @@ class FAQView(ListView):
|
||||
context_object_name = "faq_entries"
|
||||
queryset = FAQEntry.objects.filter(is_active=True)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["faq_videos"] = PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
is_active=True,
|
||||
).order_by("order")
|
||||
return ctx
|
||||
|
||||
|
||||
class ContactView(View):
|
||||
template_name = "pages/contact.html"
|
||||
@@ -84,7 +93,14 @@ class ContactView(View):
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{"form": ContactForm(), "captcha_question": self._new_captcha(request)},
|
||||
{
|
||||
"form": ContactForm(),
|
||||
"captcha_question": self._new_captcha(request),
|
||||
"contact_videos": PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
is_active=True,
|
||||
).order_by("order"),
|
||||
},
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
+96
-7
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
+280
-1
@@ -1995,12 +1995,48 @@ a.citation-count-badge:hover {
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.release-channel-label--beta {
|
||||
color: rgb(253, 186, 116);
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
border: 1px solid rgba(249, 115, 22, 0.22);
|
||||
}
|
||||
|
||||
.release-channel-label--rc {
|
||||
color: rgb(196, 181, 253);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border: 1px solid rgba(139, 92, 246, 0.22);
|
||||
}
|
||||
|
||||
.release-channel-label--preview {
|
||||
color: rgb(147, 197, 253);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.22);
|
||||
}
|
||||
|
||||
.release-channel-label--nightly {
|
||||
color: rgb(252, 165, 165);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.release-channel-label--current {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.release-channel-label--previous {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.release-channel-label--custom {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.download-card--previous {
|
||||
opacity: 0.78;
|
||||
}
|
||||
@@ -2255,9 +2291,22 @@ a.citation-count-badge:hover {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pkg-stable-inline {
|
||||
.pkg-version-meta .release-channel-label {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.versions-table-version {
|
||||
display: inline-block;
|
||||
margin-right: 0.45rem;
|
||||
}
|
||||
|
||||
.versions-table th[scope="row"] .release-channel-label {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
margin-bottom: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.pkg-version-actions {
|
||||
@@ -2350,10 +2399,29 @@ a.citation-count-badge:hover {
|
||||
.versions-table-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.versions-table-link:hover {
|
||||
background: rgba(79, 142, 247, 0.1);
|
||||
}
|
||||
|
||||
.versions-table-icon {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.82;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.versions-table-link:hover .versions-table-icon {
|
||||
opacity: 1;
|
||||
filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(79, 142, 247, 0.55));
|
||||
}
|
||||
|
||||
.versions-table-text-link {
|
||||
@@ -3440,3 +3508,214 @@ a.citation-count-badge:hover {
|
||||
.readmore-btn.is-open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Video Blocks
|
||||
============================================================ */
|
||||
.video-block {
|
||||
width: 100%;
|
||||
max-width: var(--video-max-width, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.video-block--sm {
|
||||
--video-max-width: 480px;
|
||||
}
|
||||
|
||||
.video-block--md {
|
||||
--video-max-width: 720px;
|
||||
}
|
||||
|
||||
.video-block--lg {
|
||||
--video-max-width: 960px;
|
||||
}
|
||||
|
||||
.video-block--full {
|
||||
--video-max-width: 100%;
|
||||
}
|
||||
|
||||
.video-block-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: var(--video-aspect-ratio, 16 / 9);
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(8, 12, 24, 0.55);
|
||||
}
|
||||
|
||||
.video-block--ratio-16-9 {
|
||||
--video-aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.video-block--ratio-4-3 {
|
||||
--video-aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.video-block--ratio-1-1 {
|
||||
--video-aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.video-block-inner iframe,
|
||||
.video-block-inner video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-section .section-desc {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
max-width: 680px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.product-videos-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.product-video-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.video-panel--styled {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.video-panel-ambient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-panel-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(52px);
|
||||
}
|
||||
|
||||
.video-panel-blob--1 {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
top: -90px;
|
||||
right: -50px;
|
||||
background: radial-gradient(circle, rgba(79, 142, 247, 0.42) 0%, transparent 72%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.video-panel-blob--2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
bottom: -70px;
|
||||
left: -40px;
|
||||
background: radial-gradient(circle, rgba(56, 189, 248, 0.28) 0%, transparent 72%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.video-panel-blob--3 {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
top: 45%;
|
||||
left: 55%;
|
||||
background: radial-gradient(circle, rgba(139, 92, 246, 0.18) 0%, transparent 70%);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.video-panel-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.video-panel-header {
|
||||
text-align: center;
|
||||
max-width: 680px;
|
||||
margin: 0 auto 0.35rem;
|
||||
}
|
||||
|
||||
.video-panel-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-panel-heading .section-badge {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.video-panel-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.video-panel-title svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-blue-light);
|
||||
}
|
||||
|
||||
.video-panel-desc {
|
||||
text-align: center;
|
||||
max-width: 680px;
|
||||
margin: 0 auto 1.25rem;
|
||||
}
|
||||
|
||||
.video-panel--styled .video-block-inner {
|
||||
border: 1px solid rgba(79, 142, 247, 0.22);
|
||||
box-shadow:
|
||||
0 16px 48px rgba(8, 12, 24, 0.38),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.product-videos-list .product-video-item .section-desc,
|
||||
.product-videos-list .product-video-item .rich-content.section-desc {
|
||||
text-align: center;
|
||||
max-width: 680px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.product-videos-list .video-panel--styled + .video-panel--styled,
|
||||
.product-videos-list .product-video-item + .product-video-item {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.product-videos-list .video-panel--styled,
|
||||
.product-videos-list .product-video-item {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.video-panel--styled {
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.video-panel-blob--1 {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
top: -70px;
|
||||
right: -70px;
|
||||
}
|
||||
|
||||
.video-panel-blob--2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "partials/_page_videos.html" with videos=contact_videos %}
|
||||
|
||||
<section class="section" aria-labelledby="contact-form-heading">
|
||||
<div class="container">
|
||||
<div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}">
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "partials/_page_videos.html" with videos=faq_videos %}
|
||||
|
||||
<section class="section" aria-labelledby="faq-list-heading">
|
||||
<div class="container">
|
||||
<h2 class="sr-only" id="faq-list-heading">FAQ List</h2>
|
||||
|
||||
@@ -411,6 +411,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% elif section.section_type == "video" %}
|
||||
{% include "partials/_video_section.html" with section=section %}
|
||||
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<section class="section">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% if videos %}
|
||||
<section class="section product-videos-section" aria-label="Product videos">
|
||||
<div class="container">
|
||||
<div class="product-videos-list">
|
||||
{% for video in videos %}
|
||||
{% if video.has_video %}
|
||||
<div class="product-video-item">
|
||||
{% include "partials/_video_block.html" with video=video compact_header=True %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% if video.has_video %}
|
||||
{% if video.video_styled_background %}
|
||||
<div class="video-panel video-panel--styled fade-in">
|
||||
<div class="video-panel-ambient" aria-hidden="true">
|
||||
<div class="video-panel-blob video-panel-blob--1"></div>
|
||||
<div class="video-panel-blob video-panel-blob--2"></div>
|
||||
<div class="video-panel-blob video-panel-blob--3"></div>
|
||||
</div>
|
||||
<div class="video-panel-inner">
|
||||
{% if video.badge or video.title %}
|
||||
<div class="video-panel-header">
|
||||
<div class="video-panel-heading">
|
||||
{% if video.badge %}<div class="section-badge">{{ video.badge }}</div>{% endif %}
|
||||
{% if video.title %}
|
||||
<h2 class="video-panel-title" id="video-heading-{{ video.pk }}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
{{ video.title }}
|
||||
</h2>
|
||||
{% else %}
|
||||
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
|
||||
{% endif %}
|
||||
{% if video.description %}
|
||||
<div class="rich-content section-desc video-panel-desc">{{ video.rendered_description }}</div>
|
||||
{% elif video.content %}
|
||||
<div class="rich-content section-desc video-panel-desc">{{ video.rendered_content }}</div>
|
||||
{% endif %}
|
||||
{% include "partials/_video_player.html" with video=video %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if video.badge or video.title %}
|
||||
<div class="section-header{% if compact_header %} product-video-header{% endif %}">
|
||||
{% if video.badge %}<div class="section-badge">{{ video.badge }}</div>{% endif %}
|
||||
{% if video.title %}
|
||||
<h2 class="section-title" id="video-heading-{{ video.pk }}">{{ video.title }}</h2>
|
||||
{% else %}
|
||||
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if video.description %}
|
||||
<div class="rich-content section-desc">{{ video.rendered_description }}</div>
|
||||
{% elif video.content %}
|
||||
<div class="rich-content section-desc">{{ video.rendered_content }}</div>
|
||||
{% endif %}
|
||||
{% include "partials/_video_player.html" with video=video %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% if video.has_video %}
|
||||
<div class="video-block {{ video.video_size_class }} {{ video.video_aspect_class }} fade-in">
|
||||
<div class="video-block-inner glass-card">
|
||||
{% if video.video_source == "upload" and video.video_file %}
|
||||
<video
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
{% if video.video_poster %}poster="{{ video.video_poster.url }}"{% endif %}
|
||||
title="{{ video.title|default:'Video' }}"
|
||||
>
|
||||
<source src="{{ video.video_file.url }}" />
|
||||
</video>
|
||||
{% elif video.youtube_embed_url %}
|
||||
<iframe
|
||||
src="{{ video.youtube_embed_url }}"
|
||||
title="{{ video.title|default:'Video' }}"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
></iframe>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% if section.has_video %}
|
||||
<section class="section video-section" aria-labelledby="video-heading-{{ section.pk }}">
|
||||
<div class="container">
|
||||
{% include "partials/_video_block.html" with video=section %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% load static %}
|
||||
<div class="sub-downloads-channel{% if channel.is_previous %} sub-downloads-channel--prev{% endif %}">
|
||||
{% include "products/_release_channel_badge.html" with version=channel.version %}
|
||||
{% if channel.install_cells %}
|
||||
<div class="sub-downloads-grid">
|
||||
{% for cell in channel.install_cells %}
|
||||
<div class="sub-dl-item">
|
||||
<div class="sub-dl-icon-wrap">
|
||||
{% if cell.icon %}
|
||||
<img src="{% static cell.icon %}" alt="" width="32" height="32" class="platform-icon sub-dl-icon" aria-hidden="true" />
|
||||
{% else %}
|
||||
<span class="sub-dl-source-mark" aria-hidden="true">
|
||||
<svg class="sub-dl-source-svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round">
|
||||
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="sub-dl-platform">{{ cell.label }}</span>
|
||||
<span class="download-version">{{ channel.version.version }}</span>
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if channel.version.package_resource_url %}
|
||||
<p class="sub-install-extra-resource">
|
||||
<a href="{{ channel.version.package_resource_url }}" target="_blank" rel="noopener noreferrer">Package resource</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if channel.version.release_notes %}
|
||||
<p class="sub-release-notes">{{ channel.version.release_notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
{% if version.display_channel_label %}
|
||||
<span class="release-channel-label release-channel-label--{{ version.display_channel_css_modifier }}">
|
||||
{% if version.release_channel == "stable" %}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ version.display_channel_label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -14,48 +14,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="sub-downloads-channel">
|
||||
{% if featured_version.is_featured_stable %}
|
||||
<span class="release-channel-label release-channel-label--stable">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Stable
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if featured_install_cells %}
|
||||
<div class="sub-downloads-grid">
|
||||
{% for cell in featured_install_cells %}
|
||||
<div class="sub-dl-item">
|
||||
<div class="sub-dl-icon-wrap">
|
||||
{% if cell.icon %}
|
||||
<img src="{% static cell.icon %}" alt="" width="32" height="32" class="platform-icon sub-dl-icon" aria-hidden="true" />
|
||||
{% else %}
|
||||
<span class="sub-dl-source-mark" aria-hidden="true">
|
||||
<svg class="sub-dl-source-svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round">
|
||||
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</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.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if featured_version.package_resource_url %}
|
||||
<p class="sub-install-extra-resource">
|
||||
<a href="{{ featured_version.package_resource_url }}" target="_blank" rel="noopener noreferrer">Package resource</a>
|
||||
</p>
|
||||
{% if featured_version %}
|
||||
{% include "products/_installable_download_channel.html" with channel=featured_channel %}
|
||||
{% endif %}
|
||||
|
||||
{% if featured_version.release_notes %}
|
||||
<p class="sub-release-notes">{{ featured_version.release_notes }}</p>
|
||||
{% endif %}
|
||||
{% for channel in inline_download_channels %}
|
||||
{% include "products/_installable_download_channel.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if show_older_versions_link %}
|
||||
<p class="sub-older-versions-inner">
|
||||
@@ -83,14 +48,7 @@
|
||||
<li class="pkg-version-row">
|
||||
<div class="pkg-version-meta">
|
||||
<span class="download-version">{{ ver.version }}</span>
|
||||
{% if ver.is_featured_stable %}
|
||||
<span class="release-channel-label release-channel-label--stable pkg-stable-inline">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Stable
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include "products/_release_channel_badge.html" with version=ver %}
|
||||
</div>
|
||||
<div class="pkg-version-actions">
|
||||
{% if ver.package_resource_url %}
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
<tbody>
|
||||
{% for row in archive_rows_installable %}
|
||||
<tr>
|
||||
<th scope="row">{{ row.version_obj.version }}</th>
|
||||
<th scope="row">
|
||||
<span class="versions-table-version">{{ row.version_obj.version }}</span>
|
||||
{% include "products/_release_channel_badge.html" with version=row.version_obj %}
|
||||
</th>
|
||||
{% for cell in row.cells %}
|
||||
<td>
|
||||
{% if cell.url %}
|
||||
@@ -50,7 +53,10 @@
|
||||
{% for ver in archive_versions %}
|
||||
<li class="versions-package-card glass-card fade-in">
|
||||
<div class="versions-package-head">
|
||||
<span class="download-version">{{ ver.version }}</span>
|
||||
<div class="pkg-version-meta">
|
||||
<span class="download-version">{{ ver.version }}</span>
|
||||
{% include "products/_release_channel_badge.html" with version=ver %}
|
||||
</div>
|
||||
{% if ver.package_resource_url %}
|
||||
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "partials/_page_videos.html" with videos=product_videos %}
|
||||
|
||||
{% if articles %}
|
||||
<section class="section" aria-label="Product articles">
|
||||
<div class="container">
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
|
||||
{% include "products/_releases_section.html" %}
|
||||
|
||||
{% include "partials/_page_videos.html" with videos=product_videos %}
|
||||
|
||||
{% include "products/_article_list.html" %}
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user