From 2182621d6fd23cc0a5f8d55cfa7e7581b4352480 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 24 Jun 2026 08:23:36 -0700 Subject: [PATCH] Redesign mobile project-page sidebar with chips + info-list (#1271) The mobile project page rendered the sidebar's auto-fit 140px grid above the description, jamming unrelated metadata (date, links, leads, contributor count) into shared rows that read as jumbled, and pushing the description ~500px down. Replace that on mobile (<992px) with two inline blocks in the main column: - a compact status chip strip under the title (Active/Completed, date range, contributor count); - a labeled info-list below the description: Links as clickable chips, Team as a flat seniority-ordered lead list with a native
"+N more" disclosure, and Funding as a wrapping logo row (fixes the orphaned 4th logo). The desktop two-column sticky sidebar is unchanged: it's now hidden <992px and restored >=992px, so the mobile blocks don't duplicate it (same dual-render-by- breakpoint pattern Related Projects already uses). No JS changes. Details: - views/project.py: add active_leads_ordered (flat, de-duped, seniority-ordered leads with short inline role labels) for the mobile Team list. - new snippet display_project_team_member_mobile.html for compact lead rows. - design-tokens.css: add --color-status-active-* tokens (status chip green text is 6.9:1 on its fill - AA; avoids hardcoded hex per the tokens convention). - seed_demo_projects.py: seed sponsors/grants (from the WIP branch), make demo projects public, and give the Sidewalk-like demo real links so the full info-list is verifiable locally. - tests/test_project_page.py: regression tests for the chips, link chips, Team overflow
, funding, the Completed/empty case, and that the desktop sidebar still renders. - .pa11yci.json: add a 414px-viewport project scan so a11y CI exercises the mobile-only blocks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pa11yci.json | 6 +- .../management/commands/seed_demo_projects.py | 73 ++++- website/static/website/css/design-tokens.css | 7 + website/static/website/css/project.css | 261 +++++++++++++++++- .../display_project_team_member_mobile.html | 41 +++ website/templates/website/project.html | 101 ++++++- website/tests/test_project_page.py | 134 +++++++++ website/views/project.py | 25 ++ 8 files changed, 632 insertions(+), 16 deletions(-) create mode 100644 website/templates/snippets/display_project_team_member_mobile.html create mode 100644 website/tests/test_project_page.py diff --git a/.pa11yci.json b/.pa11yci.json index 3339eb10..f7c14cbf 100644 --- a/.pa11yci.json +++ b/.pa11yci.json @@ -40,6 +40,10 @@ "http://website:8000/news/1/", "http://website:8000/news/2/", "http://website:8000/news/3/", - "http://website:8000/awards/" + "http://website:8000/awards/", + { + "url": "http://website:8000/project/sidewalk/", + "viewport": { "width": 414, "height": 896 } + } ] } \ No newline at end of file diff --git a/website/management/commands/seed_demo_projects.py b/website/management/commands/seed_demo_projects.py index cbdb0575..718f362a 100644 --- a/website/management/commands/seed_demo_projects.py +++ b/website/management/commands/seed_demo_projects.py @@ -83,18 +83,27 @@ class Command(BaseCommand): help = "Seed three demo projects (see file docstring) for visual testing of project page layouts." def handle(self, *args, **opts): - from website.models import Person, Project, Publication + from website.models import Grant, Person, Project, Publication, Sponsor from website.models.project_role import LeadProjectRoleTypes, ProjectRole - self._wipe_prior(Person, Project, Publication) - self.stdout.write(self.style.NOTICE("Creating demo people, projects, and publications…")) + self._wipe_prior(Person, Project, Publication, Grant, Sponsor) + self.stdout.write(self.style.NOTICE("Creating demo people, sponsors, projects, and publications…")) people = self._make_demo_people(Person) - self._make_demo_active_small(Project, ProjectRole, LeadProjectRoleTypes, people) + sponsors = self._make_demo_sponsors(Sponsor) + + small = self._make_demo_active_small(Project, ProjectRole, LeadProjectRoleTypes, people) active_tall = self._make_demo_active_tall(Project, ProjectRole, LeadProjectRoleTypes, people) ended_tall = self._make_demo_ended_tall(Project, ProjectRole, LeadProjectRoleTypes, people) tall_main = self._make_demo_tall_main_short_sidebar(Project, ProjectRole, LeadProjectRoleTypes, people) + # Sponsor coverage: small gets 1 sponsor; the others get the full 4 to + # exercise multi-logo wrapping and verify the funding row layout. + self._attach_grant(Grant, small, sponsors[:1], year=2024) + self._attach_grant(Grant, active_tall, sponsors, year=2019) + self._attach_grant(Grant, ended_tall, sponsors[:3], year=2018) + self._attach_grant(Grant, tall_main, sponsors[:2], year=2024) + self._make_demo_publications(Publication, active_tall, people, count=15, start_year=2019, end_year=2025) self._make_demo_publications(Publication, ended_tall, people, count=15, start_year=2018, end_year=2022) self._make_demo_publications(Publication, tall_main, people, count=15, start_year=2024, end_year=2025) @@ -111,17 +120,22 @@ def handle(self, *args, **opts): # ------------------------------------------------------------------ # Cleanup - def _wipe_prior(self, Person, Project, Publication): + def _wipe_prior(self, Person, Project, Publication, Grant, Sponsor): """Delete any prior demo data so the command is safe to re-run.""" n_projects = Project.objects.filter(short_name__startswith="demo-").count() n_people = Person.objects.filter(first_name="Demo").count() n_pubs = Publication.objects.filter(title__startswith="Demo Paper:").count() - if n_projects or n_people or n_pubs: + n_grants = Grant.objects.filter(title__startswith="Demo Grant:").count() + n_sponsors = Sponsor.objects.filter(name__startswith="Demo ").count() + if n_projects or n_people or n_pubs or n_grants or n_sponsors: self.stdout.write(self.style.WARNING( - f"Removing prior demo data: {n_projects} projects, {n_people} people, {n_pubs} publications." + f"Removing prior demo data: {n_projects} projects, {n_people} people, " + f"{n_pubs} publications, {n_grants} grants, {n_sponsors} sponsors." )) Publication.objects.filter(title__startswith="Demo Paper:").delete() + Grant.objects.filter(title__startswith="Demo Grant:").delete() Project.objects.filter(short_name__startswith="demo-").delete() + Sponsor.objects.filter(name__startswith="Demo ").delete() Person.objects.filter(first_name="Demo").delete() # ------------------------------------------------------------------ @@ -164,13 +178,48 @@ def _make_demo_people(self, Person): people[last_name] = p return people + # --- Sponsors / Grants ------------------------------------------------- + + _SPONSORS = [ + ("Demo Science Foundation", "DSF", "https://example.org/dsf"), + ("Demo Research Council", "DRC", "https://example.org/drc"), + ("Demo Tech Institute", "DTI", "https://example.org/dti"), + ("Demo Industry Partner", "DIP", "https://example.org/dip"), + ] + + def _make_demo_sponsors(self, Sponsor): + sponsors = [] + for name, short_name, url in self._SPONSORS: + s = Sponsor.objects.create( + name=name, + short_name=short_name, + url=url, + alt_text=f"{name} logo", + icon=_img(f"{short_name.lower()}_icon.gif"), + ) + sponsors.append(s) + return sponsors + + def _attach_grant(self, Grant, project, sponsors, *, year): + """Create one Grant per sponsor and link it to the project.""" + from datetime import date as _date + for idx, sponsor in enumerate(sponsors): + grant = Grant.objects.create( + title=f"Demo Grant: {project.short_name} / {sponsor.short_name} #{idx + 1}", + sponsor=sponsor, + date=_date(year, 1, 1), + funding_amount=100000 * (idx + 1), + grant_id=f"DEMO-{idx + 1}", + ) + grant.projects.add(project) + # --- Project 1: short sidebar, active ---------------------------------- def _make_demo_active_small(self, Project, ProjectRole, Roles, people): proj = Project.objects.create( name="Demo Project: Active (Short Sidebar)", short_name="demo-active-small", - is_visible=True, # demo projects are public so they render for visual testing + is_visible=True, # demo projects exist for visual testing; make them public start_date=date(2024, 1, 1), end_date=None, summary="A small active demo project for visual testing of the short-sidebar case.", @@ -196,9 +245,11 @@ def _make_demo_active_tall(self, Project, ProjectRole, Roles, people): proj = Project.objects.create( name="Demo Project: Active (Tall Sidebar — Sidewalk-like)", short_name="demo-active-tall", - is_visible=True, # demo projects are public so they render for visual testing + is_visible=True, # demo projects exist for visual testing; make them public start_date=date(2019, 1, 1), end_date=None, + website="https://example.org/demo-sidewalk", + data_url="https://example.org/demo-sidewalk/data", summary="A large active demo project with lots of current and former members — sidebar exceeds viewport height.", about="This project is intentionally large. Use it to confirm sidebar behavior when the sidebar is taller than the viewport. The 'Former Student Lead' and 'Former PI' headers should still include 'Former' because the project itself is active (only individual members' roles ended).", ) @@ -248,7 +299,7 @@ def _make_demo_ended_tall(self, Project, ProjectRole, Roles, people): proj = Project.objects.create( name="Demo Project: Ended (Tall Sidebar)", short_name="demo-ended-tall", - is_visible=True, # demo projects are public so they render for visual testing + is_visible=True, # demo projects exist for visual testing; make them public start_date=date(2018, 1, 1), end_date=proj_end, summary="A completed demo project for testing the 'Former-prefix-dropped' branch (#1245).", @@ -288,7 +339,7 @@ def _make_demo_tall_main_short_sidebar(self, Project, ProjectRole, Roles, people proj = Project.objects.create( name="Demo Project: Tall Main + Short Sidebar", short_name="demo-tall-main-short-sidebar", - is_visible=True, # demo projects are public so they render for visual testing + is_visible=True, # demo projects exist for visual testing; make them public start_date=date(2024, 1, 1), end_date=None, summary="A project with only a few sidebar entries but lots of publications, so the main content is much taller than the sidebar.", diff --git a/website/static/website/css/design-tokens.css b/website/static/website/css/design-tokens.css index a78e3e3e..bd4b910e 100644 --- a/website/static/website/css/design-tokens.css +++ b/website/static/website/css/design-tokens.css @@ -111,6 +111,13 @@ --color-badge-text: #0d5a8c; /* 5.0:1 on badge-bg - AA ✓ */ --color-badge-text-hover: #083854; + /* Project status chip (#1271) — "Active" badge on project pages. + Dark green text on a light green fill; the border is decorative (the + text + fill convey state, so it isn't held to the 3:1 UI threshold). */ + --color-status-active-text: #1b5e20; /* 6.9:1 on --color-status-active-bg - AA ✓ */ + --color-status-active-bg: #e7f3e8; + --color-status-active-border: #bfe0c1; + /* Awards */ --color-award: #c25059; /* legacy red; used by the publications award label */ diff --git a/website/static/website/css/project.css b/website/static/website/css/project.css index 967877e0..e9dcdd1f 100644 --- a/website/static/website/css/project.css +++ b/website/static/website/css/project.css @@ -216,8 +216,12 @@ ============================================================================= */ .project-sidebar { - /* On mobile, appears above main content due to order property */ - order: -1; + /* #1271: the sidebar is desktop-only. On mobile (<992px) its metadata is + rendered inline in the main column as chips + a labeled info-list + (.project-mobile-meta), so hide the sidebar below the breakpoint to avoid + duplicating that content. The ≥992px block below restores it as the sticky + right column. */ + display: none; } /* Single source of truth for the sidebar sticky layout constants. @@ -243,6 +247,7 @@ Fallback: if the JS doesn't run, var() falls back to the top-offset value, which is fine for short sidebars and degrades gracefully on tall ones (sidebar's top sticks; bottom is hidden — same as pre-JS state). */ + display: block; /* #1271: restore the sidebar (hidden on mobile) on desktop */ order: 0; position: sticky; top: var(--sidebar-sticky-top, var(--project-sidebar-top-offset)); @@ -704,3 +709,255 @@ margin-right: 0; } + +/* ============================================================================= + MOBILE METADATA (#1271) + ============================================================================= + Replaces the old auto-fit sidebar grid on small screens. Two blocks live in + the main column: a compact chip strip under the title (.project-meta-chips) + and a labeled info-list below the description (.project-meta-info) holding + Links (as clickable chips), Team (with a "+N more" disclosure), and Funding. + Both are hidden ≥992px, where the sticky right sidebar takes over. + ============================================================================= */ + +.project-mobile-meta { + display: block; +} + +@media (min-width: 992px) { + .project-mobile-meta { + display: none; /* desktop uses the sidebar instead */ + } +} + +/* --- Status / meta chips (read-only badges) -------------------------------- */ + +.project-meta-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-5); +} + +.project-chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + border-radius: var(--border-radius-full); + background: var(--color-bg-surface); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + white-space: nowrap; +} + +/* Active = green; completed projects keep the neutral grey chip. */ +.project-chip--status { + background: var(--color-status-active-bg); + color: var(--color-status-active-text); + border-color: var(--color-status-active-border); +} + +/* --- Labeled info-list (Links / Team / Funding) ---------------------------- */ + +.project-info-list { + margin: 0; +} + +.project-info-row { + display: grid; + grid-template-columns: 84px 1fr; + gap: var(--space-3); + padding: var(--space-4) 0; + border-top: 1px solid var(--color-border); +} + +.project-info-row:last-child { + border-bottom: 1px solid var(--color-border); +} + +.project-info-label { + padding-top: var(--space-1); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.project-info-value { + margin: 0; + min-width: 0; + font-size: var(--font-size-sm); + color: var(--color-text-primary); +} + +/* --- Link chips (interactive — outlined, distinct from the grey status chips). + Compact so three (Website / Code / Data) fit one line in the value column. --- */ + +.project-chip-set { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.project-meta-link { + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-height: 36px; + padding: 0 var(--space-3); + border-radius: var(--border-radius-full); + border: 1.5px solid var(--color-primary); + background: var(--color-white); + color: var(--color-primary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-decoration: none; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.project-meta-link:hover, +.project-meta-link:focus-visible { + background: var(--color-primary-light); + color: var(--color-primary-hover); +} + +.project-meta-link i { + font-size: 0.8rem; +} + +/* --- Team member rows ------------------------------------------------------ */ + +.project-team-member { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-1) 0; +} + +.project-team-member-link { + flex: 0 0 auto; + display: inline-flex; + border-radius: 50%; +} + +.project-team-member-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; +} + +.project-team-member-avatar--initials { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-primary); + color: var(--color-white); + font-family: var(--font-family-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); +} + +.project-team-member-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-tight); +} + +.project-team-member-name a { + color: var(--color-text-primary); + text-decoration: none; +} + +.project-team-member-name a:hover, +.project-team-member-name a:focus-visible { + color: var(--color-link); + text-decoration: underline; +} + +.project-team-member-role { + color: var(--color-text-muted); + font-weight: var(--font-weight-normal); +} + +/* --- "+N more" disclosure (native
, no JS) ------------------------ */ + +.project-team-more { + margin-top: var(--space-1); +} + +.project-team-more-summary { + list-style: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-height: 34px; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-link); +} + +.project-team-more-summary::-webkit-details-marker { + display: none; +} + +.project-team-more-summary:hover { + color: var(--color-link-hover); +} + +.project-team-more-caret { + transition: transform var(--transition-fast); +} + +.project-team-more[open] .project-team-more-caret { + transform: rotate(90deg); +} + +/* Swap the summary label between collapsed and expanded states. */ +.project-team-more-hide { + display: none; +} + +.project-team-more[open] .project-team-more-show { + display: none; +} + +.project-team-more[open] .project-team-more-hide { + display: inline; +} + +@media (prefers-reduced-motion: reduce) { + .project-team-more-caret, + .project-meta-link { + transition: none; + } +} + +/* --- Funding logos --------------------------------------------------------- + A wrapping flex row of capped-height logos so the funders distribute evenly + without orphaning the last logo onto its own row (issue #1271 defect #4). --- */ + +.project-funder-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.project-funder { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.project-funder-img { + max-height: 38px; + width: auto; + object-fit: contain; +} + diff --git a/website/templates/snippets/display_project_team_member_mobile.html b/website/templates/snippets/display_project_team_member_mobile.html new file mode 100644 index 00000000..dd298a79 --- /dev/null +++ b/website/templates/snippets/display_project_team_member_mobile.html @@ -0,0 +1,41 @@ +{% comment %} +================================================================================ +PROJECT TEAM MEMBER SNIPPET (mobile) — #1271 +================================================================================ + +Compact single-row rendering of one project lead for the mobile "Team" info-list: +a small round headshot (or initials fallback) + name + short inline role label. + +This is the mobile counterpart to display_project_lead_snippet.html (which renders +the desktop sidebar lead groups). Kept separate because the mobile presentation is +deliberately denser (one flat, seniority-ordered list with inline role suffixes +instead of grouped sub-sections). + +CONTEXT VARIABLES: + - lead: a ProjectRole with + .person (.url_name, .get_full_name, .image, .cropping) + .mobile_role_label: short label ("PI" / "Co-PI" / "Lead"), set in + website/views/project.py. + +DEPENDENCIES: project.css (.project-team-member* styles) +================================================================================ +{% endcomment %} +{% load thumbnail %} +{% with person=lead.person %} + +{% endwith %} diff --git a/website/templates/website/project.html b/website/templates/website/project.html index b52f1440..6940ed94 100644 --- a/website/templates/website/project.html +++ b/website/templates/website/project.html @@ -136,7 +136,19 @@

{{ project.name }}

- + + {% comment %} + Mobile-only metadata chips (#1271): a compact status / date / contributor + strip under the title. Hidden ≥992px (.project-mobile-meta), where the + sticky right sidebar carries this same information. Kept compact enough to + sit above the description without pushing it down the page. + {% endcomment %} +
+ {% if project.has_ended %}Completed{% else %}Active{% endif %} + {% if date_str %}{{ date_str }}{% endif %} + {% if num_contributors %}{{ num_contributors }} contributor{{ num_contributors|pluralize }}{% endif %} +
+
{{ project.name }} {% endif %} - + + {% comment %} + Mobile-only metadata info-list (#1271): Links (as clickable chips), Team + (leads, with "+N more" collapsed into a native
), and Funding. + Placed below the description + featured video so this supporting context no + longer sits above the description (the core complaint in #1271). Hidden + ≥992px, where the desktop sidebar renders the same data. + {% endcomment %} + {% if website or featured_code_repo_url or data_url or active_leads_ordered or sponsors %} +
+
+ + {% if website or featured_code_repo_url or data_url %} +
+
Links
+
+
+ {% if website %} + + Website + + {% endif %} + {% if featured_code_repo_url %} + + Code + + {% endif %} + {% if data_url %} + + Data + + {% endif %} +
+
+
+ {% endif %} + + {% if active_leads_ordered %} +
+
Team
+
+ {% for lead in active_leads_ordered|slice:":4" %} + {% include "snippets/display_project_team_member_mobile.html" with lead=lead %} + {% endfor %} + {% if active_leads_ordered|length > 4 %} +
+ + + + {{ active_leads_ordered|length|add:"-4" }} more + Show fewer + + {% for lead in active_leads_ordered|slice:"4:" %} + {% include "snippets/display_project_team_member_mobile.html" with lead=lead %} + {% endfor %} +
+ {% endif %} +
+
+ {% endif %} + + {% if sponsors %} +
+
Funding
+
+
+ {% for sponsor in sponsors %} + {% if sponsor.icon %} +
+ {% if sponsor.url %}{% endif %} + {{ sponsor.get_icon_alt_text }} + {% if sponsor.url %}{% endif %} +
+ {% endif %} + {% endfor %} +
+
+
+ {% endif %} + +
+
+ {% endif %} + {% if publications %}
`` disclosure ("+N more"). + +These blocks are mobile-only (CSS hides them ≥992px) but they are always present +in the rendered HTML, so we can assert on the markup here. The desktop sidebar +(`.project-sidebar`) must keep rendering unchanged. See #1271. +""" + +from datetime import date + +from django.urls import reverse + +from website.models import Grant, ProjectRole, Sponsor +from website.models.project_role import LeadProjectRoleTypes +from website.tests.base import DatabaseTestCase +from website.tests.factories import image_upload + + +class ProjectPageMobileMetaTests(DatabaseTestCase): + """Renders a fully-populated, visible project and asserts the mobile blocks.""" + + # The Team list shows this many leads before collapsing the rest into + #
. Kept in sync with project.html's slice value. + VISIBLE_LEADS = 4 + + def setUp(self): + # Ongoing (no end_date) → "Active"; start in 2021 → date range "2021–Present". + self.project = self.make_project( + name="Project Sidewalk", + short_name="sidewalk", + is_visible=True, + start_date=date(2021, 1, 1), + website="https://example.org/sidewalk", + data_url="https://example.org/sidewalk/data", + ) + + # 1 PI + 6 student leads = 7 leads, which exceeds VISIBLE_LEADS so the + # "+N more"
overflow is exercised. + self.pi = self.make_person(first_name="Jon", last_name="Froehlich") + ProjectRole.objects.create( + person=self.pi, project=self.project, + lead_project_role=LeadProjectRoleTypes.PI, start_date=date(2021, 1, 1), + ) + self.leads = [] + for i in range(6): + person = self.make_person(first_name=f"Lead{i}", last_name="Student") + ProjectRole.objects.create( + person=person, project=self.project, + lead_project_role=LeadProjectRoleTypes.STUDENT_LEAD, + start_date=date(2021, 1, 1), + ) + self.leads.append(person) + + # One sponsor via a grant so the Funding row renders. + sponsor = Sponsor.objects.create( + name="Demo Science Foundation", short_name="DSF", + url="https://example.org/dsf", alt_text="DSF logo", + icon=image_upload("dsf_icon.gif"), + ) + grant = Grant.objects.create( + title="Demo Grant: sidewalk", sponsor=sponsor, date=date(2021, 1, 1), + ) + grant.projects.add(self.project) + + self.url = reverse("website:project", args=[self.project.short_name]) + + def _get(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + return response.content.decode() + + def test_mobile_meta_block_renders(self): + html = self._get() + self.assertIn("project-mobile-meta", html) + + def test_status_chip_shows_active_for_ongoing_project(self): + html = self._get() + self.assertIn("project-meta-chips", html) + self.assertIn("Active", html) + + def test_chips_show_date_range_and_contributor_count(self): + html = self._get() + # Ongoing project starting 2021 → "2021–Present". + self.assertIn("2021", html) + # 7 people in roles → contributor count chip. + self.assertIn("contributor", html.lower()) + + def test_links_render_as_clickable_chips(self): + html = self._get() + self.assertIn("project-meta-link", html) + self.assertIn("https://example.org/sidewalk", html) + self.assertIn("https://example.org/sidewalk/data", html) + + def test_team_overflow_collapses_into_details(self): + html = self._get() + # The PI is among the always-visible leads. + self.assertIn("Jon Froehlich", html) + # With 7 leads > VISIBLE_LEADS, the overflow disclosure must appear. + self.assertIn("project-team-more", html) + self.assertIn(" without per-group logic. + # Order mirrors the sidebar: PIs → Co-PIs → research scientists → postdocs → + # student leads. Each role carries a short inline label ("PI"/"Co-PI"/"Lead"). + _lead_labels = { + LeadProjectRoleTypes.PI: 'PI', + LeadProjectRoleTypes.CO_PI: 'Co-PI', + } + active_leads_ordered = [] + _seen_lead_person_ids = set() + for _role in (project_leadership['active_PIs'] + + project_leadership['active_CoPIs'] + + project_leadership['active_research_scientist_leads'] + + project_leadership['active_postdoc_leads'] + + project_leadership['active_student_leads']): + if _role.person_id in _seen_lead_person_ids: + continue + _seen_lead_person_ids.add(_role.person_id) + # Transient attribute read by the mobile team snippet; not persisted. + _role.mobile_role_label = _lead_labels.get(_role.lead_project_role, 'Lead') + active_leads_ordered.append(_role) + # Query for related projects. Limit to top 5 # Get all candidates first related_project_candidates = project.get_related_projects_by_umbrella(match_all_umbrellas=True) @@ -133,6 +157,7 @@ def project(request, project_name): 'inactive_student_leads': project_leadership["inactive_student_leads"], 'active_postdoc_leads': project_leadership["active_postdoc_leads"], 'active_research_scientist_leads': project_leadership["active_research_scientist_leads"], + 'active_leads_ordered': active_leads_ordered, 'related_projects': related_projects, 'has_videos_beyond_featured_video': has_videos_beyond_featured_video, 'debug': settings.DEBUG}