feat: add people sections and opt-in read more

This commit is contained in:
mohamad
2026-10-14 18:33:11 +03:30
parent 4bcb5f196d
commit 473637f477
33 changed files with 722 additions and 68 deletions
+7
View File
@@ -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",
{
@@ -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),
),
]
+11
View File
@@ -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/",
+24 -11
View File
@@ -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")}),
)
@@ -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'],
},
),
]
@@ -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'},
),
]
@@ -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.'),
),
]
@@ -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'),
),
]
@@ -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.'),
),
]
@@ -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'),
),
]
@@ -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'},
),
]
@@ -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),
),
]
+88
View File
@@ -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)
+96 -1
View File
@@ -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</h3>"), html.index(">Collaborator</h3>"))
self.assertLess(html.index(">Collaborator</h3>"), html.index(">Advisor</h3>"))
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, "<h2>Results</h2>")
self.assertContains(detail, "<strong>Promising</strong>")
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</h3>'), html.index('>First collaborator</h3>'))
self.assertLess(html.index('>First collaborator</h3>'), html.index('>Second collaborator</h3>'))
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</h3>"), html.index(">Partner</h3>"))
self.assertLess(html.index(">Partner</h3>"), html.index(">Advisor</h3>"))
class ContactViewTest(TestCase):
def test_contact_returns_200(self):
+2 -2
View File
@@ -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):
+14 -10
View File
@@ -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",)}),
)
@@ -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.'),
),
]
@@ -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.'),
),
]
+8
View File
@@ -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)
+15
View File
@@ -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)