diff --git a/README.md b/README.md index 48dfdae27e4..125eede83b0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Python Version](https://img.shields.io/badge/python-3.12-blue?logo=python&logoColor=white)](#prerequisites) [![Django Version](https://img.shields.io/badge/django-4.x-51be95?logo=django&logoColor=white)](#prerequisites) [![Node Version](https://img.shields.io/badge/node.js-26.x-green?logo=node.js&logoColor=white)](#prerequisites) -[![MariaDB Version](https://img.shields.io/badge/postgres-17-blue?logo=postgresql&logoColor=white)](#prerequisites) +[![PostgreSQL Version](https://img.shields.io/badge/postgres-17-blue?logo=postgresql&logoColor=white)](#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 @@

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

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

Roles

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

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

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

External Resources

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

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

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

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

- {% if person.active_drafts.exists %} -
    - {% 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 %} + + + + + + + + + + {% for role in profile.roles %} + + + + + + {% endfor %} + +
RoleGroupEmail
{{ role.name.name }} + {% if role.name.name == 'Reviewer' %} + (See reviews) + {% endif %} + + {{ role.group.name }} + ({{ role.group.acronym }}) + + {{ role.email.address }} +
+ {% else %} +

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

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

External Resources

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

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

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

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

+ {% if profile.active_drafts %} +
    + {% 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 %} -