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")
|
||||
Reference in New Issue
Block a user