diff --git a/apps/core/people.py b/apps/core/people.py new file mode 100644 index 0000000..08c7afd --- /dev/null +++ b/apps/core/people.py @@ -0,0 +1,25 @@ +from collections import OrderedDict + + +ROLE_PRIORITY = {"lead": 0, "leader": 0, "organizer": 1, "collaborator": 2} +ROLE_TITLES = {0: "Lead / Leader", 1: "Organizers", 2: "Collaborators"} + + +def person_sort_key(person): + role = (person.role or "").strip().casefold() + return (ROLE_PRIORITY.get(role, 3), role if role not in ROLE_PRIORITY else "", person.order, person.pk or 0) + + +def group_people(people): + groups = OrderedDict() + for person in sorted(people, key=person_sort_key): + role = (person.role or "").strip() + priority = ROLE_PRIORITY.get(role.casefold(), 3) + key = priority if priority < 3 else role.casefold() + if key not in groups: + groups[key] = { + "title": ROLE_TITLES[priority] if priority < 3 else role or "Other contributors", + "people": [], + } + groups[key]["people"].append(person) + return list(groups.values()) diff --git a/apps/core/templatetags/__init__.py b/apps/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/templatetags/people.py b/apps/core/templatetags/people.py new file mode 100644 index 0000000..9a74863 --- /dev/null +++ b/apps/core/templatetags/people.py @@ -0,0 +1,10 @@ +from django import template + +from apps.core.people import group_people + +register = template.Library() + + +@register.filter +def people_rows(people): + return group_people(people) diff --git a/apps/pages/migrations/0028_alter_blogpostpartner_options_and_more.py b/apps/pages/migrations/0028_alter_blogpostpartner_options_and_more.py new file mode 100644 index 0000000..e739ac9 --- /dev/null +++ b/apps/pages/migrations/0028_alter_blogpostpartner_options_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.16 on 2026-10-28 19:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0027_aboutsectionitem_role_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='blogpostpartner', + options={'ordering': [models.Case(models.When(role__iexact='lead', then=models.Value(0)), models.When(role__iexact='leader', then=models.Value(0)), models.When(role__iexact='organizer', then=models.Value(1)), models.When(role__iexact='collaborator', then=models.Value(2)), default=models.Value(3), output_field=models.IntegerField()), 'order', 'pk'], 'verbose_name': 'Article person', 'verbose_name_plural': 'Article people'}, + ), + migrations.AlterField( + model_name='aboutsectionitem', + name='role', + field=models.CharField(blank=True, help_text='For People sections: Lead, Leader, Organizer, Collaborator, or any custom role name.', max_length=100, verbose_name='Role / group'), + ), + migrations.AlterField( + model_name='blogpostpartner', + name='order', + field=models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first within each role. Leads/Leaders, Organizers, Collaborators, then custom roles.', verbose_name='Display order'), + ), + migrations.AlterField( + model_name='blogpostpartner', + name='role', + field=models.CharField(default='Collaborator', help_text='Type Lead, Leader, Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group'), + ), + ] diff --git a/apps/pages/models.py b/apps/pages/models.py index e80a40a..4f4bcdd 100644 --- a/apps/pages/models.py +++ b/apps/pages/models.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.text import slugify +from apps.core.people import person_sort_key from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content from apps.core.video import VideoBlockMixin from apps.pages.contact_uploads import ( @@ -157,12 +158,7 @@ class AboutSection(VideoBlockMixin, models.Model): @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) + return sorted(self.items.all(), key=person_sort_key) def clean(self): super().clean() @@ -183,7 +179,7 @@ class AboutSectionItem(models.Model): "Role / group", max_length=100, blank=True, - help_text="For People sections: Organizer, Collaborator, or any custom role name.", + help_text="For People sections: Lead, Leader, 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.") @@ -212,7 +208,7 @@ class AboutSectionItem(models.Model): @property def role_label(self): - if self.role.casefold() in {"organizer", "collaborator"}: + if self.role.casefold() in {"lead", "leader", "organizer", "collaborator"}: return self.role.title() return self.role @@ -415,7 +411,7 @@ class BlogPostPartner(models.Model): "Role / group", max_length=100, default="Collaborator", - help_text="Type Organizer, Collaborator, or any other role name you want to display.", + help_text="Type Lead, Leader, Organizer, Collaborator, or any other role name.", ) name = models.CharField(max_length=200) description = models.TextField(blank=True) @@ -426,15 +422,17 @@ class BlogPostPartner(models.Model): order = models.PositiveIntegerField( "Display order", default=0, - help_text="Smaller numbers appear first. Organizers appear before collaborators, then custom roles.", + help_text="Smaller numbers appear first within each role. Leads/Leaders, Organizers, 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), + models.When(role__iexact="lead", then=models.Value(0)), + models.When(role__iexact="leader", then=models.Value(0)), + models.When(role__iexact="organizer", then=models.Value(1)), + models.When(role__iexact="collaborator", then=models.Value(2)), + default=models.Value(3), output_field=models.IntegerField(), ), "order", @@ -448,7 +446,7 @@ class BlogPostPartner(models.Model): @property def role_label(self): - if self.role.casefold() in {self.ORGANIZER, self.COLLABORATOR}: + if self.role.casefold() in {"lead", "leader", self.ORGANIZER, self.COLLABORATOR}: return self.role.title() return self.role diff --git a/apps/pages/tests/test_views.py b/apps/pages/tests/test_views.py index 6ba2f8b..6353638 100644 --- a/apps/pages/tests/test_views.py +++ b/apps/pages/tests/test_views.py @@ -44,11 +44,14 @@ class AboutViewTest(TestCase): 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) + AboutSectionItem.objects.create(section=section, title="Leader", role="Leader", content="Leader profile", order=20) html = self.client.get(reverse("pages:about")).content.decode() self.assertIn("People behind the work", html) self.assertIn("Scientific advisor", html) - self.assertLess(html.index(">Organizer"), html.index(">Collaborator")) - self.assertLess(html.index(">Collaborator"), html.index(">Advisor")) + self.assertLess(html.index(">Leader"), html.index(">Organizer")) + self.assertLess(html.index(">Organizer"), html.index(">Collaborator")) + self.assertLess(html.index(">Collaborator"), html.index(">Advisor")) + self.assertIn("Lead / Leader", html) self.assertIn('data-readmore="200"', html) self.assertEqual(html.count('data-readmore="88"'), 1) @@ -113,10 +116,15 @@ class BlogViewTest(TestCase): 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) + BlogPostPartner.objects.create(post=self.post, role="Lead", name="Lead", order=5) + BlogPostPartner.objects.create(post=self.post, role="Leader", name="Leader", order=1) html = self.client.get(self.post.get_absolute_url()).content.decode() self.assertLess(html.index('class="blog-article rich-content'), html.index('class="blog-partners"')) - self.assertLess(html.index('>Organizer'), html.index('>First collaborator')) - self.assertLess(html.index('>First collaborator'), html.index('>Second collaborator')) + self.assertLess(html.index('>Leader'), html.index('>Lead')) + self.assertLess(html.index('>Lead'), html.index('>Organizer')) + self.assertLess(html.index('>Organizer'), html.index('>First collaborator')) + self.assertLess(html.index('>First collaborator'), html.index('>Second collaborator')) + self.assertLess(html.index('Lead / Leader'), html.index('>Organizers')) def test_partner_headings_are_shared_and_description_read_more_is_independent(self): branding = SiteBranding.load() @@ -152,8 +160,8 @@ class BlogViewTest(TestCase): BlogPostPartner.objects.create(post=self.post, role=BlogPostPartner.ORGANIZER, name="Lead", order=9) html = self.client.get(self.post.get_absolute_url()).content.decode() self.assertIn("Scientific advisor", html) - self.assertLess(html.index(">Lead"), html.index(">Partner")) - self.assertLess(html.index(">Partner"), html.index(">Advisor")) + self.assertLess(html.index(">Lead"), html.index(">Partner")) + self.assertLess(html.index(">Partner"), html.index(">Advisor")) class ContactViewTest(TestCase): diff --git a/apps/products/migrations/0026_alter_articleperson_options_and_more.py b/apps/products/migrations/0026_alter_articleperson_options_and_more.py new file mode 100644 index 0000000..18c9180 --- /dev/null +++ b/apps/products/migrations/0026_alter_articleperson_options_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.16 on 2026-10-28 19:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0025_articleperson_mainproductperson_subproductperson'), + ] + + operations = [ + migrations.AlterModelOptions( + name='articleperson', + options={'ordering': [models.Case(models.When(role__iexact='lead', then=models.Value(0)), models.When(role__iexact='leader', then=models.Value(0)), models.When(role__iexact='organizer', then=models.Value(1)), models.When(role__iexact='collaborator', then=models.Value(2)), default=models.Value(3), output_field=models.IntegerField()), 'order', 'pk'], 'verbose_name': 'Project article person', 'verbose_name_plural': 'Project article people'}, + ), + migrations.AlterModelOptions( + name='mainproductperson', + options={'ordering': [models.Case(models.When(role__iexact='lead', then=models.Value(0)), models.When(role__iexact='leader', then=models.Value(0)), models.When(role__iexact='organizer', then=models.Value(1)), models.When(role__iexact='collaborator', then=models.Value(2)), default=models.Value(3), output_field=models.IntegerField()), 'order', 'pk'], 'verbose_name': 'Main project person', 'verbose_name_plural': 'Main project people'}, + ), + migrations.AlterModelOptions( + name='subproductperson', + options={'ordering': [models.Case(models.When(role__iexact='lead', then=models.Value(0)), models.When(role__iexact='leader', then=models.Value(0)), models.When(role__iexact='organizer', then=models.Value(1)), models.When(role__iexact='collaborator', then=models.Value(2)), default=models.Value(3), output_field=models.IntegerField()), 'order', 'pk'], 'verbose_name': 'Sub-project person', 'verbose_name_plural': 'Sub-project people'}, + ), + migrations.AlterField( + model_name='articleperson', + name='order', + field=models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first within each role. Leads/Leaders, Organizers, Collaborators, then custom roles.', verbose_name='Display order'), + ), + migrations.AlterField( + model_name='articleperson', + name='role', + field=models.CharField(default='Collaborator', help_text='Type Lead, Leader, Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group'), + ), + migrations.AlterField( + model_name='mainproductperson', + name='order', + field=models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first within each role. Leads/Leaders, Organizers, Collaborators, then custom roles.', verbose_name='Display order'), + ), + migrations.AlterField( + model_name='mainproductperson', + name='role', + field=models.CharField(default='Collaborator', help_text='Type Lead, Leader, Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group'), + ), + migrations.AlterField( + model_name='subproductperson', + name='order', + field=models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first within each role. Leads/Leaders, Organizers, Collaborators, then custom roles.', verbose_name='Display order'), + ), + migrations.AlterField( + model_name='subproductperson', + name='role', + field=models.CharField(default='Collaborator', help_text='Type Lead, Leader, Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group'), + ), + ] diff --git a/apps/products/models.py b/apps/products/models.py index ade4337..4ae09f3 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -227,7 +227,7 @@ class Article(models.Model): class ProjectPerson(models.Model): role = models.CharField( "Role / group", max_length=100, default="Collaborator", - help_text="Type Organizer, Collaborator, or any other role name.", + help_text="Type Lead, Leader, Organizer, Collaborator, or any other role name.", ) name = models.CharField(max_length=200) description = models.TextField(blank=True) @@ -239,23 +239,25 @@ class ProjectPerson(models.Model): 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.", + help_text="Smaller numbers appear first within each role. Leads/Leaders, Organizers, 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(), + models.When(role__iexact="lead", then=models.Value(0)), + models.When(role__iexact="leader", then=models.Value(0)), + models.When(role__iexact="organizer", then=models.Value(1)), + models.When(role__iexact="collaborator", then=models.Value(2)), + default=models.Value(3), 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 + return self.role.title() if self.role.casefold() in {"lead", "leader", "organizer", "collaborator"} else self.role def __str__(self): return f"{self.name} ({self.role_label})" diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py index d1489e1..dafa756 100644 --- a/apps/products/tests/test_views.py +++ b/apps/products/tests/test_views.py @@ -62,11 +62,17 @@ 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) + MainProductPerson.objects.create(main_product=self.main_product, name="Main leader", role="Leader", order=2) + MainProductPerson.objects.create(main_product=self.main_product, name="Main lead", role="Lead", order=1) 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 lead"), html.index("Main leader")) + self.assertLess(html.index("Main leader"), html.index("Main organizer")) self.assertLess(html.index("Main organizer"), html.index("Main collaborator")) + self.assertLess(html.index("Lead / Leader"), html.index(">Organizers")) + self.assertLess(html.index(">Organizers"), html.index(">Collaborators")) self.assertContains(response, "Article advisor") self.assertNotContains(response, "Sub collaborator") self.assertContains(response, f'id="article-{main_article.pk}-people"') diff --git a/static/css/main.css b/static/css/main.css index 5e1f7c2..0643d4a 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -123,15 +123,17 @@ body::before { .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; } +.people-role-row + .people-role-row { margin-top: 1.65rem; padding-top: 1.65rem; border-top: 1px solid rgba(255,255,255,.14); } +.people-role-heading { margin: 0 0 .85rem; color: var(--text-secondary); font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } .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, .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 h3, .blog-partner-info h4, .blog-partner-info h5, .blog-partner-info h6 { font-size: .9rem; line-height: 1.35; margin: 0 0 .4rem; } +.blog-partner-info h3 a, .blog-partner-info h4 a, .blog-partner-info h5 a, .blog-partner-info h6 a { color: inherit; } +.blog-partner-info h3 a:hover, .blog-partner-info h4 a:hover, .blog-partner-info h5 a:hover, .blog-partner-info h6 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/pages/blog_detail.html b/templates/pages/blog_detail.html index 3d99890..4c32007 100644 --- a/templates/pages/blog_detail.html +++ b/templates/pages/blog_detail.html @@ -33,11 +33,7 @@ {% if site_branding.article_people_eyebrow %}{{ site_branding.article_people_eyebrow }}{% endif %}

