diff --git a/README.md b/README.md
index 48dfdae27e4..125eede83b0 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[](#prerequisites)
[](#prerequisites)
[](#prerequisites)
-[](#prerequisites)
+[](#prerequisites)
##### The day-to-day front-end to the IETF database for people who work on IETF standards.
diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile
index d80aceaffb5..774e985af70 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:20260902T2330
LABEL maintainer="IETF Tools Team "
ENV DEBIAN_FRONTEND=noninteractive
@@ -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/build/TARGET_BASE b/dev/build/TARGET_BASE
index d40bd9e9299..dcaab2d6d7e 100644
--- a/dev/build/TARGET_BASE
+++ b/dev/build/TARGET_BASE
@@ -1 +1 @@
-20260804T1752
+20260902T2330
diff --git a/dev/diff/settings_local.py b/dev/diff/settings_local.py
index c255cac23dc..e9814a46377 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 47917e45449..8b1e8cbd679 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 e1ffd60edb8..234e49aed2a 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 dd4cf72ffd9..594b9d0e692 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/base.Dockerfile b/docker/base.Dockerfile
index 7caba501d7c..1692a58dad8 100644
--- a/docker/base.Dockerfile
+++ b/docker/base.Dockerfile
@@ -1,3 +1,31 @@
+FROM debian:trixie AS builder
+
+# Update to the desired release tag
+ARG LIBYANG_TAG=v5.8.6
+
+# Build-time dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git \
+ cmake \
+ build-essential \
+ pkg-config \
+ ca-certificates \
+ libpcre2-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /src
+RUN git clone --branch ${LIBYANG_TAG} --depth 1 \
+ https://github.com/CESNET/libyang.git libyang
+
+WORKDIR /src/libyang/build
+RUN cmake \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX=/opt/libyang \
+ -DBUILD_SHARED_LIBS=ON \
+ .. \
+ && make -j"$(nproc)" \
+ && make install
+
FROM python:3.12-trixie
LABEL maintainer="IETF Tools Team "
@@ -9,6 +37,13 @@ RUN apt-get update \
&& apt-get -qy upgrade \
&& apt-get -y install --no-install-recommends dialog 2>&1
+COPY --from=builder /opt/libyang /opt/libyang
+
+RUN echo /opt/libyang/lib > /etc/ld.so.conf.d/libyang.conf \
+ && ldconfig \
+ && ln -s /opt/libyang/bin/yanglint /usr/bin/yanglint \
+ && ln -s /opt/libyang/bin/yangre /usr/bin/yangre
+
# Add Node.js Source
RUN apt-get install -y --no-install-recommends ca-certificates curl gnupg \
&& mkdir -p /etc/apt/keyrings \
@@ -59,7 +94,6 @@ RUN apt-get update --fix-missing && apt-get install -qy --no-install-recommends
libmagic-dev \
libmariadb-dev \
libmemcached-tools \
- libyang3-tools \
locales \
make \
mariadb-client \
diff --git a/docker/celery.Dockerfile b/docker/celery.Dockerfile
index e93ca3cf77c..a69ad8b5682 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 227da0a0ace..da8f23e35ea 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/api/__init__.py b/ietf/api/__init__.py
index 30254221d12..d03ff4200ae 100644
--- a/ietf/api/__init__.py
+++ b/ietf/api/__init__.py
@@ -266,7 +266,8 @@ def dehydrate(self, bundle, for_list=True):
# Replace each with its Unicode control picture (U+2400 + codepoint) so the
# substitution is lossless and the result is valid XML.
_XML_INVALID_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
-
+# XML 1.0 also forbids the surrogate blocks and non-chars \ufffe and \uffff
+_XML_INVALID_UNICODE_RE = re.compile(r"[\ud800-\udfff\ufffe\uffff]")
class Serializer(tastypie.serializers.Serializer):
OPTION_ESCAPE_XML_INVALID = "datatracker-escape-xml-invalid"
@@ -290,6 +291,8 @@ def to_simple(self, data, options):
simple_data = _XML_INVALID_CTRL_RE.sub(
lambda m: chr(ord(m.group()) + 0x2400), simple_data
)
+ # Replace outright invalid unicode chars with the replacement char
+ simple_data = _XML_INVALID_UNICODE_RE.sub("\uFFFD", simple_data)
return simple_data
def to_etree(self, data, options=None, name=None, depth=0):
diff --git a/ietf/api/tests.py b/ietf/api/tests.py
index 75147d679a4..2784e8e1ed3 100644
--- a/ietf/api/tests.py
+++ b/ietf/api/tests.py
@@ -5,6 +5,7 @@
import datetime
import json
import html
+from itertools import chain
from unittest import mock
import os
import sys
@@ -1875,17 +1876,48 @@ def test_serializer_to_etree_handles_xml_invalid_control_chars(self):
except ValueError:
self.fail("serializer.to_etree raised ValueError on an ordinary string")
# Every control character that XML 1.0 forbids must be escaped rather than
- # causing a ValueError. This is the class of characters that triggered the
- # production exception (lxml.etree._utf8 rejects them all).
- invalid_chars = [chr(c) for c in list(range(0x00, 0x09)) + [0x0b, 0x0c] + list(range(0x0e, 0x20))]
- for ch in invalid_chars:
+ # causing a ValueError. These should be replaced by a Unicode picture char.
+ control_chars = [
+ chr(c) for c in chain(range(0x00, 0x09), [0x0B, 0x0C], range(0x0E, 0x20))
+ ]
+ for ch in control_chars:
try:
- serializer.to_etree(f"string with {ch!r} in it")
+ elt = serializer.to_etree(f"string with {str(ch)} in it")
except ValueError:
self.fail(
f"serializer.to_etree raised ValueError on a string "
f"containing control character U+{ord(ch):04X}"
)
+ else:
+ self.assertIn(
+ chr(0x2400 + ord(ch)),
+ elt.text,
+ (
+ f"character U+{ord(ch):04X} not replaced with "
+ f"Unicode control picture character"
+ ),
+ )
+
+ # Unicode surrogate ranges and non-characters are not permitted in XML 1.0
+ # either. These should be replaced by the Unicode replacement character.
+ invalid_chars = [chr(c) for c in chain(range(0xD800, 0xDFFF), [0xFFFE, 0xFFFF])]
+ for ch in invalid_chars:
+ try:
+ elt = serializer.to_etree(f"string with {str(ch)} in it")
+ except ValueError:
+ self.fail(
+ f"serializer.to_etree raised ValueError on a string "
+ f"containing invalid character U+{ord(ch):04X}"
+ )
+ else:
+ self.assertIn(
+ chr(0xFFFD),
+ elt.text,
+ (
+ f"character U+{ord(ch):04X} not replaced with "
+ f"Unicode replacement char"
+ ),
+ )
def test_post_detail_is_not_allowed(self):
"""POST to a detail route returns 405
diff --git a/ietf/api/urls.py b/ietf/api/urls.py
index 8e843d720ff..072c913ad79 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/checks.py b/ietf/checks.py
index 3853e49f04e..099d2e88d92 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/group/tests_info.py b/ietf/group/tests_info.py
index 4e0096b1859..c93525bd7e4 100644
--- a/ietf/group/tests_info.py
+++ b/ietf/group/tests_info.py
@@ -2147,7 +2147,86 @@ 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",
+ 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)
+ # 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",
+ 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',
+ meeting__date=date_today() + datetime.timedelta(days=30),
+ 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',
+ meeting__date=date_today() + datetime.timedelta(days=30),
+ 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/ietfauth/views.py b/ietf/ietfauth/views.py
index 15a37968a52..2e32dfd5d3c 100644
--- a/ietf/ietfauth/views.py
+++ b/ietf/ietfauth/views.py
@@ -760,9 +760,9 @@ def clean(self):
except ValueError:
self.add_error(
"password",
- 'Your password has been cleared because of possible password leakage. '
- 'Please use the "Forgot your password?" button below to set a new password '
- 'for your account.',
+ 'Your password has been reset due to inactivity. Please use the '
+ '"Forgot your password?" button below to set a new password for '
+ 'your account.',
)
return super().clean()
diff --git a/ietf/meeting/api.py b/ietf/meeting/api.py
new file mode 100644
index 00000000000..31042620913
--- /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/helpers.py b/ietf/meeting/helpers.py
index 39d271ae6b9..568af7422c2 100644
--- a/ietf/meeting/helpers.py
+++ b/ietf/meeting/helpers.py
@@ -1,7 +1,4 @@
-# Copyright The IETF Trust 2013-2022, All Rights Reserved
-# -*- coding: utf-8 -*-
-
-
+# Copyright The IETF Trust 2013-2026, All Rights Reserved
from collections import defaultdict
import datetime
import io
@@ -28,12 +25,20 @@
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
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):
@@ -773,6 +778,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 +861,35 @@ 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):
+ """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
+ 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(
+ possible_meetings, 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 +904,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 +977,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 +998,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 +1019,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 +1221,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/models.py b/ietf/meeting/models.py
index bf0c4dabd12..42b81b6a973 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 00000000000..70b9ee12cae
--- /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 00000000000..062ab07e4d1
--- /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 0b9ce3f607f..4c18b3633c8 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/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 3e5002a466f..763ea528720 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']
@@ -4110,15 +4185,25 @@ 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"""
+ meetings = data_for_meetings_overview(
+ Meeting.objects.filter(
+ type="interim", date__gte=date_today() - PENDING_INTERIM_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 +4258,30 @@ 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"""
+ meetings = data_for_meetings_overview(
+ Meeting.objects.filter(
+ type="interim", date__gte=date_today() - PENDING_INTERIM_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
@@ -4838,19 +4932,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)
+ regs_to_consider = (
+ Registration.objects.filter(meeting__number=num)
+ .with_plenary_ticket_details()
.select_related("person")
- if reg.person.pk in onsite_pks
- ] + [
- reg
- for reg in Registration.objects.remote()
- .filter(meeting__number=num)
- .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/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 4b3bbb2a153..24a2bf66216 100644
--- a/ietf/settings.py
+++ b/ietf/settings.py
@@ -25,7 +25,6 @@
warnings.filterwarnings("ignore", message="The USE_DEPRECATED_PYTZ setting,") # https://github.com/ietf-tools/datatracker/issues/5635
warnings.filterwarnings("ignore", message="The is_dst argument to make_aware\\(\\)") # caused by django-filters when USE_DEPRECATED_PYTZ is true
warnings.filterwarnings("ignore", message="The USE_L10N setting is deprecated.") # https://github.com/ietf-tools/datatracker/issues/5648
-warnings.filterwarnings("ignore", message="django.contrib.auth.hashers.CryptPasswordHasher is deprecated.") # https://github.com/ietf-tools/datatracker/issues/5663
# Other DeprecationWarnings
warnings.filterwarnings("ignore", message="pkg_resources is deprecated as an API", module="pyang.plugin")
@@ -70,11 +69,10 @@
BUG_REPORT_EMAIL = "tools-help@ietf.org"
PASSWORD_HASHERS = [
- 'django.contrib.auth.hashers.Argon2PasswordHasher',
- 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
- 'django.contrib.auth.hashers.PBKDF2PasswordHasher',
- 'django.contrib.auth.hashers.SHA1PasswordHasher',
- 'django.contrib.auth.hashers.CryptPasswordHasher',
+ "django.contrib.auth.hashers.Argon2PasswordHasher",
+ "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
+ "django.contrib.auth.hashers.PBKDF2PasswordHasher",
+ "django.contrib.auth.hashers.SHA1PasswordHasher",
]
@@ -888,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']
@@ -952,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'
@@ -965,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 e02b6865767..4c1bc24e93b 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 576ba3e1143..355b92c9fd1 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 abe23c1a643..28d46d0b2a4 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 b331d71631f..0ddd3a09efc 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/doc/ad_list.html b/ietf/templates/doc/ad_list.html
index 3db2ecd24f8..cb80d8977e6 100644
--- a/ietf/templates/doc/ad_list.html
+++ b/ietf/templates/doc/ad_list.html
@@ -153,7 +153,8 @@ {{ dt.type.1 }} State Counts
renderTo: element,
panning: { enabled: false },
spacing: [4, 0, 5, 0],
- height: "45%"
+ height: "45%",
+ reflow: false // reflow causes panels to grow on Firefox
},
scrollbar: { enabled: false },
tooltip: { enabled: false },
@@ -313,4 +314,26 @@ {{ dt.type.1 }} State Counts
})
})
+
{% endblock %}
\ No newline at end of file
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/meeting/proceedings_attendees.html b/ietf/templates/meeting/proceedings_attendees.html
index 0c59d4ab155..402b807223b 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 %}
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 %}
-
- {% else %}
- {{ person.first_name }} has no active roles as of {{ today|date:"Y-m-d" }}.
- {% endif %}
- {% endif %}
- {% if person.personextresource_set.exists %}
- External Resources
-
-
-
- Name
- Value
-
-
-
- {% for extres in person.personextresource_set.all %}
-
-
- {% firstof extres.display_name extres.name.name %}
-
- {{ extres.value|linkify }}
-
- {% endfor %}
-
-
- {% endif %}
-
- RFCs ({{ person.rfcs|length }})
-
- {% if person.rfcs %}
-
-
-
- RFC
- Date
- Title
- Cited by
-
-
-
- {% for doc in person.rfcs %}
-
-
- 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 %}
-
-
- {% endfor %}
-
-
- {% 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 %}
-
- {% for doc in person.active_drafts %}
-
- {{ doc.name }}
-
- {% endfor %}
-
- {% 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 %}
-
- {% for doc in person.expired_drafts %}
- {% if not doc.replaced_by %}
-
-
- {{ doc.name }}
-
-
- {% endif %}
- {% endfor %}
-
- (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 %}
+
+ {% else %}
+ {{ person.first_name }} does not currently have any active roles.
+ {% endif %}
+ {% endif %}
+ {% if profile.ext_resources %}
+ External Resources
+
+
+
+ Name
+ Value
+
+
+
+ {% for extres in profile.ext_resources %}
+
+
+ {% firstof extres.display_name extres.name.name %}
+
+ {{ extres.value|linkify }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ RFCs ({{ profile.rfcs|length }})
+
+ {% if profile.rfcs %}
+
+ {% else %}
+ {{ person.first_name }} has no RFCs.
+ {% endif %}
+
+ Active Internet-Drafts ({{ profile.active_drafts|length }})
+
+ {% if profile.active_drafts %}
+
+ {% for doc in profile.active_drafts %}
+
+ {{ doc.name }}
+
+ {% endfor %}
+
+ {% 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/ietf/templates/submit/submission_status.html b/ietf/templates/submit/submission_status.html
index cdc5dd4007e..8ecbe80e23f 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 %}
-
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..fd81351185a 100644
--- a/k8s/settings_local.py
+++ b/k8s/settings_local.py
@@ -293,6 +293,7 @@ def _multiline_to_list(s):
# Binaries that are different in the docker image
DE_GFM_BINARY = "/usr/local/bin/de-gfm"
IDSUBMIT_IDNITS_BINARY = "/usr/local/bin/idnits"
+IDSUBMIT_IDNITS3_BINARY = "/usr/local/bin/idnits3"
# Duplicating production cache from settings.py and using it whether we're in production mode or not
MEMCACHED_HOST = os.environ.get("DT_MEMCACHED_SERVICE_HOST", "127.0.0.1")
@@ -364,6 +365,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)