diff --git a/apps/core/admin.py b/apps/core/admin.py
index 9fcde81..a3c071b 100644
--- a/apps/core/admin.py
+++ b/apps/core/admin.py
@@ -36,6 +36,13 @@ class SiteBrandingAdmin(admin.ModelAdmin):
"description": "Names and text used throughout the public website.",
},
),
+ (
+ "Blog article people section",
+ {
+ "fields": ("article_people_eyebrow", "article_people_title"),
+ "description": "These headings are shared by every blog article that has people cards.",
+ },
+ ),
(
"Brand assets",
{
diff --git a/apps/core/migrations/0006_sitebranding_article_people_eyebrow_and_more.py b/apps/core/migrations/0006_sitebranding_article_people_eyebrow_and_more.py
new file mode 100644
index 0000000..6b30bf0
--- /dev/null
+++ b/apps/core/migrations/0006_sitebranding_article_people_eyebrow_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.16 on 2026-10-12 11:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0005_sitebranding_accent_color_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='sitebranding',
+ name='article_people_eyebrow',
+ field=models.CharField(blank=True, default='The people behind this article', help_text='Small heading above the people cards on every blog article.', max_length=160),
+ ),
+ migrations.AddField(
+ model_name='sitebranding',
+ name='article_people_title',
+ field=models.CharField(default='Organizers & collaborators', help_text='Heading above the people cards on every blog article.', max_length=200),
+ ),
+ ]
diff --git a/apps/core/models.py b/apps/core/models.py
index fe46ece..48c375b 100644
--- a/apps/core/models.py
+++ b/apps/core/models.py
@@ -30,6 +30,17 @@ class SiteBranding(models.Model):
default="Communication to Care — Advanced Medical Imaging and Radiomics Solutions",
blank=True,
)
+ article_people_eyebrow = models.CharField(
+ max_length=160,
+ blank=True,
+ default="The people behind this article",
+ help_text="Small heading above the people cards on every blog article.",
+ )
+ article_people_title = models.CharField(
+ max_length=200,
+ default="Organizers & collaborators",
+ help_text="Heading above the people cards on every blog article.",
+ )
icon = models.ImageField(
upload_to="branding/",
diff --git a/apps/pages/admin.py b/apps/pages/admin.py
index 3ad95d8..7d4c801 100644
--- a/apps/pages/admin.py
+++ b/apps/pages/admin.py
@@ -10,6 +10,7 @@ from .models import (
AboutSection,
AboutSectionItem,
BlogPost,
+ BlogPostPartner,
ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
@@ -27,7 +28,7 @@ class HeroSectionAdmin(admin.ModelAdmin):
fieldsets = (
("Badge", {"fields": ("badge",)}),
("Title", {"fields": ("title", "title_highlight")}),
- ("Text", {"fields": ("subtitle", "description")}),
+ ("Text", {"fields": ("subtitle", "read_more_subtitle", "description", "read_more_description")}),
("Primary Button", {"fields": ("primary_cta_text", "primary_cta_url")}),
("Secondary Button", {"fields": ("secondary_cta_text", "secondary_cta_url")}),
("Image", {"fields": ("image", "image_alt")}),
@@ -51,7 +52,7 @@ class HeroSectionAdmin(admin.ModelAdmin):
class HomepageSectionItemInline(admin.TabularInline):
model = HomepageSectionItem
extra = 1
- fields = ("icon", "title", "content", "url", "image", "order")
+ fields = ("icon", "title", "content", "read_more_content", "url", "image", "order")
ordering = ("order",)
@@ -63,7 +64,7 @@ class HomepageSectionAdmin(admin.ModelAdmin):
inlines = [HomepageSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = (
- (None, {"fields": ("section_type", "badge", "title", "description_format", "description")}),
+ (None, {"fields": ("section_type", "badge", "title", "description_format", "description", "read_more_description")}),
("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
@@ -76,7 +77,7 @@ class HomepageSectionAdmin(admin.ModelAdmin):
class AboutSectionItemInline(admin.TabularInline):
model = AboutSectionItem
extra = 1
- fields = ("icon", "title", "content", "url", "image", "image_alt", "badge", "is_featured", "order")
+ fields = ("icon", "title", "role", "content", "read_more_content", "url", "image", "image_alt", "badge", "is_featured", "order")
ordering = ("order",)
@@ -88,8 +89,8 @@ class AboutSectionAdmin(admin.ModelAdmin):
inlines = [AboutSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = (
- (None, {"fields": ("section_type", "badge", "title", "subtitle")}),
- ("Content", {"fields": ("content_format", "content")}),
+ (None, {"fields": ("section_type", "badge", "title", "subtitle", "read_more_subtitle")}),
+ ("Content", {"fields": ("content_format", "content", "read_more_content")}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
)
@@ -161,6 +162,7 @@ class CustomPageSectionItemInline(admin.TabularInline):
"title",
"content_format",
"content",
+ "read_more_content",
"url",
"image",
"image_alt",
@@ -178,10 +180,13 @@ class CustomPageSectionInline(admin.StackedInline):
"badge",
"title",
"subtitle",
+ "read_more_subtitle",
"description_format",
"description",
+ "read_more_description",
"content_format",
"content",
+ "read_more_content",
"link_text",
"link_url",
"order",
@@ -204,6 +209,7 @@ class CustomPageSectionItemOnPageInline(admin.TabularInline):
"title",
"content_format",
"content",
+ "read_more_content",
"url",
"image",
"order",
@@ -225,8 +231,8 @@ class CustomPageSectionAdmin(admin.ModelAdmin):
inlines = [CustomPageSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = (
- (None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}),
- ("Text", {"fields": ("description_format", "description", "content_format", "content")}),
+ (None, {"fields": ("page", "section_type", "badge", "title", "subtitle", "read_more_subtitle")}),
+ ("Text", {"fields": ("description_format", "description", "read_more_description", "content_format", "content", "read_more_content")}),
("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
@@ -275,6 +281,12 @@ class CustomPageAdmin(admin.ModelAdmin):
admin.site.register(CustomPageSection, CustomPageSectionAdmin)
+class BlogPostPartnerInline(admin.TabularInline):
+ model = BlogPostPartner
+ extra = 1
+ fields = ("role", "name", "description", "read_more_description", "image", "image_alt", "url", "order")
+
+
@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
list_display = ("title", "status", "published_at", "updated_at")
@@ -282,9 +294,10 @@ class BlogPostAdmin(admin.ModelAdmin):
search_fields = ("title", "excerpt", "content", "author_name")
prepopulated_fields = {"slug": ("title",)}
date_hierarchy = "published_at"
+ inlines = [BlogPostPartnerInline]
fieldsets = (
- (None, {"fields": ("title", "slug", "excerpt")}),
- ("Article", {"fields": ("content_format", "content")}),
+ (None, {"fields": ("title", "slug", "excerpt", "read_more_excerpt")}),
+ ("Article", {"fields": ("content_format", "content", "read_more_content")}),
("Cover image", {"fields": ("cover_image", "cover_image_alt")}),
("Publishing", {"fields": ("status", "published_at", "author_name")}),
)
@@ -298,7 +311,7 @@ class PageVideoAdmin(admin.ModelAdmin):
search_fields = ("title", "description", "video_url")
readonly_fields = ("video_preview",)
fieldsets = (
- (None, {"fields": ("page", "badge", "title", "description_format", "description")}),
+ (None, {"fields": ("page", "badge", "title", "description_format", "description", "read_more_description")}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
)
diff --git a/apps/pages/migrations/0020_blogpostpartner.py b/apps/pages/migrations/0020_blogpostpartner.py
new file mode 100644
index 0000000..026ad59
--- /dev/null
+++ b/apps/pages/migrations/0020_blogpostpartner.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.16 on 2026-10-12 04:39
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pages', '0019_blogpost_alter_aboutsection_video_styled_background_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BlogPostPartner',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('role', models.CharField(choices=[('organizer', 'Organizer'), ('collaborator', 'Collaborator')], default='collaborator', max_length=20)),
+ ('name', models.CharField(max_length=200)),
+ ('description', models.TextField(blank=True)),
+ ('image', models.ImageField(blank=True, null=True, upload_to='blog/partners/')),
+ ('image_alt', models.CharField(blank=True, max_length=240)),
+ ('url', models.URLField(blank=True)),
+ ('order', models.PositiveIntegerField(default=0)),
+ ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='partners', to='pages.blogpost')),
+ ],
+ options={
+ 'verbose_name': 'Article organizer or collaborator',
+ 'verbose_name_plural': 'Article organizers and collaborators',
+ 'ordering': ['role', 'order', 'pk'],
+ },
+ ),
+ ]
diff --git a/apps/pages/migrations/0021_alter_blogpostpartner_options.py b/apps/pages/migrations/0021_alter_blogpostpartner_options.py
new file mode 100644
index 0000000..62c2795
--- /dev/null
+++ b/apps/pages/migrations/0021_alter_blogpostpartner_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.16 on 2026-10-12 04:42
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pages', '0020_blogpostpartner'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='blogpostpartner',
+ options={'ordering': ['order', 'pk'], 'verbose_name': 'Article organizer or collaborator', 'verbose_name_plural': 'Article organizers and collaborators'},
+ ),
+ ]
diff --git a/apps/pages/migrations/0022_aboutsection_read_more_content_and_more.py b/apps/pages/migrations/0022_aboutsection_read_more_content_and_more.py
new file mode 100644
index 0000000..fb4fb9f
--- /dev/null
+++ b/apps/pages/migrations/0022_aboutsection_read_more_content_and_more.py
@@ -0,0 +1,53 @@
+# Generated by Django 5.2.16 on 2026-10-12 04:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pages', '0021_alter_blogpostpartner_options'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='aboutsection',
+ name='read_more_content',
+ field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'),
+ ),
+ migrations.AddField(
+ model_name='aboutsectionitem',
+ name='read_more_content',
+ field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'),
+ ),
+ migrations.AddField(
+ model_name='blogpost',
+ name='read_more_content',
+ field=models.BooleanField(default=False, help_text='Collapse article text with a Read more button.'),
+ ),
+ migrations.AddField(
+ model_name='custompagesection',
+ name='read_more_content',
+ field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'),
+ ),
+ migrations.AddField(
+ model_name='custompagesection',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text='Collapse this description with a Read more button.'),
+ ),
+ migrations.AddField(
+ model_name='custompagesectionitem',
+ name='read_more_content',
+ field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'),
+ ),
+ migrations.AddField(
+ model_name='homepagesection',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text='Collapse this description with a Read more button.'),
+ ),
+ migrations.AddField(
+ model_name='homepagesectionitem',
+ name='read_more_content',
+ field=models.BooleanField(default=False, help_text='Collapse this text with a Read more button.'),
+ ),
+ ]
diff --git a/apps/pages/migrations/0023_alter_blogpostpartner_options_and_more.py b/apps/pages/migrations/0023_alter_blogpostpartner_options_and_more.py
new file mode 100644
index 0000000..7cd0b08
--- /dev/null
+++ b/apps/pages/migrations/0023_alter_blogpostpartner_options_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2.16 on 2026-10-12 11:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pages', '0022_aboutsection_read_more_content_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='blogpostpartner',
+ options={'ordering': ['-role', 'order', 'pk'], 'verbose_name': 'Article organizer or collaborator', 'verbose_name_plural': 'Article organizers and collaborators'},
+ ),
+ migrations.AlterField(
+ model_name='blogpostpartner',
+ name='order',
+ field=models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first within each role. Organizers always appear before collaborators.', verbose_name='Display order'),
+ ),
+ ]
diff --git a/apps/pages/migrations/0024_aboutsection_read_more_subtitle_and_more.py b/apps/pages/migrations/0024_aboutsection_read_more_subtitle_and_more.py
new file mode 100644
index 0000000..557a146
--- /dev/null
+++ b/apps/pages/migrations/0024_aboutsection_read_more_subtitle_and_more.py
@@ -0,0 +1,58 @@
+# Generated by Django 5.2.16 on 2026-10-12 11:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pages', '0023_alter_blogpostpartner_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='aboutsection',
+ name='read_more_subtitle',
+ field=models.BooleanField(default=False, help_text='Collapse a long hero subtitle with Read more.'),
+ ),
+ migrations.AddField(
+ model_name='blogpost',
+ name='partners_eyebrow',
+ field=models.CharField(blank=True, default='The people behind this article', max_length=160),
+ ),
+ migrations.AddField(
+ model_name='blogpost',
+ name='partners_title',
+ field=models.CharField(default='Organizers & collaborators', max_length=200),
+ ),
+ migrations.AddField(
+ model_name='blogpost',
+ name='read_more_excerpt',
+ field=models.BooleanField(default=False, help_text='Collapse a long excerpt with Read more on the listing and article page.'),
+ ),
+ migrations.AddField(
+ model_name='blogpostpartner',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text="Collapse this person's description with Read more when it is long."),
+ ),
+ migrations.AddField(
+ model_name='custompagesection',
+ name='read_more_subtitle',
+ field=models.BooleanField(default=False, help_text='Collapse a long hero subtitle with Read more.'),
+ ),
+ migrations.AddField(
+ model_name='herosection',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text='Collapse this description with Read more when it is long.'),
+ ),
+ migrations.AddField(
+ model_name='herosection',
+ name='read_more_subtitle',
+ field=models.BooleanField(default=False, help_text='Collapse the subtitle with Read more when it is long.'),
+ ),
+ migrations.AddField(
+ model_name='pagevideo',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text='Collapse this video description with Read more.'),
+ ),
+ ]
diff --git a/apps/pages/migrations/0025_alter_blogpostpartner_options_and_more.py b/apps/pages/migrations/0025_alter_blogpostpartner_options_and_more.py
new file mode 100644
index 0000000..a4733c1
--- /dev/null
+++ b/apps/pages/migrations/0025_alter_blogpostpartner_options_and_more.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.2.16 on 2026-10-12 11:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pages', '0024_aboutsection_read_more_subtitle_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='blogpostpartner',
+ options={'ordering': [models.Case(models.When(role__iexact='organizer', then=models.Value(0)), models.When(role__iexact='collaborator', then=models.Value(1)), default=models.Value(2), output_field=models.IntegerField()), 'order', 'pk'], 'verbose_name': 'Article organizer or collaborator', 'verbose_name_plural': 'Article organizers and collaborators'},
+ ),
+ migrations.RemoveField(
+ model_name='blogpost',
+ name='partners_eyebrow',
+ ),
+ migrations.RemoveField(
+ model_name='blogpost',
+ name='partners_title',
+ ),
+ migrations.AlterField(
+ model_name='blogpostpartner',
+ name='order',
+ field=models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first. Organizers appear before collaborators, then custom roles.', verbose_name='Display order'),
+ ),
+ migrations.AlterField(
+ model_name='blogpostpartner',
+ name='role',
+ field=models.CharField(default='Collaborator', help_text='Type Organizer, Collaborator, or any other role name you want to display.', max_length=100, verbose_name='Role / group'),
+ ),
+ ]
diff --git a/apps/pages/migrations/0026_alter_blogpostpartner_options.py b/apps/pages/migrations/0026_alter_blogpostpartner_options.py
new file mode 100644
index 0000000..85ace41
--- /dev/null
+++ b/apps/pages/migrations/0026_alter_blogpostpartner_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.16 on 2026-10-12 11:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pages', '0025_alter_blogpostpartner_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='blogpostpartner',
+ options={'ordering': [models.Case(models.When(role__iexact='organizer', then=models.Value(0)), models.When(role__iexact='collaborator', then=models.Value(1)), default=models.Value(2), output_field=models.IntegerField()), 'order', 'pk'], 'verbose_name': 'Article person', 'verbose_name_plural': 'Article people'},
+ ),
+ ]
diff --git a/apps/pages/migrations/0027_aboutsectionitem_role_and_more.py b/apps/pages/migrations/0027_aboutsectionitem_role_and_more.py
new file mode 100644
index 0000000..1663f1b
--- /dev/null
+++ b/apps/pages/migrations/0027_aboutsectionitem_role_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.16 on 2026-10-14 15:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pages', '0026_alter_blogpostpartner_options'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='aboutsectionitem',
+ name='role',
+ field=models.CharField(blank=True, help_text='For People sections: Organizer, Collaborator, or any custom role name.', max_length=100, verbose_name='Role / group'),
+ ),
+ migrations.AlterField(
+ model_name='aboutsection',
+ name='section_type',
+ field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters'), ('people', 'People / Organizers & Collaborators'), ('video', 'Video')], default='custom', max_length=30),
+ ),
+ ]
diff --git a/apps/pages/models.py b/apps/pages/models.py
index 7417c8d..e80a40a 100644
--- a/apps/pages/models.py
+++ b/apps/pages/models.py
@@ -30,7 +30,9 @@ class HeroSection(models.Model):
title = models.CharField(max_length=300, blank=True, help_text="Main title line (e.g. 'Communication to Care,').")
title_highlight = models.CharField(max_length=300, blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').")
subtitle = models.CharField(max_length=500, blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').")
+ read_more_subtitle = models.BooleanField(default=False, help_text="Collapse the subtitle with Read more when it is long.")
description = models.TextField(blank=True, help_text="Longer paragraph below the subtitle.")
+ read_more_description = models.BooleanField(default=False, help_text="Collapse this description with Read more when it is long.")
primary_cta_text = models.CharField(max_length=100, blank=True, help_text="Primary button label.")
primary_cta_url = models.CharField(max_length=300, blank=True, help_text="Primary button URL (relative or absolute).")
secondary_cta_text = models.CharField(max_length=100, blank=True, help_text="Secondary (ghost) button label.")
@@ -71,6 +73,7 @@ class HomepageSection(VideoBlockMixin, models.Model):
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
description = models.TextField(blank=True)
+ read_more_description = models.BooleanField(default=False, help_text="Collapse this description with a Read more button.")
link_text = models.CharField(max_length=100, blank=True, help_text="CTA button label (About Strip).")
link_url = models.CharField(max_length=300, blank=True, help_text="CTA button URL (About Strip).")
order = models.PositiveIntegerField(default=0)
@@ -95,6 +98,7 @@ class HomepageSectionItem(models.Model):
icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).")
title = models.CharField(max_length=300, blank=True)
content = models.TextField(blank=True)
+ read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.")
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
image = models.ImageField(upload_to="homepage/items/", blank=True, null=True, help_text="Logo or image (used for Supporters cards).")
order = models.PositiveIntegerField(default=0)
@@ -115,6 +119,7 @@ class AboutSection(VideoBlockMixin, models.Model):
TYPE_HISTORY = "history"
TYPE_CUSTOM = "custom"
TYPE_SUPPORTERS = "supporters"
+ TYPE_PEOPLE = "people"
TYPE_VIDEO = "video"
TYPE_CHOICES = [
@@ -124,6 +129,7 @@ class AboutSection(VideoBlockMixin, models.Model):
(TYPE_HISTORY, "History Block"),
(TYPE_CUSTOM, "Custom Content"),
(TYPE_SUPPORTERS, "Supporters"),
+ (TYPE_PEOPLE, "People / Organizers & Collaborators"),
(TYPE_VIDEO, "Video"),
]
@@ -131,7 +137,9 @@ class AboutSection(VideoBlockMixin, models.Model):
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
subtitle = models.CharField(max_length=500, blank=True, help_text="Used as subtitle in Hero and year in History.")
+ read_more_subtitle = models.BooleanField(default=False, help_text="Collapse a long hero subtitle with Read more.")
content = models.TextField(blank=True)
+ read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.")
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
@@ -147,6 +155,15 @@ class AboutSection(VideoBlockMixin, models.Model):
def rendered_content(self):
return render_content(self.content, self.content_format)
+ @property
+ def ordered_people_items(self):
+ def sort_key(item):
+ role = item.role.strip().casefold()
+ priority = 0 if role == "organizer" else 1 if role == "collaborator" else 2
+ return priority, item.order, item.pk or 0
+
+ return sorted(self.items.all(), key=sort_key)
+
def clean(self):
super().clean()
if self.section_type == self.TYPE_VIDEO:
@@ -162,7 +179,14 @@ class AboutSectionItem(models.Model):
icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).")
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
+ role = models.CharField(
+ "Role / group",
+ max_length=100,
+ blank=True,
+ help_text="For People sections: Organizer, Collaborator, or any custom role name.",
+ )
content = models.TextField(blank=True, help_text="Description text or link label for History links.")
+ read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.")
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
image = models.ImageField(
upload_to="about/items/",
@@ -182,6 +206,16 @@ class AboutSectionItem(models.Model):
def __str__(self):
return f"{self.section} › {self.title or self.icon or self.badge or '(item)'}"
+ @property
+ def name(self):
+ return self.title
+
+ @property
+ def role_label(self):
+ if self.role.casefold() in {"organizer", "collaborator"}:
+ return self.role.title()
+ return self.role
+
class ContactSubmission(models.Model):
name = models.CharField(max_length=200)
@@ -329,7 +363,9 @@ class BlogPost(models.Model):
blank=True,
help_text="Short summary shown on the blog listing and in search previews.",
)
+ read_more_excerpt = models.BooleanField(default=False, help_text="Collapse a long excerpt with Read more on the listing and article page.")
content = models.TextField()
+ read_more_content = models.BooleanField(default=False, help_text="Collapse article text with a Read more button.")
content_format = models.CharField(
max_length=20,
choices=CONTENT_FORMAT_CHOICES,
@@ -370,6 +406,53 @@ class BlogPost(models.Model):
return reverse("pages:blog_detail", kwargs={"slug": self.slug})
+class BlogPostPartner(models.Model):
+ ORGANIZER = "organizer"
+ COLLABORATOR = "collaborator"
+
+ post = models.ForeignKey(BlogPost, on_delete=models.CASCADE, related_name="partners")
+ role = models.CharField(
+ "Role / group",
+ max_length=100,
+ default="Collaborator",
+ help_text="Type Organizer, Collaborator, or any other role name you want to display.",
+ )
+ name = models.CharField(max_length=200)
+ description = models.TextField(blank=True)
+ read_more_description = models.BooleanField(default=False, help_text="Collapse this person's description with Read more when it is long.")
+ image = models.ImageField(upload_to="blog/partners/", blank=True, null=True)
+ image_alt = models.CharField(max_length=240, blank=True)
+ url = models.URLField(blank=True)
+ order = models.PositiveIntegerField(
+ "Display order",
+ default=0,
+ help_text="Smaller numbers appear first. Organizers appear before collaborators, then custom roles.",
+ )
+
+ class Meta:
+ ordering = [
+ models.Case(
+ models.When(role__iexact="organizer", then=models.Value(0)),
+ models.When(role__iexact="collaborator", then=models.Value(1)),
+ default=models.Value(2),
+ output_field=models.IntegerField(),
+ ),
+ "order",
+ "pk",
+ ]
+ verbose_name = "Article person"
+ verbose_name_plural = "Article people"
+
+ def __str__(self):
+ return f"{self.name} ({self.role_label})"
+
+ @property
+ def role_label(self):
+ if self.role.casefold() in {self.ORGANIZER, self.COLLABORATOR}:
+ return self.role.title()
+ return self.role
+
+
class CustomPageSection(VideoBlockMixin, models.Model):
TYPE_HERO = "hero"
TYPE_INTRO = "intro"
@@ -406,8 +489,11 @@ class CustomPageSection(VideoBlockMixin, models.Model):
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
subtitle = models.CharField(max_length=500, blank=True)
+ read_more_subtitle = models.BooleanField(default=False, help_text="Collapse a long hero subtitle with Read more.")
description = models.TextField(blank=True, help_text="Short intro text (homepage-style sections).")
+ read_more_description = models.BooleanField(default=False, help_text="Collapse this description with a Read more button.")
content = models.TextField(blank=True)
+ read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.")
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN)
link_text = models.CharField(max_length=100, blank=True)
link_url = models.CharField(max_length=300, blank=True)
@@ -444,6 +530,7 @@ class PageVideo(VideoBlockMixin, models.Model):
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
description = models.TextField(blank=True)
+ read_more_description = models.BooleanField(default=False, help_text="Collapse this video description with Read more.")
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
@@ -472,6 +559,7 @@ class CustomPageSectionItem(models.Model):
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
content = models.TextField(blank=True)
+ read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.")
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN)
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
image = models.ImageField(upload_to="pages/custom/", blank=True, null=True)
diff --git a/apps/pages/tests/test_views.py b/apps/pages/tests/test_views.py
index cf2b531..6ba2f8b 100644
--- a/apps/pages/tests/test_views.py
+++ b/apps/pages/tests/test_views.py
@@ -2,7 +2,8 @@ from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
-from apps.pages.models import BlogPost, HomepageSection
+from apps.core.models import SiteBranding
+from apps.pages.models import AboutSection, AboutSectionItem, BlogPost, BlogPostPartner, HomepageSection
class HomeViewTest(TestCase):
@@ -34,6 +35,28 @@ class AboutViewTest(TestCase):
response = self.client.get(reverse("pages:about"))
self.assertTemplateUsed(response, "pages/about.html")
+ def test_people_section_orders_roles_and_controls_read_more_per_text(self):
+ section = AboutSection.objects.create(
+ section_type=AboutSection.TYPE_PEOPLE,
+ badge="Our team", title="People behind the work",
+ content="Section introduction", read_more_content=True,
+ )
+ AboutSectionItem.objects.create(section=section, title="Advisor", role="Scientific advisor", content="Advisor profile", order=1)
+ AboutSectionItem.objects.create(section=section, title="Collaborator", role="Collaborator", content="Collaboration profile", read_more_content=True, order=3)
+ AboutSectionItem.objects.create(section=section, title="Organizer", role="Organizer", content="Organizer profile", order=9)
+ html = self.client.get(reverse("pages:about")).content.decode()
+ self.assertIn("People behind the work", html)
+ self.assertIn("Scientific advisor", html)
+ self.assertLess(html.index(">Organizer"), html.index(">Collaborator"))
+ self.assertLess(html.index(">Collaborator"), html.index(">Advisor"))
+ self.assertIn('data-readmore="200"', html)
+ self.assertEqual(html.count('data-readmore="88"'), 1)
+
+ def test_empty_people_section_is_hidden(self):
+ AboutSection.objects.create(section_type=AboutSection.TYPE_PEOPLE, title="Nobody yet")
+ response = self.client.get(reverse("pages:about"))
+ self.assertNotContains(response, "Nobody yet")
+
class BlogViewTest(TestCase):
def setUp(self):
@@ -53,6 +76,8 @@ class BlogViewTest(TestCase):
self.assertContains(listing, self.post.title)
self.assertContains(detail, "
Results
")
self.assertContains(detail, "Promising")
+ self.assertNotContains(detail, "blog-partners")
+ self.assertNotContains(detail, "blog-detail-layout--with-partners")
def test_draft_post_is_not_public(self):
self.post.status = BlogPost.STATUS_DRAFT
@@ -60,6 +85,76 @@ class BlogViewTest(TestCase):
self.assertNotContains(self.client.get(reverse("pages:blog_list")), self.post.title)
self.assertEqual(self.client.get(self.post.get_absolute_url()).status_code, 404)
+ def test_article_partners_render_with_roles_and_optional_details(self):
+ BlogPostPartner.objects.create(
+ post=self.post, role=BlogPostPartner.ORGANIZER, name="Research Group",
+ description="Project lead", image="blog/partners/group.png",
+ image_alt="Research Group logo", url="https://example.org",
+ )
+ BlogPostPartner.objects.create(
+ post=self.post, role=BlogPostPartner.COLLABORATOR, name="Care Team"
+ )
+ detail = self.client.get(self.post.get_absolute_url())
+ self.assertContains(detail, "Research Group")
+ self.assertContains(detail, "Collaborator")
+ self.assertContains(detail, "Research Group logo")
+ self.assertContains(detail, "https://example.org")
+ self.assertContains(detail, "Care Team")
+
+ def test_blog_read_more_is_opt_in(self):
+ detail = self.client.get(self.post.get_absolute_url())
+ self.assertNotContains(detail, 'data-readmore="600"')
+ self.post.read_more_content = True
+ self.post.save(update_fields=["read_more_content"])
+ detail = self.client.get(self.post.get_absolute_url())
+ self.assertContains(detail, 'data-readmore="600"')
+
+ def test_partners_follow_article_and_sort_by_role_then_number(self):
+ BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.COLLABORATOR, name="Second collaborator", order=2)
+ BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.COLLABORATOR, name="First collaborator", order=1)
+ BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.ORGANIZER, name="Organizer", order=9)
+ html = self.client.get(self.post.get_absolute_url()).content.decode()
+ self.assertLess(html.index('class="blog-article rich-content'), html.index('class="blog-partners"'))
+ self.assertLess(html.index('>Organizer'), html.index('>First collaborator'))
+ self.assertLess(html.index('>First collaborator'), html.index('>Second collaborator'))
+
+ def test_partner_headings_are_shared_and_description_read_more_is_independent(self):
+ branding = SiteBranding.load()
+ branding.article_people_eyebrow = "Our team"
+ branding.article_people_title = "Project contributors"
+ branding.save(update_fields=["article_people_eyebrow", "article_people_title"])
+ partner = BlogPostPartner.objects.create(
+ post=self.post, role=BlogPostPartner.ORGANIZER,
+ name="Coordinator", description="A detailed profile", read_more_description=True,
+ )
+ html = self.client.get(self.post.get_absolute_url()).content.decode()
+ self.assertIn("Our team", html)
+ self.assertIn("Project contributors", html)
+ self.assertNotIn("The people behind this article", html)
+ self.assertIn('data-readmore="88"', html)
+ partner.read_more_description = False
+ partner.save(update_fields=["read_more_description"])
+ html = self.client.get(self.post.get_absolute_url()).content.decode()
+ self.assertNotIn('data-readmore="88"', html)
+
+ other_post = BlogPost.objects.create(
+ title="Another update", slug="another-update", content="Another article",
+ status=BlogPost.STATUS_PUBLISHED, published_at=timezone.now(),
+ )
+ BlogPostPartner.objects.create(post=other_post, name="Contributor")
+ other_html = self.client.get(other_post.get_absolute_url()).content.decode()
+ self.assertIn("Our team", other_html)
+ self.assertIn("Project contributors", other_html)
+
+ def test_custom_role_can_be_named_by_admin(self):
+ BlogPostPartner.objects.create(post=self.post, role="Scientific advisor", name="Advisor", order=0)
+ BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.COLLABORATOR, name="Partner", order=5)
+ BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.ORGANIZER, name="Lead", order=9)
+ html = self.client.get(self.post.get_absolute_url()).content.decode()
+ self.assertIn("Scientific advisor", html)
+ self.assertLess(html.index(">Lead"), html.index(">Partner"))
+ self.assertLess(html.index(">Partner"), html.index(">Advisor"))
+
class ContactViewTest(TestCase):
def test_contact_returns_200(self):
diff --git a/apps/pages/views.py b/apps/pages/views.py
index bd56ea3..7d2837c 100644
--- a/apps/pages/views.py
+++ b/apps/pages/views.py
@@ -184,7 +184,7 @@ class BlogPostListView(ListView):
return BlogPost.objects.filter(
status=BlogPost.STATUS_PUBLISHED,
published_at__lte=timezone.now(),
- )
+ ).prefetch_related("partners")
class BlogPostDetailView(DetailView):
@@ -196,7 +196,7 @@ class BlogPostDetailView(DetailView):
return BlogPost.objects.filter(
status=BlogPost.STATUS_PUBLISHED,
published_at__lte=timezone.now(),
- )
+ ).prefetch_related("partners")
class PageVideoArchiveView(ListView):
diff --git a/apps/products/admin.py b/apps/products/admin.py
index 7b65bba..9760233 100644
--- a/apps/products/admin.py
+++ b/apps/products/admin.py
@@ -9,14 +9,14 @@ from .models import Article, ArticleCitation, ArticleSection, MainProduct, Produ
class ArticleCitationInline(admin.TabularInline):
model = ArticleCitation
extra = 1
- fields = ("text", "url", "order")
+ fields = ("text", "read_more_text", "url", "order")
ordering = ("order",)
class ArticleSectionInline(admin.TabularInline):
model = ArticleSection
extra = 1
- fields = ("title", "value_format", "value", "order")
+ fields = ("title", "value_format", "value", "read_more_value", "order")
ordering = ("order",)
@@ -24,7 +24,7 @@ class SubProductArticleInline(admin.StackedInline):
model = Article
fk_name = "sub_product"
extra = 0
- fields = ("badge", "title", "description", "order")
+ fields = ("badge", "title", "description", "read_more_description", "order")
ordering = ("order",)
show_change_link = True
@@ -33,7 +33,7 @@ class MainProductArticleInline(admin.StackedInline):
model = Article
fk_name = "main_product"
extra = 0
- fields = ("badge", "title", "description", "order")
+ fields = ("badge", "title", "description", "read_more_description", "order")
ordering = ("order",)
show_change_link = True
@@ -47,6 +47,7 @@ class ProductVideoInline(admin.StackedInline):
"title",
"description_format",
"description",
+ "read_more_description",
"video_source",
"video_url",
"video_file",
@@ -63,7 +64,7 @@ class ProductVideoInline(admin.StackedInline):
class SubProductInline(admin.StackedInline):
model = SubProduct
extra = 0
- fields = ("name", "slug", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
+ fields = ("name", "slug", "short_description", "read_more_short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
ordering = ("order",)
show_change_link = True
prepopulated_fields = {"slug": ("name",)}
@@ -85,10 +86,11 @@ class MainProductAdmin(admin.ModelAdmin):
"name",
"slug",
"short_description",
+ "read_more_short_description",
)
},
),
- ("Description", {"fields": ("description_format", "description")}),
+ ("Description", {"fields": ("description_format", "description", "read_more_description")}),
("Media", {"fields": ("image",)}),
("Homepage", {"fields": ("show_on_homepage", "homepage_order")}),
("Settings", {"fields": ("order", "is_active")}),
@@ -104,6 +106,7 @@ class SubProductVideoInline(admin.StackedInline):
"title",
"description_format",
"description",
+ "read_more_description",
"video_source",
"video_url",
"video_file",
@@ -143,10 +146,11 @@ class SubProductAdmin(admin.ModelAdmin):
"name",
"slug",
"short_description",
+ "read_more_short_description",
)
},
),
- ("Description", {"fields": ("description_format", "description")}),
+ ("Description", {"fields": ("description_format", "description", "read_more_description")}),
("Media", {"fields": ("image", "logo")}),
("Homepage", {"fields": ("show_on_homepage", "homepage_order")}),
("Settings", {"fields": ("order", "is_active")}),
@@ -163,7 +167,7 @@ class ArticleAdmin(admin.ModelAdmin):
inlines = [ArticleSectionInline, ArticleCitationInline]
fieldsets = (
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
- ("Description", {"fields": ("description_format", "description")}),
+ ("Description", {"fields": ("description_format", "description", "read_more_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",)}),
)
@@ -186,7 +190,7 @@ class ProductVideoAdmin(admin.ModelAdmin):
raw_id_fields = ("main_product", "sub_product")
readonly_fields = ("video_preview",)
fieldsets = (
- (None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description")}),
+ (None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description", "read_more_description")}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
)
@@ -212,6 +216,6 @@ class ArticleSectionAdmin(admin.ModelAdmin):
raw_id_fields = ("article",)
fieldsets = (
(None, {"fields": ("article", "title")}),
- ("Content", {"fields": ("value_format", "value")}),
+ ("Content", {"fields": ("value_format", "value", "read_more_value")}),
("Settings", {"fields": ("order",)}),
)
diff --git a/apps/products/migrations/0023_article_read_more_description_and_more.py b/apps/products/migrations/0023_article_read_more_description_and_more.py
new file mode 100644
index 0000000..658a6b3
--- /dev/null
+++ b/apps/products/migrations/0023_article_read_more_description_and_more.py
@@ -0,0 +1,43 @@
+# Generated by Django 5.2.16 on 2026-10-12 04:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0022_alter_mainproduct_options_alter_productvideo_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='article',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text='Collapse this article text with Read more.'),
+ ),
+ migrations.AddField(
+ model_name='articlesection',
+ name='read_more_value',
+ field=models.BooleanField(default=False, help_text='Collapse this section text with Read more.'),
+ ),
+ migrations.AddField(
+ model_name='mainproduct',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text='Collapse the full description with Read more.'),
+ ),
+ migrations.AddField(
+ model_name='mainproduct',
+ name='read_more_short_description',
+ field=models.BooleanField(default=False, help_text='Collapse the short description with Read more.'),
+ ),
+ migrations.AddField(
+ model_name='subproduct',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text='Collapse the full description with Read more.'),
+ ),
+ migrations.AddField(
+ model_name='subproduct',
+ name='read_more_short_description',
+ field=models.BooleanField(default=False, help_text='Collapse the short description with Read more.'),
+ ),
+ ]
diff --git a/apps/products/migrations/0024_articlecitation_read_more_text_and_more.py b/apps/products/migrations/0024_articlecitation_read_more_text_and_more.py
new file mode 100644
index 0000000..38da46d
--- /dev/null
+++ b/apps/products/migrations/0024_articlecitation_read_more_text_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.16 on 2026-10-12 11:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0023_article_read_more_description_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='articlecitation',
+ name='read_more_text',
+ field=models.BooleanField(default=False, help_text='Collapse a long citation with Read more.'),
+ ),
+ migrations.AddField(
+ model_name='productvideo',
+ name='read_more_description',
+ field=models.BooleanField(default=False, help_text='Collapse this video description with Read more.'),
+ ),
+ ]
diff --git a/apps/products/models.py b/apps/products/models.py
index 82b7013..53e426b 100644
--- a/apps/products/models.py
+++ b/apps/products/models.py
@@ -63,7 +63,9 @@ class MainProduct(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
short_description = models.CharField(max_length=300)
+ read_more_short_description = models.BooleanField(default=False, help_text="Collapse the short description with Read more.")
description = models.TextField()
+ read_more_description = models.BooleanField(default=False, help_text="Collapse the full description with Read more.")
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
@@ -104,7 +106,9 @@ class SubProduct(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
short_description = models.CharField(max_length=300)
+ read_more_short_description = models.BooleanField(default=False, help_text="Collapse the short description with Read more.")
description = models.TextField()
+ read_more_description = models.BooleanField(default=False, help_text="Collapse the full description with Read more.")
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
@@ -166,6 +170,7 @@ class Article(models.Model):
)
title = models.CharField(max_length=300)
description = models.TextField()
+ read_more_description = models.BooleanField(default=False, help_text="Collapse this article text with Read more.")
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
@@ -433,6 +438,7 @@ class ArticleSection(models.Model):
)
title = models.CharField(max_length=200)
value = models.TextField()
+ read_more_value = models.BooleanField(default=False, help_text="Collapse this section text with Read more.")
value_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
@@ -458,6 +464,7 @@ class ArticleCitation(models.Model):
related_name="citations",
)
text = models.TextField(help_text="Full citation text.")
+ read_more_text = models.BooleanField(default=False, help_text="Collapse a long citation with Read more.")
url = models.URLField(blank=True, help_text="Optional link to the cited source.")
order = models.PositiveIntegerField(default=0)
@@ -488,6 +495,7 @@ class ProductVideo(VideoBlockMixin, models.Model):
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
description = models.TextField(blank=True)
+ read_more_description = models.BooleanField(default=False, help_text="Collapse this video description with Read more.")
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py
index c6ab259..d1d1097 100644
--- a/apps/products/tests/test_views.py
+++ b/apps/products/tests/test_views.py
@@ -56,6 +56,21 @@ class ProductOverviewViewTest(ProductViewsSetup):
class MainProductDetailViewTest(ProductViewsSetup):
+ def test_read_more_can_be_set_per_article_and_section(self):
+ url = self.main_product.get_absolute_url()
+ article = Article.objects.create(main_product=self.main_product, title="Notes", description="Long notes")
+ section = ArticleSection.objects.create(article=article, title="Detail", value="Long detail")
+ response = self.client.get(url)
+ self.assertNotContains(response, 'data-readmore="260"')
+ self.assertNotContains(response, 'data-readmore="160"')
+ article.read_more_description = True
+ article.save(update_fields=["read_more_description"])
+ section.read_more_value = True
+ section.save(update_fields=["read_more_value"])
+ response = self.client.get(url)
+ self.assertContains(response, 'data-readmore="260"')
+ self.assertContains(response, 'data-readmore="160"')
+
def test_detail_returns_200(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
response = self.client.get(url)
diff --git a/static/css/main.css b/static/css/main.css
index 0a7431b..b3a070d 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -112,9 +112,27 @@ body::before {
.blog-card-title a { color: var(--text-primary); }
.blog-card-excerpt { color: var(--text-secondary); margin-bottom: var(--spacing-sm); }
.blog-detail-header { max-width: 900px; }
-.blog-article-wrap { max-width: 900px; }
-.blog-cover { width: 100%; max-height: 520px; object-fit: cover; border-radius: var(--radius-lg); margin-bottom: var(--spacing-md); }
-.blog-article { padding: clamp(1.5rem, 4vw, 3rem); }
+.blog-article-wrap { max-width: 1200px; }
+.blog-cover { width: 100%; max-width: 900px; max-height: 520px; object-fit: cover; border-radius: var(--radius-lg); margin: 0 auto var(--spacing-md); }
+.blog-article { max-width: 900px; margin-inline: auto; padding: clamp(1.5rem, 4vw, 3rem); }
+.blog-partners { min-width: 0; margin-top: clamp(2.5rem, 5vw, 4rem); }
+.blog-partners--about { margin-top: 0; }
+.blog-partners-heading { padding: .3rem .15rem 1.25rem; }
+.blog-partners-eyebrow { display: block; color: var(--accent-blue-light); font-size: .65rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
+.blog-partners-heading h2 { margin-top: .45rem; font-size: clamp(1.2rem, 2vw, 1.5rem); line-height: 1.35; }
+.blog-partners-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1rem; }
+.blog-partner-card { display: flex; flex-direction: column; gap: .85rem; padding: .8rem; min-width: 0; }
+.blog-partner-image { width: 100%; aspect-ratio: 1 / 1; border-radius: 12px; background: rgba(255,255,255,.08); overflow: hidden; display: grid; place-items: center; }
+.blog-partner-image img { width: 100%; height: 100%; object-fit: cover; object-position: center 25%; }
+.blog-partner-info { min-width: 0; }
+.blog-partner-role { display: block; color: var(--accent-blue-light); font-size: .62rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; margin-bottom: .35rem; }
+.blog-partner-info h3 { font-size: .9rem; line-height: 1.35; margin: 0 0 .4rem; }
+.blog-partner-info h3 a { color: inherit; }
+.blog-partner-info h3 a:hover { color: var(--accent-blue-light); }
+.blog-partner-info p { color: var(--text-secondary); font-size: .76rem; line-height: 1.55; margin: 0; }
+@media (max-width: 950px) { .blog-partners-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+@media (max-width: 620px) { .blog-partners-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 380px) { .blog-partners-grid { grid-template-columns: 1fr; } }
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--spacing-sm); margin-top: var(--spacing-xl); color: var(--text-secondary); }
#main-content {
@@ -1438,6 +1456,7 @@ a.citation-count-badge:hover {
.citation-text {
flex: 1;
}
+.citation-text.readmore-wrap { display: block; min-width: 0; }
.citation-actions {
flex-shrink: 0;
diff --git a/static/js/main.js b/static/js/main.js
index edbfcfe..86eb3fc 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -237,7 +237,9 @@ function initReadMore() {
btn.innerHTML = 'Read more ' + chevronSVG;
el.insertAdjacentElement('afterend', btn);
- btn.addEventListener('click', function () {
+ btn.addEventListener('click', function (event) {
+ event.preventDefault();
+ event.stopPropagation();
var collapsed = el.classList.toggle('is-clamped');
if (collapsed) {
el.style.maxHeight = threshold + 'px';
diff --git a/templates/pages/blog_detail.html b/templates/pages/blog_detail.html
index 68eb82e..3d99890 100644
--- a/templates/pages/blog_detail.html
+++ b/templates/pages/blog_detail.html
@@ -19,13 +19,28 @@
{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}
{{ post.title }}
- {% if post.excerpt %}{{ post.excerpt }}
{% endif %}
+ {% if post.excerpt %}{{ post.excerpt }}
{% endif %}
{% if post.cover_image %}

{% endif %}
-
{{ post.rendered_content }}
+
{{ post.rendered_content }}
+ {% with partners=post.partners.all %}
+ {% if partners %}
+
+
+ {% if site_branding.article_people_eyebrow %}{{ site_branding.article_people_eyebrow }}{% endif %}
+
{{ site_branding.article_people_title }}
+
+
+ {% for partner in partners %}
+ {% include "partials/_person_card.html" with person=partner body=partner.description read_more=partner.read_more_description %}
+ {% endfor %}
+
+
+ {% endif %}
+ {% endwith %}
diff --git a/templates/pages/blog_list.html b/templates/pages/blog_list.html
index b1a1af4..5c4b683 100644
--- a/templates/pages/blog_list.html
+++ b/templates/pages/blog_list.html
@@ -32,7 +32,7 @@
{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}
- {% if post.excerpt %}
{{ post.excerpt }}
{% endif %}
+ {% if post.excerpt %}
{{ post.excerpt }}
{% endif %}
Read article
diff --git a/templates/pages/home.html b/templates/pages/home.html
index e2e77e1..058ebff 100644
--- a/templates/pages/home.html
+++ b/templates/pages/home.html
@@ -25,8 +25,8 @@
{% if hero.title_highlight %}{{ hero.title_highlight }}{% endif %}
{% endif %}
- {% if hero.subtitle %}{{ hero.subtitle }}
{% endif %}
- {% if hero.description %}{{ hero.description }}
{% endif %}
+ {% if hero.subtitle %}{{ hero.subtitle }}
{% endif %}
+ {% if hero.description %}{{ hero.description }}
{% endif %}
{% if hero.primary_cta_text or hero.secondary_cta_text %}
{% if hero.primary_cta_text %}
{{ hero.primary_cta_text }}{% endif %}
diff --git a/templates/partials/_page_sections.html b/templates/partials/_page_sections.html
index 1e3f21b..92a4e79 100644
--- a/templates/partials/_page_sections.html
+++ b/templates/partials/_page_sections.html
@@ -14,8 +14,8 @@
{% if section.badge %}
{{ section.badge }}
{% endif %}
{% if section.title %}
{{ section.title }}
{% endif %}
- {% if section.subtitle %}
{{ section.subtitle }}
{% endif %}
- {% if section.content %}
{{ section.rendered_content }}
{% endif %}
+ {% if section.subtitle %}
{{ section.subtitle }}
{% endif %}
+ {% if section.content %}
{{ section.rendered_content }}
{% endif %}
{% with items=section.items.all %}
{% if items %}
@@ -44,7 +44,7 @@
{% if section.title %}
{{ section.title }}
{% endif %}
{% if section.content %}
-
{{ section.rendered_content }}
+
{{ section.rendered_content }}
{% endif %}
@@ -73,7 +73,7 @@
{% if section.title %}{{ section.title }}
{% endif %}
{% endif %}
- {% if section.content %}{{ section.rendered_content }}
{% endif %}
+ {% if section.content %}{{ section.rendered_content }}
{% endif %}
{% with items=section.items.all %}
{% if items %}
@@ -98,7 +98,7 @@
{% if section.badge %}
{{ section.badge }}
{% endif %}
{% if section.title %}
{{ section.title }}
{% endif %}
- {% if section.content %}
{{ section.rendered_content }}
{% endif %}
+ {% if section.content %}
{{ section.rendered_content }}
{% endif %}
{% if links %}
{% for link in links %}
@@ -138,7 +138,7 @@
{% endif %}
{% if section.content %}
-
{{ section.rendered_content }}
+
{{ section.rendered_content }}
{% endif %}
{% with items=section.items.all %}
@@ -163,14 +163,14 @@
{% for item in section.items.all %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="feature-card" %}
{% if item.icon %}
{{ item.icon }}
{% endif %}
{{ item.title }}
-
{{ item.content }}
+
{{ item.content }}
{% include "partials/_linked_card_close.html" with url=item.url %}
{% endfor %}
@@ -183,7 +183,7 @@
{% for item in section.items.all %}
@@ -217,7 +217,7 @@
{% for product in homepage_products %}
@@ -229,7 +229,7 @@
{% endif %}
{{ product.name }}
-
{{ product.short_description }}
+
{{ product.short_description }}
Explore
About {{ main_product.name }}
-
{{ main_product.rendered_description }}
+
{{ main_product.rendered_description }}
@@ -84,7 +84,7 @@
{% endif %}
{{ sub.name }}
-
{{ sub.short_description }}
+
{{ sub.short_description }}
Learn More
@@ -44,7 +44,7 @@
About {{ sub_product.name }}
-
{{ sub_product.rendered_description }}
+
{{ sub_product.rendered_description }}