feat: add people sections and opt-in read more
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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.'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user