From 7e122b0a3e2c3172ce2c8e590cdb98f2d31b930e Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 25 Aug 2026 18:54:09 -0300 Subject: [PATCH 01/12] chore: disable obsolete CryptPasswordHasher (#11644) * chore: disable CryptPasswordHasher * chore: adjust "invalid password" message --- ietf/ietfauth/views.py | 6 +++--- ietf/settings.py | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 15a37968a5..2e32dfd5d3 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/settings.py b/ietf/settings.py index 4b3bbb2a15..e381ab6c1d 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", ] From 73ad6df217a7df9371b23f876c7d3b4178dc892d Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 25 Aug 2026 19:53:00 -0300 Subject: [PATCH 02/12] fix: prevent IESG dashboard panels from growing (#11620) --- ietf/templates/doc/ad_list.html | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/ietf/templates/doc/ad_list.html b/ietf/templates/doc/ad_list.html index 3db2ecd24f..cb80d8977e 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 From 44e190fc5309bf3df71f23a026b4c74ad0745b43 Mon Sep 17 00:00:00 2001 From: jennifer-richards <19472766+jennifer-richards@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:09:05 +0000 Subject: [PATCH 03/12] ci: update base image target version to 20260827T1454 --- dev/build/Dockerfile | 2 +- dev/build/TARGET_BASE | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile index d80aceaffb..d13405fd4b 100644 --- a/dev/build/Dockerfile +++ b/dev/build/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/ietf-tools/datatracker-app-base:20260804T1752 +FROM ghcr.io/ietf-tools/datatracker-app-base:20260827T1454 LABEL maintainer="IETF Tools Team " ENV DEBIAN_FRONTEND=noninteractive diff --git a/dev/build/TARGET_BASE b/dev/build/TARGET_BASE index d40bd9e929..d5d96a8f32 100644 --- a/dev/build/TARGET_BASE +++ b/dev/build/TARGET_BASE @@ -1 +1 @@ -20260804T1752 +20260827T1454 From 88cb3bf3c371168be371add8d002d0ef49e1a306 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 27 Aug 2026 12:51:24 -0300 Subject: [PATCH 04/12] feat: plenary attendance by email address API (#11633) * feat: first pass attended-regs API (WIP) * refactor: prefetchable Registration.attendance_type * feat: add ticket type methods + refactoring * rename `Registration.attendance_type` to `plenary_attendance_type` * add `plenary_ticket_type` * refactor to ensure consistency, reduce queries, handle bulk requests * refactor: adjust api to match changes * test: meeting RegistrationTests * fix: refactor to avoid mypy limitations * test: Registration onsite() and remote() filters * fix: lint + add some docstrings * refactor: adjust api token endpoint name * perf: bulk annotation in proceedings_attendees * test: start test coverage of new API * test: flesh out the tests * fix: failing test * fix: distinct() in onsite_or_remote() * test: fix/update tests --- ietf/api/urls.py | 2 + ietf/meeting/api.py | 61 +++++ ietf/meeting/models.py | 119 ++++++++- ietf/meeting/serializers.py | 28 +++ ietf/meeting/tests_api.py | 166 +++++++++++++ ietf/meeting/tests_models.py | 235 +++++++++++++++++- ietf/meeting/views.py | 19 +- .../meeting/proceedings_attendees.html | 2 +- 8 files changed, 603 insertions(+), 29 deletions(-) create mode 100644 ietf/meeting/api.py create mode 100644 ietf/meeting/serializers.py create mode 100644 ietf/meeting/tests_api.py diff --git a/ietf/api/urls.py b/ietf/api/urls.py index 8e843d720f..072c913ad7 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -8,6 +8,7 @@ from ietf import api from ietf.doc import views_ballot, api as doc_api +from ietf.meeting import api as meeting_api from ietf.meeting import views as meeting_views from ietf.person import api_uuid as person_uuid_api from ietf.submit import views as submit_views @@ -80,6 +81,7 @@ url(r'^meeting/(?P[A-Za-z0-9._+-]+)/agenda-data$', meeting_views.api_get_agenda_data), # Meeting session materials url(r'^meeting/session/(?P[A-Za-z0-9._+-]+)/materials$', meeting_views.api_get_session_materials), + url(r'^meeting/registration/attended/(?P[^/\x00]+)/?$', meeting_api.MeetingsAttendedByEmail.as_view(), name="ietf.api.meeting.registration.attended"), # Let MeetEcho upload bluesheets url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet), # Let MeetEcho tell us about session attendees diff --git a/ietf/meeting/api.py b/ietf/meeting/api.py new file mode 100644 index 0000000000..3104262091 --- /dev/null +++ b/ietf/meeting/api.py @@ -0,0 +1,61 @@ +# Copyright The IETF Trust 2026, All Rights Reserved +from django.db.models import IntegerField +from django.db.models.functions import Cast +from rest_framework import generics + +from ietf.meeting.models import Meeting +from ietf.meeting.serializers import PersonAttendedMeetingsSerializer +from ietf.person.models import Person + + +class MeetingsAttendedByEmail(generics.RetrieveAPIView): + """List meetings attended by a person identified by email address + + Requires API key authentication + """ + + queryset = Person.objects.all() + lookup_field = "email__address" + lookup_url_kwarg = "email" + serializer_class = PersonAttendedMeetingsSerializer + api_key_endpoint = "ietf.api.meeting.registration.attended" + + def get_object(self): + person = super().get_object() + assert isinstance(person, Person) + person.attended_registrations = self._attended_registrations(person) + return person + + @staticmethod + def _attended_registrations(person: Person): + meetings_with_data = ( + Meeting.objects.filter(type="ietf") + .annotate(number_as_int=Cast("number", output_field=IntegerField())) + .exclude(number_as_int__lt=110) + .values_list("pk") + ) + has_attended_record = ( + person.attended_set.filter( + session__meeting_id__in=meetings_with_data, + session__meeting__type="ietf", + ) + .values_list("session__meeting__id", flat=True) + .distinct() + ) + return sorted( + [ + reg + for reg in ( + person.registration_set.onsite_or_remote() + .with_plenary_ticket_details() + .filter(meeting_id__in=meetings_with_data) + .select_related("meeting") + ) + if ( + reg.attended + or reg.checkedin + or reg.meeting_id in has_attended_record + ) + ], + key=lambda reg: reg.meeting.date, + ) diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index bf0c4dabd1..42b81b6a97 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -6,6 +6,8 @@ import datetime import io import os +from functools import cached_property + import pytz import random import re @@ -19,7 +21,7 @@ from django.core.validators import MinValueValidator, RegexValidator from django.db import models -from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q +from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q, Case, When from django.db.models.functions import Coalesce from django.conf import settings from django.urls import reverse as urlreverse @@ -1564,15 +1566,53 @@ def __str__(self): return f'{self.person} at {self.session}' -class RegistrationManager(models.Manager): +class RegistrationQuerySet(models.QuerySet): def onsite(self): - return self.get_queryset().filter(tickets__attendance_type__slug='onsite') + """Only registrations with at least one `onsite` ticket + + Includes any ticket type. In particular, may be a hackathon-only registration. + """ + return self.filter(tickets__attendance_type__slug="onsite") def remote(self): - return self.get_queryset().filter(tickets__attendance_type__slug='remote').exclude(tickets__attendance_type__slug='onsite') + """Only registrations with no `onsite` and at least one `remote` ticket + + Includes any ticket type. In particular, may be a hackathon-only registration. + """ + return ( + self.filter(tickets__attendance_type__slug="remote") + .exclude(tickets__attendance_type__slug="onsite") + ) + + def onsite_or_remote(self): + """Registrations that were onsite or remote + + I.e., was a registration for the plenary meeting, not e.g. hackathon-only. + """ + return self.filter( + tickets__attendance_type__slug__in=["onsite", "remote"] + ).distinct() + + def with_plenary_ticket_details(self): + """Annotate with ticket details + + Adds private annotations accessible via @properties + """ + most_representative = RegistrationTicket.objects.filter( + registration=OuterRef("pk") + ).order_by_most_representative() + return self.annotate( + _attendance_type=Subquery( + most_representative.values("attendance_type")[:1] + ), + _ticket_type=Subquery(most_representative.values("ticket_type")[:1]), + ) + class Registration(models.Model): """Registration attendee records from the IETF registration system""" + objects = RegistrationQuerySet.as_manager() # custom manager + meeting = ForeignKey(Meeting) first_name = models.CharField(max_length=255) last_name = models.CharField(max_length=255) @@ -1586,21 +1626,74 @@ class Registration(models.Model): # checkedin indicates that the badge was picked up checkedin = models.BooleanField(default=False) - # custom manager - objects = RegistrationManager() - def __str__(self): return "{} {}".format(self.first_name, self.last_name) + @cached_property + def _plenary_ticket(self): + return self.tickets.order_by_most_representative().first() + @property - def attendance_type(self): - if self.tickets.filter(attendance_type__slug='onsite').exists(): - return 'onsite' - elif self.tickets.filter(attendance_type__slug='remote').exists(): - return 'remote' - return None + def plenary_attendance_type(self): + """Attendance type for the plenary meeting + + Attendance type for the plenary meeting. Ignores hackathon/anrw or any other + types of registration that are tracked through tickets. + """ + if hasattr(self, "_attendance_type"): + return self._attendance_type # added via with_plenary_ticket_details() + return ( + self._plenary_ticket.attendance_type_id if self._plenary_ticket else None + ) + + @property + def plenary_ticket_type(self): + """Ticket type for the plenary meeting + + Ticket type for the plenary meeting. Ignores hackathon/anrw or any other types + of registration that are tracked through tickets. + """ + if hasattr(self, "_ticket_type"): + return self._ticket_type # added via with_plenary_ticket_details() + return ( + self._plenary_ticket.ticket_type_id if self._plenary_ticket else None + ) + + +class RegistrationTicketQuerySet(models.QuerySet): + def order_by_most_representative(self): + """Order-by clause for representative tickets for plenary attendance + + Filters out tickets not applicable to the plenary IETF meeting + """ + # ordered lists of interesting types + interesting_attendance_types = ["onsite", "remote"] + interesting_ticket_types = ["student", "week_pass", "one_day", "unknown"] + case_ranking_attendance_types = Case( + *[ + When(attendance_type=att_type, then=index) + for index, att_type in enumerate(interesting_attendance_types) + ] + ) + case_ranking_ticket_types = Case( + *[ + When(ticket_type=tkt_type, then=index) + for index, tkt_type in enumerate(interesting_ticket_types) + ] + ) + return self.filter( + attendance_type__in=interesting_attendance_types, + ticket_type__in=interesting_ticket_types, + ).order_by( + case_ranking_attendance_types, + case_ranking_ticket_types, + "pk", # deterministic tie-break + ) + class RegistrationTicket(models.Model): + objects = RegistrationTicketQuerySet.as_manager() # custom manager + registration = ForeignKey(Registration, related_name='tickets') attendance_type = ForeignKey(AttendanceTypeName, on_delete=models.PROTECT) ticket_type = ForeignKey(RegistrationTicketTypeName, on_delete=models.PROTECT) diff --git a/ietf/meeting/serializers.py b/ietf/meeting/serializers.py new file mode 100644 index 0000000000..70b9ee12ca --- /dev/null +++ b/ietf/meeting/serializers.py @@ -0,0 +1,28 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +from rest_framework import serializers + +from ietf.meeting.models import Registration + + +class AttendedMeetingSerializer(serializers.ModelSerializer): + """Serialize a plenary meeting attendance record""" + meeting = serializers.SlugRelatedField(slug_field="number", read_only=True) + attendance_type = serializers.CharField( + source="plenary_attendance_type", read_only=True + ) + ticket_type = serializers.CharField( + source="plenary_ticket_type", read_only=True + ) + + class Meta: + model = Registration + fields = [ + "meeting", + "attendance_type", + "ticket_type", + ] + + +class PersonAttendedMeetingsSerializer(serializers.Serializer): + attended = AttendedMeetingSerializer(source="attended_registrations", many=True) diff --git a/ietf/meeting/tests_api.py b/ietf/meeting/tests_api.py new file mode 100644 index 0000000000..062ab07e4d --- /dev/null +++ b/ietf/meeting/tests_api.py @@ -0,0 +1,166 @@ +# Copyright The IETF Trust 2026, All Rights Reserved +from unittest.mock import PropertyMock, patch + +from django.test import override_settings +from django.urls import reverse as urlreverse + +from ietf.meeting.factories import ( + AttendedFactory, + MeetingFactory, + RegistrationFactory, +) +from ietf.meeting.models import Registration +from ietf.person.factories import EmailFactory, PersonFactory +from ietf.utils.test_utils import TestCase + + +@override_settings( + APP_API_TOKENS={"ietf.api.meeting.registration.attended": "valid-token"} +) +class MeetingsAttendedByEmailTests(TestCase): + VIEWNAME = "ietf.api.meeting.registration.attended" + + def setUp(self): + super().setUp() + self.person = PersonFactory() + + def attended_for(self, email=None): + """Retrieve the "attended" list for an email address""" + url = urlreverse( + self.VIEWNAME, kwargs={"email": email or self.person.email_address()} + ) + r = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 200) + return r.json()["attended"] + + @staticmethod + def ietf_meeting(number): + return MeetingFactory(type_id="ietf", number=number, populate_schedule=False) + + def test_endpoint_is_plumbed(self): + url = urlreverse(self.VIEWNAME, kwargs={"email": self.person.email_address()}) + # bad/missing API keys + r = self.client.get(url) + self.assertEqual(r.status_code, 403, "should require api key") + r = self.client.get(url, headers={"X-Api-Key": "invalid-token"}) + self.assertEqual(r.status_code, 403, "should require valid api key") + + # valid request + r = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 200, "should accept valid api key") + self.assertEqual(r.json(), {"attended": []}) + + # nonexistent person + self.person.email_set.update(person=None) # detach email + self.person.delete() + r = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 404, "404 for no such Person") + + def test_lists_registrations_with_attendance_evidence(self): + """Registrations for IETF 110+ that were attended are listed + + Attendance is indicated by the attended flag, the checkedin flag, or an Attended + record. These records only had their modern form starting at IETF 110. + """ + attended = self.ietf_meeting("118") + RegistrationFactory(meeting=attended, person=self.person, attended=True) + checkedin = self.ietf_meeting("119") + RegistrationFactory(meeting=checkedin, person=self.person, checkedin=True) + session_attended = self.ietf_meeting("120") + RegistrationFactory(meeting=session_attended, person=self.person) + AttendedFactory( + session__meeting=session_attended, + session__add_to_schedule=False, # these meetings have no timeslots + person=self.person, + ) + + # another person's registrations for the same meetings must not appear + other_person = PersonFactory() + for meeting in [attended, checkedin, session_attended]: + RegistrationFactory(meeting=meeting, person=other_person, attended=True) + + self.assertCountEqual( + [entry["meeting"] for entry in self.attended_for()], ["118", "119", "120"] + ) + + def test_excludes_registrations_without_attendance_evidence(self): + """Only attended IETF meetings from 110 onwards are reported""" + RegistrationFactory(meeting=self.ietf_meeting("118"), person=self.person) + RegistrationFactory( + meeting=self.ietf_meeting("109"), person=self.person, attended=True + ) + RegistrationFactory( + meeting=MeetingFactory(type_id="interim", populate_schedule=False), + person=self.person, + attended=True, + ) + + self.assertEqual(self.attended_for(), []) + + def test_reports_attendance_and_ticket_type(self): + """Ticket details are taken from the Registration's plenary_* properties""" + RegistrationFactory( + meeting=self.ietf_meeting("118"), person=self.person, attended=True + ) + + with ( + patch.object( + Registration, "plenary_attendance_type", new_callable=PropertyMock + ) as attendance_type, + patch.object( + Registration, "plenary_ticket_type", new_callable=PropertyMock + ) as ticket_type, + ): + attendance_type.return_value = "onsite" + ticket_type.return_value = "week_pass" + self.assertEqual( + self.attended_for(), + [ + { + "meeting": "118", + "attendance_type": "onsite", + "ticket_type": "week_pass", + } + ], + ) + + # absent ticket details are reported as null + attendance_type.return_value = None + ticket_type.return_value = None + self.assertEqual( + self.attended_for(), + [{"meeting": "118", "attendance_type": None, "ticket_type": None}], + ) + + def test_finds_person_by_any_email_address(self): + """Any of the Person's email addresses identifies them""" + RegistrationFactory( + meeting=self.ietf_meeting("118"), person=self.person, attended=True + ) + secondary_email = EmailFactory(person=self.person) + + by_secondary = self.attended_for(email=secondary_email.address) + self.assertEqual([entry["meeting"] for entry in by_secondary], ["118"]) + self.assertEqual(by_secondary, self.attended_for()) + + def test_excludes_non_plenary_registrations(self): + """Registrations without an onsite or remote plenary ticket are not reported""" + RegistrationFactory( + meeting=self.ietf_meeting("118"), + person=self.person, + attended=True, + with_ticket={"attendance_type_id": "onsite", "ticket_type_id": "week_pass"}, + ) + RegistrationFactory( + meeting=self.ietf_meeting("119"), + person=self.person, + attended=True, + with_ticket={ + "attendance_type_id": "hackathon_remote", + "ticket_type_id": "unknown", + }, + ) + + attended = self.attended_for() + self.assertEqual([entry["meeting"] for entry in attended], ["118"]) + self.assertEqual({entry["attendance_type"] for entry in attended}, {"onsite"}) diff --git a/ietf/meeting/tests_models.py b/ietf/meeting/tests_models.py index 0b9ce3f607..4c18b3633c 100644 --- a/ietf/meeting/tests_models.py +++ b/ietf/meeting/tests_models.py @@ -17,8 +17,9 @@ AttendedFactory, SessionPresentationFactory, ) -from ietf.meeting.factories import RegistrationFactory -from ietf.meeting.models import Session +from ietf.meeting.factories import RegistrationFactory, RegistrationTicketFactory +from ietf.meeting.models import Registration, RegistrationTicket, Session +from ietf.name.models import RegistrationTicketTypeName, AttendanceTypeName from ietf.utils.test_utils import TestCase from ietf.utils.timezone import date_today, datetime_today @@ -253,7 +254,7 @@ def test_chat_archive_url(self): def test_chat_room_name(self): session = SessionFactory(group__acronym="xyzzy") - self.assertEqual(session.chat_room_name(), "xyzzy") + self.assertEqual(session.chat_room_name(), "xyzzy") session.type_id = "plenary" self.assertEqual(session.chat_room_name(), "plenary") session.chat_room = "fnord" @@ -323,3 +324,231 @@ def test_session_recording_url_label_interim(self): f"IETF-ACRO-{session_time:%Y%m%d-%H%M}", # n.b., time in label is UTC session._session_recording_url_label(), ) + + +class RegistrationTests(TestCase): + def setUp(self): + super().setUp() + self.meeting = MeetingFactory(type_id="ietf") + + def create_registration(self, tickets): + """Create a Registration with the given (attendance, ticket) type pairs""" + registration = RegistrationFactory(meeting=self.meeting, with_ticket=False) + for attendance_type_id, ticket_type_id in tickets: + RegistrationTicketFactory( + registration=registration, + attendance_type_id=attendance_type_id, + ticket_type_id=ticket_type_id, + ) + return registration + + def test_onsite(self): + expected_onsite_pks = [ + self.create_registration([("onsite", ticket_type.pk)]).pk + for ticket_type in RegistrationTicketTypeName.objects.filter(used=True) + ] + for attendance_type in AttendanceTypeName.objects.filter(used=True): + if attendance_type.pk == "onsite": + continue # we already have one + for ticket_type in RegistrationTicketTypeName.objects.filter(used=True): + self.create_registration([(attendance_type.pk, ticket_type.pk)]) + + self.assertCountEqual( + Registration.objects.onsite().values_list("pk", flat=True), + expected_onsite_pks, + ) + + def test_remote(self): + expected_remote_pks = [ + self.create_registration([("remote", ticket_type.pk)]).pk + for ticket_type in RegistrationTicketTypeName.objects.filter(used=True) + ] + for attendance_type in AttendanceTypeName.objects.filter(used=True): + if attendance_type.pk == "remote": + continue # we already have one + for ticket_type in RegistrationTicketTypeName.objects.filter(used=True): + self.create_registration([(attendance_type.pk, ticket_type.pk)]) + + self.assertCountEqual( + Registration.objects.remote().values_list("pk", flat=True), + expected_remote_pks, + ) + + def test_onsite_or_remote(self): + expected_onsite_pks = [ + self.create_registration([("onsite", ticket_type.pk)]).pk + for ticket_type in RegistrationTicketTypeName.objects.filter(used=True) + ] + expected_remote_pks = [ + self.create_registration([("remote", ticket_type.pk)]).pk + for ticket_type in RegistrationTicketTypeName.objects.filter(used=True) + ] + for attendance_type in AttendanceTypeName.objects.filter(used=True): + if attendance_type.pk in ["onsite", "remote"]: + continue # we already have one + for ticket_type in RegistrationTicketTypeName.objects.filter(used=True): + self.create_registration([(attendance_type.pk, ticket_type.pk)]) + # create a remote ticket for an onsite registration to probe whether this + # leads to duplicate records + RegistrationTicketFactory( + # arbitrary registration + registration=(Registration.objects.get(pk=expected_onsite_pks[0])), + attendance_type_id="remote", + ticket_type_id="week_pass", + ) + self.assertCountEqual( + Registration.objects.onsite_or_remote().values_list("pk", flat=True), + expected_onsite_pks + expected_remote_pks, + ) + + # A ticket counts toward the plenary meeting only if both its attendance type + # and its ticket type are applicable. Applicable tickets are ranked by + # attendance type (onsite, then remote), then by ticket type (student, + # week_pass, one_day, then unknown), then by pk. + # + # Each case is a label, the tickets to create as (attendance_type_id, + # ticket_type_id) pairs, and the expected plenary_attendance_type and + # plenary_ticket_type. + PLENARY_TICKET_CASES = [ + ("no tickets at all", [], None, None), + ("a single plenary ticket", [("onsite", "week_pass")], "onsite", "week_pass"), + ( + "onsite outranks remote", + [("remote", "week_pass"), ("onsite", "week_pass")], + "onsite", + "week_pass", + ), + ( + "attendance type outranks ticket type", + [("remote", "student"), ("onsite", "one_day")], + "onsite", + "one_day", + ), + ( + "student outranks one_day", + [("onsite", "one_day"), ("onsite", "student")], + "onsite", + "student", + ), + ( + "unknown ticket type ranks last", + [("onsite", "unknown"), ("onsite", "one_day")], + "onsite", + "one_day", + ), + ( + "non-plenary attendance type is ignored", + [("hackathon_onsite", "hackathon_only")], + None, + None, + ), + ( + "non-plenary ticket type disqualifies its ticket entirely", + [("onsite", "hackathon_combo"), ("remote", "week_pass")], + "remote", + "week_pass", + ), + ( + "unknown is a plenary ticket type but not an attendance type", + [("unknown", "week_pass")], + None, + None, + ), + ] + + def test_plenary_ticket_details(self): + """The plenary_* properties report the most representative ticket""" + for label, tickets, attendance_type, ticket_type in self.PLENARY_TICKET_CASES: + with self.subTest(label): + registration = self.create_registration(tickets) + # refetch so the properties cannot see state left by the factories + registration = Registration.objects.get(pk=registration.pk) + self.assertEqual(registration.plenary_attendance_type, attendance_type) + self.assertEqual(registration.plenary_ticket_type, ticket_type) + + def test_plenary_ticket_details_annotated(self): + """The annotations agree with the properties + + The properties return the annotations when they are present, so the two + must rank tickets identically. + """ + for label, tickets, attendance_type, ticket_type in self.PLENARY_TICKET_CASES: + with self.subTest(label): + registration = self.create_registration(tickets) + annotated = Registration.objects.with_plenary_ticket_details().get( + pk=registration.pk + ) + self.assertEqual(annotated._attendance_type, attendance_type) + self.assertEqual(annotated._ticket_type, ticket_type) + self.assertEqual(annotated.plenary_attendance_type, attendance_type) + self.assertEqual(annotated.plenary_ticket_type, ticket_type) + + def test_plenary_ticket_details_tie_break(self): + """Tickets that rank equally are resolved by pk + + The annotations cannot distinguish these tickets - they expose only the + chosen ticket's type slugs, which are identical when tickets tie. + """ + registration = self.create_registration( + [("onsite", "week_pass"), ("onsite", "week_pass")] + ) + first, second = registration.tickets.order_by("pk") + self.assertEqual( + list(registration.tickets.order_by_most_representative()), [first, second] + ) + self.assertEqual( + Registration.objects.get(pk=registration.pk)._plenary_ticket, first + ) + + def test_plenary_ticket_details_query_counts(self): + """The annotation replaces the per-registration ticket queries""" + for tickets in [ + [("onsite", "week_pass")], + [("remote", "student")], + [("hackathon_onsite", "hackathon_only")], + ]: + self.create_registration(tickets) + expected = [("onsite", "week_pass"), ("remote", "student"), (None, None)] + + annotated = Registration.objects.with_plenary_ticket_details().order_by("pk") + with self.assertNumQueries(1): + self.assertEqual( + [(r.plenary_attendance_type, r.plenary_ticket_type) for r in annotated], + expected, + ) + + # Without the annotation, one query for the registrations plus one per + # registration. Both properties share the _plenary_ticket cache, so + # reading them together does not double the count. + plain = Registration.objects.order_by("pk") + with self.assertNumQueries(4): + self.assertEqual( + [(r.plenary_attendance_type, r.plenary_ticket_type) for r in plain], + expected, + ) + + def test_order_by_most_representative(self): + """Non-plenary tickets are dropped and the rest are ranked""" + registration = self.create_registration( + [ + ("remote", "one_day"), + ("hackathon_onsite", "hackathon_only"), + ("onsite", "unknown"), + ("onsite", "student"), + ("remote", "week_pass"), + ("unknown", "week_pass"), + ] + ) + tickets = { + (ticket.attendance_type_id, ticket.ticket_type_id): ticket + for ticket in registration.tickets.all() + } + self.assertEqual( + list(RegistrationTicket.objects.order_by_most_representative()), + [ + tickets[("onsite", "student")], + tickets[("onsite", "unknown")], + tickets[("remote", "week_pass")], + tickets[("remote", "one_day")], + ], + ) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 3e5002a466..cd7f43771b 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -4838,19 +4838,14 @@ def proceedings_attendees(request, num=None): onsite_pks = frozenset(p.pk for p in onsite) remote_pks = frozenset(p.pk for p in remote) - regs = [ - reg - for reg in Registration.objects.onsite() - .filter(meeting__number=num) - .select_related("person") - if reg.person.pk in onsite_pks - ] + [ - reg - for reg in Registration.objects.remote() - .filter(meeting__number=num) + regs_to_consider = ( + Registration.objects.filter(meeting__number=num) + .with_plenary_ticket_details() .select_related("person") - if reg.person.pk in remote_pks - ] + ) + regs = [ + reg for reg in regs_to_consider.onsite() if reg.person.pk in onsite_pks + ] + [reg for reg in regs_to_consider.remote() if reg.person.pk in remote_pks] registrations = sorted(regs, key=lambda x: (x.last_name, x.first_name)) country_codes = [r.country_code for r in registrations if r.country_code] diff --git a/ietf/templates/meeting/proceedings_attendees.html b/ietf/templates/meeting/proceedings_attendees.html index 0c59d4ab15..402b807223 100644 --- a/ietf/templates/meeting/proceedings_attendees.html +++ b/ietf/templates/meeting/proceedings_attendees.html @@ -80,7 +80,7 @@