diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py
index 4e0096b1859..c995ef9e770 100644
--- a/ietf/group/tests_info.py
+++ b/ietf/group/tests_info.py
@@ -2147,7 +2147,66 @@ def test_meeting_info(self):
self.assertEqual(response.status_code, 200)
q = PyQuery(response.content)
self.assertFalse(q('#inprogressmeets'))
-
+
+
+class PendingInterimMeetingTests(TestCase):
+ """Tests for the pending-interim warning on a group's meetings list.
+
+ The meetings page shows a ``#pending_warning`` banner when the group has an
+ interim meeting that is either awaiting approval (session status ``apprw``)
+ or approved but not yet announced (session status ``scheda``). See
+ ietf.meeting.helpers.has_pending_interim.
+ """
+
+ def _meetings_page(self, group):
+ url = urlreverse('ietf.group.views.meetings', kwargs={'acronym': group.acronym})
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ return PyQuery(response.content)
+
+ def test_pending_approval_interim_shows_warning(self):
+ """An interim awaiting approval (apprw) triggers the warning."""
+ group = GroupFactory.create(type_id='wg')
+ SessionFactory.create(meeting__type_id='interim', group=group, status_id='apprw')
+ q = self._meetings_page(group)
+ warning = q('#pending_warning')
+ self.assertTrue(warning)
+ # The warning links to both the pending-approval and to-be-announced views
+ # and names the group.
+ self.assertIn(urlreverse('ietf.meeting.views.interim_pending'),
+ [a.attrib['href'] for a in warning.find('a')])
+ self.assertIn(urlreverse('ietf.meeting.views.interim_announce'),
+ [a.attrib['href'] for a in warning.find('a')])
+ self.assertIn(group.acronym, warning.text())
+
+ def test_to_be_announced_interim_shows_warning(self):
+ """An approved-but-unannounced interim (scheda) triggers the warning."""
+ group = GroupFactory.create(type_id='wg')
+ SessionFactory.create(meeting__type_id='interim', group=group, status_id='scheda')
+ q = self._meetings_page(group)
+ self.assertTrue(q('#pending_warning'))
+
+ def test_scheduled_interim_shows_no_warning(self):
+ """A fully scheduled interim (sched) does not trigger the warning."""
+ group = GroupFactory.create(type_id='wg')
+ SessionFactory.create(meeting__type_id='interim', group=group, status_id='sched')
+ q = self._meetings_page(group)
+ self.assertFalse(q('#pending_warning'))
+
+ def test_no_interim_meetings_shows_no_warning(self):
+ """A group with no interim meetings does not trigger the warning."""
+ group = GroupFactory.create(type_id='wg')
+ q = self._meetings_page(group)
+ self.assertFalse(q('#pending_warning'))
+
+ def test_pending_interim_for_other_group_not_shown(self):
+ """A pending interim belonging to another group must not warn on this group."""
+ group = GroupFactory.create(type_id='wg')
+ other = GroupFactory.create(type_id='wg')
+ SessionFactory.create(meeting__type_id='interim', group=other, status_id='apprw')
+ q = self._meetings_page(group)
+ self.assertFalse(q('#pending_warning'))
+
class StatusUpdateTests(TestCase):
diff --git a/ietf/group/views.py b/ietf/group/views.py
index 8561a5059fc..eddd2499e06 100644
--- a/ietf/group/views.py
+++ b/ietf/group/views.py
@@ -92,7 +92,7 @@
#
from ietf.ietfauth.utils import has_role, is_authorized_in_group
from ietf.mailtrigger.utils import gather_relevant_expansions
-from ietf.meeting.helpers import get_meeting
+from ietf.meeting.helpers import get_meeting, has_pending_interim
from ietf.meeting.models import ImportantDate, SchedTimeSessAssignment, SchedulingEvent
from ietf.meeting.utils import group_sessions
from ietf.name.models import GroupTypeName, StreamName
@@ -953,6 +953,8 @@ def meetings(request, acronym, group_type=None):
future, in_progress, recent, past = group_sessions(sessions)
+ pending_interims_flag = has_pending_interim(group.acronym)
+
can_edit = group.has_role(request.user, group.features.groupman_roles)
can_always_edit = has_role(request.user, ["Secretariat", "Area Director"])
@@ -996,6 +998,7 @@ def meetings(request, acronym, group_type=None):
"can_edit": can_edit,
"can_always_edit": can_always_edit,
"cal_actions": cal_actions,
+ "pending_interims": pending_interims_flag,
},
),
)
diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py
index 39d271ae6b9..98ff3e68498 100644
--- a/ietf/meeting/helpers.py
+++ b/ietf/meeting/helpers.py
@@ -1,4 +1,4 @@
-# Copyright The IETF Trust 2013-2022, All Rights Reserved
+# Copyright The IETF Trust 2013-2026, All Rights Reserved
# -*- coding: utf-8 -*-
@@ -28,7 +28,7 @@
from ietf.mailtrigger.utils import gather_address_lists
from ietf.person.models import Person
from ietf.meeting.models import Meeting, Schedule, TimeSlot, SchedTimeSessAssignment, ImportantDate, SchedulingEvent, Session
-from ietf.meeting.utils import session_requested_by, add_event_info_to_session_qs
+from ietf.meeting.utils import session_requested_by, add_event_info_to_session_qs, data_for_meetings_overview
from ietf.name.models import ImportantDateName, SessionPurposeName
from ietf.utils import log, meetecho
from ietf.utils.mail import send_mail
@@ -773,6 +773,7 @@ def can_edit_interim_request(meeting, user):
def can_request_interim_meeting(user):
return can_manage_some_groups(user)
+
def can_view_interim_request(meeting, user):
'''Returns True if the user can see the pending interim request in the pending interim view'''
if meeting.type.slug != 'interim':
@@ -855,6 +856,29 @@ def get_earliest_session_date(formset):
def is_interim_meeting_approved(meeting):
return add_event_info_to_session_qs(meeting.session_set.all()).first().current_status == 'apprw'
+
+def has_pending_interim(acronym):
+ '''
+ This function takes a group acronym and returns True if that group has at least
+ one pending interim meeting request or a to-be-announced interim request.
+ '''
+ rv = False
+ pending = data_for_meetings_overview(Meeting.objects.filter(type='interim'), interim_status='apprw')
+ for m in pending:
+ if m.responsible_group.acronym == acronym:
+ rv = True
+ break
+
+ if not rv:
+ to_be_announced = data_for_meetings_overview(Meeting.objects.filter(type='interim'), interim_status='scheda')
+ for m in to_be_announced:
+ if m.responsible_group.acronym == acronym:
+ rv = True
+ break
+
+ return rv
+
+
def get_next_interim_number(acronym,date):
'''
This function takes a group acronym and date object and returns the next number
@@ -869,6 +893,7 @@ def get_next_interim_number(acronym,date):
serial = 0
return "%s%02d" % (base, serial+1)
+
def get_next_agenda_name(meeting):
"""Returns the next name to use for an agenda document for *meeting*"""
group = meeting.session_set.first().group
@@ -941,6 +966,7 @@ def send_interim_approval_request(meetings):
context,
cc=cc_list)
+
def send_interim_approval(user, meeting):
"""Send an email to chairs and whoever initiated the action that resulted in approval that an interim is approved"""
first_session = meeting.session_set.first()
@@ -961,6 +987,7 @@ def send_interim_approval(user, meeting):
context,
cc=cc_list)
+
def send_interim_announcement_request(meeting):
"""Sends an email to the secretariat that an interim meeting is ready for
announcement, includes the link to send the official announcement"""
@@ -981,6 +1008,7 @@ def send_interim_announcement_request(meeting):
context,
cc_list)
+
def send_interim_meeting_cancellation_notice(meeting):
"""Sends an email that a scheduled interim meeting has been cancelled."""
session = meeting.session_set.first()
@@ -1182,12 +1210,14 @@ def update_interim_session_assignment(form):
session=session,
schedule=meeting.schedule)
+
def populate_important_dates(meeting):
assert ImportantDate.objects.filter(meeting=meeting).exists() is False
assert meeting.type_id=='ietf'
for datename in ImportantDateName.objects.filter(used=True):
ImportantDate.objects.create(meeting=meeting,name=datename,date=meeting.date+datetime.timedelta(days=datename.default_offset_days))
+
def update_important_dates(meeting):
assert meeting.type_id=='ietf'
for datename in ImportantDateName.objects.filter(used=True):
diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py
index ffd37fc363d..a25998dac91 100644
--- a/ietf/meeting/utils.py
+++ b/ietf/meeting/utils.py
@@ -20,7 +20,7 @@
from django.core.cache import caches
from django.core.files.base import ContentFile
from django.db import IntegrityError
-from django.db.models import OuterRef, Subquery, TextField, Q, Value, Max
+from django.db.models import Exists, OuterRef, Subquery, TextField, Q, Value, Max
from django.db.models.functions import Coalesce
from django.template.loader import render_to_string
from django.utils import timezone
@@ -351,49 +351,60 @@ def data_for_meetings_overview(meetings, interim_status=None):
"""Return filtered meetings with sessions and group hierarchy (for the
interim menu)."""
+ # filter
+ if interim_status == "apprw":
+ session_status_condition = Q(current_status="apprw")
+ elif interim_status == "scheda":
+ session_status_condition = Q(current_status="scheda")
+ else:
+ session_status_condition = ~Q(
+ current_status__in=["apprw", "scheda", "canceledpa"]
+ )
+
+ meetings = meetings.filter(
+ ~Q(type_id="interim")
+ | Exists(
+ Session.objects.filter(meeting=OuterRef("pk"))
+ .with_current_status()
+ .filter(session_status_condition)
+ )
+ )
+
# extract sessions
for m in meetings:
m.sessions = []
- sessions = Session.objects.filter(
- meeting__in=meetings
- ).order_by(
- 'meeting', 'pk'
- ).with_current_status(
- ).select_related(
- 'group', 'group__parent'
+ sessions = (
+ Session.objects.filter(
+ meeting__in=meetings,
+ meeting__type_id="interim",
+ )
+ .order_by("meeting", "pk")
+ .with_current_status()
+ .select_related("group", "group__parent")
)
meeting_dict = {m.pk: m for m in meetings}
for s in sessions.iterator():
meeting_dict[s.meeting_id].sessions.append(s)
- # filter
- if interim_status == 'apprw':
- meetings = [
- m for m in meetings
- if not m.type_id == 'interim' or any(s.current_status == 'apprw' for s in m.sessions)
- ]
-
- elif interim_status == 'scheda':
- meetings = [
- m for m in meetings
- if not m.type_id == 'interim' or any(s.current_status == 'scheda' for s in m.sessions)
- ]
-
- else:
- meetings = [
- m for m in meetings
- if not m.type_id == 'interim' or not all(s.current_status in ['apprw', 'scheda', 'canceledpa'] for s in m.sessions)
- ]
-
- ietf_group = Group.objects.get(acronym='ietf')
+ ietf_group = (
+ Group.objects.get(acronym="ietf")
+ if any(m.type_id != "interim" for m in meetings)
+ else None
+ )
# set some useful attributes
for m in meetings:
m.end = m.date + datetime.timedelta(days=m.days)
- m.responsible_group = (m.sessions[0].group if m.sessions else None) if m.type_id == 'interim' else ietf_group
- m.interim_meeting_cancelled = m.type_id == 'interim' and all(s.current_status == 'canceled' for s in m.sessions)
+ m.responsible_group = (
+ (m.sessions[0].group if m.sessions else None)
+ if m.type_id == "interim"
+ else ietf_group
+ )
+ m.interim_meeting_cancelled = m.type_id == "interim" and all(
+ s.current_status == "canceled" for s in m.sessions
+ )
return meetings
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
index cd7f43771b3..b2f15fb2f31 100644
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -4110,15 +4110,29 @@ def delete_schedule(request, num, owner, name):
# Interim Views
# -------------------------------------------------
def interim_announce(request):
- '''View which shows interim meeting requests awaiting announcement'''
- meetings = data_for_meetings_overview(Meeting.objects.filter(type='interim').order_by('date'), interim_status='scheda')
+ """View which shows interim meeting requests awaiting announcement"""
+ # Ignore meetings older than this - gives people time to notice that a meeting was
+ # left as "scheda" and complain before the meeting falls off the visible list.
+ MAX_LOOKBACK = datetime.timedelta(days=28)
+
+ meetings = data_for_meetings_overview(
+ Meeting.objects.filter(
+ type="interim", date__gte=date_today() - MAX_LOOKBACK
+ ).order_by("date"),
+ interim_status="scheda",
+ )
menu_entries = get_interim_menu_entries(request)
- selected_menu_entry = 'announce'
+ selected_menu_entry = "announce"
- return render(request, "meeting/interim_announce.html", {
- 'menu_entries': menu_entries,
- 'selected_menu_entry': selected_menu_entry,
- 'meetings': meetings})
+ return render(
+ request,
+ "meeting/interim_announce.html",
+ {
+ "menu_entries": menu_entries,
+ "selected_menu_entry": selected_menu_entry,
+ "meetings": meetings,
+ },
+ )
@role_required('Secretariat',)
@@ -4173,21 +4187,34 @@ def interim_skip_announcement(request, number):
def interim_pending(request):
-
- '''View which shows interim meeting requests pending approval'''
- meetings = data_for_meetings_overview(Meeting.objects.filter(type='interim').order_by('date'), interim_status='apprw')
+ """View which shows interim meeting requests pending approval"""
+ # Ignore meetings older than this - gives people time to notice that a meeting was
+ # left as "apprw" and complain before the meeting falls off the visible list.
+ MAX_LOOKBACK = datetime.timedelta(days=28)
+
+ meetings = data_for_meetings_overview(
+ Meeting.objects.filter(
+ type="interim", date__gte=date_today() - MAX_LOOKBACK
+ ).order_by("date"),
+ interim_status="apprw",
+ )
menu_entries = get_interim_menu_entries(request)
- selected_menu_entry = 'pending'
+ selected_menu_entry = "pending"
for meeting in meetings:
if can_approve_interim_request(meeting, request.user):
meeting.can_approve = True
- return render(request, "meeting/interim_pending.html", {
- 'menu_entries': menu_entries,
- 'selected_menu_entry': selected_menu_entry,
- 'meetings': meetings})
+ return render(
+ request,
+ "meeting/interim_pending.html",
+ {
+ "menu_entries": menu_entries,
+ "selected_menu_entry": selected_menu_entry,
+ "meetings": meetings,
+ },
+ )
@login_required
diff --git a/ietf/person/models.py b/ietf/person/models.py
index 8b05c5f8a61..13c24bd1058 100644
--- a/ietf/person/models.py
+++ b/ietf/person/models.py
@@ -278,9 +278,21 @@ def rfcs(self):
# When RfcAuthors are populated, this may over-return if an author is dropped
# from the author list between the final draft and the published RFC. Should
# ignore DocumentAuthors when an RfcAuthor exists for a draft.
- rfcs = list(Document.objects.filter(type="rfc").filter(models.Q(documentauthor__person=self)|models.Q(rfcauthor__person=self)).distinct())
- rfcs.sort(key=lambda d: d.name )
- return rfcs
+ #
+ # The two authorship tables are queried separately and combined here. As a
+ # single ORed queryset, neither person_id index is usable and the join has to
+ # be materialized in full before being deduplicated.
+ ids = set(
+ Document.objects.filter(
+ type="rfc", documentauthor__person=self
+ ).values_list("pk", flat=True)
+ )
+ ids.update(
+ Document.objects.filter(
+ type="rfc", rfcauthor__person=self
+ ).values_list("pk", flat=True)
+ )
+ return sorted(Document.objects.filter(pk__in=ids), key=lambda d: d.name)
def active_drafts(self):
from ietf.doc.models import Document
diff --git a/ietf/person/tests.py b/ietf/person/tests.py
index 0af8271594f..848152183cf 100644
--- a/ietf/person/tests.py
+++ b/ietf/person/tests.py
@@ -27,6 +27,7 @@
import debug # pyflakes:ignore
from ietf.community.models import CommunityList
+from ietf.doc.factories import WgDraftFactory, WgRfcFactory
from ietf.group.factories import RoleFactory
from ietf.group.models import Group
from ietf.message.models import Message
@@ -125,6 +126,61 @@ def test_person_profile(self):
r = self.client.get(photo_url)
self.assertEqual(r.status_code, 200)
+ def test_person_profile_query_count(self):
+ """The page's cost must not scale with how much a person has written"""
+
+ def profile_queries(rfcs, active, expired):
+ person = PersonFactory()
+ RoleFactory(person=person, name_id="chair")
+ WgRfcFactory.create_batch(rfcs, authors=[person])
+ WgDraftFactory.create_batch(active, authors=[person])
+ WgDraftFactory.create_batch(
+ expired, authors=[person], states=[("draft", "expired")]
+ )
+ url = urlreverse(
+ "ietf.person.views.profile",
+ kwargs={"email_or_name": person.plain_name()},
+ )
+ with CaptureQueriesContext(connection) as context:
+ r = self.client.get(url)
+ self.assertEqual(r.status_code, 200)
+ return len(context.captured_queries)
+
+ few = profile_queries(1, 1, 1)
+ many = profile_queries(6, 4, 5)
+ self.assertEqual(
+ many,
+ few,
+ f"{many} queries for 15 documents vs {few} for 3 - a query per row crept in",
+ )
+
+ @override_settings(
+ CACHES={
+ "default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"},
+ "slowpages": {
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+ "LOCATION": "test-person-profile",
+ },
+ }
+ )
+ def test_person_profile_sections_cached(self):
+ person = PersonFactory()
+ WgRfcFactory(authors=[person])
+ WgDraftFactory(authors=[person])
+ url = urlreverse(
+ "ietf.person.views.profile", kwargs={"email_or_name": person.plain_name()}
+ )
+
+ first = self.client.get(url)
+ self.assertEqual(first.status_code, 200)
+ with CaptureQueriesContext(connection) as context:
+ second = self.client.get(url)
+ self.assertEqual(second.status_code, 200)
+ # The cached section is HTML, not text to be escaped again.
+ self.assertEqual(first.content, second.content)
+ self.assertContains(second, person.name)
+ self.assertLess(len(context.captured_queries), 5)
+
def test_person_profile_without_email(self):
person = PersonFactory(name="foobar@example.com")
# delete Email record
diff --git a/ietf/person/utils.py b/ietf/person/utils.py
index 3f00a04cd0d..84b98dba2f1 100755
--- a/ietf/person/utils.py
+++ b/ietf/person/utils.py
@@ -337,11 +337,14 @@ def get_dots(person):
return dots
def lookup_persons(email_or_name):
- aliases = Alias.objects.filter(name__iexact=email_or_name)
+ aliases = Alias.objects.filter(name__iexact=email_or_name).select_related("person")
persons = set(a.person for a in aliases)
if '@' in email_or_name:
- emails = Email.objects.filter(address__iexact=email_or_name)
+ # Email.address is a citext column, so an exact match is already
+ # case-insensitive and can use the index on it. Asking for iexact wraps the
+ # column in UPPER() and costs a scan of the table.
+ emails = Email.objects.filter(address=email_or_name).select_related("person")
persons.update(e.person for e in emails)
persons = [p for p in persons if p and p.id]
diff --git a/ietf/person/views.py b/ietf/person/views.py
index 52137f0fb91..4c03f138dcc 100644
--- a/ietf/person/views.py
+++ b/ietf/person/views.py
@@ -7,14 +7,15 @@
from django.conf import settings
from django.contrib import messages
-from django.db.models import Q
+from django.core.cache import caches
+from django.db.models import Count, Q
from django.http import HttpResponse, Http404
from django.shortcuts import render, redirect
from django.template.loader import render_to_string
-from django.utils import timezone
import debug # pyflakes:ignore
+from ietf.doc.models import DocEvent, RelatedDocument
from ietf.ietfauth.utils import role_required
from ietf.person.models import Email, Person
from ietf.person.fields import select2_id_name_json
@@ -26,6 +27,9 @@
merge_persons,
)
from ietf.utils.mail import send_mail_text
+from ietf.utils.timezone import RPC_TZINFO
+
+REFERENCE_RELATIONSHIPS = ("refnorm", "refinfo", "refunk", "refold")
def ajax_select2_search(request, model_name):
@@ -77,9 +81,136 @@ def ajax_select2_search(request, model_name):
return HttpResponse(select2_id_name_json(objs), content_type='application/json')
+def rfc_rows(persons):
+ """Build the RFC table rows for each person
+
+ Returns a dict keyed on person pk. The columns are gathered for every person at
+ once - read one at a time off the Document, each row costs a query per column.
+ """
+ rfcs = {p.pk: p.rfcs() for p in persons}
+ rfc_ids = {d.pk for docs in rfcs.values() for d in docs}
+
+ # The references of the draft an RFC was published from count as the RFC's own.
+ draft_of = dict(
+ RelatedDocument.objects.filter(
+ target_id__in=rfc_ids, relationship="became_rfc"
+ ).values_list("target_id", "source_id")
+ )
+ referenced_by = dict(
+ RelatedDocument.objects.filter(
+ target_id__in=rfc_ids | set(draft_of.values()),
+ relationship__in=REFERENCE_RELATIONSHIPS,
+ source__type__slug="rfc",
+ )
+ .values("target_id")
+ .annotate(count=Count("id"))
+ .values_list("target_id", "count")
+ )
+
+ # Matches Document.latest_event ordering, so the first row seen for a document
+ # is the one its pub_date would have reported.
+ published = {}
+ for doc_id, time in (
+ DocEvent.objects.filter(doc_id__in=rfc_ids, type="published_rfc")
+ .order_by("-time", "-id")
+ .values_list("doc_id", "time")
+ ):
+ published.setdefault(doc_id, time)
+
+ return {
+ pk: [
+ {
+ "doc": doc,
+ "pub_date": (
+ published[doc.pk].astimezone(RPC_TZINFO).date()
+ if doc.pk in published
+ else None
+ ),
+ "referenced_by": referenced_by.get(doc.pk, 0)
+ + referenced_by.get(draft_of.get(doc.pk), 0),
+ }
+ for doc in docs
+ ]
+ for pk, docs in rfcs.items()
+ }
+
+
+def profile_data(persons):
+ """Build everything person/profile.html renders for each of persons"""
+ rfcs = rfc_rows(persons)
+ expired = {p.pk: list(p.expired_drafts().prefetch_related("states")) for p in persons}
+ replaced = set(
+ RelatedDocument.objects.filter(
+ target_id__in={d.pk for docs in expired.values() for d in docs},
+ relationship="replaces",
+ ).values_list("target_id", flat=True)
+ )
+
+ profiles = []
+ for person in persons:
+ # Role.Meta orders by name_id alone, which leaves ties to the query plan.
+ roles = sorted(
+ person.role_set.select_related("name", "group", "email"),
+ key=lambda r: (r.name_id, r.group.acronym),
+ )
+ profiles.append(
+ {
+ "person": person,
+ "has_roles": bool(roles),
+ "roles": [
+ r
+ for r in roles
+ if r.group.state_id in ["active", "bof"]
+ and r.group.acronym != "secretariat"
+ ],
+ "ext_resources": list(
+ person.personextresource_set.select_related("name")
+ ),
+ "rfcs": rfcs[person.pk],
+ "active_drafts": list(
+ person.active_drafts().prefetch_related("states")
+ ),
+ "expired_drafts": [
+ d for d in expired[person.pk] if d.pk not in replaced
+ ],
+ "has_drafts": person.has_drafts(),
+ }
+ )
+ return profiles
+
+
+def profile_sections(persons):
+ """Render each person's part of the profile page
+
+ The rendered sections are cached, so a repeat view of a profile - including the
+ revalidation a conditional request makes - costs neither the queries nor the
+ render. Nothing in a section is tied to the moment it was rendered, so how stale
+ one can be is entirely PERSON_PROFILE_CACHE_SECONDS.
+ """
+ slowpages = caches["slowpages"]
+ keys = {person.pk: f"person:profile:{person.pk}" for person in persons}
+ sections = slowpages.get_many(list(keys.values()))
+
+ uncached = [person for person in persons if keys[person.pk] not in sections]
+ for profile in profile_data(uncached):
+ person = profile["person"]
+ section = {
+ "id": person.pk,
+ "name": str(person),
+ "has_drafts": profile["has_drafts"],
+ "html": render_to_string("person/profile_body.html", {"profile": profile}),
+ }
+ slowpages.set(keys[person.pk], section, settings.PERSON_PROFILE_CACHE_SECONDS)
+ sections[keys[person.pk]] = section
+
+ return [sections[keys[person.pk]] for person in persons]
+
+
def profile(request, email_or_name):
persons = lookup_persons(email_or_name)
- return render(request, 'person/profile.html', {'persons': persons, 'today': timezone.now()})
+ return render(
+ request, "person/profile.html", {"sections": profile_sections(persons)}
+ )
def profile_by_uuid(request, uuid):
@@ -95,7 +226,7 @@ def profile_by_uuid(request, uuid):
return render(
request,
"person/profile.html",
- {"persons": [person_uuid.person], "today": timezone.now()},
+ {"sections": profile_sections([person_uuid.person])},
)
diff --git a/ietf/settings.py b/ietf/settings.py
index e381ab6c1db..3e26e22e840 100644
--- a/ietf/settings.py
+++ b/ietf/settings.py
@@ -886,6 +886,10 @@ def skip_unreadable_post(record):
PDFIZER_CACHE_TIME = HTMLIZER_CACHE_TIME
PDFIZER_URL_PREFIX = IDTRACKER_BASE_URL+"/doc/pdf"
+# How long a rendered person profile section is served from the slowpages cache.
+# This is how stale a profile's roles and documents can be.
+PERSON_PROFILE_CACHE_SECONDS = 60*15 # 15 minutes
+
# Email settings
IPR_EMAIL_FROM = 'ietf-ipr@ietf.org'
AUDIO_IMPORT_EMAIL = ['ietf@meetecho.com']
diff --git a/ietf/templates/group/meetings.html b/ietf/templates/group/meetings.html
index 30f478da131..e8511539cc1 100644
--- a/ietf/templates/group/meetings.html
+++ b/ietf/templates/group/meetings.html
@@ -1,4 +1,4 @@
-{# Copyright The IETF Trust 2025, All Rights Reserved #}
+{# Copyright The IETF Trust 2026, All Rights Reserved #}
{% extends "group/group_base.html" %}
{% load origin static %}
{% block title %}
@@ -38,16 +38,19 @@
Meetings in progress
{% endwith %}
{% endif %}
- {% if future %}
+ {% if future or pending_interims %}