From d5e57556a7da90a0952b5a895f14d5099931bbe6 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Tue, 26 May 2026 12:10:41 -0500 Subject: [PATCH 1/2] fix: consistent counts at proceedings and proceedings/attendees --- ietf/meeting/models.py | 107 +++++++++--- ietf/meeting/tests_models.py | 329 ++++++++++++++++++++++++----------- ietf/meeting/tests_views.py | 30 +++- ietf/meeting/utils.py | 11 -- ietf/meeting/views.py | 22 ++- ietf/nomcom/utils.py | 5 +- 6 files changed, 345 insertions(+), 159 deletions(-) diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 7d9e318aab..8cf386abf2 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -236,16 +236,24 @@ def get_proceedings_materials(self): document__states__slug='active', document__states__type_id='procmaterials' ).order_by('type__order') - def get_attendance(self): - """Get the meeting attendance from the Registrations + def get_attendees(self, sessions=None): + """Get the meeting attendees from the Registrations - Returns a NamedTuple with onsite and remote attributes. Returns None if the record is unavailable - for this meeting. + Returns a pair of sets containing Person objects for persons attending + the meeting either onsite or online (remote). Returns None if the record + is unavailable for this meeting. + + sessions: optional queryset of Sessions to restrict which Attended records + count toward attendance. Defaults to all sessions at this meeting. + Note: restricting sessions does not affect the registration-based path + (checkedin / attended flags), only the Attended-record path. """ number = self.get_number() if number is None or number < 110: return None - Attendance = namedtuple('Attendance', 'onsite remote') + + if sessions is None: + sessions = self.session_set.all() # MeetingRegistration.attended started conflating badge-pickup and session attendance before IETF 114. # We've separated session attendance off to ietf.meeting.Attended, but need to report attendance at older @@ -254,11 +262,8 @@ def get_attendance(self): # Looking up by registration and attendance records separately and joining in # python is far faster than combining the Q objects in the query (~100x). # Further optimization may be possible, but the queries are tricky... - attended_per_meeting_registration = ( - Q(registration__meeting=self) & ( - Q(registration__attended=True) | - Q(registration__checkedin=True) - ) + attended_per_meeting_registration = Q(registration__meeting=self) & ( + Q(registration__attended=True) | Q(registration__checkedin=True) ) attendees_by_reg = set( Person.objects.filter(attended_per_meeting_registration).values_list( @@ -266,29 +271,81 @@ def get_attendance(self): ) ) - attended_per_meeting_attended = ( - Q(attended__session__meeting=self) - # Note that we are not filtering to plenary, wg, or rg sessions - # as we do for nomcom eligibility - if picking up a badge (see above) - # is good enough, just attending e.g. a training session is also good enough - ) attendees_by_att = set( - Person.objects.filter(attended_per_meeting_attended).values_list( + Person.objects.filter(attended__session__in=sessions).values_list( "pk", flat=True ) ) - - attendees = Person.objects.filter( - pk__in=attendees_by_att | attendees_by_reg + + attendees = Person.objects.filter(pk__in=attendees_by_att | attendees_by_reg) + onsite = set( + attendees.filter( + registration__meeting=self, + registration__tickets__attendance_type__slug="onsite", + ) + ) + remote = set( + attendees.filter( + registration__meeting=self, + registration__tickets__attendance_type__slug="remote", + ) ) - onsite = set(attendees.filter(registration__meeting=self, registration__tickets__attendance_type__slug='onsite')) - remote = set(attendees.filter(registration__meeting=self, registration__tickets__attendance_type__slug='remote')) remote.difference_update(onsite) - return Attendance( - onsite=len(onsite), - remote=len(remote) + return (onsite, remote) + + def get_attendance(self): + """Get the meeting attendance from the Registrations + + Returns a NamedTuple with onsite and remote attributes. Returns None if the record is unavailable + for this meeting. + """ + attendees = self.get_attendees() + if attendees is None: + return None + Attendance = namedtuple("Attendance", "onsite remote") + onsite, remote = attendees + return Attendance(onsite=len(onsite), remote=len(remote)) + + def nomcom_eligible_participants(self): + """Return (onsite_pks, remote_pks) as frozensets for nomcom eligibility. + + Onsite: has onsite ticket AND (checked in OR attended a qualifying session). + Remote: has remote ticket AND attended a qualifying plenary/wg/rg session. + The registration attended flag alone is not sufficient for remote. + """ + sessions = self.session_set.filter( + Q(type="plenary") | Q(group__type__in=["wg", "rg"]) + ) + attendees = self.get_attendees(sessions=sessions) + if attendees is None: + onsite_pks = frozenset( + self.registration_set.onsite() + .filter(checkedin=True) + .values_list("person", flat=True) + .distinct() + ) + remote_pks = ( + frozenset( + Attended.objects.filter(session__in=sessions) + .values_list("person", flat=True) + .distinct() + ) + - onsite_pks + ) + return (onsite_pks, remote_pks) + onsite, remote = attendees + onsite_pks = frozenset(p.pk for p in onsite) + # Remote requires actual qualifying session attendance; the registration + # attended flag alone (used as a proxy before Attended records existed) + # is not sufficient. + session_attended_pks = frozenset( + Attended.objects.filter(session__in=sessions) + .values_list("person", flat=True) + .distinct() ) + remote_pks = frozenset(p.pk for p in remote) & session_attended_pks + return (onsite_pks, remote_pks) @property def proceedings_format_version(self): diff --git a/ietf/meeting/tests_models.py b/ietf/meeting/tests_models.py index 869d9ec814..0b9ce3f607 100644 --- a/ietf/meeting/tests_models.py +++ b/ietf/meeting/tests_models.py @@ -1,6 +1,7 @@ # Copyright The IETF Trust 2021-2024, All Rights Reserved # -*- coding: utf-8 -*- """Tests of models in the Meeting application""" + import datetime from unittest.mock import patch @@ -10,7 +11,12 @@ import ietf.meeting.models from ietf.group.factories import GroupFactory, GroupHistoryFactory -from ietf.meeting.factories import MeetingFactory, SessionFactory, AttendedFactory, SessionPresentationFactory +from ietf.meeting.factories import ( + MeetingFactory, + SessionFactory, + AttendedFactory, + SessionPresentationFactory, +) from ietf.meeting.factories import RegistrationFactory from ietf.meeting.models import Session from ietf.utils.test_utils import TestCase @@ -18,105 +24,200 @@ class MeetingTests(TestCase): - def test_get_attendance_pre110(self): - """Pre-110 meetings do not calculate attendance""" - meeting = MeetingFactory(type_id='ietf', number='109') - RegistrationFactory.create_batch(3, meeting=meeting, with_ticket={'attendance_type_id': 'unknown'}) - RegistrationFactory.create_batch(4, meeting=meeting, with_ticket={'attendance_type_id': 'remote'}) - RegistrationFactory.create_batch(5, meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}) - self.assertIsNone(meeting.get_attendance()) - - def test_get_attendance_110(self): - """Look at attendance as captured at 110""" - meeting = MeetingFactory(type_id='ietf', number='110') - - # start with attendees that should be ignored - RegistrationFactory.create_batch(3, meeting=meeting, with_ticket={'attendance_type_id': 'unknown'}, attended=True) - RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'unknown'}, attended=False) - attendance = meeting.get_attendance() - self.assertIsNotNone(attendance) - self.assertEqual(attendance.remote, 0) - self.assertEqual(attendance.onsite, 0) - - # add online attendees with at least one who registered but did not attend - RegistrationFactory.create_batch(4, meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=True) - RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=False) - attendance = meeting.get_attendance() - self.assertIsNotNone(attendance) - self.assertEqual(attendance.remote, 4) - self.assertEqual(attendance.onsite, 0) - - # and the same for onsite attendees - RegistrationFactory.create_batch(5, meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=True) - RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=False) - attendance = meeting.get_attendance() - self.assertIsNotNone(attendance) - self.assertEqual(attendance.remote, 4) - self.assertEqual(attendance.onsite, 5) - - # and once more after removing all the online attendees + def test_get_attendees_pre110(self): + """Pre-110 meetings return None""" + meeting = MeetingFactory(type_id="ietf", number="109") + RegistrationFactory.create_batch( + 3, meeting=meeting, with_ticket={"attendance_type_id": "unknown"} + ) + RegistrationFactory.create_batch( + 4, meeting=meeting, with_ticket={"attendance_type_id": "remote"} + ) + RegistrationFactory.create_batch( + 5, meeting=meeting, with_ticket={"attendance_type_id": "onsite"} + ) + self.assertIsNone(meeting.get_attendees()) + + def test_get_attendees_110(self): + """Registration-based attendance (attended flag) used at IETF 110""" + meeting = MeetingFactory(type_id="ietf", number="110") + + # Unknown-ticket attendees are excluded regardless of attended flag + RegistrationFactory.create_batch( + 3, + meeting=meeting, + with_ticket={"attendance_type_id": "unknown"}, + attended=True, + ) + RegistrationFactory( + meeting=meeting, + with_ticket={"attendance_type_id": "unknown"}, + attended=False, + ) + onsite, remote = meeting.get_attendees() + self.assertEqual(len(onsite), 0) + self.assertEqual(len(remote), 0) + + # Remote attendees: only those with attended=True are included + remote_regs = RegistrationFactory.create_batch( + 4, + meeting=meeting, + with_ticket={"attendance_type_id": "remote"}, + attended=True, + ) + RegistrationFactory( + meeting=meeting, + with_ticket={"attendance_type_id": "remote"}, + attended=False, + ) + onsite, remote = meeting.get_attendees() + self.assertEqual({p.pk for p in onsite}, set()) + self.assertEqual({p.pk for p in remote}, {r.person.pk for r in remote_regs}) + + # Onsite attendees: only those with attended=True are included + onsite_regs = RegistrationFactory.create_batch( + 5, + meeting=meeting, + with_ticket={"attendance_type_id": "onsite"}, + attended=True, + ) + RegistrationFactory( + meeting=meeting, + with_ticket={"attendance_type_id": "onsite"}, + attended=False, + ) + onsite, remote = meeting.get_attendees() + self.assertEqual({p.pk for p in onsite}, {r.person.pk for r in onsite_regs}) + self.assertEqual({p.pk for p in remote}, {r.person.pk for r in remote_regs}) + + # Deleting remote registrations empties the remote set meeting.registration_set.remote().delete() - attendance = meeting.get_attendance() - self.assertIsNotNone(attendance) - self.assertEqual(attendance.remote, 0) - self.assertEqual(attendance.onsite, 5) - - def test_get_attendance_113(self): - """Simulate IETF 113 attendance gathering data""" - meeting = MeetingFactory(type_id='ietf', number='113') - RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=True, checkedin=False) - RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=False, checkedin=True) - p1 = RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=False, checkedin=False).person - AttendedFactory(session__meeting=meeting, person=p1) - p2 = RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=False, checkedin=False).person - AttendedFactory(session__meeting=meeting, person=p2) - attendance = meeting.get_attendance() - self.assertEqual(attendance.onsite, 3) - self.assertEqual(attendance.remote, 1) - - def test_get_attendance_keeps_meetings_distinct(self): + onsite, remote = meeting.get_attendees() + self.assertEqual({p.pk for p in onsite}, {r.person.pk for r in onsite_regs}) + self.assertEqual(len(remote), 0) + + def test_get_attendees_113(self): + """Simulate IETF 113: mix of attended flag, checkedin flag, and Attended records""" + meeting = MeetingFactory(type_id="ietf", number="113") + p_attended = RegistrationFactory( + meeting=meeting, + with_ticket={"attendance_type_id": "onsite"}, + attended=True, + checkedin=False, + ).person + p_checkedin = RegistrationFactory( + meeting=meeting, + with_ticket={"attendance_type_id": "onsite"}, + attended=False, + checkedin=True, + ).person + p_onsite_session = RegistrationFactory( + meeting=meeting, + with_ticket={"attendance_type_id": "onsite"}, + attended=False, + checkedin=False, + ).person + AttendedFactory(session__meeting=meeting, person=p_onsite_session) + p_remote_session = RegistrationFactory( + meeting=meeting, + with_ticket={"attendance_type_id": "remote"}, + attended=False, + checkedin=False, + ).person + AttendedFactory(session__meeting=meeting, person=p_remote_session) + onsite, remote = meeting.get_attendees() + self.assertEqual( + {p.pk for p in onsite}, {p_attended.pk, p_checkedin.pk, p_onsite_session.pk} + ) + self.assertEqual({p.pk for p in remote}, {p_remote_session.pk}) + + def test_get_attendees_keeps_meetings_distinct(self): """No cross-talk between attendance for different meetings""" - # numbers are arbitrary here - first_mtg = MeetingFactory(type_id='ietf', number='114') - second_mtg = MeetingFactory(type_id='ietf', number='115') + first_mtg = MeetingFactory(type_id="ietf", number="114") + second_mtg = MeetingFactory(type_id="ietf", number="115") - # Create a person who attended a remote session for first_mtg and onsite for second_mtg without - # checking in for either. - p = RegistrationFactory(meeting=second_mtg, with_ticket={'attendance_type_id': 'onsite'}, attended=False, checkedin=False).person + # Person with remote ticket at first_mtg and onsite ticket at second_mtg, attended both via Attended records + p = RegistrationFactory( + meeting=second_mtg, + with_ticket={"attendance_type_id": "onsite"}, + attended=False, + checkedin=False, + ).person + RegistrationFactory( + meeting=first_mtg, + person=p, + with_ticket={"attendance_type_id": "remote"}, + attended=False, + checkedin=False, + ) AttendedFactory(session__meeting=first_mtg, person=p) - RegistrationFactory(meeting=first_mtg, person=p, with_ticket={'attendance_type_id': 'remote'}, attended=False, checkedin=False) AttendedFactory(session__meeting=second_mtg, person=p) - att = first_mtg.get_attendance() - self.assertEqual(att.onsite, 0) - self.assertEqual(att.remote, 1) + first_onsite, first_remote = first_mtg.get_attendees() + self.assertEqual({q.pk for q in first_onsite}, set()) + self.assertEqual({q.pk for q in first_remote}, {p.pk}) + + second_onsite, second_remote = second_mtg.get_attendees() + self.assertEqual({q.pk for q in second_onsite}, {p.pk}) + self.assertEqual({q.pk for q in second_remote}, set()) + + def test_get_attendance(self): + """get_attendance delegates to get_attendees and returns counts as a NamedTuple""" + meeting = MeetingFactory(type_id="ietf", number="120") + onsite_regs = RegistrationFactory.create_batch( + 3, + meeting=meeting, + with_ticket={"attendance_type_id": "onsite"}, + attended=True, + ) + remote_regs = RegistrationFactory.create_batch( + 2, + meeting=meeting, + with_ticket={"attendance_type_id": "remote"}, + attended=True, + ) + att = meeting.get_attendance() + self.assertIsNotNone(att) + self.assertEqual(att.onsite, len(onsite_regs)) + self.assertEqual(att.remote, len(remote_regs)) - att = second_mtg.get_attendance() - self.assertEqual(att.onsite, 1) - self.assertEqual(att.remote, 0) + old_meeting = MeetingFactory(type_id="ietf", number="109") + self.assertIsNone(old_meeting.get_attendance()) def test_vtimezone(self): # normal time zone that should have a zoneinfo file - meeting = MeetingFactory(type_id='ietf', time_zone='America/Los_Angeles', populate_schedule=False) + meeting = MeetingFactory( + type_id="ietf", time_zone="America/Los_Angeles", populate_schedule=False + ) vtz = meeting.vtimezone() self.assertIsNotNone(vtz) self.assertGreater(len(vtz), 0) # time zone that does not have a zoneinfo file should return None - meeting = MeetingFactory(type_id='ietf', time_zone='Fake/Time_Zone', populate_schedule=False) + meeting = MeetingFactory( + type_id="ietf", time_zone="Fake/Time_Zone", populate_schedule=False + ) vtz = meeting.vtimezone() self.assertIsNone(vtz) # ioerror trying to read zoneinfo should return None - meeting = MeetingFactory(type_id='ietf', time_zone='America/Los_Angeles', populate_schedule=False) - with patch('ietf.meeting.models.io.open', side_effect=IOError): + meeting = MeetingFactory( + type_id="ietf", time_zone="America/Los_Angeles", populate_schedule=False + ) + with patch("ietf.meeting.models.io.open", side_effect=IOError): vtz = meeting.vtimezone() self.assertIsNone(vtz) def test_group_at_the_time(self): - m = MeetingFactory(type_id='ietf', date=date_today() - datetime.timedelta(days=10)) + m = MeetingFactory( + type_id="ietf", date=date_today() - datetime.timedelta(days=10) + ) cached_groups = GroupFactory.create_batch(2) m.cached_groups_at_the_time = {g.pk: g for g in cached_groups} # fake the cache - uncached_group_hist = GroupHistoryFactory(time=datetime_today() - datetime.timedelta(days=30)) - self.assertEqual(m.group_at_the_time(uncached_group_hist.group), uncached_group_hist) + uncached_group_hist = GroupHistoryFactory( + time=datetime_today() - datetime.timedelta(days=30) + ) + self.assertEqual( + m.group_at_the_time(uncached_group_hist.group), uncached_group_hist + ) self.assertIn(uncached_group_hist.group.pk, m.cached_groups_at_the_time) @@ -127,27 +228,36 @@ def test_chat_archive_url(self): meeting__number=120, # needs to use proceedings_format_version > 1 ) with override_settings(): - if hasattr(settings, 'CHAT_ARCHIVE_URL_PATTERN'): + if hasattr(settings, "CHAT_ARCHIVE_URL_PATTERN"): del settings.CHAT_ARCHIVE_URL_PATTERN self.assertEqual(session.chat_archive_url(), session.chat_room_url()) - settings.CHAT_ARCHIVE_URL_PATTERN = 'http://chat.example.com' - self.assertEqual(session.chat_archive_url(), 'http://chat.example.com') - chatlog = SessionPresentationFactory(session=session, document__type_id='chatlog').document + settings.CHAT_ARCHIVE_URL_PATTERN = "http://chat.example.com" + self.assertEqual(session.chat_archive_url(), "http://chat.example.com") + chatlog = SessionPresentationFactory( + session=session, document__type_id="chatlog" + ).document self.assertEqual(session.chat_archive_url(), chatlog.get_href()) # datatracker 8.8.0 rolled out on 2022-07-15. Before that, chat logs were jabber logs hosted at www.ietf.org. - session_with_jabber = SessionFactory(group__acronym='fakeacronym', meeting__date=datetime.date(2022,7,14)) - self.assertEqual(session_with_jabber.chat_archive_url(), 'https://www.ietf.org/jabber/logs/fakeacronym?C=M;O=D') - chatlog = SessionPresentationFactory(session=session_with_jabber, document__type_id='chatlog').document + session_with_jabber = SessionFactory( + group__acronym="fakeacronym", meeting__date=datetime.date(2022, 7, 14) + ) + self.assertEqual( + session_with_jabber.chat_archive_url(), + "https://www.ietf.org/jabber/logs/fakeacronym?C=M;O=D", + ) + chatlog = SessionPresentationFactory( + session=session_with_jabber, document__type_id="chatlog" + ).document self.assertEqual(session_with_jabber.chat_archive_url(), chatlog.get_href()) def test_chat_room_name(self): - session = SessionFactory(group__acronym='xyzzy') - self.assertEqual(session.chat_room_name(), 'xyzzy') - session.type_id = 'plenary' - self.assertEqual(session.chat_room_name(), 'plenary') - session.chat_room = 'fnord' - self.assertEqual(session.chat_room_name(), 'fnord') + session = SessionFactory(group__acronym="xyzzy") + self.assertEqual(session.chat_room_name(), "xyzzy") + session.type_id = "plenary" + self.assertEqual(session.chat_room_name(), "plenary") + session.chat_room = "fnord" + self.assertEqual(session.chat_room_name(), "fnord") def test_alpha_str(self): self.assertEqual(Session._alpha_str(0), "a") @@ -157,7 +267,11 @@ def test_alpha_str(self): self.assertEqual(Session._alpha_str(27 * 26 - 1), "zz") self.assertEqual(Session._alpha_str(27 * 26), "aaa") - @patch.object(ietf.meeting.models.Session, "_session_recording_url_label", return_value="LABEL") + @patch.object( + ietf.meeting.models.Session, + "_session_recording_url_label", + return_value="LABEL", + ) def test_session_recording_url(self, mock): for session_type in ["ietf", "interim"]: session = SessionFactory(meeting__type_id=session_type) @@ -165,20 +279,29 @@ def test_session_recording_url(self, mock): if hasattr(settings, "MEETECHO_SESSION_RECORDING_URL"): del settings.MEETECHO_SESSION_RECORDING_URL self.assertIsNone(session.session_recording_url()) - + settings.MEETECHO_SESSION_RECORDING_URL = "http://player.example.com" - self.assertEqual(session.session_recording_url(), "http://player.example.com") - - settings.MEETECHO_SESSION_RECORDING_URL = "http://player.example.com?{session_label}" - self.assertEqual(session.session_recording_url(), "http://player.example.com?LABEL") + self.assertEqual( + session.session_recording_url(), "http://player.example.com" + ) + + settings.MEETECHO_SESSION_RECORDING_URL = ( + "http://player.example.com?{session_label}" + ) + self.assertEqual( + session.session_recording_url(), "http://player.example.com?LABEL" + ) - session.meetecho_recording_name="actualname" + session.meetecho_recording_name = "actualname" session.save() - self.assertEqual(session.session_recording_url(), "http://player.example.com?actualname") + self.assertEqual( + session.session_recording_url(), + "http://player.example.com?actualname", + ) def test_session_recording_url_label_ietf(self): session = SessionFactory( - meeting__type_id='ietf', + meeting__type_id="ietf", meeting__date=date_today(), meeting__number="123", group__acronym="acro", @@ -186,15 +309,17 @@ def test_session_recording_url_label_ietf(self): session_time = session.official_timeslotassignment().timeslot.time self.assertEqual( f"IETF123-ACRO-{session_time:%Y%m%d-%H%M}", # n.b., time in label is UTC - session._session_recording_url_label()) + session._session_recording_url_label(), + ) def test_session_recording_url_label_interim(self): session = SessionFactory( - meeting__type_id='interim', + meeting__type_id="interim", meeting__date=date_today(), group__acronym="acro", ) session_time = session.official_timeslotassignment().timeslot.time self.assertEqual( f"IETF-ACRO-{session_time:%Y%m%d-%H%M}", # n.b., time in label is UTC - session._session_recording_url_label()) + session._session_recording_url_label(), + ) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 17988e50be..eea08be8c7 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -55,7 +55,7 @@ generate_proceedings_content, diff_meeting_schedules, ) -from ietf.meeting.utils import add_event_info_to_session_qs, participants_for_meeting +from ietf.meeting.utils import add_event_info_to_session_qs from ietf.meeting.utils import create_recording, delete_recording, get_next_sequence, bluesheet_data from ietf.meeting.views import session_draft_list, parse_agenda_filter_params, sessions_post_save, agenda_extract_schedule from ietf.meeting.views import get_summary_by_area, get_summary_by_type, get_summary_by_purpose, generate_agenda_data @@ -9440,20 +9440,32 @@ def test_get_next_sequence(self): sequence = get_next_sequence(group,meeting,'recording') self.assertEqual(sequence,1) - def test_participants_for_meeting(self): + def test_nomcom_eligible_participants_for_meeting(self): m = MeetingFactory.create(type_id='ietf') areg = RegistrationFactory(meeting=m, checkedin=True, with_ticket={'attendance_type_id': 'onsite'}) breg = RegistrationFactory(meeting=m, checkedin=False, with_ticket={'attendance_type_id': 'onsite'}) creg = RegistrationFactory(meeting=m, with_ticket={'attendance_type_id': 'remote'}) dreg = RegistrationFactory(meeting=m, with_ticket={'attendance_type_id': 'remote'}) AttendedFactory(session__meeting=m, session__type_id='plenary', person=creg.person) - checked_in, attended = participants_for_meeting(m) - self.assertIn(areg.person.pk, checked_in) - self.assertNotIn(breg.person.pk, checked_in) - self.assertNotIn(areg.person.pk, attended) - self.assertNotIn(breg.person.pk, attended) - self.assertIn(creg.person.pk, attended) - self.assertNotIn(dreg.person.pk, attended) + onsite_pks, remote_pks = m.nomcom_eligible_participants() + self.assertIn(areg.person.pk, onsite_pks) + self.assertNotIn(breg.person.pk, onsite_pks) + self.assertNotIn(areg.person.pk, remote_pks) + self.assertNotIn(breg.person.pk, remote_pks) + self.assertIn(creg.person.pk, remote_pks) + self.assertNotIn(dreg.person.pk, remote_pks) + + def test_nomcom_eligible_participants_remote_requires_session(self): + """Remote registration.attended=True without a qualifying session does not count (>= 110 path)""" + m = MeetingFactory.create(type_id='ietf', number='119') + # Remote with attended=True but no Attended record: should not be eligible + no_session_reg = RegistrationFactory(meeting=m, attended=True, with_ticket={'attendance_type_id': 'remote'}) + # Remote with attended=True AND a qualifying Attended record: should be eligible + session_reg = RegistrationFactory(meeting=m, attended=True, with_ticket={'attendance_type_id': 'remote'}) + AttendedFactory(session__meeting=m, session__type_id='plenary', person=session_reg.person) + onsite_pks, remote_pks = m.nomcom_eligible_participants() + self.assertNotIn(no_session_reg.person.pk, remote_pks) + self.assertIn(session_reg.person.pk, remote_pks) def test_session_attendance(self): meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number='118') diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 10ae0d3667..ffd37fc363 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -1362,17 +1362,6 @@ def post_process(doc): doc.save_with_history([e]) -def participants_for_meeting(meeting): - """ Return a tuple (checked_in, attended) - checked_in = queryset of onsite, checkedin participants values_list('person') - attended = queryset of remote participants who attended a session values_list('person') - """ - checked_in = meeting.registration_set.onsite().filter(checkedin=True).values_list('person', flat=True).distinct() - sessions = meeting.session_set.filter(Q(type='plenary') | Q(group__type__in=['wg', 'rg'])) - attended = Attended.objects.filter(session__in=sessions).values_list('person', flat=True).distinct() - return (checked_in, attended) - - def generate_proceedings_content(meeting, force_refresh=False): """Render proceedings content for a meeting and update cache diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 67a81305b4..dea4e29444 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -107,7 +107,7 @@ 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 participants_for_meeting, generate_bluesheet, bluesheet_data, save_bluesheet +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.utils import markdown @@ -4816,16 +4816,20 @@ def proceedings_attendees(request, num=None): chart_data = None if int(meeting.number) >= 118: - checked_in, attended = participants_for_meeting(meeting) - regs = list(Registration.objects.onsite().filter(meeting__number=num, checkedin=True)) - onsite_count = len(regs) - regs += [ + onsite, remote = meeting.get_attendees() + onsite_count = len(onsite) + remote_count = len(remote) + remote_pks = frozenset(p.pk for p in remote) + + regs = list( + Registration.objects.onsite().filter(meeting__number=num, checkedin=True) + ) + [ reg - for reg in Registration.objects.remote().filter(meeting__number=num).select_related('person') - if reg.person.pk in attended and reg.person.pk not in checked_in + for reg in Registration.objects.remote() + .filter(meeting__number=num) + .select_related("person") + if reg.person.pk in remote_pks ] - remote_count = len(regs) - onsite_count - 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/nomcom/utils.py b/ietf/nomcom/utils.py index a2ab680df6..4641871488 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -32,7 +32,6 @@ from ietf.person.models import Email, Person from ietf.mailtrigger.utils import gather_address_lists from ietf.meeting.models import Meeting -from ietf.meeting.utils import participants_for_meeting from ietf.utils.pipe import pipe from ietf.utils.mail import send_mail_text, send_mail, get_payload_text from ietf.utils.log import log @@ -743,8 +742,8 @@ def three_of_five_eligible_9389(previous_five, queryset=None): counts = defaultdict(lambda: 0) for meeting in previous_five: - checked_in, attended = participants_for_meeting(meeting) - for id in set(checked_in) | set(attended): + onsite_pks, remote_pks = meeting.nomcom_eligible_participants() + for id in onsite_pks | remote_pks: counts[id] += 1 return queryset.filter(pk__in=[id for id, count in counts.items() if count >= 3]) From e1a84da009b94c1c407f18fcf69394b94d016477 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Fri, 29 May 2026 11:51:48 -0500 Subject: [PATCH 2/2] fix: reconstitute reg objects directly from person objects --- ietf/meeting/views.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index dea4e29444..913c8bd302 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -4819,11 +4819,16 @@ def proceedings_attendees(request, num=None): onsite, remote = meeting.get_attendees() onsite_count = len(onsite) remote_count = len(remote) + onsite_pks = frozenset(p.pk for p in onsite) remote_pks = frozenset(p.pk for p in remote) - regs = list( - Registration.objects.onsite().filter(meeting__number=num, checkedin=True) - ) + [ + 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)