diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 6b404fe2a..0265dad4c 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -244,18 +244,35 @@ class Meeting(models.Model): number = self.get_number() if number is None or number < 110: return None - Attendance = namedtuple('Attendance', 'onsite online') + Attendance = namedtuple('Attendance', 'onsite remote') + + # MeetingRegistration.attended started conflating badge-pickup and session attendance before IETF 114. + # We've separated session attendence off to ietf.meeting.Attended, but need to report attendance at older + # meetings correctly. + + attended_per_meetingregistration = ( + Q(meetingregistration__meeting=self) & ( + Q(meetingregistration__attended=True) | + Q(meetingregistration__checkedin=True) + ) + ) + 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 + ) + attended = Person.objects.filter( + attended_per_meetingregistration | attended_per_meeting_attended + ).distinct() + + onsite=set(attended.filter(meetingregistration__meeting=self, meetingregistration__reg_type='onsite')) + remote=set(attended.filter(meetingregistration__meeting=self, meetingregistration__reg_type='remote')) + remote.difference_update(onsite) + return Attendance( - onsite=Person.objects.filter( - meetingregistration__meeting=self, - meetingregistration__attended=True, - meetingregistration__reg_type__contains='in_person', - ).distinct().count(), - online=Person.objects.filter( - meetingregistration__meeting=self, - meetingregistration__attended=True, - meetingregistration__reg_type__contains='remote', - ).distinct().count(), + onsite=len(onsite), + remote=len(remote) ) @property diff --git a/ietf/meeting/tests_models.py b/ietf/meeting/tests_models.py index dea31dc04..71f5b9361 100644 --- a/ietf/meeting/tests_models.py +++ b/ietf/meeting/tests_models.py @@ -3,7 +3,7 @@ """Tests of models in the Meeting application""" import datetime -from ietf.meeting.factories import MeetingFactory, SessionFactory +from ietf.meeting.factories import MeetingFactory, SessionFactory, AttendedFactory from ietf.stats.factories import MeetingRegistrationFactory from ietf.utils.test_utils import TestCase @@ -17,41 +17,75 @@ class MeetingTests(TestCase): MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person') self.assertIsNone(meeting.get_attendance()) - def test_get_attendance(self): - """Post-110 meetings do calculate 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 - MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='') + MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='', attended=True) MeetingRegistrationFactory(meeting=meeting, reg_type='', attended=False) attendance = meeting.get_attendance() self.assertIsNotNone(attendance) - self.assertEqual(attendance.online, 0) + self.assertEqual(attendance.remote, 0) self.assertEqual(attendance.onsite, 0) # add online attendees with at least one who registered but did not attend - MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote') + MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote', attended=True) MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False) attendance = meeting.get_attendance() self.assertIsNotNone(attendance) - self.assertEqual(attendance.online, 4) + self.assertEqual(attendance.remote, 4) self.assertEqual(attendance.onsite, 0) # and the same for onsite attendees - MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person') + MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='onsite', attended=True) MeetingRegistrationFactory(meeting=meeting, reg_type='in_person', attended=False) attendance = meeting.get_attendance() self.assertIsNotNone(attendance) - self.assertEqual(attendance.online, 4) + self.assertEqual(attendance.remote, 4) self.assertEqual(attendance.onsite, 5) # and once more after removing all the online attendees meeting.meetingregistration_set.filter(reg_type='remote').delete() attendance = meeting.get_attendance() self.assertIsNotNone(attendance) - self.assertEqual(attendance.online, 0) + 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') + MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=True, checkedin=False) + MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=False, checkedin=True) + p1 = MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', attended=False, checkedin=False).person + AttendedFactory(session__meeting=meeting, person=p1) + p2 = MeetingRegistrationFactory(meeting=meeting, reg_type='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): + """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') + + # Create a person who attended a remote session for first_mtg and onsite for second_mtg without + # checking in for either. + p = MeetingRegistrationFactory(meeting=second_mtg, reg_type='onsite', attended=False, checkedin=False).person + AttendedFactory(session__meeting=first_mtg, person=p) + MeetingRegistrationFactory(meeting=first_mtg, person=p, reg_type='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) + + att = second_mtg.get_attendance() + self.assertEqual(att.onsite, 1) + self.assertEqual(att.remote, 0) + class SessionTests(TestCase): def test_chat_archive_url_with_jabber(self): diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index 0732d394b..27f55329b 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -2289,7 +2289,7 @@ class rfc8713EligibilityTests(TestCase): for combo in combinations(meetings,combo_len): p = PersonFactory() for m in combo: - MeetingRegistrationFactory(person=p, meeting=m) + MeetingRegistrationFactory(person=p, meeting=m, attended=True) if combo_len<3: self.ineligible_people.append(p) else: @@ -2302,7 +2302,7 @@ class rfc8713EligibilityTests(TestCase): self.other_date = datetime.date(2009,5,1) self.other_people = PersonFactory.create_batch(1) for date in (datetime.date(2009,3,1), datetime.date(2008,11,1), datetime.date(2008,7,1)): - MeetingRegistrationFactory(person=self.other_people[0],meeting__date=date, meeting__type_id='ietf') + MeetingRegistrationFactory(person=self.other_people[0],meeting__date=date, meeting__type_id='ietf', attended=True) def test_is_person_eligible(self): @@ -2347,7 +2347,7 @@ class rfc8788EligibilityTests(TestCase): for combo in combinations(meetings,combo_len): p = PersonFactory() for m in combo: - MeetingRegistrationFactory(person=p, meeting=m) + MeetingRegistrationFactory(person=p, meeting=m, attended=True) if combo_len<3: self.ineligible_people.append(p) else: @@ -2395,7 +2395,7 @@ class rfc8989EligibilityTests(TestCase): for combo in combinations(prev_five,combo_len): p = PersonFactory() for m in combo: - MeetingRegistrationFactory(person=p, meeting=m) + MeetingRegistrationFactory(person=p, meeting=m, attended=True) # not checkedin because this forces looking at older meetings AttendedFactory(session__meeting=m, session__type_id='plenary',person=p) if combo_len<3: ineligible_people.append(p) @@ -2638,7 +2638,7 @@ class VolunteerTests(TestCase): self.assertContains(r, 'NomCom is not accepting volunteers at this time', status_code=200) nomcom.is_accepting_volunteers = True nomcom.save() - MeetingRegistrationFactory(person=person, affiliation='mtg_affiliation') + MeetingRegistrationFactory(person=person, affiliation='mtg_affiliation', checkedin=True) r = self.client.get(url) self.assertContains(r, 'Volunteer for NomCom', status_code=200) self.assertContains(r, 'mtg_affiliation') @@ -2710,7 +2710,7 @@ class VolunteerDecoratorUnitTests(TestCase): ('106', datetime.date(2019, 11, 16)), ]] for m in meetings: - MeetingRegistrationFactory(meeting=m,person=meeting_person) + MeetingRegistrationFactory(meeting=m, person=meeting_person, attended=True) AttendedFactory(session__meeting=m, session__type_id='plenary', person=meeting_person) nomcom.volunteer_set.create(person=meeting_person) diff --git a/ietf/stats/factories.py b/ietf/stats/factories.py index 6e160dd1b..7eba12675 100644 --- a/ietf/stats/factories.py +++ b/ietf/stats/factories.py @@ -15,4 +15,5 @@ class MeetingRegistrationFactory(factory.django.DjangoModelFactory): reg_type = 'onsite' first_name = factory.LazyAttribute(lambda obj: obj.person.first_name()) last_name = factory.LazyAttribute(lambda obj: obj.person.last_name()) - attended = True + attended = False + checkedin = False diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py index d34d7b11b..32a0dfea8 100644 --- a/ietf/stats/tests.py +++ b/ietf/stats/tests.py @@ -21,12 +21,13 @@ from ietf.submit.models import Submission from ietf.doc.factories import WgDraftFactory, WgRfcFactory from ietf.doc.models import Document, DocAlias, State, RelatedDocument, NewRevisionDocEvent, DocumentAuthor from ietf.group.factories import RoleFactory -from ietf.meeting.factories import MeetingFactory +from ietf.meeting.factories import MeetingFactory, AttendedFactory from ietf.person.factories import PersonFactory from ietf.person.models import Person, Email from ietf.name.models import FormalLanguageName, DocRelationshipName, CountryName from ietf.review.factories import ReviewRequestFactory, ReviewerSettingsFactory, ReviewAssignmentFactory from ietf.stats.models import MeetingRegistration, CountryAlias +from ietf.stats.factories import MeetingRegistrationFactory from ietf.stats.utils import get_meeting_registration_data @@ -122,11 +123,11 @@ class StatisticsTests(TestCase): def test_meeting_stats(self): # create some data for the statistics meeting = MeetingFactory(type_id='ietf', date=datetime.date.today(), number="96") - MeetingRegistration.objects.create(first_name='John', last_name='Smith', country_code='US', email="john.smith@example.us", meeting=meeting, attended=True) + MeetingRegistrationFactory(first_name='John', last_name='Smith', country_code='US', email="john.smith@example.us", meeting=meeting, attended=True) CountryAlias.objects.get_or_create(alias="US", country=CountryName.objects.get(slug="US")) - MeetingRegistration.objects.create(first_name='Jaume', last_name='Guillaume', country_code='FR', email="jaume.guillaume@example.fr", meeting=meeting, attended=True) + p = MeetingRegistrationFactory(first_name='Jaume', last_name='Guillaume', country_code='FR', email="jaume.guillaume@example.fr", meeting=meeting, attended=False).person CountryAlias.objects.get_or_create(alias="FR", country=CountryName.objects.get(slug="FR")) - + AttendedFactory(session__meeting=meeting,person=p) # check redirect url = urlreverse(ietf.stats.views.meeting_stats) diff --git a/ietf/stats/utils.py b/ietf/stats/utils.py index b6ff321e3..ca1163e07 100644 --- a/ietf/stats/utils.py +++ b/ietf/stats/utils.py @@ -7,6 +7,7 @@ import requests from collections import defaultdict from django.conf import settings +from django.db.models import Q import debug # pyflakes:ignore @@ -320,8 +321,10 @@ def get_meeting_registration_data(meeting): raise RuntimeError("Bad response from registrations API: %s, '%s'" % (response.status_code, response.content)) num_total = MeetingRegistration.objects.filter( meeting_id=meeting.pk, - attended=True, - reg_type__in=['onsite', 'remote']).count() + reg_type__in=['onsite', 'remote'] + ).filter( + Q(attended=True) | Q(checkedin=True) + ).count() if meeting.attendees is None or num_total > meeting.attendees: meeting.attendees = num_total meeting.save() diff --git a/ietf/stats/views.py b/ietf/stats/views.py index 593fc1947..1667387bc 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -817,8 +817,10 @@ def meeting_stats(request, num=None, stats_type=None): if meeting and any(stats_type == t[0] for t in possible_stats_types): attendees = MeetingRegistration.objects.filter( meeting=meeting, - attended=True, - reg_type__in=['onsite', 'remote']) + reg_type__in=['onsite', 'remote'] + ).filter( + Q( attended=True) | Q( checkedin=True ) + ) if stats_type == "country": stats_title = "Number of attendees for {} {} per country".format(meeting.type.name, meeting.number) @@ -893,7 +895,10 @@ def meeting_stats(request, num=None, stats_type=None): attendees = MeetingRegistration.objects.filter( meeting__type="ietf", attended=True, - reg_type__in=['onsite', 'remote']).select_related('meeting') + reg_type__in=['onsite', 'remote'] + ).filter( + Q( attended=True) | Q( checkedin=True ) + ).select_related('meeting') if stats_type == "overview": stats_title = "Number of attendees per meeting" diff --git a/ietf/templates/meeting/proceedings/title.html b/ietf/templates/meeting/proceedings/title.html index 6e7fe8068..afeaac8ea 100644 --- a/ietf/templates/meeting/proceedings/title.html +++ b/ietf/templates/meeting/proceedings/title.html @@ -13,9 +13,9 @@ This renders the title block for the meeting proceedings page. {% if attendance is not None %}