Skip to content

Commit 6dd6165

Browse files
fix: correct meeting attendance calculations (ietf-tools#4536)
* fix: correct meeting attendance calculations * test: change meetingregistration factory defaults * test: Setup stats tests to verify counts honor meeting.Attended But the tests aren't actually looking to see what numbers get generated yet. * test: add test for attendance cross-talk between meetings * fix: limit attendance count query to single meeting * refactor: rename attendance.online to .remote * fix: only count a given person as onsite or remote, but never both * test: align tests with cleanup Co-authored-by: Jennifer Richards <jennifer@painless-security.com>
1 parent 50668c9 commit 6dd6165

8 files changed

Lines changed: 100 additions & 39 deletions

File tree

ietf/meeting/models.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -244,18 +244,35 @@ def get_attendance(self):
244244
number = self.get_number()
245245
if number is None or number < 110:
246246
return None
247-
Attendance = namedtuple('Attendance', 'onsite online')
247+
Attendance = namedtuple('Attendance', 'onsite remote')
248+
249+
# MeetingRegistration.attended started conflating badge-pickup and session attendance before IETF 114.
250+
# We've separated session attendence off to ietf.meeting.Attended, but need to report attendance at older
251+
# meetings correctly.
252+
253+
attended_per_meetingregistration = (
254+
Q(meetingregistration__meeting=self) & (
255+
Q(meetingregistration__attended=True) |
256+
Q(meetingregistration__checkedin=True)
257+
)
258+
)
259+
attended_per_meeting_attended = (
260+
Q(attended__session__meeting=self)
261+
# Note that we are not filtering to plenary, wg, or rg sessions
262+
# as we do for nomcom eligibility - if picking up a badge (see above)
263+
# is good enough, just attending e.g. a training session is also good enough
264+
)
265+
attended = Person.objects.filter(
266+
attended_per_meetingregistration | attended_per_meeting_attended
267+
).distinct()
268+
269+
onsite=set(attended.filter(meetingregistration__meeting=self, meetingregistration__reg_type='onsite'))
270+
remote=set(attended.filter(meetingregistration__meeting=self, meetingregistration__reg_type='remote'))
271+
remote.difference_update(onsite)
272+
248273
return Attendance(
249-
onsite=Person.objects.filter(
250-
meetingregistration__meeting=self,
251-
meetingregistration__attended=True,
252-
meetingregistration__reg_type__contains='in_person',
253-
).distinct().count(),
254-
online=Person.objects.filter(
255-
meetingregistration__meeting=self,
256-
meetingregistration__attended=True,
257-
meetingregistration__reg_type__contains='remote',
258-
).distinct().count(),
274+
onsite=len(onsite),
275+
remote=len(remote)
259276
)
260277

261278
@property

ietf/meeting/tests_models.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""Tests of models in the Meeting application"""
44
import datetime
55

6-
from ietf.meeting.factories import MeetingFactory, SessionFactory
6+
from ietf.meeting.factories import MeetingFactory, SessionFactory, AttendedFactory
77
from ietf.stats.factories import MeetingRegistrationFactory
88
from ietf.utils.test_utils import TestCase
99

@@ -17,41 +17,75 @@ def test_get_attendance_pre110(self):
1717
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person')
1818
self.assertIsNone(meeting.get_attendance())
1919

20-
def test_get_attendance(self):
21-
"""Post-110 meetings do calculate attendance"""
20+
def test_get_attendance_110(self):
21+
"""Look at attendance as captured at 110"""
2222
meeting = MeetingFactory(type_id='ietf', number='110')
2323

2424
# start with attendees that should be ignored
25-
MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='')
25+
MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='', attended=True)
2626
MeetingRegistrationFactory(meeting=meeting, reg_type='', attended=False)
2727
attendance = meeting.get_attendance()
2828
self.assertIsNotNone(attendance)
29-
self.assertEqual(attendance.online, 0)
29+
self.assertEqual(attendance.remote, 0)
3030
self.assertEqual(attendance.onsite, 0)
3131

3232
# add online attendees with at least one who registered but did not attend
33-
MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote')
33+
MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote', attended=True)
3434
MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False)
3535
attendance = meeting.get_attendance()
3636
self.assertIsNotNone(attendance)
37-
self.assertEqual(attendance.online, 4)
37+
self.assertEqual(attendance.remote, 4)
3838
self.assertEqual(attendance.onsite, 0)
3939

4040
# and the same for onsite attendees
41-
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person')
41+
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='onsite', attended=True)
4242
MeetingRegistrationFactory(meeting=meeting, reg_type='in_person', attended=False)
4343
attendance = meeting.get_attendance()
4444
self.assertIsNotNone(attendance)
45-
self.assertEqual(attendance.online, 4)
45+
self.assertEqual(attendance.remote, 4)
4646
self.assertEqual(attendance.onsite, 5)
4747

4848
# and once more after removing all the online attendees
4949
meeting.meetingregistration_set.filter(reg_type='remote').delete()
5050
attendance = meeting.get_attendance()
5151
self.assertIsNotNone(attendance)
52-
self.assertEqual(attendance.online, 0)
52+
self.assertEqual(attendance.remote, 0)
5353
self.assertEqual(attendance.onsite, 5)
5454

55+
def test_get_attendance_113(self):
56+
"""Simulate IETF 113 attendance gathering data"""
57+
meeting = MeetingFactory(type_id='ietf', number='113')
58+
MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=True, checkedin=False)
59+
MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=False, checkedin=True)
60+
p1 = MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=False, checkedin=False).person
61+
AttendedFactory(session__meeting=meeting, person=p1)
62+
p2 = MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False, checkedin=False).person
63+
AttendedFactory(session__meeting=meeting, person=p2)
64+
attendance = meeting.get_attendance()
65+
self.assertEqual(attendance.onsite, 3)
66+
self.assertEqual(attendance.remote, 1)
67+
68+
def test_get_attendance_keeps_meetings_distinct(self):
69+
"""No cross-talk between attendance for different meetings"""
70+
# numbers are arbitrary here
71+
first_mtg = MeetingFactory(type_id='ietf', number='114')
72+
second_mtg = MeetingFactory(type_id='ietf', number='115')
73+
74+
# Create a person who attended a remote session for first_mtg and onsite for second_mtg without
75+
# checking in for either.
76+
p = MeetingRegistrationFactory(meeting=second_mtg, reg_type='onsite', attended=False, checkedin=False).person
77+
AttendedFactory(session__meeting=first_mtg, person=p)
78+
MeetingRegistrationFactory(meeting=first_mtg, person=p, reg_type='remote', attended=False, checkedin=False)
79+
AttendedFactory(session__meeting=second_mtg, person=p)
80+
81+
att = first_mtg.get_attendance()
82+
self.assertEqual(att.onsite, 0)
83+
self.assertEqual(att.remote, 1)
84+
85+
att = second_mtg.get_attendance()
86+
self.assertEqual(att.onsite, 1)
87+
self.assertEqual(att.remote, 0)
88+
5589

5690
class SessionTests(TestCase):
5791
def test_chat_archive_url_with_jabber(self):

ietf/nomcom/tests.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2289,7 +2289,7 @@ def setUp(self):
22892289
for combo in combinations(meetings,combo_len):
22902290
p = PersonFactory()
22912291
for m in combo:
2292-
MeetingRegistrationFactory(person=p, meeting=m)
2292+
MeetingRegistrationFactory(person=p, meeting=m, attended=True)
22932293
if combo_len<3:
22942294
self.ineligible_people.append(p)
22952295
else:
@@ -2302,7 +2302,7 @@ def setUp(self):
23022302
self.other_date = datetime.date(2009,5,1)
23032303
self.other_people = PersonFactory.create_batch(1)
23042304
for date in (datetime.date(2009,3,1), datetime.date(2008,11,1), datetime.date(2008,7,1)):
2305-
MeetingRegistrationFactory(person=self.other_people[0],meeting__date=date, meeting__type_id='ietf')
2305+
MeetingRegistrationFactory(person=self.other_people[0],meeting__date=date, meeting__type_id='ietf', attended=True)
23062306

23072307

23082308
def test_is_person_eligible(self):
@@ -2347,7 +2347,7 @@ def setUp(self):
23472347
for combo in combinations(meetings,combo_len):
23482348
p = PersonFactory()
23492349
for m in combo:
2350-
MeetingRegistrationFactory(person=p, meeting=m)
2350+
MeetingRegistrationFactory(person=p, meeting=m, attended=True)
23512351
if combo_len<3:
23522352
self.ineligible_people.append(p)
23532353
else:
@@ -2395,7 +2395,7 @@ def test_elig_by_meetings(self):
23952395
for combo in combinations(prev_five,combo_len):
23962396
p = PersonFactory()
23972397
for m in combo:
2398-
MeetingRegistrationFactory(person=p, meeting=m)
2398+
MeetingRegistrationFactory(person=p, meeting=m, attended=True) # not checkedin because this forces looking at older meetings
23992399
AttendedFactory(session__meeting=m, session__type_id='plenary',person=p)
24002400
if combo_len<3:
24012401
ineligible_people.append(p)
@@ -2638,7 +2638,7 @@ def test_volunteer(self):
26382638
self.assertContains(r, 'NomCom is not accepting volunteers at this time', status_code=200)
26392639
nomcom.is_accepting_volunteers = True
26402640
nomcom.save()
2641-
MeetingRegistrationFactory(person=person, affiliation='mtg_affiliation')
2641+
MeetingRegistrationFactory(person=person, affiliation='mtg_affiliation', checkedin=True)
26422642
r = self.client.get(url)
26432643
self.assertContains(r, 'Volunteer for NomCom', status_code=200)
26442644
self.assertContains(r, 'mtg_affiliation')
@@ -2710,7 +2710,7 @@ def test_decorate_volunteers_with_qualifications(self):
27102710
('106', datetime.date(2019, 11, 16)),
27112711
]]
27122712
for m in meetings:
2713-
MeetingRegistrationFactory(meeting=m,person=meeting_person)
2713+
MeetingRegistrationFactory(meeting=m, person=meeting_person, attended=True)
27142714
AttendedFactory(session__meeting=m, session__type_id='plenary', person=meeting_person)
27152715
nomcom.volunteer_set.create(person=meeting_person)
27162716

ietf/stats/factories.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ class Meta:
1515
reg_type = 'onsite'
1616
first_name = factory.LazyAttribute(lambda obj: obj.person.first_name())
1717
last_name = factory.LazyAttribute(lambda obj: obj.person.last_name())
18-
attended = True
18+
attended = False
19+
checkedin = False

ietf/stats/tests.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@
2121
from ietf.doc.factories import WgDraftFactory, WgRfcFactory
2222
from ietf.doc.models import Document, DocAlias, State, RelatedDocument, NewRevisionDocEvent, DocumentAuthor
2323
from ietf.group.factories import RoleFactory
24-
from ietf.meeting.factories import MeetingFactory
24+
from ietf.meeting.factories import MeetingFactory, AttendedFactory
2525
from ietf.person.factories import PersonFactory
2626
from ietf.person.models import Person, Email
2727
from ietf.name.models import FormalLanguageName, DocRelationshipName, CountryName
2828
from ietf.review.factories import ReviewRequestFactory, ReviewerSettingsFactory, ReviewAssignmentFactory
2929
from ietf.stats.models import MeetingRegistration, CountryAlias
30+
from ietf.stats.factories import MeetingRegistrationFactory
3031
from ietf.stats.utils import get_meeting_registration_data
3132

3233

@@ -122,11 +123,11 @@ def test_document_stats(self):
122123
def test_meeting_stats(self):
123124
# create some data for the statistics
124125
meeting = MeetingFactory(type_id='ietf', date=datetime.date.today(), number="96")
125-
MeetingRegistration.objects.create(first_name='John', last_name='Smith', country_code='US', email="john.smith@example.us", meeting=meeting, attended=True)
126+
MeetingRegistrationFactory(first_name='John', last_name='Smith', country_code='US', email="john.smith@example.us", meeting=meeting, attended=True)
126127
CountryAlias.objects.get_or_create(alias="US", country=CountryName.objects.get(slug="US"))
127-
MeetingRegistration.objects.create(first_name='Jaume', last_name='Guillaume', country_code='FR', email="jaume.guillaume@example.fr", meeting=meeting, attended=True)
128+
p = MeetingRegistrationFactory(first_name='Jaume', last_name='Guillaume', country_code='FR', email="jaume.guillaume@example.fr", meeting=meeting, attended=False).person
128129
CountryAlias.objects.get_or_create(alias="FR", country=CountryName.objects.get(slug="FR"))
129-
130+
AttendedFactory(session__meeting=meeting,person=p)
130131
# check redirect
131132
url = urlreverse(ietf.stats.views.meeting_stats)
132133

ietf/stats/utils.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from collections import defaultdict
88

99
from django.conf import settings
10+
from django.db.models import Q
1011

1112
import debug # pyflakes:ignore
1213

@@ -320,8 +321,10 @@ def get_meeting_registration_data(meeting):
320321
raise RuntimeError("Bad response from registrations API: %s, '%s'" % (response.status_code, response.content))
321322
num_total = MeetingRegistration.objects.filter(
322323
meeting_id=meeting.pk,
323-
attended=True,
324-
reg_type__in=['onsite', 'remote']).count()
324+
reg_type__in=['onsite', 'remote']
325+
).filter(
326+
Q(attended=True) | Q(checkedin=True)
327+
).count()
325328
if meeting.attendees is None or num_total > meeting.attendees:
326329
meeting.attendees = num_total
327330
meeting.save()

ietf/stats/views.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -817,8 +817,10 @@ def reg_name(r):
817817
if meeting and any(stats_type == t[0] for t in possible_stats_types):
818818
attendees = MeetingRegistration.objects.filter(
819819
meeting=meeting,
820-
attended=True,
821-
reg_type__in=['onsite', 'remote'])
820+
reg_type__in=['onsite', 'remote']
821+
).filter(
822+
Q( attended=True) | Q( checkedin=True )
823+
)
822824

823825
if stats_type == "country":
824826
stats_title = "Number of attendees for {} {} per country".format(meeting.type.name, meeting.number)
@@ -893,7 +895,10 @@ def reg_name(r):
893895
attendees = MeetingRegistration.objects.filter(
894896
meeting__type="ietf",
895897
attended=True,
896-
reg_type__in=['onsite', 'remote']).select_related('meeting')
898+
reg_type__in=['onsite', 'remote']
899+
).filter(
900+
Q( attended=True) | Q( checkedin=True )
901+
).select_related('meeting')
897902

898903
if stats_type == "overview":
899904
stats_title = "Number of attendees per meeting"

ietf/templates/meeting/proceedings/title.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ <h1>
1313
{% if attendance is not None %}
1414
<div class="proceedings-info lead">
1515
{% if attendance.onsite > 0 %}
16-
{{ attendance.onsite }} onsite participant{{ attendance.onsite|pluralize }}{% if attendance.online > 0 %},{% endif %}
16+
{{ attendance.onsite }} onsite participant{{ attendance.onsite|pluralize }}{% if attendance.remote > 0 %},{% endif %}
1717
{% endif %}
18-
{% if attendance.online > 0 %}{{ attendance.online }} online participant{{ attendance.online|pluralize }}{% endif %}
18+
{% if attendance.remote > 0 %}{{ attendance.remote }} online participant{{ attendance.remote|pluralize }}{% endif %}
1919
</div>
2020
{% endif %}
2121
</div>

0 commit comments

Comments
 (0)