feat: dynamic main icon
This commit is contained in:
@@ -47,3 +47,4 @@ venv.bak/
|
|||||||
|
|
||||||
media/
|
media/
|
||||||
staticfiles/
|
staticfiles/
|
||||||
|
*.DS_Store
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from .models import SiteBranding, SiteContact
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SiteBranding)
|
||||||
|
class SiteBrandingAdmin(admin.ModelAdmin):
|
||||||
|
fieldsets = (
|
||||||
|
("Icon", {"fields": ("icon", "icon_alt")}),
|
||||||
|
(
|
||||||
|
"Sizes",
|
||||||
|
{
|
||||||
|
"fields": ("navbar_icon_size", "footer_icon_size"),
|
||||||
|
"description": "Square dimensions in pixels for each placement.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Appearance",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"object_fit",
|
||||||
|
"show_border",
|
||||||
|
"border_width",
|
||||||
|
"border_color",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return not SiteBranding.objects.exists()
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
branding = SiteBranding.objects.first()
|
||||||
|
if branding:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("admin:core_sitebranding_change", args=[branding.pk])
|
||||||
|
)
|
||||||
|
return super().changelist_view(request, extra_context)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SiteContact)
|
||||||
|
class SiteContactAdmin(admin.ModelAdmin):
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Email",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"support_email",
|
||||||
|
"email_card_title",
|
||||||
|
"email_card_description",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Discord",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"discord_url",
|
||||||
|
"discord_label",
|
||||||
|
"discord_card_title",
|
||||||
|
"discord_card_description",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Office address",
|
||||||
|
{
|
||||||
|
"fields": ("office_address", "office_card_title"),
|
||||||
|
"description": "Enter one address line per row.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return not SiteContact.objects.exists()
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
contact = SiteContact.objects.first()
|
||||||
|
if contact:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("admin:core_sitecontact_change", args=[contact.pk])
|
||||||
|
)
|
||||||
|
return super().changelist_view(request, extra_context)
|
||||||
@@ -1,19 +1,35 @@
|
|||||||
|
from django.db.models import Prefetch
|
||||||
|
|
||||||
|
from apps.core.models import SiteBranding, SiteContact
|
||||||
from apps.products.models import MainProduct, SubProduct
|
from apps.products.models import MainProduct, SubProduct
|
||||||
|
|
||||||
|
|
||||||
|
def site_branding(request):
|
||||||
|
return {"site_branding": SiteBranding.load()}
|
||||||
|
|
||||||
|
|
||||||
|
def site_contact(request):
|
||||||
|
return {"site_contact": SiteContact.load()}
|
||||||
|
|
||||||
|
|
||||||
def navigation(request):
|
def navigation(request):
|
||||||
main_products = (
|
main_products = (
|
||||||
MainProduct.objects.filter(is_active=True)
|
MainProduct.objects.filter(is_active=True)
|
||||||
.prefetch_related("sub_products")
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"sub_products",
|
||||||
|
queryset=SubProduct.objects.filter(is_active=True).order_by(
|
||||||
|
"order", "name"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by("order", "name")
|
.order_by("order", "name")
|
||||||
)
|
)
|
||||||
all_sub_products = list(
|
all_footer_products = list(main_products[:5])
|
||||||
SubProduct.objects.filter(is_active=True).order_by("order", "name")[:5]
|
footer_main_products = all_footer_products[:4]
|
||||||
)
|
footer_main_products_has_more = len(all_footer_products) == 5
|
||||||
footer_sub_products = all_sub_products[:4]
|
|
||||||
footer_sub_products_has_more = len(all_sub_products) == 5
|
|
||||||
return {
|
return {
|
||||||
"nav_main_products": main_products,
|
"nav_main_products": main_products,
|
||||||
"footer_sub_products": footer_sub_products,
|
"footer_main_products": footer_main_products,
|
||||||
"footer_sub_products_has_more": footer_sub_products_has_more,
|
"footer_main_products_has_more": footer_main_products_has_more,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.core.models import SiteContact
|
||||||
from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem
|
from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem
|
||||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||||
|
|
||||||
@@ -254,9 +255,9 @@ FAQ_ENTRIES = [
|
|||||||
{
|
{
|
||||||
"question": "Where can I get support or report issues?",
|
"question": "Where can I get support or report issues?",
|
||||||
"answer": (
|
"answer": (
|
||||||
"Support is available via email at support@radiuma.com and through our "
|
"Support is available via email and through our community Discord server "
|
||||||
"community Discord server. For bug reports and feature requests, please "
|
"(see the Contact page for current details). For bug reports and feature "
|
||||||
"use the Discord forum or contact us directly by email."
|
"requests, please use the Discord forum or contact us directly by email."
|
||||||
),
|
),
|
||||||
"order": 6,
|
"order": 6,
|
||||||
},
|
},
|
||||||
@@ -402,6 +403,7 @@ class Command(BaseCommand):
|
|||||||
self._seed_downloads()
|
self._seed_downloads()
|
||||||
self._seed_homepage_sections()
|
self._seed_homepage_sections()
|
||||||
self._seed_hero()
|
self._seed_hero()
|
||||||
|
self._seed_site_contact()
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Content seeded successfully."))
|
self.stdout.write(self.style.SUCCESS("Content seeded successfully."))
|
||||||
|
|
||||||
@@ -540,3 +542,38 @@ class Command(BaseCommand):
|
|||||||
setattr(hero, field, value)
|
setattr(hero, field, value)
|
||||||
hero.save()
|
hero.save()
|
||||||
self.stdout.write(" Updated hero section")
|
self.stdout.write(" Updated hero section")
|
||||||
|
|
||||||
|
def _seed_site_contact(self):
|
||||||
|
contact, created = SiteContact.objects.get_or_create(
|
||||||
|
pk=1,
|
||||||
|
defaults={
|
||||||
|
"support_email": "support@radiuma.com",
|
||||||
|
"discord_url": "https://discord.gg/9XxA6pV9hb",
|
||||||
|
"email_card_description": "For direct software support:",
|
||||||
|
"discord_card_description": "Join for community support and announcements.",
|
||||||
|
"office_address": (
|
||||||
|
"BC Cancer Research Center\n"
|
||||||
|
"675 West 10th Ave, Office 6-112\n"
|
||||||
|
"Vancouver, BC, V5Z 1L3\n"
|
||||||
|
"Canada"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
updates = {
|
||||||
|
"support_email": "support@radiuma.com",
|
||||||
|
"discord_url": "https://discord.gg/9XxA6pV9hb",
|
||||||
|
"email_card_description": "For direct software support:",
|
||||||
|
"discord_card_description": "Join for community support and announcements.",
|
||||||
|
"office_address": (
|
||||||
|
"BC Cancer Research Center\n"
|
||||||
|
"675 West 10th Ave, Office 6-112\n"
|
||||||
|
"Vancouver, BC, V5Z 1L3\n"
|
||||||
|
"Canada"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(contact, field, value)
|
||||||
|
contact.save()
|
||||||
|
action = "Created" if created else "Updated"
|
||||||
|
self.stdout.write(f" {action} site contact")
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2026-05-25 09:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SiteBranding',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('icon', models.ImageField(blank=True, help_text='Logo shown in the site header and footer. Leave empty to use the default static icon.', null=True, upload_to='branding/')),
|
||||||
|
('icon_alt', models.CharField(blank=True, help_text='Alt text for the brand icon (decorative icons can stay empty).', max_length=200)),
|
||||||
|
('navbar_icon_size', models.PositiveSmallIntegerField(default=26, help_text='Width and height in pixels for the header icon.')),
|
||||||
|
('footer_icon_size', models.PositiveSmallIntegerField(default=34, help_text='Width and height in pixels for the footer icon.')),
|
||||||
|
('show_border', models.BooleanField(default=False, help_text='Draw a border around the brand icon.')),
|
||||||
|
('border_color', models.CharField(default='#c4b5fd', help_text='Border color as hex (e.g. #c4b5fd).', max_length=7)),
|
||||||
|
('border_width', models.PositiveSmallIntegerField(default=1, help_text='Border width in pixels.')),
|
||||||
|
('object_fit', models.CharField(choices=[('cover', 'Cover (fill square, may crop)'), ('contain', 'Contain (fit inside square)')], default='cover', max_length=10)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Site Branding',
|
||||||
|
'verbose_name_plural': 'Site Branding',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2026-05-25 11:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_site_contact(apps, schema_editor):
|
||||||
|
SiteContact = apps.get_model("core", "SiteContact")
|
||||||
|
SiteContact.objects.get_or_create(
|
||||||
|
pk=1,
|
||||||
|
defaults={
|
||||||
|
"support_email": "support@radiuma.com",
|
||||||
|
"discord_url": "https://discord.gg/9XxA6pV9hb",
|
||||||
|
"office_address": (
|
||||||
|
"BC Cancer Research Center\n"
|
||||||
|
"675 West 10th Ave, Office 6-112\n"
|
||||||
|
"Vancouver, BC, V5Z 1L3\n"
|
||||||
|
"Canada"
|
||||||
|
),
|
||||||
|
"email_card_description": "For direct software support:",
|
||||||
|
"discord_card_description": "Join for community support and announcements.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0001_site_branding'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SiteContact',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('support_email', models.EmailField(blank=True, help_text='Shown in the footer and on the contact page. Hidden when empty.', max_length=254)),
|
||||||
|
('discord_url', models.URLField(blank=True, help_text='Discord invite link. Hidden when empty.')),
|
||||||
|
('discord_label', models.CharField(blank=True, help_text='Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.', max_length=100)),
|
||||||
|
('office_address', models.TextField(blank=True, help_text='Physical address; one line per row. Hidden when empty.')),
|
||||||
|
('email_card_title', models.CharField(blank=True, help_text='Contact page email card heading. Defaults to “Email Support”.', max_length=200)),
|
||||||
|
('email_card_description', models.CharField(blank=True, help_text='Short text under the email card heading on the contact page.', max_length=500)),
|
||||||
|
('discord_card_title', models.CharField(blank=True, help_text='Contact page Discord card heading. Defaults to “Discord Community”.', max_length=200)),
|
||||||
|
('discord_card_description', models.CharField(blank=True, help_text='Short text under the Discord card heading on the contact page.', max_length=500)),
|
||||||
|
('office_card_title', models.CharField(blank=True, help_text='Contact page office card heading. Defaults to “Office”.', max_length=200)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Site Contact',
|
||||||
|
'verbose_name_plural': 'Site Contact',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_site_contact, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class SiteBranding(models.Model):
|
||||||
|
OBJECT_FIT_COVER = "cover"
|
||||||
|
OBJECT_FIT_CONTAIN = "contain"
|
||||||
|
OBJECT_FIT_CHOICES = [
|
||||||
|
(OBJECT_FIT_COVER, "Cover (fill square, may crop)"),
|
||||||
|
(OBJECT_FIT_CONTAIN, "Contain (fit inside square)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
icon = models.ImageField(
|
||||||
|
upload_to="branding/",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.",
|
||||||
|
)
|
||||||
|
icon_alt = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text="Alt text for the brand icon (decorative icons can stay empty).",
|
||||||
|
)
|
||||||
|
navbar_icon_size = models.PositiveSmallIntegerField(
|
||||||
|
default=26,
|
||||||
|
help_text="Width and height in pixels for the header icon.",
|
||||||
|
)
|
||||||
|
footer_icon_size = models.PositiveSmallIntegerField(
|
||||||
|
default=34,
|
||||||
|
help_text="Width and height in pixels for the footer icon.",
|
||||||
|
)
|
||||||
|
show_border = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Draw a border around the brand icon.",
|
||||||
|
)
|
||||||
|
border_color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
default="#c4b5fd",
|
||||||
|
help_text="Border color as hex (e.g. #c4b5fd).",
|
||||||
|
)
|
||||||
|
border_width = models.PositiveSmallIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Border width in pixels.",
|
||||||
|
)
|
||||||
|
object_fit = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=OBJECT_FIT_CHOICES,
|
||||||
|
default=OBJECT_FIT_COVER,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Site Branding"
|
||||||
|
verbose_name_plural = "Site Branding"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Site Branding"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls):
|
||||||
|
obj, _ = cls.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def icon_style(self, size_px):
|
||||||
|
parts = [
|
||||||
|
f"width:{size_px}px",
|
||||||
|
f"height:{size_px}px",
|
||||||
|
f"object-fit:{self.object_fit}",
|
||||||
|
]
|
||||||
|
if self.show_border:
|
||||||
|
parts.append(f"border:{self.border_width}px solid {self.border_color}")
|
||||||
|
return ";".join(parts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def navbar_icon_style(self):
|
||||||
|
return self.icon_style(self.navbar_icon_size)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def footer_icon_style(self):
|
||||||
|
return self.icon_style(self.footer_icon_size)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteContact(models.Model):
|
||||||
|
support_email = models.EmailField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Shown in the footer and on the contact page. Hidden when empty.",
|
||||||
|
)
|
||||||
|
discord_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Discord invite link. Hidden when empty.",
|
||||||
|
)
|
||||||
|
discord_label = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text="Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.",
|
||||||
|
)
|
||||||
|
office_address = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Physical address; one line per row. Hidden when empty.",
|
||||||
|
)
|
||||||
|
email_card_title = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text="Contact page email card heading. Defaults to “Email Support”.",
|
||||||
|
)
|
||||||
|
email_card_description = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
help_text="Short text under the email card heading on the contact page.",
|
||||||
|
)
|
||||||
|
discord_card_title = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text="Contact page Discord card heading. Defaults to “Discord Community”.",
|
||||||
|
)
|
||||||
|
discord_card_description = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
help_text="Short text under the Discord card heading on the contact page.",
|
||||||
|
)
|
||||||
|
office_card_title = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text="Contact page office card heading. Defaults to “Office”.",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Site Contact"
|
||||||
|
verbose_name_plural = "Site Contact"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Site Contact"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls):
|
||||||
|
obj, _ = cls.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discord_label_display(self):
|
||||||
|
return self.discord_label.strip() or "Join Discord"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def office_address_lines(self):
|
||||||
|
if not self.office_address.strip():
|
||||||
|
return []
|
||||||
|
return [line.strip() for line in self.office_address.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_support_email(self):
|
||||||
|
return bool(self.support_email)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_discord(self):
|
||||||
|
return bool(self.discord_url)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_office_address(self):
|
||||||
|
return bool(self.office_address_lines)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_footer_contact(self):
|
||||||
|
return self.has_support_email or self.has_office_address
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_contact_sidebar(self):
|
||||||
|
return self.has_support_email or self.has_discord or self.has_office_address
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email_card_title_display(self):
|
||||||
|
return self.email_card_title.strip() or "Email Support"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discord_card_title_display(self):
|
||||||
|
return self.discord_card_title.strip() or "Discord Community"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def office_card_title_display(self):
|
||||||
|
return self.office_card_title.strip() or "Office"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from apps.core.context_processors import site_branding
|
||||||
|
from apps.core.models import SiteBranding
|
||||||
|
|
||||||
|
|
||||||
|
class SiteBrandingModelTests(TestCase):
|
||||||
|
def test_load_creates_singleton(self):
|
||||||
|
branding = SiteBranding.load()
|
||||||
|
self.assertEqual(branding.pk, 1)
|
||||||
|
self.assertEqual(SiteBranding.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_icon_style_includes_border_when_enabled(self):
|
||||||
|
branding = SiteBranding.load()
|
||||||
|
branding.show_border = True
|
||||||
|
branding.border_color = "#ffffff"
|
||||||
|
branding.border_width = 2
|
||||||
|
branding.navbar_icon_size = 30
|
||||||
|
style = branding.navbar_icon_style
|
||||||
|
self.assertIn("width:30px", style)
|
||||||
|
self.assertIn("border:2px solid #ffffff", style)
|
||||||
|
|
||||||
|
def test_icon_style_omits_border_when_disabled(self):
|
||||||
|
branding = SiteBranding.load()
|
||||||
|
branding.show_border = False
|
||||||
|
self.assertNotIn("border:", branding.navbar_icon_style)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteBrandingContextProcessorTests(TestCase):
|
||||||
|
def test_site_branding_in_context(self):
|
||||||
|
SiteBranding.load()
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
ctx = site_branding(request)
|
||||||
|
self.assertIn("site_branding", ctx)
|
||||||
|
self.assertIsInstance(ctx["site_branding"], SiteBranding)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from django.test import Client, RequestFactory, TestCase
|
||||||
|
|
||||||
|
from apps.core.context_processors import site_contact
|
||||||
|
from apps.core.models import SiteContact
|
||||||
|
|
||||||
|
|
||||||
|
class SiteContactModelTests(TestCase):
|
||||||
|
def test_office_address_lines_skips_blank_lines(self):
|
||||||
|
contact = SiteContact.load()
|
||||||
|
contact.office_address = "Line one\n\nLine two"
|
||||||
|
self.assertEqual(contact.office_address_lines, ["Line one", "Line two"])
|
||||||
|
|
||||||
|
def test_has_footer_contact_requires_email_or_office(self):
|
||||||
|
contact = SiteContact.load()
|
||||||
|
contact.support_email = ""
|
||||||
|
contact.office_address = ""
|
||||||
|
contact.discord_url = "https://discord.gg/example"
|
||||||
|
self.assertFalse(contact.has_footer_contact)
|
||||||
|
self.assertTrue(contact.has_discord)
|
||||||
|
|
||||||
|
def test_discord_label_default(self):
|
||||||
|
contact = SiteContact.load()
|
||||||
|
contact.discord_label = ""
|
||||||
|
self.assertEqual(contact.discord_label_display, "Join Discord")
|
||||||
|
|
||||||
|
|
||||||
|
class SiteContactContextProcessorTests(TestCase):
|
||||||
|
def test_site_contact_in_context(self):
|
||||||
|
SiteContact.load()
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
ctx = site_contact(request)
|
||||||
|
self.assertIn("site_contact", ctx)
|
||||||
|
self.assertIsInstance(ctx["site_contact"], SiteContact)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteContactTemplateTests(TestCase):
|
||||||
|
def test_footer_hides_email_when_empty(self):
|
||||||
|
contact = SiteContact.load()
|
||||||
|
contact.support_email = ""
|
||||||
|
contact.discord_url = ""
|
||||||
|
contact.office_address = ""
|
||||||
|
contact.save()
|
||||||
|
response = Client().get("/")
|
||||||
|
self.assertNotContains(response, "mailto:")
|
||||||
|
self.assertNotContains(response, "discord.gg")
|
||||||
|
|
||||||
|
def test_contact_page_hides_sidebar_when_empty(self):
|
||||||
|
contact = SiteContact.load()
|
||||||
|
contact.support_email = ""
|
||||||
|
contact.discord_url = ""
|
||||||
|
contact.office_address = ""
|
||||||
|
contact.save()
|
||||||
|
response = Client().get("/contact/")
|
||||||
|
self.assertNotContains(response, "contact-sidebar")
|
||||||
|
self.assertContains(response, "contact-layout--full")
|
||||||
+64
-25
@@ -17,30 +17,52 @@ class ArticleSectionInline(admin.TabularInline):
|
|||||||
ordering = ("order",)
|
ordering = ("order",)
|
||||||
|
|
||||||
|
|
||||||
class ArticleInline(admin.StackedInline):
|
class SubProductArticleInline(admin.StackedInline):
|
||||||
model = Article
|
model = Article
|
||||||
|
fk_name = "sub_product"
|
||||||
extra = 0
|
extra = 0
|
||||||
fields = ("badge", "title", "description", "order")
|
fields = ("badge", "title", "description", "order")
|
||||||
ordering = ("order",)
|
ordering = ("order",)
|
||||||
show_change_link = True
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
|
class MainProductArticleInline(admin.StackedInline):
|
||||||
|
model = Article
|
||||||
|
fk_name = "main_product"
|
||||||
|
extra = 0
|
||||||
|
fields = ("badge", "title", "description", "order")
|
||||||
|
ordering = ("order",)
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
|
RELEASE_VERSION_INLINE_FIELDS = (
|
||||||
|
"version",
|
||||||
|
"is_featured_stable",
|
||||||
|
"windows_download_url",
|
||||||
|
"macos_download_url",
|
||||||
|
"linux_download_url",
|
||||||
|
"source_code_url",
|
||||||
|
"package_resource_url",
|
||||||
|
"release_notes",
|
||||||
|
"is_active",
|
||||||
|
"order",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubProductVersionInline(admin.TabularInline):
|
class SubProductVersionInline(admin.TabularInline):
|
||||||
model = SubProductVersion
|
model = SubProductVersion
|
||||||
|
fk_name = "sub_product"
|
||||||
extra = 0
|
extra = 0
|
||||||
ordering = ("order", "version")
|
ordering = ("order", "version")
|
||||||
fields = (
|
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||||
"version",
|
|
||||||
"is_featured_stable",
|
|
||||||
"windows_download_url",
|
class MainProductVersionInline(admin.TabularInline):
|
||||||
"macos_download_url",
|
model = SubProductVersion
|
||||||
"linux_download_url",
|
fk_name = "main_product"
|
||||||
"source_code_url",
|
extra = 0
|
||||||
"package_resource_url",
|
ordering = ("order", "version")
|
||||||
"release_notes",
|
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||||
"is_active",
|
|
||||||
"order",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SubProductInline(admin.StackedInline):
|
class SubProductInline(admin.StackedInline):
|
||||||
@@ -59,9 +81,9 @@ class MainProductAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("name", "description")
|
search_fields = ("name", "description")
|
||||||
prepopulated_fields = {"slug": ("name",)}
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
list_editable = ("order", "is_active")
|
list_editable = ("order", "is_active")
|
||||||
inlines = [SubProductInline]
|
inlines = [MainProductArticleInline, MainProductVersionInline, SubProductInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name", "slug", "short_description")}),
|
(None, {"fields": ("name", "slug", "short_description", "distribution")}),
|
||||||
("Description", {"fields": ("description_format", "description")}),
|
("Description", {"fields": ("description_format", "description")}),
|
||||||
("Media", {"fields": ("image",)}),
|
("Media", {"fields": ("image",)}),
|
||||||
("Settings", {"fields": ("order", "is_active")}),
|
("Settings", {"fields": ("order", "is_active")}),
|
||||||
@@ -85,7 +107,7 @@ class SubProductAdmin(admin.ModelAdmin):
|
|||||||
prepopulated_fields = {"slug": ("name",)}
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||||
raw_id_fields = ("main_product",)
|
raw_id_fields = ("main_product",)
|
||||||
inlines = [ArticleInline, SubProductVersionInline]
|
inlines = [SubProductArticleInline, SubProductVersionInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}),
|
(None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}),
|
||||||
("Description", {"fields": ("description_format", "description")}),
|
("Description", {"fields": ("description_format", "description")}),
|
||||||
@@ -97,19 +119,27 @@ class SubProductAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Article)
|
@admin.register(Article)
|
||||||
class ArticleAdmin(admin.ModelAdmin):
|
class ArticleAdmin(admin.ModelAdmin):
|
||||||
list_display = ("title", "sub_product", "order", "created_at")
|
list_display = ("title", "parent", "order", "created_at")
|
||||||
list_filter = ("sub_product__main_product",)
|
list_filter = ("main_product", "sub_product__main_product")
|
||||||
search_fields = ("title", "description")
|
search_fields = ("title", "description", "main_product__name", "sub_product__name")
|
||||||
list_editable = ("order",)
|
list_editable = ("order",)
|
||||||
raw_id_fields = ("sub_product",)
|
raw_id_fields = ("main_product", "sub_product")
|
||||||
inlines = [ArticleSectionInline, ArticleCitationInline]
|
inlines = [ArticleSectionInline, ArticleCitationInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("sub_product", "badge", "title")}),
|
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
|
||||||
("Description", {"fields": ("description_format", "description")}),
|
("Description", {"fields": ("description_format", "description")}),
|
||||||
("Citation Badge", {"fields": ("citation_count_display", "citation_count_label"), "description": "Number and optional label for the dashed citation circle badge. Both are optional — a label without a number shows an empty circle with the label; neither hides the badge entirely."}),
|
("Citation Badge", {"fields": ("citation_count_display", "citation_count_label"), "description": "Number and optional label for the dashed citation circle badge. Both are optional — a label without a number shows an empty circle with the label; neither hides the badge entirely."}),
|
||||||
("Settings", {"fields": ("order",)}),
|
("Settings", {"fields": ("order",)}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@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.register(ArticleSection)
|
@admin.register(ArticleSection)
|
||||||
class ArticleSectionAdmin(admin.ModelAdmin):
|
class ArticleSectionAdmin(admin.ModelAdmin):
|
||||||
@@ -127,21 +157,22 @@ class ArticleSectionAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(SubProductVersion)
|
@admin.register(SubProductVersion)
|
||||||
class SubProductVersionAdmin(admin.ModelAdmin):
|
class SubProductVersionAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
"sub_product",
|
"parent",
|
||||||
"version",
|
"version",
|
||||||
"is_featured_stable",
|
"is_featured_stable",
|
||||||
"is_active",
|
"is_active",
|
||||||
"order",
|
"order",
|
||||||
)
|
)
|
||||||
list_filter = ("is_active", "is_featured_stable", "sub_product__distribution")
|
list_filter = ("is_active", "is_featured_stable", "main_product", "sub_product__main_product")
|
||||||
search_fields = ("sub_product__name", "version")
|
search_fields = ("main_product__name", "sub_product__name", "version")
|
||||||
list_editable = ("is_active", "order")
|
list_editable = ("is_active", "order")
|
||||||
raw_id_fields = ("sub_product",)
|
raw_id_fields = ("main_product", "sub_product")
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
|
"main_product",
|
||||||
"sub_product",
|
"sub_product",
|
||||||
"version",
|
"version",
|
||||||
"is_featured_stable",
|
"is_featured_stable",
|
||||||
@@ -163,3 +194,11 @@ class SubProductVersionAdmin(admin.ModelAdmin):
|
|||||||
("Details", {"fields": ("release_notes",)}),
|
("Details", {"fields": ("release_notes",)}),
|
||||||
("Settings", {"fields": ("is_active", "order")}),
|
("Settings", {"fields": ("is_active", "order")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@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 "—"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2026-05-23 10:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0008_article_citation_count_label'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='article',
|
||||||
|
name='main_product',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.mainproduct'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='article',
|
||||||
|
name='sub_product',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='article',
|
||||||
|
constraint=models.CheckConstraint(check=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='article_exactly_one_parent'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2026-05-23 10:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0009_article_main_product'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='subproductversion',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mainproduct',
|
||||||
|
name='distribution',
|
||||||
|
field=models.CharField(choices=[('installable', 'Installable application'), ('package', 'Package (external / non-installable)')], default='installable', help_text='Only affects how releases appear on the public site.', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subproductversion',
|
||||||
|
name='main_product',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.mainproduct'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subproductversion',
|
||||||
|
name='sub_product',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.subproduct'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='subproductversion',
|
||||||
|
constraint=models.CheckConstraint(check=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='release_version_exactly_one_parent'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='subproductversion',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('sub_product__isnull', False)), fields=('sub_product', 'version'), name='release_version_unique_sub_product_version'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='subproductversion',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('main_product__isnull', False)), fields=('main_product', 'version'), name='release_version_unique_main_product_version'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+89
-8
@@ -1,3 +1,4 @@
|
|||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
@@ -12,6 +13,13 @@ INSTALLABLE_PLATFORM_SPECS = (
|
|||||||
("source_code_url", "Source code", None),
|
("source_code_url", "Source code", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DISTRIBUTION_INSTALLABLE = "installable"
|
||||||
|
DISTRIBUTION_PACKAGE = "package"
|
||||||
|
DISTRIBUTION_CHOICES = [
|
||||||
|
(DISTRIBUTION_INSTALLABLE, "Installable application"),
|
||||||
|
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class MainProduct(models.Model):
|
class MainProduct(models.Model):
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
@@ -22,6 +30,12 @@ class MainProduct(models.Model):
|
|||||||
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
|
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
|
||||||
)
|
)
|
||||||
image = models.ImageField(upload_to="products/main/", blank=True, null=True)
|
image = models.ImageField(upload_to="products/main/", blank=True, null=True)
|
||||||
|
distribution = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=DISTRIBUTION_CHOICES,
|
||||||
|
default=DISTRIBUTION_INSTALLABLE,
|
||||||
|
help_text="Only affects how releases appear on the public site.",
|
||||||
|
)
|
||||||
order = models.PositiveIntegerField(default=0)
|
order = models.PositiveIntegerField(default=0)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -47,14 +61,17 @@ class MainProduct(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
|
return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
|
||||||
|
|
||||||
|
def get_versions_archive_url(self):
|
||||||
|
return reverse(
|
||||||
|
"products:main_product_versions",
|
||||||
|
kwargs={"main_slug": self.slug},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubProduct(models.Model):
|
class SubProduct(models.Model):
|
||||||
DISTRIBUTION_INSTALLABLE = "installable"
|
DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE
|
||||||
DISTRIBUTION_PACKAGE = "package"
|
DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE
|
||||||
DISTRIBUTION_CHOICES = [
|
DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES
|
||||||
(DISTRIBUTION_INSTALLABLE, "Installable application"),
|
|
||||||
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
|
|
||||||
]
|
|
||||||
|
|
||||||
main_product = models.ForeignKey(
|
main_product = models.ForeignKey(
|
||||||
MainProduct,
|
MainProduct,
|
||||||
@@ -121,10 +138,19 @@ class SubProduct(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Article(models.Model):
|
class Article(models.Model):
|
||||||
|
main_product = models.ForeignKey(
|
||||||
|
MainProduct,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="articles",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
sub_product = models.ForeignKey(
|
sub_product = models.ForeignKey(
|
||||||
SubProduct,
|
SubProduct,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="articles",
|
related_name="articles",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
badge = models.CharField(
|
badge = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
@@ -155,20 +181,47 @@ class Article(models.Model):
|
|||||||
ordering = ["order", "title"]
|
ordering = ["order", "title"]
|
||||||
verbose_name = "Article"
|
verbose_name = "Article"
|
||||||
verbose_name_plural = "Articles"
|
verbose_name_plural = "Articles"
|
||||||
|
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="article_exactly_one_parent",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rendered_description(self):
|
def rendered_description(self):
|
||||||
return render_content(self.description, self.description_format)
|
return render_content(self.description, self.description_format)
|
||||||
|
|
||||||
|
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(
|
||||||
|
"An article must belong to exactly one main product or sub-product."
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
class SubProductVersion(models.Model):
|
class SubProductVersion(models.Model):
|
||||||
|
main_product = models.ForeignKey(
|
||||||
|
MainProduct,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="versions",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
sub_product = models.ForeignKey(
|
sub_product = models.ForeignKey(
|
||||||
SubProduct,
|
SubProduct,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="versions",
|
related_name="versions",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
version = models.CharField(max_length=80)
|
version = models.CharField(max_length=80)
|
||||||
is_featured_stable = models.BooleanField(
|
is_featured_stable = models.BooleanField(
|
||||||
@@ -189,12 +242,40 @@ class SubProductVersion(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["order", "version", "pk"]
|
ordering = ["order", "version", "pk"]
|
||||||
unique_together = [["sub_product", "version"]]
|
|
||||||
verbose_name = "Release version"
|
verbose_name = "Release version"
|
||||||
verbose_name_plural = "Release versions"
|
verbose_name_plural = "Release versions"
|
||||||
|
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="release_version_exactly_one_parent",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["sub_product", "version"],
|
||||||
|
condition=models.Q(sub_product__isnull=False),
|
||||||
|
name="release_version_unique_sub_product_version",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["main_product", "version"],
|
||||||
|
condition=models.Q(main_product__isnull=False),
|
||||||
|
name="release_version_unique_main_product_version",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
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 release version must belong to exactly one main product or sub-product."
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.sub_product.name} v{self.version}"
|
parent = self.sub_product or self.main_product
|
||||||
|
return f"{parent.name} v{self.version}"
|
||||||
|
|
||||||
def install_urls_for_specs(self, specs):
|
def install_urls_for_specs(self, specs):
|
||||||
out = []
|
out = []
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from .models import DISTRIBUTION_INSTALLABLE, DISTRIBUTION_PACKAGE, INSTALLABLE_PLATFORM_SPECS
|
||||||
|
|
||||||
|
|
||||||
|
def featured_version(qs):
|
||||||
|
ordered = qs.order_by("order", "pk")
|
||||||
|
cand = ordered.filter(is_featured_stable=True).first()
|
||||||
|
return cand if cand else ordered.first()
|
||||||
|
|
||||||
|
|
||||||
|
def install_specs_from_versions(version_list):
|
||||||
|
present = set()
|
||||||
|
for ver in version_list:
|
||||||
|
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
|
||||||
|
if (getattr(ver, field_name) or "").strip():
|
||||||
|
present.add(field_name)
|
||||||
|
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
|
||||||
|
|
||||||
|
|
||||||
|
def build_release_context(distribution, active_versions):
|
||||||
|
context = {
|
||||||
|
"distribution_installable": distribution == DISTRIBUTION_INSTALLABLE,
|
||||||
|
"distribution_package": distribution == DISTRIBUTION_PACKAGE,
|
||||||
|
"show_releases_section": False,
|
||||||
|
"featured_version": None,
|
||||||
|
"featured_install_cells": [],
|
||||||
|
"show_older_versions_link": False,
|
||||||
|
"package_versions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
):
|
||||||
|
for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"):
|
||||||
|
if cand.has_any_install_asset() or (cand.package_resource_url or "").strip():
|
||||||
|
featured = cand
|
||||||
|
break
|
||||||
|
context["featured_version"] = featured
|
||||||
|
if featured and (
|
||||||
|
featured.has_any_install_asset()
|
||||||
|
or (featured.package_resource_url or "").strip()
|
||||||
|
):
|
||||||
|
if featured.has_any_install_asset():
|
||||||
|
specs = install_specs_from_versions([featured])
|
||||||
|
context["featured_install_cells"] = featured.install_urls_for_specs(specs)
|
||||||
|
context["show_releases_section"] = True
|
||||||
|
older_list = list(
|
||||||
|
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
|
||||||
|
if featured
|
||||||
|
else active_versions.order_by("order", "pk")
|
||||||
|
)
|
||||||
|
context["show_older_versions_link"] = len(older_list) > 0
|
||||||
|
|
||||||
|
elif distribution == DISTRIBUTION_PACKAGE:
|
||||||
|
pkg_versions = list(active_versions.order_by("order", "pk"))
|
||||||
|
context["package_versions"] = pkg_versions
|
||||||
|
context["show_releases_section"] = len(pkg_versions) > 0
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def build_archive_context(distribution, active_versions):
|
||||||
|
featured = featured_version(active_versions)
|
||||||
|
archive = list(
|
||||||
|
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
|
||||||
|
if featured
|
||||||
|
else active_versions.order_by("order", "pk")
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"featured_version": featured,
|
||||||
|
"archive_versions": archive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if distribution == DISTRIBUTION_INSTALLABLE:
|
||||||
|
specs = install_specs_from_versions(archive)
|
||||||
|
context["archive_specs"] = specs
|
||||||
|
archive_rows_installable = []
|
||||||
|
for ver in archive:
|
||||||
|
cells = []
|
||||||
|
for field_name, label, icon in specs:
|
||||||
|
u = (getattr(ver, field_name) or "").strip()
|
||||||
|
cells.append(
|
||||||
|
{"field": field_name, "label": label, "icon": icon, "url": u}
|
||||||
|
)
|
||||||
|
archive_rows_installable.append({"version_obj": ver, "cells": cells})
|
||||||
|
context["archive_rows_installable"] = archive_rows_installable
|
||||||
|
context["archive_rows_package"] = False
|
||||||
|
context["distribution_installable"] = True
|
||||||
|
context["distribution_package"] = False
|
||||||
|
else:
|
||||||
|
context["archive_specs"] = ()
|
||||||
|
context["archive_rows_installable"] = []
|
||||||
|
context["archive_rows_package"] = True
|
||||||
|
context["distribution_installable"] = False
|
||||||
|
context["distribution_package"] = True
|
||||||
|
|
||||||
|
return context
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from apps.products.models import (
|
||||||
|
Article,
|
||||||
|
ArticleSection,
|
||||||
|
MainProduct,
|
||||||
|
SubProduct,
|
||||||
|
SubProductVersion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MainProductModelTest(TestCase):
|
class MainProductModelTest(TestCase):
|
||||||
@@ -112,6 +120,72 @@ class ArticleModelTest(TestCase):
|
|||||||
self.sub_product.delete()
|
self.sub_product.delete()
|
||||||
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
|
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
|
||||||
|
|
||||||
|
def test_main_product_article(self):
|
||||||
|
article = Article.objects.create(
|
||||||
|
main_product=self.main_product,
|
||||||
|
title="Product Article",
|
||||||
|
description="Body.",
|
||||||
|
)
|
||||||
|
self.assertEqual(article.main_product, self.main_product)
|
||||||
|
self.assertIsNone(article.sub_product_id)
|
||||||
|
|
||||||
|
def test_cascade_delete_with_main_product(self):
|
||||||
|
article = Article.objects.create(
|
||||||
|
main_product=self.main_product,
|
||||||
|
title="Product Article",
|
||||||
|
description="Body.",
|
||||||
|
)
|
||||||
|
article_pk = article.pk
|
||||||
|
self.main_product.delete()
|
||||||
|
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
|
||||||
|
|
||||||
|
def test_requires_exactly_one_parent(self):
|
||||||
|
article = Article(
|
||||||
|
main_product=self.main_product,
|
||||||
|
sub_product=self.sub_product,
|
||||||
|
title="Invalid",
|
||||||
|
description="Body.",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
article.full_clean()
|
||||||
|
|
||||||
|
def test_requires_a_parent(self):
|
||||||
|
article = Article(title="Orphan", description="Body.")
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
article.full_clean()
|
||||||
|
|
||||||
|
|
||||||
|
class SubProductVersionModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.main_product = MainProduct.objects.create(
|
||||||
|
name="Main",
|
||||||
|
short_description="Short",
|
||||||
|
description="Desc",
|
||||||
|
)
|
||||||
|
self.sub_product = SubProduct.objects.create(
|
||||||
|
main_product=self.main_product,
|
||||||
|
name="Sub",
|
||||||
|
short_description="Short",
|
||||||
|
description="Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_main_product_version(self):
|
||||||
|
version = SubProductVersion.objects.create(
|
||||||
|
main_product=self.main_product,
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
self.assertEqual(version.main_product, self.main_product)
|
||||||
|
self.assertIsNone(version.sub_product_id)
|
||||||
|
|
||||||
|
def test_requires_exactly_one_parent(self):
|
||||||
|
version = SubProductVersion(
|
||||||
|
main_product=self.main_product,
|
||||||
|
sub_product=self.sub_product,
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
version.full_clean()
|
||||||
|
|
||||||
|
|
||||||
class ArticleSectionModelTest(TestCase):
|
class ArticleSectionModelTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ class MainProductDetailViewTest(ProductViewsSetup):
|
|||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_detail_articles_in_context(self):
|
||||||
|
main_article = Article.objects.create(
|
||||||
|
main_product=self.main_product,
|
||||||
|
title="Overview",
|
||||||
|
description="Main product article.",
|
||||||
|
)
|
||||||
|
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertIn("articles", response.context)
|
||||||
|
self.assertIn(main_article, list(response.context["articles"]))
|
||||||
|
self.assertNotIn(self.article, list(response.context["articles"]))
|
||||||
|
|
||||||
|
|
||||||
class SubProductDetailViewTest(ProductViewsSetup):
|
class SubProductDetailViewTest(ProductViewsSetup):
|
||||||
def test_sub_detail_returns_200(self):
|
def test_sub_detail_returns_200(self):
|
||||||
@@ -161,4 +173,40 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
|
|||||||
)
|
)
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTemplateUsed(response, "products/sub_versions.html")
|
self.assertTemplateUsed(response, "products/versions_archive.html")
|
||||||
|
|
||||||
|
def test_main_versions_returns_200(self):
|
||||||
|
from apps.products.models import SubProductVersion
|
||||||
|
|
||||||
|
SubProductVersion.objects.create(
|
||||||
|
main_product=self.main_product,
|
||||||
|
version="9.9",
|
||||||
|
is_featured_stable=True,
|
||||||
|
windows_download_url="https://example.com/win.exe",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
SubProductVersion.objects.create(
|
||||||
|
main_product=self.main_product,
|
||||||
|
version="9.8",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
url = reverse(
|
||||||
|
"products:main_product_versions",
|
||||||
|
kwargs={"main_slug": "radiuma"},
|
||||||
|
)
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "products/versions_archive.html")
|
||||||
|
|
||||||
|
def test_main_detail_releases_in_context(self):
|
||||||
|
from apps.products.models import SubProductVersion
|
||||||
|
|
||||||
|
SubProductVersion.objects.create(
|
||||||
|
main_product=self.main_product,
|
||||||
|
version="1.0",
|
||||||
|
windows_download_url="https://example.com/win.exe",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertTrue(response.context["show_releases_section"])
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ urlpatterns = [
|
|||||||
views.MainProductDetailView.as_view(),
|
views.MainProductDetailView.as_view(),
|
||||||
name="main_product_detail",
|
name="main_product_detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"<slug:main_slug>/versions/",
|
||||||
|
views.MainProductOlderVersionsView.as_view(),
|
||||||
|
name="main_product_versions",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"<slug:main_slug>/<slug:sub_slug>/versions/",
|
"<slug:main_slug>/<slug:sub_slug>/versions/",
|
||||||
views.SubProductOlderVersionsView.as_view(),
|
views.SubProductOlderVersionsView.as_view(),
|
||||||
|
|||||||
+47
-96
@@ -1,22 +1,8 @@
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.views.generic import DetailView, ListView, TemplateView
|
from django.views.generic import DetailView, ListView, TemplateView
|
||||||
|
|
||||||
from .models import INSTALLABLE_PLATFORM_SPECS, MainProduct, SubProduct
|
from .models import MainProduct, SubProduct
|
||||||
|
from .release_context import build_archive_context, build_release_context
|
||||||
|
|
||||||
def _featured_version(qs):
|
|
||||||
ordered = qs.order_by("order", "pk")
|
|
||||||
cand = ordered.filter(is_featured_stable=True).first()
|
|
||||||
return cand if cand else ordered.first()
|
|
||||||
|
|
||||||
|
|
||||||
def _install_specs_from_versions(version_list):
|
|
||||||
present = set()
|
|
||||||
for ver in version_list:
|
|
||||||
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
|
|
||||||
if (getattr(ver, field_name) or "").strip():
|
|
||||||
present.add(field_name)
|
|
||||||
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
|
|
||||||
|
|
||||||
|
|
||||||
class ProductOverviewView(ListView):
|
class ProductOverviewView(ListView):
|
||||||
@@ -37,6 +23,39 @@ class MainProductDetailView(DetailView):
|
|||||||
"sub_products"
|
"sub_products"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["articles"] = self.object.articles.prefetch_related(
|
||||||
|
"sections", "citations"
|
||||||
|
).all()
|
||||||
|
release_context = build_release_context(
|
||||||
|
self.object.distribution,
|
||||||
|
self.object.versions.filter(is_active=True),
|
||||||
|
)
|
||||||
|
release_context["versions_archive_url"] = self.object.get_versions_archive_url()
|
||||||
|
context.update(release_context)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class MainProductOlderVersionsView(TemplateView):
|
||||||
|
template_name = "products/versions_archive.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
main_product = get_object_or_404(
|
||||||
|
MainProduct,
|
||||||
|
slug=self.kwargs["main_slug"],
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
active_versions = main_product.versions.filter(is_active=True)
|
||||||
|
context["main_product"] = main_product
|
||||||
|
context["sub_product"] = None
|
||||||
|
context["product_detail_url"] = main_product.get_absolute_url()
|
||||||
|
context.update(
|
||||||
|
build_archive_context(main_product.distribution, active_versions)
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class SubProductDetailView(TemplateView):
|
class SubProductDetailView(TemplateView):
|
||||||
template_name = "products/sub_detail.html"
|
template_name = "products/sub_detail.html"
|
||||||
@@ -56,63 +75,25 @@ class SubProductDetailView(TemplateView):
|
|||||||
)
|
)
|
||||||
context["main_product"] = main_product
|
context["main_product"] = main_product
|
||||||
context["sub_product"] = sub_product
|
context["sub_product"] = sub_product
|
||||||
context["articles"] = sub_product.articles.prefetch_related("sections").all()
|
context["articles"] = sub_product.articles.prefetch_related(
|
||||||
|
"sections", "citations"
|
||||||
|
).all()
|
||||||
context["siblings"] = (
|
context["siblings"] = (
|
||||||
SubProduct.objects.filter(main_product=main_product, is_active=True)
|
SubProduct.objects.filter(main_product=main_product, is_active=True)
|
||||||
.exclude(pk=sub_product.pk)
|
.exclude(pk=sub_product.pk)
|
||||||
.order_by("order", "name")
|
.order_by("order", "name")
|
||||||
)
|
)
|
||||||
|
|
||||||
active_versions = sub_product.versions.filter(is_active=True)
|
active_versions = sub_product.versions.filter(is_active=True)
|
||||||
|
release_context = build_release_context(
|
||||||
context["distribution_installable"] = (
|
sub_product.distribution, active_versions
|
||||||
sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE
|
|
||||||
)
|
)
|
||||||
context["distribution_package"] = (
|
release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
|
||||||
sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE
|
context.update(release_context)
|
||||||
)
|
|
||||||
context["show_releases_section"] = False
|
|
||||||
context["featured_version"] = None
|
|
||||||
context["featured_install_cells"] = []
|
|
||||||
context["show_older_versions_link"] = False
|
|
||||||
context["package_versions"] = []
|
|
||||||
|
|
||||||
if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE:
|
|
||||||
featured = _featured_version(active_versions)
|
|
||||||
if (
|
|
||||||
featured
|
|
||||||
and not featured.has_any_install_asset()
|
|
||||||
and not (featured.package_resource_url or "").strip()
|
|
||||||
):
|
|
||||||
for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"):
|
|
||||||
if cand.has_any_install_asset() or (cand.package_resource_url or "").strip():
|
|
||||||
featured = cand
|
|
||||||
break
|
|
||||||
context["featured_version"] = featured
|
|
||||||
if featured and (
|
|
||||||
featured.has_any_install_asset()
|
|
||||||
or (featured.package_resource_url or "").strip()
|
|
||||||
):
|
|
||||||
if featured.has_any_install_asset():
|
|
||||||
specs = _install_specs_from_versions([featured])
|
|
||||||
context["featured_install_cells"] = featured.install_urls_for_specs(specs)
|
|
||||||
context["show_releases_section"] = True
|
|
||||||
older_list = list(
|
|
||||||
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
|
|
||||||
if featured else active_versions.order_by("order", "pk")
|
|
||||||
)
|
|
||||||
context["show_older_versions_link"] = len(older_list) > 0
|
|
||||||
|
|
||||||
elif sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE:
|
|
||||||
pkg_versions = list(active_versions.order_by("order", "pk"))
|
|
||||||
context["package_versions"] = pkg_versions
|
|
||||||
context["show_releases_section"] = len(pkg_versions) > 0
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class SubProductOlderVersionsView(TemplateView):
|
class SubProductOlderVersionsView(TemplateView):
|
||||||
template_name = "products/sub_versions.html"
|
template_name = "products/versions_archive.html"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
@@ -128,40 +109,10 @@ class SubProductOlderVersionsView(TemplateView):
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
active_versions = sub_product.versions.filter(is_active=True)
|
active_versions = sub_product.versions.filter(is_active=True)
|
||||||
featured = _featured_version(active_versions)
|
|
||||||
archive = list(
|
|
||||||
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
|
|
||||||
if featured
|
|
||||||
else active_versions.order_by("order", "pk")
|
|
||||||
)
|
|
||||||
context["main_product"] = main_product
|
context["main_product"] = main_product
|
||||||
context["sub_product"] = sub_product
|
context["sub_product"] = sub_product
|
||||||
context["featured_version"] = featured
|
context["product_detail_url"] = sub_product.get_absolute_url()
|
||||||
context["archive_versions"] = archive
|
context.update(
|
||||||
|
build_archive_context(sub_product.distribution, active_versions)
|
||||||
if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE:
|
)
|
||||||
specs = _install_specs_from_versions(archive)
|
|
||||||
context["archive_specs"] = specs
|
|
||||||
archive_rows_installable = []
|
|
||||||
for ver in archive:
|
|
||||||
cells = []
|
|
||||||
for field_name, label, icon in specs:
|
|
||||||
u = getattr(ver, field_name, "") or ""
|
|
||||||
u = u.strip()
|
|
||||||
cells.append(
|
|
||||||
{"field": field_name, "label": label, "icon": icon, "url": u}
|
|
||||||
)
|
|
||||||
archive_rows_installable.append(
|
|
||||||
{"version_obj": ver, "cells": cells}
|
|
||||||
)
|
|
||||||
context["archive_rows_installable"] = archive_rows_installable
|
|
||||||
context["archive_rows_package"] = False
|
|
||||||
context["distribution_installable"] = True
|
|
||||||
context["distribution_package"] = False
|
|
||||||
else:
|
|
||||||
context["archive_specs"] = ()
|
|
||||||
context["archive_rows_installable"] = []
|
|
||||||
context["archive_rows_package"] = True
|
|
||||||
context["distribution_installable"] = False
|
|
||||||
context["distribution_package"] = True
|
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"apps.core.context_processors.site_branding",
|
||||||
|
"apps.core.context_processors.site_contact",
|
||||||
"apps.core.context_processors.navigation",
|
"apps.core.context_processors.navigation",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+149
-49
@@ -344,8 +344,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.brand-icon {
|
.brand-icon {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
border: 1px solid #c4b5fd;
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -390,7 +389,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-arrow {
|
.nav-arrow {
|
||||||
transition: transform 0.22s ease;
|
transition: transform 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.has-megamenu:hover .nav-arrow,
|
.nav-item.has-megamenu:hover .nav-arrow,
|
||||||
@@ -428,7 +427,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
transition: opacity 0.22s ease, visibility 0.22s ease, transform 0.22s ease;
|
transition: opacity 0.6s ease, visibility 0.6s ease, transform 0.6s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
@@ -444,37 +443,91 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.megamenu-inner {
|
.megamenu-inner {
|
||||||
max-width: var(--container-max);
|
max-width: var(--container-max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem var(--container-padding);
|
padding: 1.5rem var(--container-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-grid {
|
.megamenu-products-scroll {
|
||||||
display: grid;
|
overflow-x: auto;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
overflow-y: hidden;
|
||||||
gap: 1.5rem;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(79, 142, 247, 0.45) transparent;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-column {
|
.megamenu-products-scroll::-webkit-scrollbar {
|
||||||
padding: 1rem;
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-products-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-products-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(79, 142, 247, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-products-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(79, 142, 247, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-products {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-product-item {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
max-width: 220px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: var(--glass-bg);
|
||||||
transition: var(--transition-fast);
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-column:hover {
|
.megamenu-product-item:hover,
|
||||||
background: var(--glass-bg);
|
.megamenu-product-item:focus-within {
|
||||||
|
border-color: rgba(79, 142, 247, 0.35);
|
||||||
|
background: rgba(79, 142, 247, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-product-title {
|
.megamenu-product-link {
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
padding: 1rem;
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-product-title:hover {
|
.megamenu-product-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-product-link:hover .megamenu-product-name,
|
||||||
|
.megamenu-product-item:focus-within > .megamenu-product-link .megamenu-product-name {
|
||||||
|
color: var(--accent-blue-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-sub-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.4s ease, color 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron,
|
||||||
|
.megamenu-product-item.has-subproducts:focus-within .megamenu-sub-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
color: var(--accent-blue-light);
|
color: var(--accent-blue-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,44 +535,47 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 0.85rem;
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-sublist {
|
.megamenu-sublist {
|
||||||
display: flex;
|
list-style: none;
|
||||||
flex-direction: column;
|
margin: 0;
|
||||||
gap: 0.2rem;
|
padding: 0;
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 0.4s ease, opacity 0.35s ease, border-color 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-product-item.has-subproducts:hover .megamenu-sublist,
|
||||||
|
.megamenu-product-item.has-subproducts:focus-within .megamenu-sublist {
|
||||||
|
max-height: 320px;
|
||||||
|
opacity: 1;
|
||||||
|
border-top-color: var(--glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-sublink {
|
.megamenu-sublink {
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
padding: 0.45rem 1rem;
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 0.3rem 0.4rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: var(--transition-fast);
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-sublink:hover {
|
.megamenu-sublink:hover,
|
||||||
|
.megamenu-sublink:focus-visible {
|
||||||
color: var(--accent-blue-light);
|
color: var(--accent-blue-light);
|
||||||
background: rgba(79, 142, 247, 0.08);
|
background: rgba(79, 142, 247, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sublink-dot {
|
.megamenu-sublink:last-child {
|
||||||
width: 5px; height: 5px;
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-blue);
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.megamenu-sublink:hover .sublink-dot {
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 0 6px var(--accent-blue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -831,7 +887,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 0;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,7 +970,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 52px;
|
width: 52px;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1054,6 +1110,21 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-product-layout {
|
||||||
|
padding-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-product-main .product-detail-text h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-product-main .sub-downloads {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Sub-products Grid
|
Sub-products Grid
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -1136,12 +1207,17 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-product-main {
|
.sub-product-main,
|
||||||
|
.main-product-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-product-main .sub-downloads {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sub-product-image {
|
.sub-product-image {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1563,6 +1639,10 @@ a.citation-count-badge:hover {
|
|||||||
padding: 2.5rem;
|
padding: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-custom-card {
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.about-intro h2 {
|
.about-intro h2 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
@@ -2319,6 +2399,10 @@ a.citation-count-badge:hover {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-layout--full {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-form-wrap {
|
.contact-form-wrap {
|
||||||
padding: 2.5rem;
|
padding: 2.5rem;
|
||||||
}
|
}
|
||||||
@@ -2805,11 +2889,27 @@ a.citation-count-badge:hover {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.megamenu-grid {
|
.megamenu-products-scroll {
|
||||||
grid-template-columns: 1fr;
|
overflow-x: visible;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-products {
|
||||||
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.megamenu-product-item {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megamenu-product-item.has-subproducts .megamenu-sublist {
|
||||||
|
max-height: none;
|
||||||
|
opacity: 1;
|
||||||
|
border-top-color: var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item.has-megamenu:hover .megamenu {
|
.nav-item.has-megamenu:hover .megamenu {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ function initMegaMenu() {
|
|||||||
hideTimer = setTimeout(() => {
|
hideTimer = setTimeout(() => {
|
||||||
productsItem.classList.remove('open');
|
productsItem.classList.remove('open');
|
||||||
trigger.setAttribute('aria-expanded', 'false');
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
}, 180);
|
}, 420);
|
||||||
}
|
}
|
||||||
|
|
||||||
productsItem.addEventListener('mouseenter', openMenu);
|
productsItem.addEventListener('mouseenter', openMenu);
|
||||||
|
|||||||
@@ -105,8 +105,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if section.content %}
|
{% if section.content %}
|
||||||
<div class="glass-card fade-in">
|
<div class="glass-card fade-in about-custom-card">
|
||||||
<div class="rich-content" style="padding: 2rem;" data-readmore="200">{{ section.rendered_content }}</div>
|
<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% with items=section.items.all %}
|
{% with items=section.items.all %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<section class="section" aria-labelledby="contact-form-heading">
|
<section class="section" aria-labelledby="contact-form-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="contact-layout">
|
<div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}">
|
||||||
|
|
||||||
<div class="contact-form-wrap glass-card fade-in">
|
<div class="contact-form-wrap glass-card fade-in">
|
||||||
<h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2>
|
<h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2>
|
||||||
@@ -75,7 +75,9 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if site_contact.has_contact_sidebar %}
|
||||||
<aside class="contact-sidebar">
|
<aside class="contact-sidebar">
|
||||||
|
{% if site_contact.has_support_email %}
|
||||||
<div class="contact-card glass-card fade-in">
|
<div class="contact-card glass-card fade-in">
|
||||||
<div class="contact-icon" aria-hidden="true">
|
<div class="contact-icon" aria-hidden="true">
|
||||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||||
@@ -83,22 +85,30 @@
|
|||||||
<path d="M5 9L16 16L27 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
<path d="M5 9L16 16L27 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="contact-card-title">Email Support</h3>
|
<h3 class="contact-card-title">{{ site_contact.email_card_title_display }}</h3>
|
||||||
<p class="contact-card-desc">For direct software support:</p>
|
{% if site_contact.email_card_description %}
|
||||||
<a href="mailto:support@radiuma.com" class="contact-email-link">support@radiuma.com</a>
|
<p class="contact-card-desc">{{ site_contact.email_card_description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% include "partials/_contact_email.html" with link_class="contact-email-link" %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if site_contact.has_discord %}
|
||||||
<div class="contact-card glass-card fade-in">
|
<div class="contact-card glass-card fade-in">
|
||||||
<div class="contact-icon" aria-hidden="true">
|
<div class="contact-icon" aria-hidden="true">
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="contact-card-title">Discord Community</h3>
|
<h3 class="contact-card-title">{{ site_contact.discord_card_title_display }}</h3>
|
||||||
<p class="contact-card-desc">Join for community support and announcements.</p>
|
{% if site_contact.discord_card_description %}
|
||||||
<a href="https://discord.gg/9XxA6pV9hb" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">Join Discord</a>
|
<p class="contact-card-desc">{{ site_contact.discord_card_description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% include "partials/_contact_discord.html" with button_class="btn-primary btn--sm" %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if site_contact.has_office_address %}
|
||||||
<div class="contact-card glass-card fade-in">
|
<div class="contact-card glass-card fade-in">
|
||||||
<div class="contact-icon" aria-hidden="true">
|
<div class="contact-icon" aria-hidden="true">
|
||||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||||
@@ -106,15 +116,12 @@
|
|||||||
<circle cx="16" cy="12" r="3" stroke="currentColor" stroke-width="1.5"/>
|
<circle cx="16" cy="12" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="contact-card-title">Office</h3>
|
<h3 class="contact-card-title">{{ site_contact.office_card_title_display }}</h3>
|
||||||
<address class="contact-address">
|
{% include "partials/_contact_office.html" with address_class="contact-address" %}
|
||||||
<p>BC Cancer Research Center</p>
|
|
||||||
<p>675 West 10th Ave, Office 6-112</p>
|
|
||||||
<p>Vancouver, BC, V5Z 1L3</p>
|
|
||||||
<p>Canada</p>
|
|
||||||
</address>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% if placement == "footer" %}
|
||||||
|
<img
|
||||||
|
{% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %}
|
||||||
|
{% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
|
||||||
|
class="brand-icon"
|
||||||
|
width="{{ site_branding.footer_icon_size }}"
|
||||||
|
height="{{ site_branding.footer_icon_size }}"
|
||||||
|
style="{{ site_branding.footer_icon_style }}"
|
||||||
|
/>
|
||||||
|
{% else %}
|
||||||
|
<img
|
||||||
|
{% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %}
|
||||||
|
{% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
|
||||||
|
class="brand-icon"
|
||||||
|
width="{{ site_branding.navbar_icon_size }}"
|
||||||
|
height="{{ site_branding.navbar_icon_size }}"
|
||||||
|
style="{{ site_branding.navbar_icon_style }}"
|
||||||
|
/>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% if site_contact.has_discord %}
|
||||||
|
<a href="{{ site_contact.discord_url }}" class="{{ button_class|default:'btn-ghost btn-sm' }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
||||||
|
</svg>
|
||||||
|
{{ site_contact.discord_label_display }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{% if site_contact.has_support_email %}
|
||||||
|
<a href="mailto:{{ site_contact.support_email }}" class="{{ link_class|default:'footer-email' }}">{{ site_contact.support_email }}</a>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{% if site_contact.has_office_address %}
|
||||||
|
<address class="{{ address_class|default:'footer-address' }}">
|
||||||
|
{% for line in site_contact.office_address_lines %}
|
||||||
|
<p>{{ line }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</address>
|
||||||
|
{% endif %}
|
||||||
@@ -9,18 +9,13 @@
|
|||||||
|
|
||||||
<div class="footer-brand-col">
|
<div class="footer-brand-col">
|
||||||
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Radiuma home">
|
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Radiuma home">
|
||||||
<img src="{% static 'images/favicon-180.png' %}" alt="" class="brand-icon" width="34" height="34" aria-hidden="true" />
|
{% include "partials/_brand_icon.html" with placement="footer" %}
|
||||||
<span class="brand-name">Radiuma</span>
|
<span class="brand-name">Radiuma</span>
|
||||||
</a>
|
</a>
|
||||||
<p class="footer-tagline">
|
<p class="footer-tagline">
|
||||||
Advancing medical imaging and radiomics research through innovative, standardized software solutions.
|
Advancing medical imaging and radiomics research through innovative, standardized software solutions.
|
||||||
</p>
|
</p>
|
||||||
<a href="https://discord.gg/9XxA6pV9hb" class="btn-ghost btn-sm" target="_blank" rel="noopener noreferrer">
|
{% include "partials/_contact_discord.html" with button_class="btn-ghost btn-sm" %}
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
|
||||||
</svg>
|
|
||||||
Join Discord
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-links-col">
|
<div class="footer-links-col">
|
||||||
@@ -34,29 +29,27 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if footer_sub_products %}
|
{% if footer_main_products %}
|
||||||
<div class="footer-links-col">
|
<div class="footer-links-col">
|
||||||
<h3 class="footer-heading">Products</h3>
|
<h3 class="footer-heading">Products</h3>
|
||||||
<ul class="footer-links" role="list">
|
<ul class="footer-links" role="list">
|
||||||
{% for sub in footer_sub_products %}
|
{% for product in footer_main_products %}
|
||||||
<li><a href="{{ sub.get_absolute_url }}">{{ sub.name }}</a></li>
|
<li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if footer_sub_products_has_more %}
|
{% if footer_main_products_has_more %}
|
||||||
<li><a href="/products/radiuma/">more</a></li>
|
<li><a href="{% url 'products:main_product_detail' main_slug='radiuma' %}">more</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if site_contact.has_footer_contact %}
|
||||||
<div class="footer-contact-col">
|
<div class="footer-contact-col">
|
||||||
<h3 class="footer-heading">Contact</h3>
|
<h3 class="footer-heading">Contact</h3>
|
||||||
<address class="footer-address">
|
{% include "partials/_contact_office.html" with address_class="footer-address" %}
|
||||||
<p>BC Cancer Research Center</p>
|
{% include "partials/_contact_email.html" with link_class="footer-email" %}
|
||||||
<p>675 West 10th Ave, Office 6-112</p>
|
|
||||||
<p>Vancouver, BC, V5Z 1L3</p>
|
|
||||||
</address>
|
|
||||||
<a href="mailto:support@radiuma.com" class="footer-email">support@radiuma.com</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="navbar-container">
|
<div class="navbar-container">
|
||||||
|
|
||||||
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Radiuma home">
|
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Radiuma home">
|
||||||
<img src="{% static 'images/favicon-180.png' %}" alt="" class="brand-icon" width="24" height="24" aria-hidden="true" />
|
{% include "partials/_brand_icon.html" with placement="navbar" %}
|
||||||
<span class="brand-name">Radiuma</span>
|
<span class="brand-name">Radiuma</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -32,34 +32,33 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="megamenu" role="menu" aria-labelledby="productsTrigger">
|
<div class="megamenu" role="menu" aria-labelledby="productsTrigger">
|
||||||
<div class="megamenu-inner">
|
<div class="megamenu-inner">
|
||||||
<div class="megamenu-grid">
|
<div class="megamenu-products-scroll" tabindex="0" aria-label="Product list">
|
||||||
{% for product in nav_main_products %}
|
<ul class="megamenu-products" role="list">
|
||||||
<div class="megamenu-column">
|
{% for product in nav_main_products %}
|
||||||
<a href="{{ product.get_absolute_url }}" class="megamenu-product-title" role="menuitem">
|
<li class="megamenu-product-item{% if product.sub_products.all %} has-subproducts{% endif %}">
|
||||||
{{ product.name }}
|
<a href="{{ product.get_absolute_url }}" class="megamenu-product-link" role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<span class="megamenu-product-name">
|
||||||
<path d="M3 7H11M11 7L8 4M11 7L8 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
{{ product.name }}
|
||||||
</svg>
|
{% if product.sub_products.all %}
|
||||||
</a>
|
<svg class="megamenu-sub-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
<p class="megamenu-product-desc">{{ product.short_description }}</p>
|
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
{% with subs=product.sub_products.all %}
|
</svg>
|
||||||
{% if subs %}
|
{% endif %}
|
||||||
<ul class="megamenu-sublist" role="list">
|
</span>
|
||||||
{% for sub in subs %}
|
<span class="megamenu-product-desc">{{ product.short_description }}</span>
|
||||||
{% if sub.is_active %}
|
</a>
|
||||||
<li>
|
{% if product.sub_products.all %}
|
||||||
<a href="{{ sub.get_absolute_url }}" class="megamenu-sublink" role="menuitem">
|
<ul class="megamenu-sublist" role="list" aria-label="{{ product.name }} modules">
|
||||||
<span class="sublink-dot"></span>
|
{% for sub in product.sub_products.all %}
|
||||||
{{ sub.name }}
|
<li>
|
||||||
</a>
|
<a href="{{ sub.get_absolute_url }}" class="megamenu-sublink" role="menuitem">{{ sub.name }}</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
</li>
|
||||||
</ul>
|
{% endfor %}
|
||||||
{% endif %}
|
</ul>
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% if articles %}
|
||||||
|
<section aria-label="Content sections">
|
||||||
|
<div class="articles-list">
|
||||||
|
{% for article in articles %}
|
||||||
|
<article class="article-card glass-card fade-in" aria-labelledby="article-{{ article.pk }}-title">
|
||||||
|
{% if article.badge %}<div class="article-card-header"><div class="section-badge article-badge">{{ article.badge }}</div></div>{% endif %}
|
||||||
|
<h3 class="article-title" id="article-{{ article.pk }}-title">{{ article.title }}</h3>
|
||||||
|
<div class="article-description rich-content" data-readmore="260">{{ article.rendered_description }}</div>
|
||||||
|
{% if article.sections.all %}
|
||||||
|
<div class="article-sections">
|
||||||
|
<dl class="article-sections-list">
|
||||||
|
{% for section in article.sections.all %}
|
||||||
|
<div class="article-section-item">
|
||||||
|
<dt class="section-key">{{ section.title }}</dt>
|
||||||
|
<dd class="section-value rich-content" data-readmore="160">{{ section.rendered_value }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if article.citations.all %}
|
||||||
|
<div class="article-citations" id="article-{{ article.pk }}-citations">
|
||||||
|
<h4 class="article-citations-title">Citations</h4>
|
||||||
|
<ol class="article-citations-list">
|
||||||
|
{% for citation in article.citations.all %}
|
||||||
|
<li class="article-citation-item">
|
||||||
|
<span class="citation-text">{{ citation.text }}</span>
|
||||||
|
{% if citation.url %}
|
||||||
|
<span class="citation-actions">
|
||||||
|
<a href="{{ citation.url }}" class="citation-link" target="_blank" rel="noopener noreferrer" aria-label="Open citation source">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M7 3H3a1 1 0 00-1 1v9a1 1 0 001 1h9a1 1 0 001-1V9M10 2h4m0 0v4m0-4L7 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="citation-copy" data-citation-url="{{ citation.url }}" aria-label="Copy citation link">
|
||||||
|
<svg class="citation-copy-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="5" y="5" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="citation-copy-icon-done" width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="citation-copy-feedback" aria-live="polite" hidden>Copied!</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if article.citation_count_display is not None or article.citation_count_label %}
|
||||||
|
{% if article.citations.exists %}
|
||||||
|
<a href="#article-{{ article.pk }}-citations" class="citation-count-badge" aria-label="{% if article.citation_count_display %}{{ article.citation_count_display }} {% endif %}{% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %}">
|
||||||
|
{% if article.citation_count_display is not None %}<span class="citation-count-number">{{ article.citation_count_display }}</span>{% endif %}
|
||||||
|
{% if article.citation_count_label %}<span class="citation-count-label">{{ article.citation_count_label }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="citation-count-badge" aria-label="{% if article.citation_count_display %}{{ article.citation_count_display }} {% endif %}{% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %}">
|
||||||
|
{% if article.citation_count_display is not None %}<span class="citation-count-number">{{ article.citation_count_display }}</span>{% endif %}
|
||||||
|
{% if article.citation_count_label %}<span class="citation-count-label">{{ article.citation_count_label }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% if show_releases_section %}
|
||||||
|
{% if distribution_installable %}
|
||||||
|
<section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading">
|
||||||
|
<h2 class="sub-downloads-title" id="sub-dl-heading">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Downloads
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<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">v{{ featured_version.version }}</span>
|
||||||
|
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.label == "Source code" %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
|
||||||
|
</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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if featured_version.release_notes %}
|
||||||
|
<p class="sub-release-notes">{{ featured_version.release_notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_older_versions_link %}
|
||||||
|
<p class="sub-older-versions-inner">
|
||||||
|
<a href="{{ versions_archive_url }}" class="link-arrow">Older releases</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% elif distribution_package %}
|
||||||
|
<section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading">
|
||||||
|
<h2 class="sub-downloads-title" id="sub-resources-heading">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
|
</svg>
|
||||||
|
Resources
|
||||||
|
</h2>
|
||||||
|
<ul class="pkg-version-list" role="list">
|
||||||
|
{% for ver in package_versions %}
|
||||||
|
<li class="pkg-version-row">
|
||||||
|
<div class="pkg-version-meta">
|
||||||
|
<span class="download-version">v{{ 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 %}
|
||||||
|
</div>
|
||||||
|
<div class="pkg-version-actions">
|
||||||
|
{% if ver.package_resource_url %}
|
||||||
|
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">Open</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if ver.source_code_url %}
|
||||||
|
<a href="{{ ver.source_code_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Source</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if ver.release_notes %}
|
||||||
|
<p class="pkg-version-notes">{{ ver.release_notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% if not archive_versions %}
|
||||||
|
<p class="versions-empty glass-card">There are no archived releases{% if sub_product %} for this module{% else %} for this product{% endif %}.</p>
|
||||||
|
{% elif distribution_installable %}
|
||||||
|
<div class="versions-table-wrap glass-card fade-in">
|
||||||
|
<table class="versions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Version</th>
|
||||||
|
{% for fname, label, icon in archive_specs %}
|
||||||
|
<th scope="col">{{ label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in archive_rows_installable %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{ row.version_obj.version }}</th>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
<td>
|
||||||
|
{% if cell.url %}
|
||||||
|
{% if cell.icon %}
|
||||||
|
<a href="{{ cell.url }}" class="versions-table-link">
|
||||||
|
<img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{{ cell.label }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ cell.url }}" class="versions-table-text-link" rel="noopener noreferrer">Source</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="versions-na">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% if row.version_obj.release_notes %}
|
||||||
|
<tr class="versions-notes-row">
|
||||||
|
<td colspan="{{ archive_specs|length|add:1 }}">
|
||||||
|
<span class="versions-notes-label">Notes</span>
|
||||||
|
<p class="versions-notes-body">{{ row.version_obj.release_notes }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<ul class="versions-package-list" role="list">
|
||||||
|
{% for ver in archive_versions %}
|
||||||
|
<li class="versions-package-card glass-card fade-in">
|
||||||
|
<div class="versions-package-head">
|
||||||
|
<span class="download-version">v{{ ver.version }}</span>
|
||||||
|
{% if ver.package_resource_url %}
|
||||||
|
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Open</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if ver.release_notes %}
|
||||||
|
<p class="versions-notes-body">{{ ver.release_notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
@@ -31,24 +31,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section" aria-labelledby="product-desc-heading">
|
<section class="section main-product-layout" aria-labelledby="product-desc-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="product-detail-intro glass-card fade-in">
|
<div class="main-product-main">
|
||||||
<h2 class="sr-only" id="product-desc-heading">About {{ main_product.name }}</h2>
|
<div class="product-detail-intro glass-card fade-in">
|
||||||
<div class="product-detail-image">
|
<div class="product-detail-image">
|
||||||
{% if main_product.image %}
|
{% if main_product.image %}
|
||||||
<img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" />
|
<img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{% static 'images/logo.png' %}" alt="{{ main_product.name }} logo" loading="lazy" class="product-logo-img" />
|
<img src="{% static 'images/logo.png' %}" alt="{{ main_product.name }} logo" loading="lazy" class="product-logo-img" />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="product-detail-text">
|
<div class="product-detail-text">
|
||||||
<div class="product-detail-desc rich-content" data-readmore="220">{{ main_product.rendered_description }}</div>
|
<h2 id="product-desc-heading">About {{ main_product.name }}</h2>
|
||||||
|
<div class="product-detail-desc rich-content" data-readmore="220">{{ main_product.rendered_description }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include "products/_releases_section.html" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if articles %}
|
||||||
|
<section class="section" aria-label="Product articles">
|
||||||
|
<div class="container">
|
||||||
|
{% include "products/_article_list.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% with subs=main_product.sub_products.all %}
|
{% with subs=main_product.sub_products.all %}
|
||||||
{% if subs %}
|
{% if subs %}
|
||||||
<section class="section" aria-labelledby="subproducts-heading">
|
<section class="section" aria-labelledby="subproducts-heading">
|
||||||
|
|||||||
@@ -47,177 +47,9 @@
|
|||||||
<div class="rich-content" data-readmore="220">{{ sub_product.rendered_description }}</div>
|
<div class="rich-content" data-readmore="220">{{ sub_product.rendered_description }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if show_releases_section %}
|
{% include "products/_releases_section.html" %}
|
||||||
{% if distribution_installable %}
|
|
||||||
<section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading">
|
|
||||||
<h2 class="sub-downloads-title" id="sub-dl-heading">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
||||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Downloads
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="sub-downloads-channel">
|
{% include "products/_article_list.html" %}
|
||||||
{% 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">v{{ featured_version.version }}</span>
|
|
||||||
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.label == "Source code" %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
|
|
||||||
</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>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if featured_version.release_notes %}
|
|
||||||
<p class="sub-release-notes">{{ featured_version.release_notes }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if show_older_versions_link %}
|
|
||||||
<p class="sub-older-versions-inner">
|
|
||||||
<a href="{{ sub_product.get_versions_archive_url }}" class="link-arrow">Older releases</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% elif distribution_package %}
|
|
||||||
<section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading">
|
|
||||||
<h2 class="sub-downloads-title" id="sub-resources-heading">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
||||||
</svg>
|
|
||||||
Resources
|
|
||||||
</h2>
|
|
||||||
<ul class="pkg-version-list" role="list">
|
|
||||||
{% for ver in package_versions %}
|
|
||||||
<li class="pkg-version-row">
|
|
||||||
<div class="pkg-version-meta">
|
|
||||||
<span class="download-version">v{{ 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 %}
|
|
||||||
</div>
|
|
||||||
<div class="pkg-version-actions">
|
|
||||||
{% if ver.package_resource_url %}
|
|
||||||
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">Open</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if ver.source_code_url %}
|
|
||||||
<a href="{{ ver.source_code_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Source</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if ver.release_notes %}
|
|
||||||
<p class="pkg-version-notes">{{ ver.release_notes }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if articles %}
|
|
||||||
<section aria-label="Content sections">
|
|
||||||
<div class="articles-list">
|
|
||||||
{% for article in articles %}
|
|
||||||
<article class="article-card glass-card fade-in" aria-labelledby="article-{{ article.pk }}-title">
|
|
||||||
{% if article.badge %}<div class="article-card-header"><div class="section-badge article-badge">{{ article.badge }}</div></div>{% endif %}
|
|
||||||
<h3 class="article-title" id="article-{{ article.pk }}-title">{{ article.title }}</h3>
|
|
||||||
<div class="article-description rich-content" data-readmore="260">{{ article.rendered_description }}</div>
|
|
||||||
{% if article.sections.all %}
|
|
||||||
<div class="article-sections">
|
|
||||||
<dl class="article-sections-list">
|
|
||||||
{% for section in article.sections.all %}
|
|
||||||
<div class="article-section-item">
|
|
||||||
<dt class="section-key">{{ section.title }}</dt>
|
|
||||||
<dd class="section-value rich-content" data-readmore="160">{{ section.rendered_value }}</dd>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if article.citations.all %}
|
|
||||||
<div class="article-citations" id="article-{{ article.pk }}-citations">
|
|
||||||
<h4 class="article-citations-title">Citations</h4>
|
|
||||||
<ol class="article-citations-list">
|
|
||||||
{% for citation in article.citations.all %}
|
|
||||||
<li class="article-citation-item">
|
|
||||||
<span class="citation-text">{{ citation.text }}</span>
|
|
||||||
{% if citation.url %}
|
|
||||||
<span class="citation-actions">
|
|
||||||
<a href="{{ citation.url }}" class="citation-link" target="_blank" rel="noopener noreferrer" aria-label="Open citation source">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M7 3H3a1 1 0 00-1 1v9a1 1 0 001 1h9a1 1 0 001-1V9M10 2h4m0 0v4m0-4L7 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="citation-copy" data-citation-url="{{ citation.url }}" aria-label="Copy citation link">
|
|
||||||
<svg class="citation-copy-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<rect x="5" y="5" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<svg class="citation-copy-icon-done" width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<span class="citation-copy-feedback" aria-live="polite" hidden>Copied!</span>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if article.citation_count_display is not None or article.citation_count_label %}
|
|
||||||
{% if article.citations.exists %}
|
|
||||||
<a href="#article-{{ article.pk }}-citations" class="citation-count-badge" aria-label="{% if article.citation_count_display %}{{ article.citation_count_display }} {% endif %}{% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %}">
|
|
||||||
{% if article.citation_count_display is not None %}<span class="citation-count-number">{{ article.citation_count_display }}</span>{% endif %}
|
|
||||||
{% if article.citation_count_label %}<span class="citation-count-label">{{ article.citation_count_label }}</span>{% endif %}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<div class="citation-count-badge" aria-label="{% if article.citation_count_display %}{{ article.citation_count_display }} {% endif %}{% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %}">
|
|
||||||
{% if article.citation_count_display is not None %}<span class="citation-count-number">{{ article.citation_count_display }}</span>{% endif %}
|
|
||||||
{% if article.citation_count_label %}<span class="citation-count-label">{{ article.citation_count_label }}</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="sub-product-sidebar" aria-label="Related modules">
|
<aside class="sub-product-sidebar" aria-label="Related modules">
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Older releases — {{ sub_product.name }} — {{ main_product.name }}{% endblock %}
|
|
||||||
{% block meta_description %}Archived releases for {{ sub_product.name }}.{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<section class="page-hero" aria-labelledby="versions-heading">
|
|
||||||
<div class="page-hero-blobs" aria-hidden="true">
|
|
||||||
<div class="blob blob--1"></div>
|
|
||||||
<div class="blob blob--2"></div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
|
||||||
<ol class="breadcrumb-list" role="list">
|
|
||||||
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
|
||||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
|
||||||
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
|
||||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
|
||||||
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
|
|
||||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
|
||||||
<li><a href="{{ sub_product.get_absolute_url }}">{{ sub_product.name }}</a></li>
|
|
||||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
|
||||||
<li aria-current="page">Older releases</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<div class="page-hero-content">
|
|
||||||
<div class="section-badge">{{ sub_product.name }}</div>
|
|
||||||
<h1 class="page-hero-title" id="versions-heading">Older releases</h1>
|
|
||||||
<p class="page-hero-subtitle">Archived versions of this module.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section versions-archive-section">
|
|
||||||
<div class="container">
|
|
||||||
{% if not archive_versions %}
|
|
||||||
<p class="versions-empty glass-card">There are no archived releases for this module.</p>
|
|
||||||
{% elif distribution_installable %}
|
|
||||||
<div class="versions-table-wrap glass-card fade-in">
|
|
||||||
<table class="versions-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Version</th>
|
|
||||||
{% for fname, label, icon in archive_specs %}
|
|
||||||
<th scope="col">{{ label }}</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in archive_rows_installable %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{{ row.version_obj.version }}</th>
|
|
||||||
{% for cell in row.cells %}
|
|
||||||
<td>
|
|
||||||
{% if cell.url %}
|
|
||||||
{% if cell.icon %}
|
|
||||||
<a href="{{ cell.url }}" class="versions-table-link">
|
|
||||||
<img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
|
|
||||||
<span class="sr-only">{{ cell.label }}</span>
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ cell.url }}" class="versions-table-text-link" rel="noopener noreferrer">Source</a>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<span class="versions-na">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% if row.version_obj.release_notes %}
|
|
||||||
<tr class="versions-notes-row">
|
|
||||||
<td colspan="{{ archive_specs|length|add:1 }}">
|
|
||||||
<span class="versions-notes-label">Notes</span>
|
|
||||||
<p class="versions-notes-body">{{ row.version_obj.release_notes }}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<ul class="versions-package-list" role="list">
|
|
||||||
{% for ver in archive_versions %}
|
|
||||||
<li class="versions-package-card glass-card fade-in">
|
|
||||||
<div class="versions-package-head">
|
|
||||||
<span class="download-version">v{{ ver.version }}</span>
|
|
||||||
{% if ver.package_resource_url %}
|
|
||||||
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Open</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if ver.release_notes %}
|
|
||||||
<p class="versions-notes-body">{{ ver.release_notes }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Older releases — {% if sub_product %}{{ sub_product.name }} — {% endif %}{{ main_product.name }}{% endblock %}
|
||||||
|
{% block meta_description %}Archived releases{% if sub_product %} for {{ sub_product.name }}{% else %} for {{ main_product.name }}{% endif %}.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="page-hero" aria-labelledby="versions-heading">
|
||||||
|
<div class="page-hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<ol class="breadcrumb-list" role="list">
|
||||||
|
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
|
||||||
|
{% if sub_product %}
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li><a href="{{ sub_product.get_absolute_url }}">{{ sub_product.name }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li aria-current="page">Older releases</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="page-hero-content">
|
||||||
|
<div class="section-badge">{% if sub_product %}{{ sub_product.name }}{% else %}{{ main_product.name }}{% endif %}</div>
|
||||||
|
<h1 class="page-hero-title" id="versions-heading">Older releases</h1>
|
||||||
|
<p class="page-hero-subtitle">Archived versions of this {% if sub_product %}module{% else %}product{% endif %}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section versions-archive-section">
|
||||||
|
<div class="container">
|
||||||
|
{% include "products/_versions_archive_body.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user