diff --git a/apps/core/admin.py b/apps/core/admin.py index a3c071b..c038098 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -37,10 +37,17 @@ class SiteBrandingAdmin(admin.ModelAdmin): }, ), ( - "Blog article people section", + "Article people section", { "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.", }, ), ( diff --git a/apps/core/migrations/0007_sitebranding_project_people_eyebrow_and_more.py b/apps/core/migrations/0007_sitebranding_project_people_eyebrow_and_more.py new file mode 100644 index 0000000..b78132e --- /dev/null +++ b/apps/core/migrations/0007_sitebranding_project_people_eyebrow_and_more.py @@ -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), + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index 48c375b..5cf42c6 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -34,12 +34,20 @@ class SiteBranding(models.Model): max_length=160, blank=True, 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( max_length=200, 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( diff --git a/apps/products/admin.py b/apps/products/admin.py index 9760233..2125de7 100644 --- a/apps/products/admin.py +++ b/apps/products/admin.py @@ -3,7 +3,28 @@ from django.contrib import admin from apps.core.admin_video import video_admin_preview 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): @@ -77,7 +98,7 @@ class MainProductAdmin(admin.ModelAdmin): search_fields = ("name", "description") prepopulated_fields = {"slug": ("name",)} list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") - inlines = [MainProductArticleInline, ProductVideoInline, SubProductInline] + inlines = [MainProductPersonInline, MainProductArticleInline, ProductVideoInline, SubProductInline] fieldsets = ( ( None, @@ -136,7 +157,7 @@ class SubProductAdmin(admin.ModelAdmin): prepopulated_fields = {"slug": ("name",)} list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") raw_id_fields = ("main_product",) - inlines = [SubProductArticleInline, SubProductVideoInline] + inlines = [SubProductPersonInline, SubProductArticleInline, SubProductVideoInline] fieldsets = ( ( None, @@ -164,7 +185,7 @@ class ArticleAdmin(admin.ModelAdmin): search_fields = ("title", "description", "main_product__name", "sub_product__name") list_editable = ("order",) raw_id_fields = ("main_product", "sub_product") - inlines = [ArticleSectionInline, ArticleCitationInline] + inlines = [ArticlePersonInline, ArticleSectionInline, ArticleCitationInline] fieldsets = ( (None, {"fields": ("main_product", "sub_product", "badge", "title")}), ("Description", {"fields": ("description_format", "description", "read_more_description")}), diff --git a/apps/products/migrations/0025_articleperson_mainproductperson_subproductperson.py b/apps/products/migrations/0025_articleperson_mainproductperson_subproductperson.py new file mode 100644 index 0000000..d3398d5 --- /dev/null +++ b/apps/products/migrations/0025_articleperson_mainproductperson_subproductperson.py @@ -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, + }, + ), + ] diff --git a/apps/products/models.py b/apps/products/models.py index 53e426b..ade4337 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -207,6 +207,10 @@ class Article(models.Model): def rendered_description(self): return render_content(self.description, self.description_format) + @property + def people_heading_id(self): + return f"article-{self.pk}-people" + def clean(self): super().clean() has_main = self.main_product_id is not None @@ -220,6 +224,67 @@ class Article(models.Model): 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): main_product = models.ForeignKey( MainProduct, diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py index d1d1097..d1489e1 100644 --- a/apps/products/tests/test_views.py +++ b/apps/products/tests/test_views.py @@ -1,7 +1,10 @@ from django.test import TestCase 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): @@ -56,6 +59,31 @@ class ProductOverviewViewTest(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): url = self.main_product.get_absolute_url() article = Article.objects.create(main_product=self.main_product, title="Notes", description="Long notes") @@ -112,6 +140,15 @@ class MainProductDetailViewTest(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): url = reverse( "products:sub_product_detail", diff --git a/apps/products/views.py b/apps/products/views.py index 99adff9..489150b 100644 --- a/apps/products/views.py +++ b/apps/products/views.py @@ -23,7 +23,7 @@ class ProductOverviewView(ListView): template_name = "products/overview.html" context_object_name = "main_products" 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" slug_url_kwarg = "main_slug" queryset = MainProduct.objects.filter(is_active=True).prefetch_related( - "sub_products" + "sub_products", "people" ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["articles"] = self.object.articles.prefetch_related( - "sections", "citations" + "sections", "citations", "people" ).all() videos = self.object.videos.filter(is_active=True).order_by("order", "pk") context.update( @@ -65,7 +65,7 @@ class SubProductDetailView(TemplateView): is_active=True, ) sub_product = get_object_or_404( - SubProduct, + SubProduct.objects.prefetch_related("people"), slug=self.kwargs["sub_slug"], main_product=main_product, is_active=True, @@ -73,7 +73,7 @@ class SubProductDetailView(TemplateView): context["main_product"] = main_product context["sub_product"] = sub_product context["articles"] = sub_product.articles.prefetch_related( - "sections", "citations" + "sections", "citations", "people" ).all() videos = sub_product.videos.filter(is_active=True).order_by("order", "pk") context.update( diff --git a/static/css/main.css b/static/css/main.css index b3a070d..5e1f7c2 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -117,18 +117,21 @@ body::before { .blog-article { max-width: 900px; margin-inline: auto; padding: clamp(1.5rem, 4vw, 3rem); } .blog-partners { min-width: 0; margin-top: clamp(2.5rem, 5vw, 4rem); } .blog-partners--about { margin-top: 0; } +.blog-partners--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-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 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-partner-card { display: flex; flex-direction: column; gap: .85rem; padding: .8rem; min-width: 0; } .blog-partner-image { width: 100%; aspect-ratio: 1 / 1; border-radius: 12px; background: rgba(255,255,255,.08); overflow: hidden; display: grid; place-items: center; } .blog-partner-image img { width: 100%; height: 100%; object-fit: cover; object-position: center 25%; } .blog-partner-info { min-width: 0; } .blog-partner-role { display: block; color: var(--accent-blue-light); font-size: .62rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; margin-bottom: .35rem; } -.blog-partner-info h3 { font-size: .9rem; line-height: 1.35; margin: 0 0 .4rem; } -.blog-partner-info h3 a { color: inherit; } -.blog-partner-info h3 a:hover { color: var(--accent-blue-light); } +.blog-partner-info h3, .blog-partner-info h5 { font-size: .9rem; line-height: 1.35; margin: 0 0 .4rem; } +.blog-partner-info h3 a, .blog-partner-info h5 a { color: inherit; } +.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; } @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)); } } diff --git a/templates/partials/_people_section.html b/templates/partials/_people_section.html new file mode 100644 index 0000000..41f74e4 --- /dev/null +++ b/templates/partials/_people_section.html @@ -0,0 +1,13 @@ +{% if people %} +
+
+ {% if eyebrow %}{{ eyebrow }}{% endif %} + {% if embedded %}

{{ title }}

{% else %}

{{ title }}

{% endif %} +
+
+ {% for person in people %} + {% include "partials/_person_card.html" with person=person body=person.description read_more=person.read_more_description embedded=embedded %} + {% endfor %} +
+
+{% endif %} diff --git a/templates/partials/_person_card.html b/templates/partials/_person_card.html index a0ef70d..f5d4d9d 100644 --- a/templates/partials/_person_card.html +++ b/templates/partials/_person_card.html @@ -2,7 +2,7 @@ {% if person.image %}
{{ person.image_alt|default:person.name }}
{% endif %}
{% if person.role_label %}{{ person.role_label }}{% endif %} -

{% if person.url %}{{ person.name }}{% else %}{{ person.name }}{% endif %}

+ {% if embedded %}
{% if person.url %}{{ person.name }}{% else %}{{ person.name }}{% endif %}
{% else %}

{% if person.url %}{{ person.name }}{% else %}{{ person.name }}{% endif %}

{% endif %} {% if body %}

{{ body }}

{% endif %}
diff --git a/templates/products/_article_list.html b/templates/products/_article_list.html index a88ca06..738c9d4 100644 --- a/templates/products/_article_list.html +++ b/templates/products/_article_list.html @@ -62,6 +62,7 @@ {% 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 %} {% endfor %} diff --git a/templates/products/main_detail.html b/templates/products/main_detail.html index c53770f..5730b48 100644 --- a/templates/products/main_detail.html +++ b/templates/products/main_detail.html @@ -101,4 +101,8 @@ {% endif %} {% endwith %} +
+ {% 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" %} +
+ {% endblock %} diff --git a/templates/products/sub_detail.html b/templates/products/sub_detail.html index 58b2bab..d19eac2 100644 --- a/templates/products/sub_detail.html +++ b/templates/products/sub_detail.html @@ -84,6 +84,7 @@ + {% 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" %}