})
})
+
{% endblock %}
\ No newline at end of file
From 44e190fc5309bf3df71f23a026b4c74ad0745b43 Mon Sep 17 00:00:00 2001
From: jennifer-richards <19472766+jennifer-richards@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:09:05 +0000
Subject: [PATCH 03/12] ci: update base image target version to 20260827T1454
---
dev/build/Dockerfile | 2 +-
dev/build/TARGET_BASE | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile
index d80aceaffb..d13405fd4b 100644
--- a/dev/build/Dockerfile
+++ b/dev/build/Dockerfile
@@ -1,4 +1,4 @@
-FROM ghcr.io/ietf-tools/datatracker-app-base:20260804T1752
+FROM ghcr.io/ietf-tools/datatracker-app-base:20260827T1454
LABEL maintainer="IETF Tools Team "
ENV DEBIAN_FRONTEND=noninteractive
diff --git a/dev/build/TARGET_BASE b/dev/build/TARGET_BASE
index d40bd9e929..d5d96a8f32 100644
--- a/dev/build/TARGET_BASE
+++ b/dev/build/TARGET_BASE
@@ -1 +1 @@
-20260804T1752
+20260827T1454
From 88cb3bf3c371168be371add8d002d0ef49e1a306 Mon Sep 17 00:00:00 2001
From: Jennifer Richards
Date: Thu, 27 Aug 2026 12:51:24 -0300
Subject: [PATCH 04/12] feat: plenary attendance by email address API (#11633)
* feat: first pass attended-regs API (WIP)
* refactor: prefetchable Registration.attendance_type
* feat: add ticket type methods + refactoring
* rename `Registration.attendance_type` to `plenary_attendance_type`
* add `plenary_ticket_type`
* refactor to ensure consistency, reduce queries, handle bulk requests
* refactor: adjust api to match changes
* test: meeting RegistrationTests
* fix: refactor to avoid mypy limitations
* test: Registration onsite() and remote() filters
* fix: lint + add some docstrings
* refactor: adjust api token endpoint name
* perf: bulk annotation in proceedings_attendees
* test: start test coverage of new API
* test: flesh out the tests
* fix: failing test
* fix: distinct() in onsite_or_remote()
* test: fix/update tests
---
ietf/api/urls.py | 2 +
ietf/meeting/api.py | 61 +++++
ietf/meeting/models.py | 119 ++++++++-
ietf/meeting/serializers.py | 28 +++
ietf/meeting/tests_api.py | 166 +++++++++++++
ietf/meeting/tests_models.py | 235 +++++++++++++++++-
ietf/meeting/views.py | 19 +-
.../meeting/proceedings_attendees.html | 2 +-
8 files changed, 603 insertions(+), 29 deletions(-)
create mode 100644 ietf/meeting/api.py
create mode 100644 ietf/meeting/serializers.py
create mode 100644 ietf/meeting/tests_api.py
diff --git a/ietf/api/urls.py b/ietf/api/urls.py
index 8e843d720f..072c913ad7 100644
--- a/ietf/api/urls.py
+++ b/ietf/api/urls.py
@@ -8,6 +8,7 @@
from ietf import api
from ietf.doc import views_ballot, api as doc_api
+from ietf.meeting import api as meeting_api
from ietf.meeting import views as meeting_views
from ietf.person import api_uuid as person_uuid_api
from ietf.submit import views as submit_views
@@ -80,6 +81,7 @@
url(r'^meeting/(?P[A-Za-z0-9._+-]+)/agenda-data$', meeting_views.api_get_agenda_data),
# Meeting session materials
url(r'^meeting/session/(?P[A-Za-z0-9._+-]+)/materials$', meeting_views.api_get_session_materials),
+ url(r'^meeting/registration/attended/(?P[^/\x00]+)/?$', meeting_api.MeetingsAttendedByEmail.as_view(), name="ietf.api.meeting.registration.attended"),
# Let MeetEcho upload bluesheets
url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet),
# Let MeetEcho tell us about session attendees
diff --git a/ietf/meeting/api.py b/ietf/meeting/api.py
new file mode 100644
index 0000000000..3104262091
--- /dev/null
+++ b/ietf/meeting/api.py
@@ -0,0 +1,61 @@
+# Copyright The IETF Trust 2026, All Rights Reserved
+from django.db.models import IntegerField
+from django.db.models.functions import Cast
+from rest_framework import generics
+
+from ietf.meeting.models import Meeting
+from ietf.meeting.serializers import PersonAttendedMeetingsSerializer
+from ietf.person.models import Person
+
+
+class MeetingsAttendedByEmail(generics.RetrieveAPIView):
+ """List meetings attended by a person identified by email address
+
+ Requires API key authentication
+ """
+
+ queryset = Person.objects.all()
+ lookup_field = "email__address"
+ lookup_url_kwarg = "email"
+ serializer_class = PersonAttendedMeetingsSerializer
+ api_key_endpoint = "ietf.api.meeting.registration.attended"
+
+ def get_object(self):
+ person = super().get_object()
+ assert isinstance(person, Person)
+ person.attended_registrations = self._attended_registrations(person)
+ return person
+
+ @staticmethod
+ def _attended_registrations(person: Person):
+ meetings_with_data = (
+ Meeting.objects.filter(type="ietf")
+ .annotate(number_as_int=Cast("number", output_field=IntegerField()))
+ .exclude(number_as_int__lt=110)
+ .values_list("pk")
+ )
+ has_attended_record = (
+ person.attended_set.filter(
+ session__meeting_id__in=meetings_with_data,
+ session__meeting__type="ietf",
+ )
+ .values_list("session__meeting__id", flat=True)
+ .distinct()
+ )
+ return sorted(
+ [
+ reg
+ for reg in (
+ person.registration_set.onsite_or_remote()
+ .with_plenary_ticket_details()
+ .filter(meeting_id__in=meetings_with_data)
+ .select_related("meeting")
+ )
+ if (
+ reg.attended
+ or reg.checkedin
+ or reg.meeting_id in has_attended_record
+ )
+ ],
+ key=lambda reg: reg.meeting.date,
+ )
diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py
index bf0c4dabd1..42b81b6a97 100644
--- a/ietf/meeting/models.py
+++ b/ietf/meeting/models.py
@@ -6,6 +6,8 @@
import datetime
import io
import os
+from functools import cached_property
+
import pytz
import random
import re
@@ -19,7 +21,7 @@
from django.core.validators import MinValueValidator, RegexValidator
from django.db import models
-from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q
+from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q, Case, When
from django.db.models.functions import Coalesce
from django.conf import settings
from django.urls import reverse as urlreverse
@@ -1564,15 +1566,53 @@ def __str__(self):
return f'{self.person} at {self.session}'
-class RegistrationManager(models.Manager):
+class RegistrationQuerySet(models.QuerySet):
def onsite(self):
- return self.get_queryset().filter(tickets__attendance_type__slug='onsite')
+ """Only registrations with at least one `onsite` ticket
+
+ Includes any ticket type. In particular, may be a hackathon-only registration.
+ """
+ return self.filter(tickets__attendance_type__slug="onsite")
def remote(self):
- return self.get_queryset().filter(tickets__attendance_type__slug='remote').exclude(tickets__attendance_type__slug='onsite')
+ """Only registrations with no `onsite` and at least one `remote` ticket
+
+ Includes any ticket type. In particular, may be a hackathon-only registration.
+ """
+ return (
+ self.filter(tickets__attendance_type__slug="remote")
+ .exclude(tickets__attendance_type__slug="onsite")
+ )
+
+ def onsite_or_remote(self):
+ """Registrations that were onsite or remote
+
+ I.e., was a registration for the plenary meeting, not e.g. hackathon-only.
+ """
+ return self.filter(
+ tickets__attendance_type__slug__in=["onsite", "remote"]
+ ).distinct()
+
+ def with_plenary_ticket_details(self):
+ """Annotate with ticket details
+
+ Adds private annotations accessible via @properties
+ """
+ most_representative = RegistrationTicket.objects.filter(
+ registration=OuterRef("pk")
+ ).order_by_most_representative()
+ return self.annotate(
+ _attendance_type=Subquery(
+ most_representative.values("attendance_type")[:1]
+ ),
+ _ticket_type=Subquery(most_representative.values("ticket_type")[:1]),
+ )
+
class Registration(models.Model):
"""Registration attendee records from the IETF registration system"""
+ objects = RegistrationQuerySet.as_manager() # custom manager
+
meeting = ForeignKey(Meeting)
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
@@ -1586,21 +1626,74 @@ class Registration(models.Model):
# checkedin indicates that the badge was picked up
checkedin = models.BooleanField(default=False)
- # custom manager
- objects = RegistrationManager()
-
def __str__(self):
return "{} {}".format(self.first_name, self.last_name)
+ @cached_property
+ def _plenary_ticket(self):
+ return self.tickets.order_by_most_representative().first()
+
@property
- def attendance_type(self):
- if self.tickets.filter(attendance_type__slug='onsite').exists():
- return 'onsite'
- elif self.tickets.filter(attendance_type__slug='remote').exists():
- return 'remote'
- return None
+ def plenary_attendance_type(self):
+ """Attendance type for the plenary meeting
+
+ Attendance type for the plenary meeting. Ignores hackathon/anrw or any other
+ types of registration that are tracked through tickets.
+ """
+ if hasattr(self, "_attendance_type"):
+ return self._attendance_type # added via with_plenary_ticket_details()
+ return (
+ self._plenary_ticket.attendance_type_id if self._plenary_ticket else None
+ )
+
+ @property
+ def plenary_ticket_type(self):
+ """Ticket type for the plenary meeting
+
+ Ticket type for the plenary meeting. Ignores hackathon/anrw or any other types
+ of registration that are tracked through tickets.
+ """
+ if hasattr(self, "_ticket_type"):
+ return self._ticket_type # added via with_plenary_ticket_details()
+ return (
+ self._plenary_ticket.ticket_type_id if self._plenary_ticket else None
+ )
+
+
+class RegistrationTicketQuerySet(models.QuerySet):
+ def order_by_most_representative(self):
+ """Order-by clause for representative tickets for plenary attendance
+
+ Filters out tickets not applicable to the plenary IETF meeting
+ """
+ # ordered lists of interesting types
+ interesting_attendance_types = ["onsite", "remote"]
+ interesting_ticket_types = ["student", "week_pass", "one_day", "unknown"]
+ case_ranking_attendance_types = Case(
+ *[
+ When(attendance_type=att_type, then=index)
+ for index, att_type in enumerate(interesting_attendance_types)
+ ]
+ )
+ case_ranking_ticket_types = Case(
+ *[
+ When(ticket_type=tkt_type, then=index)
+ for index, tkt_type in enumerate(interesting_ticket_types)
+ ]
+ )
+ return self.filter(
+ attendance_type__in=interesting_attendance_types,
+ ticket_type__in=interesting_ticket_types,
+ ).order_by(
+ case_ranking_attendance_types,
+ case_ranking_ticket_types,
+ "pk", # deterministic tie-break
+ )
+
class RegistrationTicket(models.Model):
+ objects = RegistrationTicketQuerySet.as_manager() # custom manager
+
registration = ForeignKey(Registration, related_name='tickets')
attendance_type = ForeignKey(AttendanceTypeName, on_delete=models.PROTECT)
ticket_type = ForeignKey(RegistrationTicketTypeName, on_delete=models.PROTECT)
diff --git a/ietf/meeting/serializers.py b/ietf/meeting/serializers.py
new file mode 100644
index 0000000000..70b9ee12ca
--- /dev/null
+++ b/ietf/meeting/serializers.py
@@ -0,0 +1,28 @@
+# Copyright The IETF Trust 2026, All Rights Reserved
+
+from rest_framework import serializers
+
+from ietf.meeting.models import Registration
+
+
+class AttendedMeetingSerializer(serializers.ModelSerializer):
+ """Serialize a plenary meeting attendance record"""
+ meeting = serializers.SlugRelatedField(slug_field="number", read_only=True)
+ attendance_type = serializers.CharField(
+ source="plenary_attendance_type", read_only=True
+ )
+ ticket_type = serializers.CharField(
+ source="plenary_ticket_type", read_only=True
+ )
+
+ class Meta:
+ model = Registration
+ fields = [
+ "meeting",
+ "attendance_type",
+ "ticket_type",
+ ]
+
+
+class PersonAttendedMeetingsSerializer(serializers.Serializer):
+ attended = AttendedMeetingSerializer(source="attended_registrations", many=True)
diff --git a/ietf/meeting/tests_api.py b/ietf/meeting/tests_api.py
new file mode 100644
index 0000000000..062ab07e4d
--- /dev/null
+++ b/ietf/meeting/tests_api.py
@@ -0,0 +1,166 @@
+# Copyright The IETF Trust 2026, All Rights Reserved
+from unittest.mock import PropertyMock, patch
+
+from django.test import override_settings
+from django.urls import reverse as urlreverse
+
+from ietf.meeting.factories import (
+ AttendedFactory,
+ MeetingFactory,
+ RegistrationFactory,
+)
+from ietf.meeting.models import Registration
+from ietf.person.factories import EmailFactory, PersonFactory
+from ietf.utils.test_utils import TestCase
+
+
+@override_settings(
+ APP_API_TOKENS={"ietf.api.meeting.registration.attended": "valid-token"}
+)
+class MeetingsAttendedByEmailTests(TestCase):
+ VIEWNAME = "ietf.api.meeting.registration.attended"
+
+ def setUp(self):
+ super().setUp()
+ self.person = PersonFactory()
+
+ def attended_for(self, email=None):
+ """Retrieve the "attended" list for an email address"""
+ url = urlreverse(
+ self.VIEWNAME, kwargs={"email": email or self.person.email_address()}
+ )
+ r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
+ self.assertEqual(r.status_code, 200)
+ return r.json()["attended"]
+
+ @staticmethod
+ def ietf_meeting(number):
+ return MeetingFactory(type_id="ietf", number=number, populate_schedule=False)
+
+ def test_endpoint_is_plumbed(self):
+ url = urlreverse(self.VIEWNAME, kwargs={"email": self.person.email_address()})
+ # bad/missing API keys
+ r = self.client.get(url)
+ self.assertEqual(r.status_code, 403, "should require api key")
+ r = self.client.get(url, headers={"X-Api-Key": "invalid-token"})
+ self.assertEqual(r.status_code, 403, "should require valid api key")
+
+ # valid request
+ r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
+ self.assertEqual(r.status_code, 200, "should accept valid api key")
+ self.assertEqual(r.json(), {"attended": []})
+
+ # nonexistent person
+ self.person.email_set.update(person=None) # detach email
+ self.person.delete()
+ r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
+ self.assertEqual(r.status_code, 404, "404 for no such Person")
+
+ def test_lists_registrations_with_attendance_evidence(self):
+ """Registrations for IETF 110+ that were attended are listed
+
+ Attendance is indicated by the attended flag, the checkedin flag, or an Attended
+ record. These records only had their modern form starting at IETF 110.
+ """
+ attended = self.ietf_meeting("118")
+ RegistrationFactory(meeting=attended, person=self.person, attended=True)
+ checkedin = self.ietf_meeting("119")
+ RegistrationFactory(meeting=checkedin, person=self.person, checkedin=True)
+ session_attended = self.ietf_meeting("120")
+ RegistrationFactory(meeting=session_attended, person=self.person)
+ AttendedFactory(
+ session__meeting=session_attended,
+ session__add_to_schedule=False, # these meetings have no timeslots
+ person=self.person,
+ )
+
+ # another person's registrations for the same meetings must not appear
+ other_person = PersonFactory()
+ for meeting in [attended, checkedin, session_attended]:
+ RegistrationFactory(meeting=meeting, person=other_person, attended=True)
+
+ self.assertCountEqual(
+ [entry["meeting"] for entry in self.attended_for()], ["118", "119", "120"]
+ )
+
+ def test_excludes_registrations_without_attendance_evidence(self):
+ """Only attended IETF meetings from 110 onwards are reported"""
+ RegistrationFactory(meeting=self.ietf_meeting("118"), person=self.person)
+ RegistrationFactory(
+ meeting=self.ietf_meeting("109"), person=self.person, attended=True
+ )
+ RegistrationFactory(
+ meeting=MeetingFactory(type_id="interim", populate_schedule=False),
+ person=self.person,
+ attended=True,
+ )
+
+ self.assertEqual(self.attended_for(), [])
+
+ def test_reports_attendance_and_ticket_type(self):
+ """Ticket details are taken from the Registration's plenary_* properties"""
+ RegistrationFactory(
+ meeting=self.ietf_meeting("118"), person=self.person, attended=True
+ )
+
+ with (
+ patch.object(
+ Registration, "plenary_attendance_type", new_callable=PropertyMock
+ ) as attendance_type,
+ patch.object(
+ Registration, "plenary_ticket_type", new_callable=PropertyMock
+ ) as ticket_type,
+ ):
+ attendance_type.return_value = "onsite"
+ ticket_type.return_value = "week_pass"
+ self.assertEqual(
+ self.attended_for(),
+ [
+ {
+ "meeting": "118",
+ "attendance_type": "onsite",
+ "ticket_type": "week_pass",
+ }
+ ],
+ )
+
+ # absent ticket details are reported as null
+ attendance_type.return_value = None
+ ticket_type.return_value = None
+ self.assertEqual(
+ self.attended_for(),
+ [{"meeting": "118", "attendance_type": None, "ticket_type": None}],
+ )
+
+ def test_finds_person_by_any_email_address(self):
+ """Any of the Person's email addresses identifies them"""
+ RegistrationFactory(
+ meeting=self.ietf_meeting("118"), person=self.person, attended=True
+ )
+ secondary_email = EmailFactory(person=self.person)
+
+ by_secondary = self.attended_for(email=secondary_email.address)
+ self.assertEqual([entry["meeting"] for entry in by_secondary], ["118"])
+ self.assertEqual(by_secondary, self.attended_for())
+
+ def test_excludes_non_plenary_registrations(self):
+ """Registrations without an onsite or remote plenary ticket are not reported"""
+ RegistrationFactory(
+ meeting=self.ietf_meeting("118"),
+ person=self.person,
+ attended=True,
+ with_ticket={"attendance_type_id": "onsite", "ticket_type_id": "week_pass"},
+ )
+ RegistrationFactory(
+ meeting=self.ietf_meeting("119"),
+ person=self.person,
+ attended=True,
+ with_ticket={
+ "attendance_type_id": "hackathon_remote",
+ "ticket_type_id": "unknown",
+ },
+ )
+
+ attended = self.attended_for()
+ self.assertEqual([entry["meeting"] for entry in attended], ["118"])
+ self.assertEqual({entry["attendance_type"] for entry in attended}, {"onsite"})
diff --git a/ietf/meeting/tests_models.py b/ietf/meeting/tests_models.py
index 0b9ce3f607..4c18b3633c 100644
--- a/ietf/meeting/tests_models.py
+++ b/ietf/meeting/tests_models.py
@@ -17,8 +17,9 @@
AttendedFactory,
SessionPresentationFactory,
)
-from ietf.meeting.factories import RegistrationFactory
-from ietf.meeting.models import Session
+from ietf.meeting.factories import RegistrationFactory, RegistrationTicketFactory
+from ietf.meeting.models import Registration, RegistrationTicket, Session
+from ietf.name.models import RegistrationTicketTypeName, AttendanceTypeName
from ietf.utils.test_utils import TestCase
from ietf.utils.timezone import date_today, datetime_today
@@ -253,7 +254,7 @@ def test_chat_archive_url(self):
def test_chat_room_name(self):
session = SessionFactory(group__acronym="xyzzy")
- self.assertEqual(session.chat_room_name(), "xyzzy")
+ self.assertEqual(session.chat_room_name(), "xyzzy")
session.type_id = "plenary"
self.assertEqual(session.chat_room_name(), "plenary")
session.chat_room = "fnord"
@@ -323,3 +324,231 @@ def test_session_recording_url_label_interim(self):
f"IETF-ACRO-{session_time:%Y%m%d-%H%M}", # n.b., time in label is UTC
session._session_recording_url_label(),
)
+
+
+class RegistrationTests(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.meeting = MeetingFactory(type_id="ietf")
+
+ def create_registration(self, tickets):
+ """Create a Registration with the given (attendance, ticket) type pairs"""
+ registration = RegistrationFactory(meeting=self.meeting, with_ticket=False)
+ for attendance_type_id, ticket_type_id in tickets:
+ RegistrationTicketFactory(
+ registration=registration,
+ attendance_type_id=attendance_type_id,
+ ticket_type_id=ticket_type_id,
+ )
+ return registration
+
+ def test_onsite(self):
+ expected_onsite_pks = [
+ self.create_registration([("onsite", ticket_type.pk)]).pk
+ for ticket_type in RegistrationTicketTypeName.objects.filter(used=True)
+ ]
+ for attendance_type in AttendanceTypeName.objects.filter(used=True):
+ if attendance_type.pk == "onsite":
+ continue # we already have one
+ for ticket_type in RegistrationTicketTypeName.objects.filter(used=True):
+ self.create_registration([(attendance_type.pk, ticket_type.pk)])
+
+ self.assertCountEqual(
+ Registration.objects.onsite().values_list("pk", flat=True),
+ expected_onsite_pks,
+ )
+
+ def test_remote(self):
+ expected_remote_pks = [
+ self.create_registration([("remote", ticket_type.pk)]).pk
+ for ticket_type in RegistrationTicketTypeName.objects.filter(used=True)
+ ]
+ for attendance_type in AttendanceTypeName.objects.filter(used=True):
+ if attendance_type.pk == "remote":
+ continue # we already have one
+ for ticket_type in RegistrationTicketTypeName.objects.filter(used=True):
+ self.create_registration([(attendance_type.pk, ticket_type.pk)])
+
+ self.assertCountEqual(
+ Registration.objects.remote().values_list("pk", flat=True),
+ expected_remote_pks,
+ )
+
+ def test_onsite_or_remote(self):
+ expected_onsite_pks = [
+ self.create_registration([("onsite", ticket_type.pk)]).pk
+ for ticket_type in RegistrationTicketTypeName.objects.filter(used=True)
+ ]
+ expected_remote_pks = [
+ self.create_registration([("remote", ticket_type.pk)]).pk
+ for ticket_type in RegistrationTicketTypeName.objects.filter(used=True)
+ ]
+ for attendance_type in AttendanceTypeName.objects.filter(used=True):
+ if attendance_type.pk in ["onsite", "remote"]:
+ continue # we already have one
+ for ticket_type in RegistrationTicketTypeName.objects.filter(used=True):
+ self.create_registration([(attendance_type.pk, ticket_type.pk)])
+ # create a remote ticket for an onsite registration to probe whether this
+ # leads to duplicate records
+ RegistrationTicketFactory(
+ # arbitrary registration
+ registration=(Registration.objects.get(pk=expected_onsite_pks[0])),
+ attendance_type_id="remote",
+ ticket_type_id="week_pass",
+ )
+ self.assertCountEqual(
+ Registration.objects.onsite_or_remote().values_list("pk", flat=True),
+ expected_onsite_pks + expected_remote_pks,
+ )
+
+ # A ticket counts toward the plenary meeting only if both its attendance type
+ # and its ticket type are applicable. Applicable tickets are ranked by
+ # attendance type (onsite, then remote), then by ticket type (student,
+ # week_pass, one_day, then unknown), then by pk.
+ #
+ # Each case is a label, the tickets to create as (attendance_type_id,
+ # ticket_type_id) pairs, and the expected plenary_attendance_type and
+ # plenary_ticket_type.
+ PLENARY_TICKET_CASES = [
+ ("no tickets at all", [], None, None),
+ ("a single plenary ticket", [("onsite", "week_pass")], "onsite", "week_pass"),
+ (
+ "onsite outranks remote",
+ [("remote", "week_pass"), ("onsite", "week_pass")],
+ "onsite",
+ "week_pass",
+ ),
+ (
+ "attendance type outranks ticket type",
+ [("remote", "student"), ("onsite", "one_day")],
+ "onsite",
+ "one_day",
+ ),
+ (
+ "student outranks one_day",
+ [("onsite", "one_day"), ("onsite", "student")],
+ "onsite",
+ "student",
+ ),
+ (
+ "unknown ticket type ranks last",
+ [("onsite", "unknown"), ("onsite", "one_day")],
+ "onsite",
+ "one_day",
+ ),
+ (
+ "non-plenary attendance type is ignored",
+ [("hackathon_onsite", "hackathon_only")],
+ None,
+ None,
+ ),
+ (
+ "non-plenary ticket type disqualifies its ticket entirely",
+ [("onsite", "hackathon_combo"), ("remote", "week_pass")],
+ "remote",
+ "week_pass",
+ ),
+ (
+ "unknown is a plenary ticket type but not an attendance type",
+ [("unknown", "week_pass")],
+ None,
+ None,
+ ),
+ ]
+
+ def test_plenary_ticket_details(self):
+ """The plenary_* properties report the most representative ticket"""
+ for label, tickets, attendance_type, ticket_type in self.PLENARY_TICKET_CASES:
+ with self.subTest(label):
+ registration = self.create_registration(tickets)
+ # refetch so the properties cannot see state left by the factories
+ registration = Registration.objects.get(pk=registration.pk)
+ self.assertEqual(registration.plenary_attendance_type, attendance_type)
+ self.assertEqual(registration.plenary_ticket_type, ticket_type)
+
+ def test_plenary_ticket_details_annotated(self):
+ """The annotations agree with the properties
+
+ The properties return the annotations when they are present, so the two
+ must rank tickets identically.
+ """
+ for label, tickets, attendance_type, ticket_type in self.PLENARY_TICKET_CASES:
+ with self.subTest(label):
+ registration = self.create_registration(tickets)
+ annotated = Registration.objects.with_plenary_ticket_details().get(
+ pk=registration.pk
+ )
+ self.assertEqual(annotated._attendance_type, attendance_type)
+ self.assertEqual(annotated._ticket_type, ticket_type)
+ self.assertEqual(annotated.plenary_attendance_type, attendance_type)
+ self.assertEqual(annotated.plenary_ticket_type, ticket_type)
+
+ def test_plenary_ticket_details_tie_break(self):
+ """Tickets that rank equally are resolved by pk
+
+ The annotations cannot distinguish these tickets - they expose only the
+ chosen ticket's type slugs, which are identical when tickets tie.
+ """
+ registration = self.create_registration(
+ [("onsite", "week_pass"), ("onsite", "week_pass")]
+ )
+ first, second = registration.tickets.order_by("pk")
+ self.assertEqual(
+ list(registration.tickets.order_by_most_representative()), [first, second]
+ )
+ self.assertEqual(
+ Registration.objects.get(pk=registration.pk)._plenary_ticket, first
+ )
+
+ def test_plenary_ticket_details_query_counts(self):
+ """The annotation replaces the per-registration ticket queries"""
+ for tickets in [
+ [("onsite", "week_pass")],
+ [("remote", "student")],
+ [("hackathon_onsite", "hackathon_only")],
+ ]:
+ self.create_registration(tickets)
+ expected = [("onsite", "week_pass"), ("remote", "student"), (None, None)]
+
+ annotated = Registration.objects.with_plenary_ticket_details().order_by("pk")
+ with self.assertNumQueries(1):
+ self.assertEqual(
+ [(r.plenary_attendance_type, r.plenary_ticket_type) for r in annotated],
+ expected,
+ )
+
+ # Without the annotation, one query for the registrations plus one per
+ # registration. Both properties share the _plenary_ticket cache, so
+ # reading them together does not double the count.
+ plain = Registration.objects.order_by("pk")
+ with self.assertNumQueries(4):
+ self.assertEqual(
+ [(r.plenary_attendance_type, r.plenary_ticket_type) for r in plain],
+ expected,
+ )
+
+ def test_order_by_most_representative(self):
+ """Non-plenary tickets are dropped and the rest are ranked"""
+ registration = self.create_registration(
+ [
+ ("remote", "one_day"),
+ ("hackathon_onsite", "hackathon_only"),
+ ("onsite", "unknown"),
+ ("onsite", "student"),
+ ("remote", "week_pass"),
+ ("unknown", "week_pass"),
+ ]
+ )
+ tickets = {
+ (ticket.attendance_type_id, ticket.ticket_type_id): ticket
+ for ticket in registration.tickets.all()
+ }
+ self.assertEqual(
+ list(RegistrationTicket.objects.order_by_most_representative()),
+ [
+ tickets[("onsite", "student")],
+ tickets[("onsite", "unknown")],
+ tickets[("remote", "week_pass")],
+ tickets[("remote", "one_day")],
+ ],
+ )
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
index 3e5002a466..cd7f43771b 100644
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -4838,19 +4838,14 @@ def proceedings_attendees(request, num=None):
onsite_pks = frozenset(p.pk for p in onsite)
remote_pks = frozenset(p.pk for p in remote)
- regs = [
- reg
- for reg in Registration.objects.onsite()
- .filter(meeting__number=num)
- .select_related("person")
- if reg.person.pk in onsite_pks
- ] + [
- reg
- for reg in Registration.objects.remote()
- .filter(meeting__number=num)
+ regs_to_consider = (
+ Registration.objects.filter(meeting__number=num)
+ .with_plenary_ticket_details()
.select_related("person")
- if reg.person.pk in remote_pks
- ]
+ )
+ regs = [
+ reg for reg in regs_to_consider.onsite() if reg.person.pk in onsite_pks
+ ] + [reg for reg in regs_to_consider.remote() if reg.person.pk in remote_pks]
registrations = sorted(regs, key=lambda x: (x.last_name, x.first_name))
country_codes = [r.country_code for r in registrations if r.country_code]
diff --git a/ietf/templates/meeting/proceedings_attendees.html b/ietf/templates/meeting/proceedings_attendees.html
index 0c59d4ab15..402b807223 100644
--- a/ietf/templates/meeting/proceedings_attendees.html
+++ b/ietf/templates/meeting/proceedings_attendees.html
@@ -80,7 +80,7 @@
{{ reg.first_name }}
{{ reg.affiliation }}
{{ reg.country_code }}
-
{{ reg.attendance_type }}
+
{{ reg.plenary_attendance_type }}
{% endfor %}
From b771d6096dc877b1a4bcd0fcdbac888154ba4be5 Mon Sep 17 00:00:00 2001
From: Jennifer Richards
Date: Thu, 27 Aug 2026 17:27:19 -0300
Subject: [PATCH 05/12] refactor: optimize data_for_meetings_overview (#11657)
* refactor: limit sessions to `interim` meetings
* refactor: only fetch ietf_group if needed
* refactor: filter in db, not python
* refactor: limit meeting queries for interim views
Presumably a meeting will be no longer be pending or awaiting
announcement by a year after its date.
* chore: adjust lookback / comments
* style: ruff ruff
---
ietf/meeting/utils.py | 71 +++++++++++++++++++++++++------------------
ietf/meeting/views.py | 57 +++++++++++++++++++++++++---------
2 files changed, 83 insertions(+), 45 deletions(-)
diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py
index ffd37fc363..a25998dac9 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 cd7f43771b..b2f15fb2f3 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
From ba394a1e38f9eeb444d0d7fa16ebdcd0d387abcb Mon Sep 17 00:00:00 2001
From: Robert Sparks
Date: Thu, 27 Aug 2026 15:28:45 -0500
Subject: [PATCH 06/12] perf: optimize the person endpoint (#11635)
* perf: cap per-client concurrency on /person/ and /api/v1/
Keyed on the Cloudflare client address, with an empty key for every other
path so only these two prefixes are limited.
Co-Authored-By: Claude Opus 5 (1M context)
* perf: query the two authorship tables separately in Person.rfcs()
Co-Authored-By: Claude Opus 5 (1M context)
* perf: avoid table scans and per-alias queries in lookup_persons
Co-Authored-By: Claude Opus 5 (1M context)
* perf: assemble the profile page's data in the view
Gathers the RFC publication dates, reference counts and replaced-draft set for
every listed person in one query each, rather than a query per table cell, and
evaluates each per-person list once instead of on every template reference.
The expired Internet-Drafts heading now counts the drafts it lists; it counted
the replaced ones the list omits. Roles with the same name sort by group
acronym instead of by whatever order the query returned.
Co-Authored-By: Claude Opus 5 (1M context)
* perf: cache each rendered profile section
A repeat view of a profile, including the revalidation behind a conditional
request, now costs neither the queries nor the render. Sections are keyed on
person and date rather than position on the page, so the per-section element
ids move from a loop counter to the person's id.
Co-Authored-By: Claude Opus 5 (1M context)
* test: guard the profile page's query count and section cache
Co-Authored-By: Claude Opus 5 (1M context)
* revert: perf: cap per-client concurrency on /person/ and /api/v1/
This reverts commit fed51f07cbb2c38147dafedbe108568a95fc7ba8.
* refactor: make the profile section cache lifetime a setting
PERSON_PROFILE_CACHE_SECONDS, overridable from the environment in the k8s
deployment.
Co-Authored-By: Claude Opus 5 (1M context)
* fix: stop dating the profile page's empty-section messages
The dates claimed a precision the page does not have: sections are cached
independently, so the data behind two of them can differ by a cache lifetime
while both printed the same date. Without them nothing in a section depends on
when it was rendered, so the cache key no longer needs the date either.
Co-Authored-By: Claude Opus 5 (1M context)
---------
Co-authored-by: Claude Opus 5 (1M context)
---
ietf/person/models.py | 18 ++-
ietf/person/tests.py | 56 ++++++++
ietf/person/utils.py | 7 +-
ietf/person/views.py | 139 ++++++++++++++++++-
ietf/settings.py | 4 +
ietf/templates/person/profile.html | 175 ++----------------------
ietf/templates/person/profile_body.html | 152 ++++++++++++++++++++
k8s/secrets.yaml.example | 4 +
k8s/settings_local.py | 6 +
9 files changed, 388 insertions(+), 173 deletions(-)
create mode 100644 ietf/templates/person/profile_body.html
diff --git a/ietf/person/models.py b/ietf/person/models.py
index 8b05c5f8a6..13c24bd105 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 0af8271594..848152183c 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 3f00a04cd0..84b98dba2f 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 52137f0fb9..4c03f138dc 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 e381ab6c1d..3e26e22e84 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/person/profile.html b/ietf/templates/person/profile.html
index a78a90412f..3704d5a10f 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 %}
-
From 48e9d556aa72caa09a318ea7a684f8f65dd57e77 Mon Sep 17 00:00:00 2001
From: Jennifer Richards
Date: Thu, 27 Aug 2026 22:10:18 -0300
Subject: [PATCH 08/12] refactor: date-limit has_pending_interim queries
(#11659)
* refactor: date-limit for has_pending_interim()
* style: clean up imports in meeting/views.py
* refactor: use common lookback time everywhere
* style: ruff ruff
* test: fix failing tests
Put test meetings in the future instead of arbitrary old date
---
ietf/group/tests_info.py | 28 +++++-
ietf/meeting/helpers.py | 25 +++--
ietf/meeting/views.py | 191 ++++++++++++++++++++++++++-------------
3 files changed, 171 insertions(+), 73 deletions(-)
diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py
index c995ef9e77..c93525bd7e 100644
--- a/ietf/group/tests_info.py
+++ b/ietf/group/tests_info.py
@@ -2167,7 +2167,12 @@ def _meetings_page(self, group):
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')
+ SessionFactory.create(
+ meeting__type_id="interim",
+ meeting__date=date_today() + datetime.timedelta(days=30),
+ group=group,
+ status_id="apprw",
+ )
q = self._meetings_page(group)
warning = q('#pending_warning')
self.assertTrue(warning)
@@ -2182,14 +2187,24 @@ def test_pending_approval_interim_shows_warning(self):
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')
+ SessionFactory.create(
+ meeting__type_id="interim",
+ meeting__date=date_today() + datetime.timedelta(days=30),
+ 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')
+ SessionFactory.create(
+ meeting__type_id='interim',
+ meeting__date=date_today() + datetime.timedelta(days=30),
+ group=group,
+ status_id='sched',
+ )
q = self._meetings_page(group)
self.assertFalse(q('#pending_warning'))
@@ -2203,7 +2218,12 @@ 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')
+ SessionFactory.create(
+ meeting__type_id='interim',
+ meeting__date=date_today() + datetime.timedelta(days=30),
+ group=other,
+ status_id='apprw',
+ )
q = self._meetings_page(group)
self.assertFalse(q('#pending_warning'))
diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py
index 98ff3e6849..568af7422c 100644
--- a/ietf/meeting/helpers.py
+++ b/ietf/meeting/helpers.py
@@ -1,7 +1,4 @@
# Copyright The IETF Trust 2013-2026, All Rights Reserved
-# -*- coding: utf-8 -*-
-
-
from collections import defaultdict
import datetime
import io
@@ -34,6 +31,14 @@
from ietf.utils.mail import send_mail
from ietf.utils.pipe import pipe
from ietf.utils.text import xslugify
+from ietf.utils.timezone import date_today
+
+# Ignore meetings older than this when querying for pending interims. It's expected that
+# every interim should be out of the pending (apprw / scheda) states well before the
+# scheduled date. Look back a few weeks so that a meeting that is somehow left in a
+# pending state does not fall off the interface until people have had time to notice.
+#
+PENDING_INTERIM_MAX_LOOKBACK = datetime.timedelta(days=28)
def get_meeting(num=None, type_in=('ietf',), days=28):
@@ -858,19 +863,25 @@ def is_interim_meeting_approved(meeting):
def has_pending_interim(acronym):
- '''
+ """Check whether group identified by acronym has a pending interim
+
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')
+ possible_meetings = Meeting.objects.filter(
+ type="interim", date__gte=date_today() - PENDING_INTERIM_MAX_LOOKBACK
+ )
+ pending = data_for_meetings_overview(possible_meetings, 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')
+ to_be_announced = data_for_meetings_overview(
+ possible_meetings, interim_status="scheda"
+ )
for m in to_be_announced:
if m.responsible_group.acronym == acronym:
rv = True
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
index b2f15fb2f3..763ea52872 100644
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -1,6 +1,4 @@
-# Copyright The IETF Trust 2007-2024, All Rights Reserved
-# -*- coding: utf-8 -*-
-
+# Copyright The IETF Trust 2007-2026, All Rights Reserved
import csv
import datetime
@@ -20,6 +18,7 @@
from collections import OrderedDict, Counter, deque, defaultdict, namedtuple
from functools import partialmethod
import jsonschema
+from icalendar import Calendar, Event
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit, urlparse
from tempfile import mkstemp
@@ -30,10 +29,18 @@
from django.core.cache import caches
from django.core.files.storage import storages
from django.shortcuts import render, redirect, get_object_or_404
-from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseForbidden,
- HttpResponseNotFound, Http404, HttpResponseBadRequest,
- JsonResponse, HttpResponseGone, HttpResponseNotAllowed,
- FileResponse)
+from django.http import (
+ HttpResponse,
+ HttpResponseRedirect,
+ HttpResponseForbidden,
+ HttpResponseNotFound,
+ Http404,
+ HttpResponseBadRequest,
+ JsonResponse,
+ HttpResponseGone,
+ HttpResponseNotAllowed,
+ FileResponse,
+)
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
@@ -55,6 +62,7 @@
import debug # pyflakes:ignore
+from ietf.api.ietf_utils import requires_api_token
from ietf.doc.fields import SearchableDocumentsField
from ietf.doc.models import Document, State, DocEvent, NewRevisionDocEvent
from ietf.doc.storage_utils import (
@@ -62,54 +70,130 @@
retrieve_bytes,
store_file,
)
+from ietf.doc.templatetags.ietf_filters import absurl
from ietf.group.models import Group
-from ietf.group.utils import can_manage_session_materials, can_manage_some_groups, can_manage_group
+from ietf.group.utils import (
+ can_manage_session_materials,
+ can_manage_some_groups,
+ can_manage_group,
+)
from ietf.person.models import Person, User
from ietf.ietfauth.utils import role_required, has_role, user_is_person
from ietf.mailtrigger.utils import gather_address_lists
-from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, \
- SessionPresentation, TimeSlot, SlideSubmission, Attended
-from ..blobdb.models import ResolvedMaterial
-from ietf.meeting.models import ImportantDate, SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName
-from ietf.meeting.models import Registration
-from ietf.meeting.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm, ImportMinutesForm,
- TimeSlotCreateForm, TimeSlotEditForm, SessionCancelForm, SessionEditForm )
-from ietf.meeting.helpers import get_person_by_email, get_schedule_by_name
-from ietf.meeting.helpers import get_meeting, get_ietf_meeting, get_current_ietf_meeting_num
-from ietf.meeting.helpers import get_schedule, schedule_permissions
-from ietf.meeting.helpers import preprocess_assignments_for_agenda, read_agenda_file
-from ietf.meeting.helpers import AgendaFilterOrganizer, AgendaKeywordTagger
-from ietf.meeting.helpers import convert_draft_to_pdf, get_earliest_session_date
-from ietf.meeting.helpers import can_view_interim_request, can_approve_interim_request
-from ietf.meeting.helpers import can_edit_interim_request
-from ietf.meeting.helpers import can_request_interim_meeting, get_announcement_initial
-from ietf.meeting.helpers import sessions_post_save, is_interim_meeting_approved
-from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice
-from ietf.meeting.helpers import send_interim_approval
-from ietf.meeting.helpers import send_interim_approval_request
-from ietf.meeting.helpers import send_interim_announcement_request, sessions_post_cancel
+from ietf.meeting.models import (
+ Meeting,
+ Session,
+ Schedule,
+ FloorPlan,
+ SessionPresentation,
+ TimeSlot,
+ SlideSubmission,
+ Attended,
+)
+from ietf.blobdb.models import ResolvedMaterial
+from ietf.blobdb.storage import BlobdbStorage, BlobFile
+from ietf.meeting.models import (
+ ImportantDate,
+ SessionStatusName,
+ SchedulingEvent,
+ SchedTimeSessAssignment,
+ Room,
+ TimeSlotTypeName,
+ Registration,
+)
+from ietf.meeting.forms import (
+ CustomDurationField,
+ SwapDaysForm,
+ SwapTimeslotsForm,
+ ImportMinutesForm,
+ TimeSlotCreateForm,
+ TimeSlotEditForm,
+ SessionCancelForm,
+ SessionEditForm,
+ InterimMeetingModelForm,
+ InterimAnnounceForm,
+ InterimSessionModelForm,
+ InterimCancelForm,
+ InterimSessionInlineFormSet,
+ RequestMinutesForm,
+ UploadAgendaForm,
+ UploadBlueSheetForm,
+ UploadMinutesForm,
+ UploadSlidesForm,
+ UploadNarrativeMinutesForm,
+)
+from ietf.meeting.helpers import (
+ get_person_by_email,
+ get_schedule_by_name,
+ get_meeting,
+ get_ietf_meeting,
+ get_current_ietf_meeting_num,
+ get_schedule,
+ schedule_permissions,
+ preprocess_assignments_for_agenda,
+ read_agenda_file,
+ AgendaFilterOrganizer,
+ AgendaKeywordTagger,
+ convert_draft_to_pdf,
+ get_earliest_session_date,
+ can_view_interim_request,
+ can_approve_interim_request,
+ can_edit_interim_request,
+ can_request_interim_meeting,
+ get_announcement_initial,
+ sessions_post_save,
+ is_interim_meeting_approved,
+ send_interim_meeting_cancellation_notice,
+ send_interim_session_cancellation_notice,
+ send_interim_approval,
+ send_interim_approval_request,
+ send_interim_announcement_request,
+ sessions_post_cancel,
+ PENDING_INTERIM_MAX_LOOKBACK,
+)
from ietf.meeting.utils import (
condition_slide_order,
finalize,
generate_proceedings_content,
organize_proceedings_sessions,
resolve_uploaded_material,
- sort_accept_tuple, store_blobs_for_one_material_doc,
+ sort_accept_tuple,
+ store_blobs_for_one_material_doc,
+)
+from ietf.meeting.utils import (
+ add_event_info_to_session_qs,
+ session_time_for_sorting,
+ session_requested_by,
+ SaveMaterialsError,
+ current_session_status,
+ get_meeting_sessions,
+ SessionNotScheduledError,
+ data_for_meetings_overview,
+ handle_upload_file,
+ save_session_minutes_revision,
+ preprocess_constraints_for_meeting_schedule_editor,
+ diff_meeting_schedules,
+ prefetch_schedule_diff_objects,
+ swap_meeting_schedule_timeslot_assignments,
+ bulk_create_timeslots,
+ preprocess_meeting_important_dates,
+ new_doc_for_session,
+ write_doc_for_session,
+ get_activity_stats,
+ post_process,
+ create_recording,
+ delete_recording,
+ generate_bluesheet,
+ bluesheet_data,
+ save_bluesheet,
)
-from ietf.meeting.utils import add_event_info_to_session_qs
-from ietf.meeting.utils import session_time_for_sorting
-from ietf.meeting.utils import session_requested_by, SaveMaterialsError
-from ietf.meeting.utils import current_session_status, get_meeting_sessions, SessionNotScheduledError
-from ietf.meeting.utils import data_for_meetings_overview, handle_upload_file, save_session_minutes_revision
-from ietf.meeting.utils import preprocess_constraints_for_meeting_schedule_editor
-from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_objects
-from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments, bulk_create_timeslots
-from ietf.meeting.utils import preprocess_meeting_important_dates
-from ietf.meeting.utils import new_doc_for_session, write_doc_for_session
-from ietf.meeting.utils import get_activity_stats, post_process, create_recording, delete_recording
-from ietf.meeting.utils import generate_bluesheet, bluesheet_data, save_bluesheet
from ietf.message.utils import infer_message
-from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName, CountryName
+from ietf.name.models import (
+ SlideSubmissionStatusName,
+ ProceedingsMaterialTypeName,
+ SessionPurposeName,
+ CountryName,
+)
from ietf.utils import markdown
from ietf.utils.decorators import require_api_key
from ietf.utils.hedgedoc import Note, NoteError
@@ -124,15 +208,6 @@
from ietf.utils.timezone import datetime_today, date_today
from ietf.settings import YOUTUBE_DOMAINS
-from .forms import (InterimMeetingModelForm, InterimAnnounceForm, InterimSessionModelForm,
- InterimCancelForm, InterimSessionInlineFormSet, RequestMinutesForm,
- UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm,
- UploadNarrativeMinutesForm)
-
-from icalendar import Calendar, Event
-from ietf.doc.templatetags.ietf_filters import absurl
-from ..api.ietf_utils import requires_api_token
-from ..blobdb.storage import BlobdbStorage, BlobFile
request_summary_exclude_group_types = ['team']
@@ -4111,13 +4186,9 @@ def delete_schedule(request, num, owner, name):
# -------------------------------------------------
def interim_announce(request):
"""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
+ type="interim", date__gte=date_today() - PENDING_INTERIM_MAX_LOOKBACK
).order_by("date"),
interim_status="scheda",
)
@@ -4188,13 +4259,9 @@ def interim_skip_announcement(request, number):
def interim_pending(request):
"""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
+ type="interim", date__gte=date_today() - PENDING_INTERIM_MAX_LOOKBACK
).order_by("date"),
interim_status="apprw",
)
From ba956859089058aa7d59a2c35256ba1e66c7bf46 Mon Sep 17 00:00:00 2001
From: Robert Sparks
Date: Tue, 1 Sep 2026 11:44:12 -0500
Subject: [PATCH 09/12] feat: add idnits3 to draft submission checks (#11678)
* feat: add idnits3 to draft submission checks
* fix: move idnits3 install out of the base image
* fix: waffle back to pinning idnits3
* chore: remove dead code
* chore: ruff formatting
* fix: use f-string when building __str__
* fix: guard against message being None
---
dev/build/Dockerfile | 8 +
dev/diff/settings_local.py | 1 +
dev/tests/prepare.sh | 4 +
dev/tests/settings_local.py | 1 +
docker/app.Dockerfile | 8 +
docker/celery.Dockerfile | 8 +
docker/configs/settings_local.py | 1 +
ietf/checks.py | 20 +-
ietf/settings.py | 6 +
ietf/submit/checkers.py | 168 +++++++++++++
ietf/submit/models.py | 27 ++-
ietf/submit/tests.py | 238 +++++++++++++++++++
ietf/submit/utils.py | 10 +-
ietf/templates/submit/submission_status.html | 20 +-
k8s/settings_local.py | 1 +
15 files changed, 506 insertions(+), 15 deletions(-)
diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile
index d13405fd4b..7f6c7453b2 100644
--- a/dev/build/Dockerfile
+++ b/dev/build/Dockerfile
@@ -25,6 +25,14 @@ RUN pip3 --disable-pip-version-check --no-cache-dir install -r requirements.txt
ietf/manage.py patch_libraries && \
rm -f ietf/settings_local.py
+# Install idnits3. The version is pinned here and in the other container
+# definitions - grep for @ietf-tools/idnits to bump them together.
+# It goes in its own npm prefix so that it can never disturb the idnits2 script
+# at /usr/local/bin/idnits that comes from the base image.
+RUN npm install -g --prefix /usr/local/idnits3 @ietf-tools/idnits@3.1.0 && \
+ ln -sf /usr/local/idnits3/bin/idnits /usr/local/bin/idnits3 && \
+ idnits3 --version
+
RUN chmod +x start.sh && \
chmod +x datatracker-start.sh && \
chmod +x migration-start.sh && \
diff --git a/dev/diff/settings_local.py b/dev/diff/settings_local.py
index c255cac23d..e9814a4637 100644
--- a/dev/diff/settings_local.py
+++ b/dev/diff/settings_local.py
@@ -18,6 +18,7 @@
IDSUBMIT_IDNITS_BINARY = "/usr/local/bin/idnits"
+IDSUBMIT_IDNITS3_BINARY = "/usr/local/bin/idnits3"
IDSUBMIT_REPOSITORY_PATH = "test/id/"
IDSUBMIT_STAGING_PATH = "test/staging/"
diff --git a/dev/tests/prepare.sh b/dev/tests/prepare.sh
index 47917e4544..8b1e8cbd67 100644
--- a/dev/tests/prepare.sh
+++ b/dev/tests/prepare.sh
@@ -8,6 +8,10 @@ echo "Copying config files..."
cp ./dev/tests/settings_local.py ./ietf/settings_local.py
echo "Ensure all requirements.txt packages are installed..."
pip --disable-pip-version-check --no-cache-dir install -r requirements.txt
+echo "Installing idnits3..."
+npm install -g --prefix /usr/local/idnits3 @ietf-tools/idnits@3.1.0
+ln -sf /usr/local/idnits3/bin/idnits /usr/local/bin/idnits3
+idnits3 --version
echo "Compiling native node packages..."
npm ci
echo "Building static assets..."
diff --git a/dev/tests/settings_local.py b/dev/tests/settings_local.py
index e1ffd60edb..234e49aed2 100644
--- a/dev/tests/settings_local.py
+++ b/dev/tests/settings_local.py
@@ -17,6 +17,7 @@
}
IDSUBMIT_IDNITS_BINARY = "/usr/local/bin/idnits"
+IDSUBMIT_IDNITS3_BINARY = "/usr/local/bin/idnits3"
IDSUBMIT_REPOSITORY_PATH = "/assets/ietfdata/doc/draft/repository"
IDSUBMIT_STAGING_PATH = "/assets/www6s/staging/"
diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile
index dd4cf72ffd..594b9d0e69 100644
--- a/docker/app.Dockerfile
+++ b/docker/app.Dockerfile
@@ -47,6 +47,14 @@ RUN groupmod --gid $USER_GID $USERNAME \
&& chown -R $USER_UID:$USER_GID /home/$USERNAME \
|| exit 0
+# Install idnits3. The version is pinned here and in the other container
+# definitions - grep for @ietf-tools/idnits to bump them together.
+# It goes in its own npm prefix so that it can never disturb the idnits2 script
+# at /usr/local/bin/idnits that comes from the base image.
+RUN npm install -g --prefix /usr/local/idnits3 @ietf-tools/idnits@3.1.0 && \
+ ln -sf /usr/local/idnits3/bin/idnits /usr/local/bin/idnits3 && \
+ idnits3 --version
+
# Switch to local dev user
USER dev:dev
diff --git a/docker/celery.Dockerfile b/docker/celery.Dockerfile
index e93ca3cf77..a69ad8b568 100644
--- a/docker/celery.Dockerfile
+++ b/docker/celery.Dockerfile
@@ -41,6 +41,14 @@ RUN groupmod --gid $USER_GID $USERNAME \
&& chown -R $USER_UID:$USER_GID /home/$USERNAME \
|| exit 0
+# Install idnits3. The version is pinned here and in the other container
+# definitions - grep for @ietf-tools/idnits to bump them together.
+# It goes in its own npm prefix so that it can never disturb the idnits2 script
+# at /usr/local/bin/idnits that comes from the base image.
+RUN npm install -g --prefix /usr/local/idnits3 @ietf-tools/idnits@3.1.0 && \
+ ln -sf /usr/local/idnits3/bin/idnits /usr/local/bin/idnits3 && \
+ idnits3 --version
+
# Switch to local dev user
USER dev:dev
diff --git a/docker/configs/settings_local.py b/docker/configs/settings_local.py
index 227da0a0ac..da8f23e35e 100644
--- a/docker/configs/settings_local.py
+++ b/docker/configs/settings_local.py
@@ -24,6 +24,7 @@
}
IDSUBMIT_IDNITS_BINARY = "/usr/local/bin/idnits"
+IDSUBMIT_IDNITS3_BINARY = "/usr/local/bin/idnits3"
IDSUBMIT_STAGING_PATH = "/assets/www6s/staging/"
AGENDA_PATH = '/assets/www6s/proceedings/'
diff --git a/ietf/checks.py b/ietf/checks.py
index 3853e49f04..099d2e88d9 100644
--- a/ietf/checks.py
+++ b/ietf/checks.py
@@ -54,6 +54,9 @@ def check_id_submission_files(app_configs, **kwargs):
return []
#
errors = []
+ hint = ("Please either update the local settings to point at the correct\n"
+ "\tfile, or if the setting is correct, make sure the file is in place and\n"
+ "\thas the right permissions.\n")
for s in ("IDSUBMIT_IDNITS_BINARY", ):
p = getattr(settings, s)
if not os.path.exists(p):
@@ -61,11 +64,22 @@ def check_id_submission_files(app_configs, **kwargs):
"A file used by the I-D submission tool does not exist\n"
"at the path given in the settings file. The setting is:\n"
" %s = %s" % (s, p),
- hint = ("Please either update the local settings to point at the correct\n"
- "\tfile, or if the setting is correct, make sure the file is in place and\n"
- "\thas the right permissions.\n"),
+ hint = hint,
id = "datatracker.E0007",
))
+ # The idnits3 check is advisory - a submission is not blocked by it, and is
+ # not blocked by its absence either, so only warn if it is not installed.
+ for s in ("IDSUBMIT_IDNITS3_BINARY", ):
+ p = getattr(settings, s)
+ if not os.path.exists(p):
+ errors.append(checks.Warning(
+ "A file used by the I-D submission tool does not exist\n"
+ "at the path given in the settings file. The advisory checks it\n"
+ "provides will be skipped. The setting is:\n"
+ " %s = %s" % (s, p),
+ hint = hint,
+ id = "datatracker.W0007",
+ ))
return errors
diff --git a/ietf/settings.py b/ietf/settings.py
index 3e26e22e84..24a2bf6621 100644
--- a/ietf/settings.py
+++ b/ietf/settings.py
@@ -954,6 +954,11 @@ def skip_unreadable_post(record):
IDSUBMIT_STAGING_PATH = '/a/www/www6s/staging/'
IDSUBMIT_STAGING_URL = '//www.ietf.org/staging/'
IDSUBMIT_IDNITS_BINARY = '/a/www/ietf-datatracker/scripts/idnits'
+IDSUBMIT_IDNITS3_BINARY = '/usr/local/bin/idnits3'
+# Set True to skip the idnits3 checks that need to fetch remote documents
+IDSUBMIT_IDNITS3_OFFLINE = False
+# Seconds to allow an idnits3 run before giving up on it
+IDSUBMIT_IDNITS3_TIMEOUT = 300
SUBMIT_PYANG_COMMAND = 'pyang --verbose --ietf -p {libs} {model}'
SUBMIT_YANGLINT_COMMAND = 'yanglint --verbose -p {tmplib} -p {rfclib} -p {draftlib} -p {ianalib} -p {cataloglib} {model} -i'
@@ -967,6 +972,7 @@ def skip_unreadable_post(record):
IDSUBMIT_CHECKER_CLASSES = (
"ietf.submit.checkers.DraftIdnitsChecker",
+ "ietf.submit.checkers.DraftIdnits3Checker",
"ietf.submit.checkers.DraftYangChecker",
# "ietf.submit.checkers.DraftYangvalidatorChecker",
)
diff --git a/ietf/submit/checkers.py b/ietf/submit/checkers.py
index e02b686576..4c1bc24e93 100644
--- a/ietf/submit/checkers.py
+++ b/ietf/submit/checkers.py
@@ -3,10 +3,12 @@
import io
+import json
import os
from pathlib import Path
import re
import shutil
+import subprocess
import sys
import tempfile
@@ -311,3 +313,169 @@ def check_file_txt(self, path):
info['items'] = items
info['code']['yang'] = model_list
return passed, message, errors, warnings, info
+
+
+class DraftIdnits3Checker(object):
+ """
+ Draft checker class for idnits3, run in "submission" mode.
+
+ idnits3 understands both text and XML Internet-Drafts, so both
+ check_file_xml() and check_file_txt() are defined; the XML is preferred
+ when the submission includes it, since that is the form the author wrote.
+
+ This checker is advisory: it never fails a submission, even when idnits3
+ reports errors that would block it once idnits3 becomes a required check.
+ It records whether it would have blocked in the check's `items` so that the
+ submitter can be warned. A run that could not be completed at all (missing
+ or broken binary, unparsable output) returns None for `passed`, which marks
+ the check as "did not apply" and hides it from the submitter.
+ """
+
+ name = "idnits3 check"
+
+ symbol = ""
+
+ _severities = (
+ ("ValidationError", "Errors"),
+ ("ValidationWarning", "Warnings"),
+ ("ValidationComment", "Comments"),
+ )
+
+ def __init__(self, options=None):
+ if options is None:
+ # --mode submission limits the checks to those relevant when a
+ # draft is submitted; --output json gives us reliable per-severity
+ # counts and structured nits to render ourselves.
+ options = ["--mode", "submission", "--output", "json", "--no-color"]
+ if settings.IDSUBMIT_IDNITS3_OFFLINE:
+ options.append("--offline")
+ assert isinstance(options, list)
+ self.options = options
+
+ def _version(self):
+ try:
+ result = subprocess.run(
+ [settings.IDSUBMIT_IDNITS3_BINARY, "--version"],
+ capture_output=True,
+ timeout=settings.IDSUBMIT_IDNITS3_TIMEOUT,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return "unknown version"
+ if result.returncode != 0:
+ return "unknown version"
+ return result.stdout.decode("utf-8", errors="replace").strip()
+
+ def _render(self, nits, counts):
+ """Render the idnits3 nits as the text shown to the submitter"""
+ lines = [
+ "idnits %s (submission mode): %d error%s, %d warning%s, %d comment%s"
+ % (
+ self._version(),
+ counts["error"], "" if counts["error"] == 1 else "s",
+ counts["warning"], "" if counts["warning"] == 1 else "s",
+ counts["comment"], "" if counts["comment"] == 1 else "s",
+ ),
+ "",
+ "These results do not affect this submission. Errors reported here are",
+ "expected to prevent submission once idnits3 becomes a required check.",
+ "",
+ ]
+ if not nits:
+ lines.append("No nits found.")
+ return "\n".join(lines) + "\n"
+ index = 0
+ for severity, heading in self._severities:
+ of_severity = [n for n in nits if n.get("severity") == severity]
+ if not of_severity:
+ continue
+ lines.append("%s:" % heading)
+ lines.append("")
+ for nit in of_severity:
+ index += 1
+ indent = " " * 6
+ lines.append("%4d. %s" % (index, nit.get("code", "UNKNOWN")))
+ lines.append("%s%s" % (indent, nit.get("desc", "")))
+ if nit.get("text"):
+ lines.append("%sText: %s" % (indent, nit["text"]))
+ if nit.get("path"):
+ lines.append("%sPath: %s" % (indent, nit["path"]))
+ if nit.get("line"):
+ lines.append(
+ "%sAt: %s"
+ % (
+ indent,
+ ", ".join(
+ "line %s column %s" % (loc.get("line"), loc.get("pos"))
+ for loc in nit["line"]
+ ),
+ )
+ )
+ if nit.get("ref"):
+ lines.append("%sSee %s" % (indent, nit["ref"]))
+ lines.append("")
+ return "\n".join(lines) + "\n"
+
+ def _check_file(self, path):
+ info = {
+ "checker": self.name,
+ "items": [],
+ "code": {},
+ "advisory": True,
+ "would_block_in_future": False,
+ }
+ cmd = [settings.IDSUBMIT_IDNITS3_BINARY] + self.options + [str(path)]
+ try:
+ result = subprocess.run(
+ cmd, capture_output=True, timeout=settings.IDSUBMIT_IDNITS3_TIMEOUT
+ )
+ except (OSError, subprocess.SubprocessError) as err:
+ message = "idnits3 error: %s:\n %s" % (" ".join(cmd), err)
+ log(message)
+ return None, message, 0, 0, info
+ if result.returncode != 0:
+ message = "idnits3 error: %s:\n Error %s: %s" % (
+ " ".join(cmd),
+ result.returncode,
+ result.stderr.decode("utf-8", errors="replace"),
+ )
+ log(message)
+ return None, message, 0, 0, info
+ try:
+ output = json.loads(result.stdout.decode("utf-8", errors="replace"))
+ counts = {
+ severity: int(output["nitsBySeverity"].get(severity, 0))
+ for severity in ("error", "warning", "comment")
+ }
+ nits = output.get("nits", [])
+ if not isinstance(nits, list) or not all(
+ isinstance(nit, dict) for nit in nits
+ ):
+ raise ValueError("'nits' is not a list of nits")
+ except (AttributeError, KeyError, TypeError, ValueError) as err:
+ message = "idnits3 error: %s:\n Could not parse the idnits3 output: %s" % (
+ " ".join(cmd),
+ err,
+ )
+ log(message)
+ return None, message, 0, 0, info
+
+ info["items"] = [
+ (
+ nit["line"][0]["line"] if nit.get("line") else None,
+ None,
+ "%s: %s" % (nit.get("code", "UNKNOWN"), nit.get("desc", "")),
+ )
+ for nit in nits
+ ]
+ info["would_block_in_future"] = counts["error"] > 0
+ # Comments are neither errors nor warnings, but reporting them as
+ # warnings is the only way to get them in front of the submitter.
+ warnings = counts["warning"] + counts["comment"]
+ # Always passes -- idnits3 does not block submission yet.
+ return True, self._render(nits, counts), counts["error"], warnings, info
+
+ def check_file_txt(self, path):
+ return self._check_file(path)
+
+ def check_file_xml(self, path):
+ return self._check_file(path)
diff --git a/ietf/submit/models.py b/ietf/submit/models.py
index 576ba3e114..355b92c9fd 100644
--- a/ietf/submit/models.py
+++ b/ietf/submit/models.py
@@ -130,21 +130,32 @@ def closed_wg_drafts_replaced(self):
class SubmissionCheck(models.Model):
time = models.DateTimeField(default=timezone.now)
- submission = ForeignKey(Submission, related_name='checks')
+ submission = ForeignKey(Submission, related_name="checks")
checker = models.CharField(max_length=256, blank=True)
passed = models.BooleanField(null=True, default=False)
message = models.TextField(null=True, blank=True)
errors = models.IntegerField(null=True, blank=True, default=None)
warnings = models.IntegerField(null=True, blank=True, default=None)
items = models.JSONField(null=True, blank=True, default=dict)
- symbol = models.CharField(max_length=64, default='')
- #
+ symbol = models.CharField(max_length=64, default="")
+
def __str__(self):
- return "%s submission check: %s: %s" % (self.checker, 'Passed' if self.passed else 'Failed', self.message[:48]+'...')
- def has_warnings(self):
- return self.warnings != '[]'
- def has_errors(self):
- return self.errors != '[]'
+ return (
+ f"{self.checker} submission check: "
+ f"{'Passed' if self.passed else 'Failed'}: {(self.message or '')[:48]}..."
+ )
+
+ @property
+ def is_advisory(self):
+ """Is this a check whose result cannot prevent a submission?"""
+ return bool(isinstance(self.items, dict) and self.items.get("advisory"))
+
+ @property
+ def would_block_in_future(self):
+ """Would this advisory check have blocked the submission if it were required?"""
+ return bool(
+ isinstance(self.items, dict) and self.items.get("would_block_in_future")
+ )
class SubmissionEvent(models.Model):
submission = ForeignKey(Submission)
diff --git a/ietf/submit/tests.py b/ietf/submit/tests.py
index abe23c1a64..28d46d0b2a 100644
--- a/ietf/submit/tests.py
+++ b/ietf/submit/tests.py
@@ -5,10 +5,13 @@
import datetime
import email
import io
+import json
from unittest import mock
import os
import re
+import shutil
import sys
+import tempfile
from io import StringIO
from pyquery import PyQuery
@@ -41,6 +44,7 @@
from ietf.name.models import DraftSubmissionStateName, FormalLanguageName
from ietf.person.models import Person
from ietf.person.factories import UserFactory, PersonFactory, EmailFactory
+from ietf.submit.checkers import DraftIdnits3Checker
from ietf.submit.factories import SubmissionFactory, SubmissionExtResourceFactory
from ietf.submit.forms import SubmissionBaseUploadForm, SubmissionAutoUploadForm
from ietf.submit.models import Submission, Preapproval, SubmissionExtResource
@@ -3460,6 +3464,240 @@ def test_submission_checks(self):
status_code=200,
)
+ def test_advisory_submission_checks(self):
+ """An advisory check reports its result but does not block the submission"""
+ submission = SubmissionFactory(state_id="uploaded")
+ url = urlreverse(
+ "ietf.submit.views.submission_status",
+ kwargs={"submission_id": submission.pk},
+ )
+ check = submission.checks.create(
+ checker="idnits3 check",
+ passed=True,
+ message="idnits3 message",
+ errors=2,
+ warnings=1,
+ items={"advisory": True, "would_block_in_future": True},
+ )
+ r = self.client.get(url)
+ # The submission still passes - the advisory check does not block it ...
+ self.assertContains(
+ r, "Your Internet-Draft has been verified to pass the submission checks."
+ )
+ # ... but the submitter is warned that it would in the future
+ self.assertContains(r, "The idnits3 check returned 2 errors")
+ self.assertContains(r, "and 1 warning.")
+ self.assertContains(r, "would then be rejected")
+ self.assertContains(r, "idnits3 message")
+
+ # Warnings only - nothing that would block later
+ check.errors = 0
+ check.items = {"advisory": True, "would_block_in_future": False}
+ check.save()
+ r = self.client.get(url)
+ self.assertNotContains(r, "would then be rejected")
+ self.assertContains(r, "The idnits3 check returned 1 warning.")
+ self.assertContains(r, "None of them would stop this submission.")
+
+ # Nothing at all to report
+ check.warnings = 0
+ check.save()
+ r = self.client.get(url)
+ self.assertNotContains(r, "would then be rejected")
+ self.assertNotContains(r, "None of them would stop this submission.")
+
+
+class Idnits3CheckerTests(TestCase):
+ """Tests of DraftIdnits3Checker
+
+ Most of these run the checker against a stand-in for the idnits3 binary so that
+ they neither depend on idnits3 being installed nor on network access.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.tempdir = Path(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.tempdir)
+ self.draft_path = self.tempdir / "draft-somebody-test-00.txt"
+ self.draft_path.write_text("Not really an Internet-Draft\n")
+ self.args_path = self.tempdir / "args"
+
+ def fake_idnits3(self, stdout="", returncode=0):
+ """Write a stand-in for the idnits3 binary and return its path
+
+ The stand-in records the arguments it was called with in self.args_path.
+ """
+ path = self.tempdir / "idnits3"
+ path.write_text(
+ "#!/bin/sh\n"
+ 'if [ "$1" = "--version" ]; then echo "3.1.0"; exit 0; fi\n'
+ f'echo "$@" > {self.args_path}\n'
+ "cat <<'IDNITS3_EOF'\n"
+ f"{stdout}\n"
+ "IDNITS3_EOF\n"
+ f"exit {returncode}\n"
+ )
+ path.chmod(0o755)
+ return str(path)
+
+ @staticmethod
+ def idnits3_output(nits):
+ counts = {"error": 0, "warning": 0, "comment": 0}
+ for nit in nits:
+ counts[nit["severity"].replace("Validation", "").lower()] += 1
+ return json.dumps(
+ {
+ "result": "fail" if nits else "pass",
+ "file": {"path": "draft-somebody-test-00.txt", "size": 29},
+ "nitsBySeverity": counts,
+ "nits": nits,
+ }
+ )
+
+ def test_reports_nits_without_blocking(self):
+ output = self.idnits3_output(
+ [
+ {
+ "severity": "ValidationError",
+ "code": "MISSING_ABSTRACT_SECTION",
+ "desc": "The abstract section is missing.",
+ "ref": "https://authors.ietf.org/required-content#abstract",
+ "path": "rfc.front.abstract",
+ },
+ {
+ "severity": "ValidationWarning",
+ "code": "LINE_TOO_LONG",
+ "desc": "The document contains 1 over-long line.",
+ "line": [{"line": 7, "pos": 80}],
+ },
+ {
+ "severity": "ValidationComment",
+ "code": "SOME_COMMENT",
+ "desc": "Just a comment.",
+ },
+ ]
+ )
+ with override_settings(IDSUBMIT_IDNITS3_BINARY=self.fake_idnits3(output)):
+ passed, message, errors, warnings, info = DraftIdnits3Checker().check_file_txt(
+ self.draft_path
+ )
+ # The check must not fail the submission, even though idnits3 found errors
+ self.assertTrue(passed)
+ self.assertEqual(errors, 1)
+ self.assertEqual(warnings, 2) # warnings and comments are both reported as warnings
+ self.assertTrue(info["advisory"])
+ self.assertTrue(info["would_block_in_future"])
+ self.assertEqual(len(info["items"]), 3)
+ self.assertIn("MISSING_ABSTRACT_SECTION", message)
+ self.assertIn("The abstract section is missing.", message)
+ self.assertIn("rfc.front.abstract", message)
+ self.assertIn("https://authors.ietf.org/required-content#abstract", message)
+ self.assertIn("line 7 column 80", message)
+ self.assertIn("SOME_COMMENT", message)
+ self.assertIn("3.1.0", message)
+
+ def test_would_not_block_without_errors(self):
+ output = self.idnits3_output(
+ [
+ {
+ "severity": "ValidationWarning",
+ "code": "LINE_TOO_LONG",
+ "desc": "The document contains 1 over-long line.",
+ }
+ ]
+ )
+ with override_settings(IDSUBMIT_IDNITS3_BINARY=self.fake_idnits3(output)):
+ passed, message, errors, warnings, info = DraftIdnits3Checker().check_file_txt(
+ self.draft_path
+ )
+ self.assertTrue(passed)
+ self.assertEqual(errors, 0)
+ self.assertEqual(warnings, 1)
+ self.assertFalse(info["would_block_in_future"])
+
+ def test_clean_document(self):
+ with override_settings(
+ IDSUBMIT_IDNITS3_BINARY=self.fake_idnits3(self.idnits3_output([]))
+ ):
+ passed, message, errors, warnings, info = DraftIdnits3Checker().check_file_txt(
+ self.draft_path
+ )
+ self.assertTrue(passed)
+ self.assertEqual((errors, warnings), (0, 0))
+ self.assertFalse(info["would_block_in_future"])
+ self.assertIn("No nits found.", message)
+
+ def test_runs_in_submission_mode(self):
+ with override_settings(
+ IDSUBMIT_IDNITS3_BINARY=self.fake_idnits3(self.idnits3_output([]))
+ ):
+ DraftIdnits3Checker().check_file_xml(self.draft_path)
+ args = self.args_path.read_text().split()
+ self.assertEqual(args[:2], ["--mode", "submission"])
+ self.assertIn("--output", args)
+ self.assertIn("json", args)
+ self.assertNotIn("--offline", args)
+ self.assertEqual(args[-1], str(self.draft_path))
+
+ def test_offline_setting(self):
+ with override_settings(IDSUBMIT_IDNITS3_OFFLINE=True):
+ self.assertIn("--offline", DraftIdnits3Checker().options)
+ with override_settings(IDSUBMIT_IDNITS3_OFFLINE=False):
+ self.assertNotIn("--offline", DraftIdnits3Checker().options)
+
+ def test_missing_binary_does_not_apply(self):
+ with override_settings(
+ IDSUBMIT_IDNITS3_BINARY=str(self.tempdir / "there-is-no-idnits3")
+ ):
+ passed, message, errors, warnings, info = DraftIdnits3Checker().check_file_txt(
+ self.draft_path
+ )
+ self.assertIsNone(passed) # "did not apply", so the submitter is not shown it
+ self.assertEqual((errors, warnings), (0, 0))
+ self.assertFalse(info["would_block_in_future"])
+ self.assertIn("idnits3 error", message)
+
+ def test_failed_run_does_not_apply(self):
+ with override_settings(
+ IDSUBMIT_IDNITS3_BINARY=self.fake_idnits3("boom", returncode=1)
+ ):
+ passed, message, __, __, info = DraftIdnits3Checker().check_file_txt(
+ self.draft_path
+ )
+ self.assertIsNone(passed)
+ self.assertFalse(info["would_block_in_future"])
+ self.assertIn("idnits3 error", message)
+
+ def test_unparsable_output_does_not_apply(self):
+ with override_settings(
+ IDSUBMIT_IDNITS3_BINARY=self.fake_idnits3("this is not json")
+ ):
+ passed, message, __, __, info = DraftIdnits3Checker().check_file_txt(
+ self.draft_path
+ )
+ self.assertIsNone(passed)
+ self.assertFalse(info["would_block_in_future"])
+ self.assertIn("Could not parse the idnits3 output", message)
+
+ @override_settings(IDSUBMIT_IDNITS3_OFFLINE=True)
+ def test_real_idnits3(self):
+ """The real idnits3 produces output this checker can make sense of"""
+ if not os.path.exists(settings.IDSUBMIT_IDNITS3_BINARY):
+ # idnits3 is installed into the datatracker and celery images, and
+ # into the CI test container by dev/tests/prepare.sh - but it is not
+ # in the base image, so a bare base container will not have it
+ self.skipTest(f"idnits3 is not installed at {settings.IDSUBMIT_IDNITS3_BINARY}")
+ name = "draft-somebody-test-idnits3-00"
+ text, __ = submission_file_contents(name, None, "test_submission.txt")
+ path = self.tempdir / f"{name}.txt"
+ path.write_text(text)
+ passed, message, errors, warnings, info = DraftIdnits3Checker().check_file_txt(path)
+ self.assertTrue(passed) # idnits3 never blocks a submission
+ self.assertTrue(info["advisory"])
+ self.assertEqual(info["would_block_in_future"], errors > 0)
+ self.assertEqual(len(info["items"]), errors + warnings)
+ self.assertIn("submission mode", message)
+
class YangCheckerTests(TestCase):
@mock.patch("ietf.submit.utils.apply_yang_checker_to_draft")
diff --git a/ietf/submit/utils.py b/ietf/submit/utils.py
index b331d71631..0ddd3a09ef 100644
--- a/ietf/submit/utils.py
+++ b/ietf/submit/utils.py
@@ -829,10 +829,16 @@ def apply_check(submission, checker, method, fn):
message=message, errors=errors, warnings=warnings, items=info,
symbol=checker.symbol)
check.save()
- # ordered list of methods to try
+ # ordered list of methods to try - skip formats that were not submitted or
+ # generated, so that (e.g.) a checker that prefers XML still runs on the
+ # text of a submission that has no XML
for method in ("check_fragment_xml", "check_file_xml", "check_fragment_txt", "check_file_txt", ):
ext = method[-3:]
- if hasattr(checker, method) and ext in file_name:
+ if (
+ hasattr(checker, method)
+ and ext in file_name
+ and os.path.exists(file_name[ext])
+ ):
apply_check(submission, checker, method, file_name[ext])
break
diff --git a/ietf/templates/submit/submission_status.html b/ietf/templates/submit/submission_status.html
index cdc5dd4007..8ecbe80e23 100644
--- a/ietf/templates/submit/submission_status.html
+++ b/ietf/templates/submit/submission_status.html
@@ -59,7 +59,23 @@
Submission checks
{% endif %}
{% for check in submission.latest_checks %}
- {% if check.errors %}
+ {% if check.is_advisory %}
+ {% if check.would_block_in_future %}
+
+ The {{ check.checker }} returned {{ check.errors }} error{{ check.errors|pluralize }}
+ and {{ check.warnings }} warning{{ check.warnings|pluralize }}.
+ The {{ check.checker }} does not affect this submission, so those errors have
+ not stopped it. It is expected to become a required submission check,
+ however, and this Internet-Draft would then be rejected. Click the button
+ below to see details, and please fix those before you submit again.
+
+ {% elif check.warnings %}
+
+ The {{ check.checker }} returned {{ check.warnings }} warning{{ check.warnings|pluralize }}.
+ None of them would stop this submission.
+
+ {% endif %}
+ {% elif check.errors %}
The {{ check.checker }} returned {{ check.errors }} error{{ check.errors|pluralize }}
and {{ check.warnings }} warning{{ check.warnings|pluralize }}; click the button
@@ -72,7 +88,7 @@
Submission checks
{% endif %}
{% endfor %}
{% for check in submission.latest_checks %}
-