feat: dynamic main icon

This commit is contained in:
mohamad
2026-05-25 15:57:24 +03:30
parent 4875d8b6c3
commit f08d0aacc0
38 changed files with 1565 additions and 540 deletions
+92
View File
@@ -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)
+24 -8
View File
@@ -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,
}
+40 -3
View File
@@ -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',
},
),
]
+52
View File
@@ -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),
]
View File
+177
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
+35
View File
@@ -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)
+55
View File
@@ -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")