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 %}

Future Meetings - {% for cal_action in cal_actions %} - - {{ cal_action.label }} - - {% endfor %} + {% if future %} + {% for cal_action in cal_actions %} + + {{ cal_action.label }} + + {% endfor %} + {% endif %}

+ {% if future %} @@ -63,6 +66,14 @@

{% endwith %}

+ {% endif %} + {% if pending_interims %} +

+ One or more {{ group.acronym }} interim meetings are + pending approval or + waiting to be announced. +

+ {% endif %} {% endif %} {% if past or recent %}

Past Meetings (within the last four years)

diff --git a/ietf/templates/person/profile.html b/ietf/templates/person/profile.html index a78a90412f7..3704d5a10f3 100644 --- a/ietf/templates/person/profile.html +++ b/ietf/templates/person/profile.html @@ -1,175 +1,22 @@ {% extends "base.html" %} -{# Copyright The IETF Trust 2015-2022, All Rights Reserved #} +{# Copyright The IETF Trust 2015-2026, All Rights Reserved #} {% load origin %} -{% load markup_tags %} {% load static %} -{% load ietf_filters textfilters %} -{% load group_filters %} {% block pagehead %} {% endblock %} -{% block title %}Profile for {{ persons.0 }}{% endblock %} +{% block title %}Profile for {{ sections.0.name }}{% endblock %} {% block content %} {% origin %} - {% if persons|length > 1 %} + {% if sections|length > 1 %}

More than one person with this name has been found. Showing all.

{% endif %} - {% for person in persons %} + {% for section in sections %} {% if not forloop.first %}
{% endif %} -

- {{ person.name }} - {% if person.ascii != person.name %} -
- ({{ person.ascii }}) - {% endif %} - {% if person.pronouns %} -
- Pronouns: {{person.pronouns}} - {% endif %} -

-
- {% if person.photo %} -
{% include "person/photo.html" with person=person %}
- {% endif %} - {{ person.biography|apply_markup:"restructuredtext"|urlize_ietf_docs|linkify }} -
- {% if person.role_set.exists %} -

Roles

- {% if person.role_set.all|active_roles %} - - - - - - - - - - {% for role in person.role_set.all|active_roles %} - - - - - - {% endfor %} - -
RoleGroupEmail
{{ role.name.name }} - {% if role.name.name == 'Reviewer' %} - (See reviews) - {% endif %} - - {{ role.group.name }} - ({{ role.group.acronym }}) - - {{ role.email.address }} -
- {% else %} -

{{ person.first_name }} has no active roles as of {{ today|date:"Y-m-d" }}.

- {% endif %} - {% endif %} - {% if person.personextresource_set.exists %} -

External Resources

- - - - - - - - - {% for extres in person.personextresource_set.all %} - - - - - {% endfor %} - -
NameValue
- {% firstof extres.display_name extres.name.name %} - {{ extres.value|linkify }}
- {% endif %} -

- RFCs ({{ person.rfcs|length }}) -

- {% if person.rfcs %} - - - - - - - - - - - {% for doc in person.rfcs %} - - - - - - - {% endfor %} - -
RFCDateTitleCited by
- RFC {{ doc.rfc_number }} - {{ doc.pub_date|date:"b Y"|title }}{{ doc.title|urlize_ietf_docs }} - {% with doc.referenced_by_rfcs_as_rfc_or_draft.count as refbycount %} - {% if refbycount %} - - {{ refbycount }} RFC{{ refbycount|pluralize }} - - {% endif %} - {% endwith %} -
- {% else %} - {{ person.first_name }} has no RFCs as of {{ today|date:"Y-m-d" }}. - {% endif %} -

- Active Internet-Drafts ({{ person.active_drafts|length }}) -

- {% if person.active_drafts.exists %} - - {% else %} - {{ person.first_name }} has no active Internet-Drafts as of {{ today|date:"Y-m-d" }}. - {% endif %} -

- Expired Internet-Drafts ({{ person.expired_drafts|length }}) -

- {% if person.expired_drafts.exists %} - - (Excluding replaced Internet-Drafts.) - {% else %} - {{ person.first_name }} has no expired Internet-Drafts as of {{ today|date:"Y-m-d" }}. - {% endif %} - {% if person.has_drafts %} -

- Internet-Draft Activity -

-
-
- {% endif %} + {{ section.html }} {% endfor %} {% endblock %} {% block js %} @@ -177,13 +24,13 @@

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/ietf/templates/person/profile_body.html b/ietf/templates/person/profile_body.html new file mode 100644 index 00000000000..ff9970b1fb6 --- /dev/null +++ b/ietf/templates/person/profile_body.html @@ -0,0 +1,152 @@ +{# Copyright The IETF Trust 2015-2026, All Rights Reserved #} +{% load markup_tags %} +{% load ietf_filters textfilters %} +{% with person=profile.person %} +

+ {{ person.name }} + {% if person.ascii != person.name %} +
+ ({{ person.ascii }}) + {% endif %} + {% if person.pronouns %} +
+ Pronouns: {{person.pronouns}} + {% endif %} +

+
+ {% if person.photo %} +
{% include "person/photo.html" with person=person %}
+ {% endif %} + {{ person.biography|apply_markup:"restructuredtext"|urlize_ietf_docs|linkify }} +
+ {% if profile.has_roles %} +

Roles

+ {% if profile.roles %} + + + + + + + + + + {% for role in profile.roles %} + + + + + + {% endfor %} + +
RoleGroupEmail
{{ role.name.name }} + {% if role.name.name == 'Reviewer' %} + (See reviews) + {% endif %} + + {{ role.group.name }} + ({{ role.group.acronym }}) + + {{ role.email.address }} +
+ {% else %} +

{{ person.first_name }} does not currently have any active roles.

+ {% endif %} + {% endif %} + {% if profile.ext_resources %} +

External Resources

+ + + + + + + + + {% for extres in profile.ext_resources %} + + + + + {% endfor %} + +
NameValue
+ {% firstof extres.display_name extres.name.name %} + {{ extres.value|linkify }}
+ {% endif %} +

+ RFCs ({{ profile.rfcs|length }}) +

+ {% if profile.rfcs %} + + + + + + + + + + + {% for row in profile.rfcs %} + + + + + + + {% endfor %} + +
RFCDateTitleCited by
+ RFC {{ row.doc.rfc_number }} + {{ row.pub_date|date:"b Y"|title }}{{ row.doc.title|urlize_ietf_docs }} + {% if row.referenced_by %} + + {{ row.referenced_by }} RFC{{ row.referenced_by|pluralize }} + + {% endif %} +
+ {% else %} + {{ person.first_name }} has no RFCs. + {% endif %} +

+ Active Internet-Drafts ({{ profile.active_drafts|length }}) +

+ {% if profile.active_drafts %} + + {% else %} + {{ person.first_name }} has no active Internet-Drafts. + {% endif %} +

+ Expired Internet-Drafts ({{ profile.expired_drafts|length }}) +

+ {% if profile.expired_drafts %} + + (Excluding replaced Internet-Drafts.) + {% else %} + {{ person.first_name }} has no expired Internet-Drafts. + {% endif %} + {% if profile.has_drafts %} +

+ Internet-Draft Activity +

+
+
+ {% endif %} +{% endwith %} diff --git a/k8s/secrets.yaml.example b/k8s/secrets.yaml.example index 38fb06b3670..70931018a8e 100644 --- a/k8s/secrets.yaml.example +++ b/k8s/secrets.yaml.example @@ -77,6 +77,10 @@ stringData: # DATATRACKER_CSRF_TRUSTED_ORIGINS: |- # https://datatracker.staging.ietf.org + # How long a rendered person profile section is cached, in seconds. + # Defaults to 900. Set to 0 to serve every profile fresh. + # DATATRACKER_PERSON_PROFILE_CACHE_SECONDS: "900" + # Scout configuration DATATRACKER_SCOUT_KEY: "this-is-the-scout-key" DATATRACKER_SCOUT_NAME: "StagingDatatracker" diff --git a/k8s/settings_local.py b/k8s/settings_local.py index e12599e18e9..898cfb56304 100644 --- a/k8s/settings_local.py +++ b/k8s/settings_local.py @@ -364,6 +364,12 @@ def _multiline_to_list(s): }, } +_person_profile_cache_seconds = os.environ.get( + "DATATRACKER_PERSON_PROFILE_CACHE_SECONDS", None +) +if _person_profile_cache_seconds is not None: + PERSON_PROFILE_CACHE_SECONDS = int(_person_profile_cache_seconds) + _csrf_trusted_origins_str = os.environ.get("DATATRACKER_CSRF_TRUSTED_ORIGINS") if _csrf_trusted_origins_str is not None: CSRF_TRUSTED_ORIGINS = _multiline_to_list(_csrf_trusted_origins_str)