feat: dynamic main icon
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
def site_branding(request):
|
||||
return {"site_branding": SiteBranding.load()}
|
||||
|
||||
|
||||
def site_contact(request):
|
||||
return {"site_contact": SiteContact.load()}
|
||||
|
||||
|
||||
def navigation(request):
|
||||
main_products = (
|
||||
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")
|
||||
)
|
||||
all_sub_products = list(
|
||||
SubProduct.objects.filter(is_active=True).order_by("order", "name")[:5]
|
||||
)
|
||||
footer_sub_products = all_sub_products[:4]
|
||||
footer_sub_products_has_more = len(all_sub_products) == 5
|
||||
all_footer_products = list(main_products[:5])
|
||||
footer_main_products = all_footer_products[:4]
|
||||
footer_main_products_has_more = len(all_footer_products) == 5
|
||||
return {
|
||||
"nav_main_products": main_products,
|
||||
"footer_sub_products": footer_sub_products,
|
||||
"footer_sub_products_has_more": footer_sub_products_has_more,
|
||||
"footer_main_products": footer_main_products,
|
||||
"footer_main_products_has_more": footer_main_products_has_more,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.core.models import SiteContact
|
||||
from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||
|
||||
@@ -254,9 +255,9 @@ FAQ_ENTRIES = [
|
||||
{
|
||||
"question": "Where can I get support or report issues?",
|
||||
"answer": (
|
||||
"Support is available via email at support@radiuma.com and through our "
|
||||
"community Discord server. For bug reports and feature requests, please "
|
||||
"use the Discord forum or contact us directly by email."
|
||||
"Support is available via email and through our community Discord server "
|
||||
"(see the Contact page for current details). For bug reports and feature "
|
||||
"requests, please use the Discord forum or contact us directly by email."
|
||||
),
|
||||
"order": 6,
|
||||
},
|
||||
@@ -402,6 +403,7 @@ class Command(BaseCommand):
|
||||
self._seed_downloads()
|
||||
self._seed_homepage_sections()
|
||||
self._seed_hero()
|
||||
self._seed_site_contact()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Content seeded successfully."))
|
||||
|
||||
@@ -540,3 +542,38 @@ class Command(BaseCommand):
|
||||
setattr(hero, field, value)
|
||||
hero.save()
|
||||
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",)
|
||||
|
||||
|
||||
class ArticleInline(admin.StackedInline):
|
||||
class SubProductArticleInline(admin.StackedInline):
|
||||
model = Article
|
||||
fk_name = "sub_product"
|
||||
extra = 0
|
||||
fields = ("badge", "title", "description", "order")
|
||||
ordering = ("order",)
|
||||
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):
|
||||
model = SubProductVersion
|
||||
fk_name = "sub_product"
|
||||
extra = 0
|
||||
ordering = ("order", "version")
|
||||
fields = (
|
||||
"version",
|
||||
"is_featured_stable",
|
||||
"windows_download_url",
|
||||
"macos_download_url",
|
||||
"linux_download_url",
|
||||
"source_code_url",
|
||||
"package_resource_url",
|
||||
"release_notes",
|
||||
"is_active",
|
||||
"order",
|
||||
)
|
||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||
|
||||
|
||||
class MainProductVersionInline(admin.TabularInline):
|
||||
model = SubProductVersion
|
||||
fk_name = "main_product"
|
||||
extra = 0
|
||||
ordering = ("order", "version")
|
||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||
|
||||
|
||||
class SubProductInline(admin.StackedInline):
|
||||
@@ -59,9 +81,9 @@ class MainProductAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active")
|
||||
inlines = [SubProductInline]
|
||||
inlines = [MainProductArticleInline, MainProductVersionInline, SubProductInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "slug", "short_description")}),
|
||||
(None, {"fields": ("name", "slug", "short_description", "distribution")}),
|
||||
("Description", {"fields": ("description_format", "description")}),
|
||||
("Media", {"fields": ("image",)}),
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
@@ -85,7 +107,7 @@ class SubProductAdmin(admin.ModelAdmin):
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
raw_id_fields = ("main_product",)
|
||||
inlines = [ArticleInline, SubProductVersionInline]
|
||||
inlines = [SubProductArticleInline, SubProductVersionInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}),
|
||||
("Description", {"fields": ("description_format", "description")}),
|
||||
@@ -97,19 +119,27 @@ class SubProductAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Article)
|
||||
class ArticleAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "sub_product", "order", "created_at")
|
||||
list_filter = ("sub_product__main_product",)
|
||||
search_fields = ("title", "description")
|
||||
list_display = ("title", "parent", "order", "created_at")
|
||||
list_filter = ("main_product", "sub_product__main_product")
|
||||
search_fields = ("title", "description", "main_product__name", "sub_product__name")
|
||||
list_editable = ("order",)
|
||||
raw_id_fields = ("sub_product",)
|
||||
raw_id_fields = ("main_product", "sub_product")
|
||||
inlines = [ArticleSectionInline, ArticleCitationInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("sub_product", "badge", "title")}),
|
||||
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
|
||||
("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."}),
|
||||
("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)
|
||||
class ArticleSectionAdmin(admin.ModelAdmin):
|
||||
@@ -127,21 +157,22 @@ class ArticleSectionAdmin(admin.ModelAdmin):
|
||||
@admin.register(SubProductVersion)
|
||||
class SubProductVersionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"sub_product",
|
||||
"parent",
|
||||
"version",
|
||||
"is_featured_stable",
|
||||
"is_active",
|
||||
"order",
|
||||
)
|
||||
list_filter = ("is_active", "is_featured_stable", "sub_product__distribution")
|
||||
search_fields = ("sub_product__name", "version")
|
||||
list_filter = ("is_active", "is_featured_stable", "main_product", "sub_product__main_product")
|
||||
search_fields = ("main_product__name", "sub_product__name", "version")
|
||||
list_editable = ("is_active", "order")
|
||||
raw_id_fields = ("sub_product",)
|
||||
raw_id_fields = ("main_product", "sub_product")
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"main_product",
|
||||
"sub_product",
|
||||
"version",
|
||||
"is_featured_stable",
|
||||
@@ -163,3 +194,11 @@ class SubProductVersionAdmin(admin.ModelAdmin):
|
||||
("Details", {"fields": ("release_notes",)}),
|
||||
("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.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
@@ -12,6 +13,13 @@ INSTALLABLE_PLATFORM_SPECS = (
|
||||
("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):
|
||||
name = models.CharField(max_length=200)
|
||||
@@ -22,6 +30,12 @@ class MainProduct(models.Model):
|
||||
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
|
||||
)
|
||||
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)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -47,14 +61,17 @@ class MainProduct(models.Model):
|
||||
def get_absolute_url(self):
|
||||
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):
|
||||
DISTRIBUTION_INSTALLABLE = "installable"
|
||||
DISTRIBUTION_PACKAGE = "package"
|
||||
DISTRIBUTION_CHOICES = [
|
||||
(DISTRIBUTION_INSTALLABLE, "Installable application"),
|
||||
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
|
||||
]
|
||||
DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE
|
||||
DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE
|
||||
DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES
|
||||
|
||||
main_product = models.ForeignKey(
|
||||
MainProduct,
|
||||
@@ -121,10 +138,19 @@ class SubProduct(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(
|
||||
SubProduct,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="articles",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
badge = models.CharField(
|
||||
max_length=100,
|
||||
@@ -155,20 +181,47 @@ class Article(models.Model):
|
||||
ordering = ["order", "title"]
|
||||
verbose_name = "Article"
|
||||
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
|
||||
def rendered_description(self):
|
||||
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):
|
||||
return self.title
|
||||
|
||||
|
||||
class SubProductVersion(models.Model):
|
||||
main_product = models.ForeignKey(
|
||||
MainProduct,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="versions",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
sub_product = models.ForeignKey(
|
||||
SubProduct,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="versions",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
version = models.CharField(max_length=80)
|
||||
is_featured_stable = models.BooleanField(
|
||||
@@ -189,12 +242,40 @@ class SubProductVersion(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "version", "pk"]
|
||||
unique_together = [["sub_product", "version"]]
|
||||
verbose_name = "Release version"
|
||||
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):
|
||||
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):
|
||||
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.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):
|
||||
@@ -112,6 +120,72 @@ class ArticleModelTest(TestCase):
|
||||
self.sub_product.delete()
|
||||
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):
|
||||
def setUp(self):
|
||||
|
||||
@@ -83,6 +83,18 @@ class MainProductDetailViewTest(ProductViewsSetup):
|
||||
response = self.client.get(url)
|
||||
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):
|
||||
def test_sub_detail_returns_200(self):
|
||||
@@ -161,4 +173,40 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
|
||||
)
|
||||
response = self.client.get(url)
|
||||
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(),
|
||||
name="main_product_detail",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/versions/",
|
||||
views.MainProductOlderVersionsView.as_view(),
|
||||
name="main_product_versions",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/<slug:sub_slug>/versions/",
|
||||
views.SubProductOlderVersionsView.as_view(),
|
||||
|
||||
+47
-96
@@ -1,22 +1,8 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
from .models import INSTALLABLE_PLATFORM_SPECS, MainProduct, SubProduct
|
||||
|
||||
|
||||
def _featured_version(qs):
|
||||
ordered = qs.order_by("order", "pk")
|
||||
cand = ordered.filter(is_featured_stable=True).first()
|
||||
return cand if cand else ordered.first()
|
||||
|
||||
|
||||
def _install_specs_from_versions(version_list):
|
||||
present = set()
|
||||
for ver in version_list:
|
||||
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
|
||||
if (getattr(ver, field_name) or "").strip():
|
||||
present.add(field_name)
|
||||
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
|
||||
from .models import MainProduct, SubProduct
|
||||
from .release_context import build_archive_context, build_release_context
|
||||
|
||||
|
||||
class ProductOverviewView(ListView):
|
||||
@@ -37,6 +23,39 @@ class MainProductDetailView(DetailView):
|
||||
"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):
|
||||
template_name = "products/sub_detail.html"
|
||||
@@ -56,63 +75,25 @@ class SubProductDetailView(TemplateView):
|
||||
)
|
||||
context["main_product"] = main_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"] = (
|
||||
SubProduct.objects.filter(main_product=main_product, is_active=True)
|
||||
.exclude(pk=sub_product.pk)
|
||||
.order_by("order", "name")
|
||||
)
|
||||
|
||||
active_versions = sub_product.versions.filter(is_active=True)
|
||||
|
||||
context["distribution_installable"] = (
|
||||
sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE
|
||||
release_context = build_release_context(
|
||||
sub_product.distribution, active_versions
|
||||
)
|
||||
context["distribution_package"] = (
|
||||
sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE
|
||||
)
|
||||
context["show_releases_section"] = False
|
||||
context["featured_version"] = None
|
||||
context["featured_install_cells"] = []
|
||||
context["show_older_versions_link"] = False
|
||||
context["package_versions"] = []
|
||||
|
||||
if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE:
|
||||
featured = _featured_version(active_versions)
|
||||
if (
|
||||
featured
|
||||
and not featured.has_any_install_asset()
|
||||
and not (featured.package_resource_url or "").strip()
|
||||
):
|
||||
for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"):
|
||||
if cand.has_any_install_asset() or (cand.package_resource_url or "").strip():
|
||||
featured = cand
|
||||
break
|
||||
context["featured_version"] = featured
|
||||
if featured and (
|
||||
featured.has_any_install_asset()
|
||||
or (featured.package_resource_url or "").strip()
|
||||
):
|
||||
if featured.has_any_install_asset():
|
||||
specs = _install_specs_from_versions([featured])
|
||||
context["featured_install_cells"] = featured.install_urls_for_specs(specs)
|
||||
context["show_releases_section"] = True
|
||||
older_list = list(
|
||||
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
|
||||
if featured else active_versions.order_by("order", "pk")
|
||||
)
|
||||
context["show_older_versions_link"] = len(older_list) > 0
|
||||
|
||||
elif sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE:
|
||||
pkg_versions = list(active_versions.order_by("order", "pk"))
|
||||
context["package_versions"] = pkg_versions
|
||||
context["show_releases_section"] = len(pkg_versions) > 0
|
||||
|
||||
release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
|
||||
context.update(release_context)
|
||||
return context
|
||||
|
||||
|
||||
class SubProductOlderVersionsView(TemplateView):
|
||||
template_name = "products/sub_versions.html"
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
@@ -128,40 +109,10 @@ class SubProductOlderVersionsView(TemplateView):
|
||||
is_active=True,
|
||||
)
|
||||
active_versions = sub_product.versions.filter(is_active=True)
|
||||
featured = _featured_version(active_versions)
|
||||
archive = list(
|
||||
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
|
||||
if featured
|
||||
else active_versions.order_by("order", "pk")
|
||||
)
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = sub_product
|
||||
context["featured_version"] = featured
|
||||
context["archive_versions"] = archive
|
||||
|
||||
if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE:
|
||||
specs = _install_specs_from_versions(archive)
|
||||
context["archive_specs"] = specs
|
||||
archive_rows_installable = []
|
||||
for ver in archive:
|
||||
cells = []
|
||||
for field_name, label, icon in specs:
|
||||
u = getattr(ver, field_name, "") or ""
|
||||
u = u.strip()
|
||||
cells.append(
|
||||
{"field": field_name, "label": label, "icon": icon, "url": u}
|
||||
)
|
||||
archive_rows_installable.append(
|
||||
{"version_obj": ver, "cells": cells}
|
||||
)
|
||||
context["archive_rows_installable"] = archive_rows_installable
|
||||
context["archive_rows_package"] = False
|
||||
context["distribution_installable"] = True
|
||||
context["distribution_package"] = False
|
||||
else:
|
||||
context["archive_specs"] = ()
|
||||
context["archive_rows_installable"] = []
|
||||
context["archive_rows_package"] = True
|
||||
context["distribution_installable"] = False
|
||||
context["distribution_package"] = True
|
||||
context["product_detail_url"] = sub_product.get_absolute_url()
|
||||
context.update(
|
||||
build_archive_context(sub_product.distribution, active_versions)
|
||||
)
|
||||
return context
|
||||
|
||||
Reference in New Issue
Block a user