{{ site_branding.article_people_title }}

-
- {% for partner in partners %} - {% include "partials/_person_card.html" with person=partner body=partner.description read_more=partner.read_more_description %} - {% endfor %} -
+ {% include "partials/_people_rows.html" with people=partners %} {% endif %} {% endwith %} diff --git a/templates/partials/_page_sections.html b/templates/partials/_page_sections.html index 92a4e79..140df51 100644 --- a/templates/partials/_page_sections.html +++ b/templates/partials/_page_sections.html @@ -322,11 +322,7 @@

{{ section.title|default:"People" }}

{% if section.content %}
{{ section.rendered_content }}
{% endif %} -
- {% for person in people %} - {% include "partials/_person_card.html" with person=person body=person.content read_more=person.read_more_content %} - {% endfor %} -
+ {% include "partials/_people_rows.html" with people=people about=True %} diff --git a/templates/partials/_people_rows.html b/templates/partials/_people_rows.html new file mode 100644 index 0000000..14bb372 --- /dev/null +++ b/templates/partials/_people_rows.html @@ -0,0 +1,15 @@ +{% load people %} +{% for row in people|people_rows %} +
+ {% if embedded %}
{{ row.title }}
{% else %}

{{ row.title }}

{% endif %} +
+ {% for person in row.people %} + {% if about %} + {% include "partials/_person_card.html" with person=person body=person.content read_more=person.read_more_content grouped=True %} + {% else %} + {% include "partials/_person_card.html" with person=person body=person.description read_more=person.read_more_description embedded=embedded grouped=True %} + {% endif %} + {% endfor %} +
+
+{% endfor %} diff --git a/templates/partials/_people_section.html b/templates/partials/_people_section.html index 41f74e4..325e76a 100644 --- a/templates/partials/_people_section.html +++ b/templates/partials/_people_section.html @@ -4,10 +4,6 @@ {% 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 %} -
+ {% include "partials/_people_rows.html" with people=people embedded=embedded %} {% endif %} diff --git a/templates/partials/_person_card.html b/templates/partials/_person_card.html index f5d4d9d..287f95e 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 embedded %}
{% if person.url %}{{ person.name }}{% else %}{{ person.name }}{% endif %}
{% else %}

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

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

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

{% else %}

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

{% endif %} {% if body %}

{{ body }}

{% endif %}