feat: add people to projects and articles
This commit is contained in:
+9
-2
@@ -37,10 +37,17 @@ class SiteBrandingAdmin(admin.ModelAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Blog article people section",
|
"Article people section",
|
||||||
{
|
{
|
||||||
"fields": ("article_people_eyebrow", "article_people_title"),
|
"fields": ("article_people_eyebrow", "article_people_title"),
|
||||||
"description": "These headings are shared by every blog article that has people cards.",
|
"description": "These headings are shared by blog articles and project articles that have people cards.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Project people section",
|
||||||
|
{
|
||||||
|
"fields": ("project_people_eyebrow", "project_people_title"),
|
||||||
|
"description": "These headings are shared by main projects and sub-projects that have people cards.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-10-28 14:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0006_sitebranding_article_people_eyebrow_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sitebranding',
|
||||||
|
name='project_people_eyebrow',
|
||||||
|
field=models.CharField(blank=True, default='The people behind this project', help_text='Small heading above the people cards on main projects and sub-projects.', max_length=160),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sitebranding',
|
||||||
|
name='project_people_title',
|
||||||
|
field=models.CharField(default='Organizers & collaborators', help_text='Heading above the people cards on main projects and sub-projects.', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
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 blog and project articles.', max_length=160),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sitebranding',
|
||||||
|
name='article_people_title',
|
||||||
|
field=models.CharField(default='Organizers & collaborators', help_text='Heading above the people cards on blog and project articles.', max_length=200),
|
||||||
|
),
|
||||||
|
]
|
||||||
+10
-2
@@ -34,12 +34,20 @@ class SiteBranding(models.Model):
|
|||||||
max_length=160,
|
max_length=160,
|
||||||
blank=True,
|
blank=True,
|
||||||
default="The people behind this article",
|
default="The people behind this article",
|
||||||
help_text="Small heading above the people cards on every blog article.",
|
help_text="Small heading above the people cards on blog and project articles.",
|
||||||
)
|
)
|
||||||
article_people_title = models.CharField(
|
article_people_title = models.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
default="Organizers & collaborators",
|
default="Organizers & collaborators",
|
||||||
help_text="Heading above the people cards on every blog article.",
|
help_text="Heading above the people cards on blog and project articles.",
|
||||||
|
)
|
||||||
|
project_people_eyebrow = models.CharField(
|
||||||
|
max_length=160, blank=True, default="The people behind this project",
|
||||||
|
help_text="Small heading above the people cards on main projects and sub-projects.",
|
||||||
|
)
|
||||||
|
project_people_title = models.CharField(
|
||||||
|
max_length=200, default="Organizers & collaborators",
|
||||||
|
help_text="Heading above the people cards on main projects and sub-projects.",
|
||||||
)
|
)
|
||||||
|
|
||||||
icon = models.ImageField(
|
icon = models.ImageField(
|
||||||
|
|||||||
+25
-4
@@ -3,7 +3,28 @@ from django.contrib import admin
|
|||||||
from apps.core.admin_video import video_admin_preview
|
from apps.core.admin_video import video_admin_preview
|
||||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||||
|
|
||||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct
|
from .models import (
|
||||||
|
Article, ArticleCitation, ArticlePerson, ArticleSection, MainProduct,
|
||||||
|
MainProductPerson, ProductVideo, SubProduct, SubProductPerson,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PersonInline(admin.StackedInline):
|
||||||
|
extra = 0
|
||||||
|
fields = ("role", "name", "description", "read_more_description", "image", "image_alt", "url", "order")
|
||||||
|
ordering = ("order",)
|
||||||
|
|
||||||
|
|
||||||
|
class MainProductPersonInline(PersonInline):
|
||||||
|
model = MainProductPerson
|
||||||
|
|
||||||
|
|
||||||
|
class SubProductPersonInline(PersonInline):
|
||||||
|
model = SubProductPerson
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlePersonInline(PersonInline):
|
||||||
|
model = ArticlePerson
|
||||||
|
|
||||||
|
|
||||||
class ArticleCitationInline(admin.TabularInline):
|
class ArticleCitationInline(admin.TabularInline):
|
||||||
@@ -77,7 +98,7 @@ class MainProductAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("name", "description")
|
search_fields = ("name", "description")
|
||||||
prepopulated_fields = {"slug": ("name",)}
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||||
inlines = [MainProductArticleInline, ProductVideoInline, SubProductInline]
|
inlines = [MainProductPersonInline, MainProductArticleInline, ProductVideoInline, SubProductInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
@@ -136,7 +157,7 @@ class SubProductAdmin(admin.ModelAdmin):
|
|||||||
prepopulated_fields = {"slug": ("name",)}
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||||
raw_id_fields = ("main_product",)
|
raw_id_fields = ("main_product",)
|
||||||
inlines = [SubProductArticleInline, SubProductVideoInline]
|
inlines = [SubProductPersonInline, SubProductArticleInline, SubProductVideoInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
@@ -164,7 +185,7 @@ class ArticleAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("title", "description", "main_product__name", "sub_product__name")
|
search_fields = ("title", "description", "main_product__name", "sub_product__name")
|
||||||
list_editable = ("order",)
|
list_editable = ("order",)
|
||||||
raw_id_fields = ("main_product", "sub_product")
|
raw_id_fields = ("main_product", "sub_product")
|
||||||
inlines = [ArticleSectionInline, ArticleCitationInline]
|
inlines = [ArticlePersonInline, ArticleSectionInline, ArticleCitationInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
|
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
|
||||||
("Description", {"fields": ("description_format", "description", "read_more_description")}),
|
("Description", {"fields": ("description_format", "description", "read_more_description")}),
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-10-28 14:09
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('products', '0024_articlecitation_read_more_text_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ArticlePerson',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('role', models.CharField(default='Collaborator', help_text='Type Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group')),
|
||||||
|
('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(blank=True, null=True, upload_to='products/people/')),
|
||||||
|
('image_alt', models.CharField(blank=True, max_length=240)),
|
||||||
|
('url', models.URLField(blank=True)),
|
||||||
|
('order', models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first. Organizers appear before collaborators, then custom roles.', verbose_name='Display order')),
|
||||||
|
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='people', to='products.article')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Project article person',
|
||||||
|
'verbose_name_plural': 'Project article people',
|
||||||
|
'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'],
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MainProductPerson',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('role', models.CharField(default='Collaborator', help_text='Type Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group')),
|
||||||
|
('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(blank=True, null=True, upload_to='products/people/')),
|
||||||
|
('image_alt', models.CharField(blank=True, max_length=240)),
|
||||||
|
('url', models.URLField(blank=True)),
|
||||||
|
('order', models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first. Organizers appear before collaborators, then custom roles.', verbose_name='Display order')),
|
||||||
|
('main_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='people', to='products.mainproduct')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Main project person',
|
||||||
|
'verbose_name_plural': 'Main project people',
|
||||||
|
'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'],
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SubProductPerson',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('role', models.CharField(default='Collaborator', help_text='Type Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group')),
|
||||||
|
('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(blank=True, null=True, upload_to='products/people/')),
|
||||||
|
('image_alt', models.CharField(blank=True, max_length=240)),
|
||||||
|
('url', models.URLField(blank=True)),
|
||||||
|
('order', models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first. Organizers appear before collaborators, then custom roles.', verbose_name='Display order')),
|
||||||
|
('sub_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='people', to='products.subproduct')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Sub-project person',
|
||||||
|
'verbose_name_plural': 'Sub-project people',
|
||||||
|
'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'],
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -207,6 +207,10 @@ class Article(models.Model):
|
|||||||
def rendered_description(self):
|
def rendered_description(self):
|
||||||
return render_content(self.description, self.description_format)
|
return render_content(self.description, self.description_format)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def people_heading_id(self):
|
||||||
|
return f"article-{self.pk}-people"
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
has_main = self.main_product_id is not None
|
has_main = self.main_product_id is not None
|
||||||
@@ -220,6 +224,67 @@ class Article(models.Model):
|
|||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectPerson(models.Model):
|
||||||
|
role = models.CharField(
|
||||||
|
"Role / group", max_length=100, default="Collaborator",
|
||||||
|
help_text="Type Organizer, Collaborator, or any other role name.",
|
||||||
|
)
|
||||||
|
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="products/people/", 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:
|
||||||
|
abstract = True
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def role_label(self):
|
||||||
|
return self.role.title() if self.role.casefold() in {"organizer", "collaborator"} else self.role
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.role_label})"
|
||||||
|
|
||||||
|
|
||||||
|
class MainProductPerson(ProjectPerson):
|
||||||
|
main_product = models.ForeignKey(MainProduct, on_delete=models.CASCADE, related_name="people")
|
||||||
|
|
||||||
|
class Meta(ProjectPerson.Meta):
|
||||||
|
verbose_name = "Main project person"
|
||||||
|
verbose_name_plural = "Main project people"
|
||||||
|
|
||||||
|
|
||||||
|
class SubProductPerson(ProjectPerson):
|
||||||
|
sub_product = models.ForeignKey(SubProduct, on_delete=models.CASCADE, related_name="people")
|
||||||
|
|
||||||
|
class Meta(ProjectPerson.Meta):
|
||||||
|
verbose_name = "Sub-project person"
|
||||||
|
verbose_name_plural = "Sub-project people"
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlePerson(ProjectPerson):
|
||||||
|
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="people")
|
||||||
|
|
||||||
|
class Meta(ProjectPerson.Meta):
|
||||||
|
verbose_name = "Project article person"
|
||||||
|
verbose_name_plural = "Project article people"
|
||||||
|
|
||||||
|
|
||||||
class SubProductVersion(models.Model):
|
class SubProductVersion(models.Model):
|
||||||
main_product = models.ForeignKey(
|
main_product = models.ForeignKey(
|
||||||
MainProduct,
|
MainProduct,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
from apps.products.models import (
|
||||||
|
Article, ArticlePerson, ArticleSection, MainProduct, MainProductPerson,
|
||||||
|
SubProduct, SubProductPerson,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProductViewsSetup(TestCase):
|
class ProductViewsSetup(TestCase):
|
||||||
@@ -56,6 +59,31 @@ class ProductOverviewViewTest(ProductViewsSetup):
|
|||||||
|
|
||||||
|
|
||||||
class MainProductDetailViewTest(ProductViewsSetup):
|
class MainProductDetailViewTest(ProductViewsSetup):
|
||||||
|
def test_project_and_article_people_are_independent_and_ordered(self):
|
||||||
|
MainProductPerson.objects.create(main_product=self.main_product, name="Main collaborator", role="Collaborator", order=1)
|
||||||
|
MainProductPerson.objects.create(main_product=self.main_product, name="Main organizer", role="Organizer", order=9)
|
||||||
|
main_article = Article.objects.create(main_product=self.main_product, title="Main article", description="Text")
|
||||||
|
ArticlePerson.objects.create(article=main_article, name="Article advisor", role="Scientific advisor", order=0)
|
||||||
|
response = self.client.get(self.main_product.get_absolute_url())
|
||||||
|
html = response.content.decode()
|
||||||
|
self.assertLess(html.index("Main organizer"), html.index("Main collaborator"))
|
||||||
|
self.assertContains(response, "Article advisor")
|
||||||
|
self.assertNotContains(response, "Sub collaborator")
|
||||||
|
self.assertContains(response, f'id="article-{main_article.pk}-people"')
|
||||||
|
|
||||||
|
def test_people_section_is_hidden_until_created_and_read_more_is_optional(self):
|
||||||
|
response = self.client.get(self.main_product.get_absolute_url())
|
||||||
|
self.assertNotContains(response, 'id="main-project-people"')
|
||||||
|
person = MainProductPerson.objects.create(
|
||||||
|
main_product=self.main_product, name="Project leader", description="A long biography",
|
||||||
|
)
|
||||||
|
response = self.client.get(self.main_product.get_absolute_url())
|
||||||
|
self.assertContains(response, "Project leader")
|
||||||
|
self.assertNotContains(response, 'data-readmore="88"')
|
||||||
|
person.read_more_description = True
|
||||||
|
person.save(update_fields=["read_more_description"])
|
||||||
|
self.assertContains(self.client.get(self.main_product.get_absolute_url()), 'data-readmore="88"')
|
||||||
|
|
||||||
def test_read_more_can_be_set_per_article_and_section(self):
|
def test_read_more_can_be_set_per_article_and_section(self):
|
||||||
url = self.main_product.get_absolute_url()
|
url = self.main_product.get_absolute_url()
|
||||||
article = Article.objects.create(main_product=self.main_product, title="Notes", description="Long notes")
|
article = Article.objects.create(main_product=self.main_product, title="Notes", description="Long notes")
|
||||||
@@ -112,6 +140,15 @@ class MainProductDetailViewTest(ProductViewsSetup):
|
|||||||
|
|
||||||
|
|
||||||
class SubProductDetailViewTest(ProductViewsSetup):
|
class SubProductDetailViewTest(ProductViewsSetup):
|
||||||
|
def test_sub_project_and_article_people_render_separately(self):
|
||||||
|
SubProductPerson.objects.create(sub_product=self.sub_product, name="Sub organizer", role="Organizer")
|
||||||
|
ArticlePerson.objects.create(article=self.article, name="Article collaborator", role="Collaborator")
|
||||||
|
response = self.client.get(self.sub_product.get_absolute_url())
|
||||||
|
self.assertContains(response, "Sub organizer")
|
||||||
|
self.assertContains(response, "Article collaborator")
|
||||||
|
self.assertContains(response, 'id="sub-project-people"')
|
||||||
|
self.assertNotContains(self.client.get(self.main_product.get_absolute_url()), "Sub organizer")
|
||||||
|
|
||||||
def test_sub_detail_returns_200(self):
|
def test_sub_detail_returns_200(self):
|
||||||
url = reverse(
|
url = reverse(
|
||||||
"products:sub_product_detail",
|
"products:sub_product_detail",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ProductOverviewView(ListView):
|
|||||||
template_name = "products/overview.html"
|
template_name = "products/overview.html"
|
||||||
context_object_name = "main_products"
|
context_object_name = "main_products"
|
||||||
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
|
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
|
||||||
"sub_products"
|
"sub_products", "people"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,13 +33,13 @@ class MainProductDetailView(DetailView):
|
|||||||
context_object_name = "main_product"
|
context_object_name = "main_product"
|
||||||
slug_url_kwarg = "main_slug"
|
slug_url_kwarg = "main_slug"
|
||||||
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
|
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
|
||||||
"sub_products"
|
"sub_products", "people"
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["articles"] = self.object.articles.prefetch_related(
|
context["articles"] = self.object.articles.prefetch_related(
|
||||||
"sections", "citations"
|
"sections", "citations", "people"
|
||||||
).all()
|
).all()
|
||||||
videos = self.object.videos.filter(is_active=True).order_by("order", "pk")
|
videos = self.object.videos.filter(is_active=True).order_by("order", "pk")
|
||||||
context.update(
|
context.update(
|
||||||
@@ -65,7 +65,7 @@ class SubProductDetailView(TemplateView):
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
sub_product = get_object_or_404(
|
sub_product = get_object_or_404(
|
||||||
SubProduct,
|
SubProduct.objects.prefetch_related("people"),
|
||||||
slug=self.kwargs["sub_slug"],
|
slug=self.kwargs["sub_slug"],
|
||||||
main_product=main_product,
|
main_product=main_product,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
@@ -73,7 +73,7 @@ class SubProductDetailView(TemplateView):
|
|||||||
context["main_product"] = main_product
|
context["main_product"] = main_product
|
||||||
context["sub_product"] = sub_product
|
context["sub_product"] = sub_product
|
||||||
context["articles"] = sub_product.articles.prefetch_related(
|
context["articles"] = sub_product.articles.prefetch_related(
|
||||||
"sections", "citations"
|
"sections", "citations", "people"
|
||||||
).all()
|
).all()
|
||||||
videos = sub_product.videos.filter(is_active=True).order_by("order", "pk")
|
videos = sub_product.videos.filter(is_active=True).order_by("order", "pk")
|
||||||
context.update(
|
context.update(
|
||||||
|
|||||||
+6
-3
@@ -117,18 +117,21 @@ body::before {
|
|||||||
.blog-article { max-width: 900px; margin-inline: auto; padding: clamp(1.5rem, 4vw, 3rem); }
|
.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 { min-width: 0; margin-top: clamp(2.5rem, 5vw, 4rem); }
|
||||||
.blog-partners--about { margin-top: 0; }
|
.blog-partners--about { margin-top: 0; }
|
||||||
|
.blog-partners--embedded { margin-top: 2rem; border-top: 1px solid rgba(255,255,255,.12); padding-top: 1.5rem; }
|
||||||
|
.blog-partners--embedded .blog-partners-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 145px), 1fr)); }
|
||||||
.blog-partners-heading { padding: .3rem .15rem 1.25rem; }
|
.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-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-heading h2 { margin-top: .45rem; font-size: clamp(1.2rem, 2vw, 1.5rem); line-height: 1.35; }
|
||||||
|
.blog-partners-heading h4 { margin-top: .45rem; font-size: 1.15rem; line-height: 1.35; }
|
||||||
.blog-partners-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1rem; }
|
.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-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 { 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-image img { width: 100%; height: 100%; object-fit: cover; object-position: center 25%; }
|
||||||
.blog-partner-info { min-width: 0; }
|
.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-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, .blog-partner-info h5 { font-size: .9rem; line-height: 1.35; margin: 0 0 .4rem; }
|
||||||
.blog-partner-info h3 a { color: inherit; }
|
.blog-partner-info h3 a, .blog-partner-info h5 a { color: inherit; }
|
||||||
.blog-partner-info h3 a:hover { color: var(--accent-blue-light); }
|
.blog-partner-info h3 a:hover, .blog-partner-info h5 a:hover { color: var(--accent-blue-light); }
|
||||||
.blog-partner-info p { color: var(--text-secondary); font-size: .76rem; line-height: 1.55; margin: 0; }
|
.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: 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: 620px) { .blog-partners-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% if people %}
|
||||||
|
<section class="blog-partners{% if embedded %} blog-partners--embedded{% endif %}" aria-labelledby="{{ heading_id }}">
|
||||||
|
<div class="blog-partners-heading">
|
||||||
|
{% if eyebrow %}<span class="blog-partners-eyebrow">{{ eyebrow }}</span>{% endif %}
|
||||||
|
{% if embedded %}<h4 id="{{ heading_id }}">{{ title }}</h4>{% else %}<h2 id="{{ heading_id }}">{{ title }}</h2>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="blog-partners-grid">
|
||||||
|
{% for person in people %}
|
||||||
|
{% include "partials/_person_card.html" with person=person body=person.description read_more=person.read_more_description embedded=embedded %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{% if person.image %}<div class="blog-partner-image"><img src="{{ person.image.url }}" alt="{{ person.image_alt|default:person.name }}" loading="lazy"></div>{% endif %}
|
{% if person.image %}<div class="blog-partner-image"><img src="{{ person.image.url }}" alt="{{ person.image_alt|default:person.name }}" loading="lazy"></div>{% endif %}
|
||||||
<div class="blog-partner-info">
|
<div class="blog-partner-info">
|
||||||
{% if person.role_label %}<span class="blog-partner-role">{{ person.role_label }}</span>{% endif %}
|
{% if person.role_label %}<span class="blog-partner-role">{{ person.role_label }}</span>{% endif %}
|
||||||
<h3>{% if person.url %}<a href="{{ person.url }}" target="_blank" rel="noopener noreferrer">{{ person.name }}</a>{% else %}{{ person.name }}{% endif %}</h3>
|
{% if embedded %}<h5>{% if person.url %}<a href="{{ person.url }}" target="_blank" rel="noopener noreferrer">{{ person.name }}</a>{% else %}{{ person.name }}{% endif %}</h5>{% else %}<h3>{% if person.url %}<a href="{{ person.url }}" target="_blank" rel="noopener noreferrer">{{ person.name }}</a>{% else %}{{ person.name }}{% endif %}</h3>{% endif %}
|
||||||
{% if body %}<p {% if read_more %}data-readmore="88"{% endif %}>{{ body }}</p>{% endif %}
|
{% if body %}<p {% if read_more %}data-readmore="88"{% endif %}>{{ body }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% include "partials/_people_section.html" with people=article.people.all eyebrow=site_branding.article_people_eyebrow title=site_branding.article_people_title heading_id=article.people_heading_id embedded=True %}
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,4 +101,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% include "partials/_people_section.html" with people=main_product.people.all eyebrow=site_branding.project_people_eyebrow title=site_branding.project_people_title heading_id="main-project-people" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{% include "partials/_people_section.html" with people=sub_product.people.all eyebrow=site_branding.project_people_eyebrow title=site_branding.project_people_title heading_id="sub-project-people" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user