diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 809b45cc2b..32f33dcc63 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -833,111 +833,91 @@ def test_api_new_meeting_registration_nomcom_volunteer(self): def test_api_new_meeting_registration_v2(self): meeting = MeetingFactory(type_id='ietf') person = PersonFactory() - regs = [ - { - 'affiliation': "Alguma Corporação", - 'country_code': 'PT', - 'email': person.email().address, - 'first_name': person.first_name(), - 'last_name': person.last_name(), - 'meeting': str(meeting.number), - 'reg_type': 'onsite', - 'ticket_type': 'week_pass', - 'checkedin': False, - 'is_nomcom_volunteer': False, - 'cancelled': False, - } - ] - + reg_detail = { + 'email': person.email().address, + 'first_name': person.first_name(), + 'last_name': person.last_name(), + 'meeting': meeting.number, + 'affiliation': "Alguma Corporação", + 'country_code': 'PT', + 'checkedin': False, + 'is_nomcom_volunteer': False, + 'cancelled': False, + 'tickets': [{'attendance_type': 'onsite', 'ticket_type': 'week_pass'}], + } + reg_data = {'objects': {reg_detail['email']: reg_detail}} url = urlreverse('ietf.api.views.api_new_meeting_registration_v2') # # Test invalid key - r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "invalid-token"}) + r = self.client.post(url, data=json.dumps(reg_data), content_type='application/json', headers={"X-Api-Key": "invalid-token"}) self.assertEqual(r.status_code, 403) # # Test invalid data - bad_regs = copy.deepcopy(regs) - del(bad_regs[0]['email']) - r = self.client.post(url, data=json.dumps(bad_regs), content_type='application/json', headers={"X-Api-Key": "valid-token"}) + bad_reg_data = copy.deepcopy(reg_data) + del bad_reg_data['objects'][reg_detail['email']]['email'] + r = self.client.post(url, data=json.dumps(bad_reg_data), content_type='application/json', headers={"X-Api-Key": "valid-token"}) self.assertEqual(r.status_code, 400) # # Test valid POST - r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"}) + r = self.client.post(url, data=json.dumps(reg_data), content_type='application/json', headers={"X-Api-Key": "valid-token"}) self.assertContains(r, "Success", status_code=202) # # Check record - reg = regs[0] - objects = Registration.objects.filter(email=reg['email'], meeting__number=reg['meeting']) + objects = Registration.objects.filter(email=reg_detail['email'], meeting__number=reg_detail['meeting']) self.assertEqual(objects.count(), 1) obj = objects[0] for key in ['affiliation', 'country_code', 'first_name', 'last_name', 'checkedin']: - self.assertEqual(getattr(obj, key), False if key=='checkedin' else reg.get(key) , "Bad data for field '%s'" % key) + self.assertEqual(getattr(obj, key), False if key == 'checkedin' else reg_detail.get(key), f"Bad data for field {key}") self.assertEqual(obj.tickets.count(), 1) ticket = obj.tickets.first() - self.assertEqual(ticket.ticket_type.slug, regs[0]['ticket_type']) - self.assertEqual(ticket.attendance_type.slug, regs[0]['reg_type']) + self.assertEqual(ticket.ticket_type.slug, reg_detail['tickets'][0]['ticket_type']) + self.assertEqual(ticket.attendance_type.slug, reg_detail['tickets'][0]['attendance_type']) self.assertEqual(obj.person, person) # # Test update (switch to remote) - regs = [ - { - 'affiliation': "Alguma Corporação", - 'country_code': 'PT', - 'email': person.email().address, - 'first_name': person.first_name(), - 'last_name': person.last_name(), - 'meeting': str(meeting.number), - 'reg_type': 'remote', - 'ticket_type': 'week_pass', - 'checkedin': False, - 'is_nomcom_volunteer': False, - 'cancelled': False, - } - ] - r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"}) + reg_detail = { + 'affiliation': "Alguma Corporação", + 'country_code': 'PT', + 'email': person.email().address, + 'first_name': person.first_name(), + 'last_name': person.last_name(), + 'meeting': meeting.number, + 'checkedin': False, + 'is_nomcom_volunteer': False, + 'cancelled': False, + 'tickets': [{'attendance_type': 'remote', 'ticket_type': 'week_pass'}], + } + reg_data = {'objects': {reg_detail['email']: reg_detail}} + r = self.client.post(url, data=json.dumps(reg_data), content_type='application/json', headers={"X-Api-Key": "valid-token"}) self.assertContains(r, "Success", status_code=202) - objects = Registration.objects.filter(email=reg['email'], meeting__number=reg['meeting']) + objects = Registration.objects.filter(email=reg_detail['email'], meeting__number=reg_detail['meeting']) self.assertEqual(objects.count(), 1) obj = objects[0] self.assertEqual(obj.tickets.count(), 1) ticket = obj.tickets.first() - self.assertEqual(ticket.ticket_type.slug, regs[0]['ticket_type']) - self.assertEqual(ticket.attendance_type.slug, regs[0]['reg_type']) + self.assertEqual(ticket.ticket_type.slug, reg_detail['tickets'][0]['ticket_type']) + self.assertEqual(ticket.attendance_type.slug, reg_detail['tickets'][0]['attendance_type']) # # Test multiple - regs = [ - { - 'affiliation': "Alguma Corporação", - 'country_code': 'PT', - 'email': person.email().address, - 'first_name': person.first_name(), - 'last_name': person.last_name(), - 'meeting': str(meeting.number), - 'reg_type': 'onsite', - 'ticket_type': 'one_day', - 'checkedin': False, - 'is_nomcom_volunteer': False, - 'cancelled': False, - }, - - { - 'affiliation': "Alguma Corporação", - 'country_code': 'PT', - 'email': person.email().address, - 'first_name': person.first_name(), - 'last_name': person.last_name(), - 'meeting': str(meeting.number), - 'reg_type': 'remote', - 'ticket_type': 'week_pass', - 'checkedin': False, - 'is_nomcom_volunteer': False, - 'cancelled': False, - } - ] - - r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"}) + reg_detail = { + 'affiliation': "Alguma Corporação", + 'country_code': 'PT', + 'email': person.email().address, + 'first_name': person.first_name(), + 'last_name': person.last_name(), + 'meeting': meeting.number, + 'checkedin': False, + 'is_nomcom_volunteer': False, + 'cancelled': False, + 'tickets': [ + {'attendance_type': 'onsite', 'ticket_type': 'one_day'}, + {'attendance_type': 'remote', 'ticket_type': 'week_pass'}, + ], + } + reg_data = {'objects': {reg_detail['email']: reg_detail}} + r = self.client.post(url, data=json.dumps(reg_data), content_type='application/json', headers={"X-Api-Key": "valid-token"}) self.assertContains(r, "Success", status_code=202) - objects = Registration.objects.filter(email=reg['email'], meeting__number=reg['meeting']) + objects = Registration.objects.filter(email=reg_detail['email'], meeting__number=reg_detail['meeting']) self.assertEqual(objects.count(), 1) obj = objects[0] self.assertEqual(obj.tickets.count(), 2) @@ -948,51 +928,46 @@ def test_api_new_meeting_registration_v2(self): def test_api_new_meeting_registration_v2_cancelled(self): meeting = MeetingFactory(type_id='ietf') person = PersonFactory() - regs = [ - { - 'affiliation': "Acme", - 'country_code': 'US', - 'email': person.email().address, - 'first_name': person.first_name(), - 'last_name': person.last_name(), - 'meeting': str(meeting.number), - 'reg_type': 'onsite', - 'ticket_type': 'week_pass', - 'checkedin': False, - 'is_nomcom_volunteer': False, - 'cancelled': False, - } - ] + reg_detail = { + 'affiliation': "Acme", + 'country_code': 'US', + 'email': person.email().address, + 'first_name': person.first_name(), + 'last_name': person.last_name(), + 'meeting': meeting.number, + 'checkedin': False, + 'is_nomcom_volunteer': False, + 'cancelled': False, + 'tickets': [{'attendance_type': 'onsite', 'ticket_type': 'week_pass'}], + } + reg_data = {'objects': {reg_detail['email']: reg_detail}} url = urlreverse('ietf.api.views.api_new_meeting_registration_v2') self.assertEqual(Registration.objects.count(), 0) - r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"}) + r = self.client.post(url, data=json.dumps(reg_data), content_type='application/json', headers={"X-Api-Key": "valid-token"}) self.assertContains(r, "Success", status_code=202) self.assertEqual(Registration.objects.count(), 1) - regs[0]['cancelled'] = True - r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"}) + reg_detail['cancelled'] = True + r = self.client.post(url, data=json.dumps(reg_data), content_type='application/json', headers={"X-Api-Key": "valid-token"}) self.assertContains(r, "Success", status_code=202) self.assertEqual(Registration.objects.count(), 0) - @override_settings(APP_API_TOKENS={"ietf.api.views.api_new_meeting_registration_v2": ["valid-token"]}) + @override_settings(APP_API_TOKENS={"ietf.api.views.api_new_meeting_registration_v2": ["valid-token"]}) def test_api_new_meeting_registration_v2_nomcom(self): meeting = MeetingFactory(type_id='ietf') person = PersonFactory() - regs = [ - { - 'affiliation': "Acme", - 'country_code': 'US', - 'email': person.email().address, - 'first_name': person.first_name(), - 'last_name': person.last_name(), - 'meeting': str(meeting.number), - 'reg_type': 'onsite', - 'ticket_type': 'week_pass', - 'checkedin': False, - 'is_nomcom_volunteer': False, - 'cancelled': False, - } - ] - + reg_detail = { + 'affiliation': "Acme", + 'country_code': 'US', + 'email': person.email().address, + 'first_name': person.first_name(), + 'last_name': person.last_name(), + 'meeting': meeting.number, + 'checkedin': False, + 'is_nomcom_volunteer': False, + 'cancelled': False, + 'tickets': [{'attendance_type': 'onsite', 'ticket_type': 'week_pass'}], + } + reg_data = {'objects': {reg_detail['email']: reg_detail}} url = urlreverse('ietf.api.views.api_new_meeting_registration_v2') now = datetime.datetime.now() if now.month > 10: @@ -1003,14 +978,14 @@ def test_api_new_meeting_registration_v2_nomcom(self): nomcom = NomComFactory.create(is_accepting_volunteers=True, **nomcom_kwargs_for_year(year)) # first test is_nomcom_volunteer False - r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"}) + r = self.client.post(url, data=json.dumps(reg_data), content_type='application/json', headers={"X-Api-Key": "valid-token"}) self.assertContains(r, "Success", status_code=202) # assert no Volunteers exists self.assertEqual(Volunteer.objects.count(), 0) # test is_nomcom_volunteer True - regs[0]['is_nomcom_volunteer'] = True - r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"}) + reg_detail['is_nomcom_volunteer'] = True + r = self.client.post(url, data=json.dumps(reg_data), content_type='application/json', headers={"X-Api-Key": "valid-token"}) self.assertContains(r, "Success", status_code=202) # assert Volunteer exists self.assertEqual(Volunteer.objects.count(), 1) diff --git a/ietf/api/views.py b/ietf/api/views.py index 97b9793048..06a386b2e3 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -45,7 +45,8 @@ from ietf.ietfauth.utils import role_required from ietf.ietfauth.views import send_account_creation_email from ietf.ipr.utils import ingest_response_email as ipr_ingest_response_email -from ietf.meeting.models import Meeting, Registration +from ietf.meeting.models import Meeting +from ietf.meeting.utils import import_registration_json_validator, process_single_registration from ietf.nomcom.models import Volunteer, NomCom from ietf.nomcom.utils import ingest_feedback_email as nomcom_ingest_feedback_email from ietf.person.models import Person, Email @@ -241,31 +242,6 @@ def err(code, text): return HttpResponse(status=405) -_new_registration_json_validator = jsonschema.Draft202012Validator( - schema={ - "type": "array", - "items": { - "type": "object", - "properties": { - "meeting": {"type": "string"}, - "first_name": {"type": "string"}, - "last_name": {"type": "string"}, - "affiliation": {"type": "string"}, - "country_code": {"type": "string"}, - "email": {"type": "string"}, - "reg_type": {"type": "string"}, - "ticket_type": {"type": "string"}, - "checkedin": {"type": "boolean"}, - "is_nomcom_volunteer": {"type": "boolean"}, - "cancelled": {"type": "boolean"}, - }, - "required": ["meeting", "first_name", "last_name", "affiliation", "country_code", "email", "reg_type", "ticket_type", "checkedin", "is_nomcom_volunteer", "cancelled"], - "additionalProperties": "false" - } - } -) - - @requires_api_token @csrf_exempt def api_new_meeting_registration_v2(request): @@ -285,7 +261,7 @@ def _api_response(result): # Validate try: payload = json.loads(request.body) - _new_registration_json_validator.validate(payload) + import_registration_json_validator.validate(payload) except json.decoder.JSONDecodeError as err: return _http_err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}") except jsonschema.exceptions.ValidationError as err: @@ -293,91 +269,23 @@ def _api_response(result): except Exception: return _http_err(400, "Invalid request format") - # Validate consistency - # - if receive multiple records they should be for same meeting, same person (email) - if len(payload) > 1: - if len(set([r['meeting'] for r in payload])) != 1: - return _http_err(400, "Different meeting values") - if len(set([r['email'] for r in payload])) != 1: - return _http_err(400, "Different email values") - - # Validate meeting - number = payload[0]['meeting'] + # Get the meeting ID from the first registration, the API only deals with one meeting at a time + first_email = next(iter(payload['objects'])) + meeting_number = payload['objects'][first_email]['meeting'] try: - meeting = Meeting.objects.get(number=number) + meeting = Meeting.objects.get(number=meeting_number) except Meeting.DoesNotExist: - return _http_err(400, "Invalid meeting value: '%s'" % (number, )) + return _http_err(400, f"Invalid meeting value: {meeting_number}") - # Validate email - email = payload[0]['email'] + # confirm email exists try: - validate_email(email) - except ValidationError: - return _http_err(400, "Invalid email value: '%s'" % (email, )) - - # get person - person = Person.objects.filter(email__address=email).first() - if not person: - log.log(f"api_new_meeting_registration_v2 no Person found for {email}") - - registration = payload[0] - # handle cancelled - if registration['cancelled']: - if len(payload) > 1: - return _http_err(400, "Error. Received cancelled registration notification with more than one record. ({})".format(email)) - try: - obj = Registration.objects.get(meeting=meeting, email=email) - except Registration.DoesNotExist: - return _http_err(400, "Error. Received cancelled registration notification for non-existing registration. ({})".format(email)) - if obj.tickets.count() == 1: - obj.delete() - else: - obj.tickets.filter( - attendance_type__slug=registration.reg_type, - ticket_type__slug=registration.ticket_type).delete() - return HttpResponse('Success', status=202, content_type='text/plain') + Email.objects.get(address=first_email) + except Email.DoesNotExist: + return _http_err(400, f"Unknown email: {first_email}") - # create or update MeetingRegistration - update_fields = ['first_name', 'last_name', 'affiliation', 'country_code', 'checkedin', 'is_nomcom_volunteer'] - try: - reg = Registration.objects.get(meeting=meeting, email=email) - for key, value in registration.items(): - if key in update_fields: - setattr(reg, key, value) - reg.save() - except Registration.DoesNotExist: - reg = Registration.objects.create( - meeting_id=meeting.pk, - person=person, - email=email, - first_name=registration['first_name'], - last_name=registration['last_name'], - affiliation=registration['affiliation'], - country_code=registration['country_code'], - checkedin=registration['checkedin']) - - # handle registration tickets - reg.tickets.all().delete() - for registration in payload: - reg.tickets.create( - attendance_type_id=registration['reg_type'], - ticket_type_id=registration['ticket_type'], - ) - # handle nomcom volunteer - if registration['is_nomcom_volunteer'] and person: - try: - nomcom = NomCom.objects.get(is_accepting_volunteers=True) - except (NomCom.DoesNotExist, NomCom.MultipleObjectsReturned): - nomcom = None - if nomcom: - Volunteer.objects.get_or_create( - nomcom=nomcom, - person=person, - defaults={ - "affiliation": registration["affiliation"], - "origin": "registration" - } - ) + reg_data = payload['objects'][first_email] + + process_single_registration(reg_data, meeting) return HttpResponse('Success', status=202, content_type='text/plain') diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index fed764e8bd..f6d7671bc9 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -32,14 +32,13 @@ from ietf.group.factories import GroupFactory, RoleFactory from ietf.group.models import Group, Role, RoleName from ietf.ietfauth.utils import has_role -from ietf.meeting.factories import MeetingFactory +from ietf.meeting.factories import MeetingFactory, RegistrationFactory, RegistrationTicketFactory from ietf.nomcom.factories import NomComFactory from ietf.person.factories import PersonFactory, EmailFactory, UserFactory, PersonalApiKeyFactory from ietf.person.models import Person, Email from ietf.person.tasks import send_apikey_usage_emails_task from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory from ietf.review.models import ReviewWish, UnavailablePeriod -from ietf.stats.models import MeetingRegistration from ietf.utils.mail import outbox, empty_outbox, get_payload_text from ietf.utils.test_utils import TestCase, login_testing_unauthorized from ietf.utils.timezone import date_today @@ -1016,11 +1015,15 @@ def test_oidc_code_auth(self): EmailFactory(person=person) email_list = person.email_set.all().values_list('address', flat=True) meeting = MeetingFactory(type_id='ietf', date=date_today()) - MeetingRegistration.objects.create( - meeting=meeting, person=None, first_name=person.first_name(), last_name=person.last_name(), - email=email_list[0], ticket_type='full_week', reg_type='remote', affiliation='Some Company', - ) - + reg_person = RegistrationFactory( + meeting=meeting, + person=person, + first_name=person.first_name(), + last_name=person.last_name(), + email=email_list[0], + affiliation='Some Company', + with_ticket={'attendance_type_id': 'remote', 'ticket_type_id': 'week_pass'}, + ) # Get access authorisation session = {} session["state"] = rndstr() @@ -1073,35 +1076,48 @@ def test_oidc_code_auth(self): for key in ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash']: self.assertIn(key, access_token_info['id_token']) - # Get userinfo, check keys present + # Get userinfo, check keys present, most common scenario userinfo = client.do_user_info_request(state=params["state"], scope=args['scope']) for key in [ 'email', 'family_name', 'given_name', 'meeting', 'name', 'pronouns', 'roles', 'ticket_type', 'reg_type', 'affiliation', 'picture', 'dots', ]: self.assertIn(key, userinfo) self.assertTrue(userinfo[key]) self.assertIn('remote', set(userinfo['reg_type'].split())) - self.assertNotIn('hackathon', set(userinfo['reg_type'].split())) + self.assertNotIn('hackathon_onsite', set(userinfo['reg_type'].split())) self.assertIn(active_group.acronym, [i[1] for i in userinfo['roles']]) self.assertNotIn(closed_group.acronym, [i[1] for i in userinfo['roles']]) - # Create another registration, with a different email - MeetingRegistration.objects.create( - meeting=meeting, person=None, first_name=person.first_name(), last_name=person.last_name(), - email=email_list[1], ticket_type='one_day', reg_type='hackathon', affiliation='Some Company, Inc', - ) + # Create a registration, with only email, no person (rare if at all) + reg_person.delete() + reg_email = RegistrationFactory( + meeting=meeting, + person=None, + first_name=person.first_name(), + last_name=person.last_name(), + email=email_list[1], + affiliation='Some Company, Inc', + with_ticket={'attendance_type_id': 'hackathon_onsite', 'ticket_type_id': 'one_day'}, + ) userinfo = client.do_user_info_request(state=params["state"], scope=args['scope']) - self.assertIn('hackathon', set(userinfo['reg_type'].split())) - self.assertIn('remote', set(userinfo['reg_type'].split())) - self.assertIn('full_week', set(userinfo['ticket_type'].split())) - self.assertIn('Some Company', userinfo['affiliation']) - - # Create a third registration, with a composite reg type - MeetingRegistration.objects.create( - meeting=meeting, person=None, first_name=person.first_name(), last_name=person.last_name(), - email=email_list[1], ticket_type='one_day', reg_type='hackathon remote', affiliation='Some Company, Inc', - ) + self.assertIn('hackathon_onsite', set(userinfo['reg_type'].split())) + self.assertNotIn('remote', set(userinfo['reg_type'].split())) + self.assertIn('one_day', set(userinfo['ticket_type'].split())) + self.assertIn('Some Company, Inc', userinfo['affiliation']) + + # Test with multiple tickets + reg_email.delete() + creg = RegistrationFactory( + meeting=meeting, + person=None, + first_name=person.first_name(), + last_name=person.last_name(), + email=email_list[1], + affiliation='Some Company, Inc', + with_ticket={'attendance_type_id': 'hackathon_remote', 'ticket_type_id': 'week_pass'}, + ) + RegistrationTicketFactory(registration=creg, attendance_type_id='remote', ticket_type_id='week_pass') userinfo = client.do_user_info_request(state=params["state"], scope=args['scope']) - self.assertEqual(set(userinfo['reg_type'].split()), set(['remote', 'hackathon'])) + self.assertEqual(set(userinfo['reg_type'].split()), set(['remote', 'hackathon_remote'])) # Check that ending a session works r = client.do_end_session_request(state=params["state"], scope=args['scope']) diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index b4c6da14ea..efdd6f3ea6 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -346,13 +346,14 @@ def scope_pronouns(self): ) def scope_registration(self): + # import here to avoid circular imports from ietf.meeting.helpers import get_current_ietf_meeting - from ietf.stats.models import MeetingRegistration + from ietf.meeting.models import Registration meeting = get_current_ietf_meeting() person = self.user.person email_list = person.email_set.values_list('address') q = Q(person=person, meeting=meeting) | Q(email__in=email_list, meeting=meeting) - regs = MeetingRegistration.objects.filter(q).distinct() + regs = Registration.objects.filter(q).distinct() for reg in regs: if not reg.person_id: reg.person = person @@ -363,19 +364,20 @@ def scope_registration(self): ticket_types = set([]) reg_types = set([]) for reg in regs: - ticket_types.add(reg.ticket_type) - reg_types.add(reg.reg_type) + for ticket in reg.tickets.all(): + ticket_types.add(ticket.ticket_type.slug) + reg_types.add(ticket.attendance_type.slug) info = { - 'meeting': meeting.number, + 'meeting': meeting.number, # full_week, one_day, student: - 'ticket_type': ' '.join(ticket_types), + 'ticket_type': ' '.join(ticket_types), # onsite, remote, hackathon_onsite, hackathon_remote: - 'reg_type': ' '.join(reg_types), - 'affiliation': ([ reg.affiliation for reg in regs if reg.affiliation ] or [''])[0], + 'reg_type': ' '.join(reg_types), + 'affiliation': ([reg.affiliation for reg in regs if reg.affiliation] or [''])[0], } return info - + def can_request_rfc_publication(user, doc): """Answers whether this user has an appropriate role to send this document to the RFC Editor for publication as an RFC. diff --git a/ietf/meeting/factories.py b/ietf/meeting/factories.py index b3d21830fe..fc0ce8387c 100644 --- a/ietf/meeting/factories.py +++ b/ietf/meeting/factories.py @@ -321,6 +321,15 @@ class Meta: class RegistrationFactory(factory.django.DjangoModelFactory): + """ + This will create an associated onsite week_pass ticket by default. + Methods of calling: + + RegistrationFactory() create a ticket with defaults, onsite + RegistrationFactory(with_ticket=True) same as above + RegistrationFactory(with_ticket={'attendance_type_id': 'remote'}) creates ticket with overrides + RegistrationFactory(with_ticket=False) does not create a ticket + """ class Meta: model = Registration skip_postgeneration_save = True @@ -335,6 +344,16 @@ class Meta: attended = False checkedin = False + @factory.post_generation + def with_ticket(self, create, extracted, **kwargs): + if not create: + return + if extracted is False: + # Explicitly disable ticket creation + return + ticket_kwargs = extracted if isinstance(extracted, dict) else {} + RegistrationTicketFactory(registration=self, **ticket_kwargs) + class RegistrationTicketFactory(factory.django.DjangoModelFactory): class Meta: @@ -342,5 +361,5 @@ class Meta: skip_postgeneration_save = True registration = factory.SubFactory(RegistrationFactory) - attendance_type_id = 'onsite' - ticket_type_id = 'week_pass' + attendance_type_id = factory.LazyAttribute(lambda _: 'onsite') + ticket_type_id = factory.LazyAttribute(lambda _: 'week_pass') diff --git a/ietf/meeting/migrations/0013_correct_reg_checkedin.py b/ietf/meeting/migrations/0013_correct_reg_checkedin.py new file mode 100644 index 0000000000..88b3efceac --- /dev/null +++ b/ietf/meeting/migrations/0013_correct_reg_checkedin.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.21 on 2025-05-20 22:28 + +''' +The original migration had a flaw. If a participant had both a remote and onsite +registration, which is rare but does occur, which registration the checkedin state +came from was indeterminate. If it came from the remote registration it would be +False which might be wrong. This migration finds all registrations with onsite tickets +and checkedin is False, and checks if it is correct, and fixes if needed. +''' + +from django.db import migrations +import datetime + + +def forward(apps, schema_editor): + Registration = apps.get_model('meeting', 'Registration') + MeetingRegistration = apps.get_model('stats', 'MeetingRegistration') + today = datetime.date.today() + for reg in Registration.objects.filter(tickets__attendance_type__slug='onsite', checkedin=False, meeting__date__lt=today).order_by('meeting__number'): + # get original MeetingRegistration + mregs = MeetingRegistration.objects.filter(meeting=reg.meeting, email=reg.email, reg_type='onsite') + mregs_checkedin = [mr.checkedin for mr in mregs] + if any(mregs_checkedin): + reg.checkedin = True + reg.save() + print(f'updating {reg.meeting}:{reg.email}:{reg.pk}') + + +def reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("meeting", "0012_registration_registrationticket"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 003f8cd76e..cc5241efa2 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -233,9 +233,9 @@ def get_proceedings_materials(self): ).order_by('type__order') def get_attendance(self): - """Get the meeting attendance from the MeetingRegistrations + """Get the meeting attendance from the Registrations - Returns a NamedTuple with onsite and online attributes. Returns None if the record is unavailable + Returns a NamedTuple with onsite and remote attributes. Returns None if the record is unavailable for this meeting. """ number = self.get_number() @@ -247,10 +247,10 @@ def get_attendance(self): # We've separated session attendance 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_registration = ( + Q(registration__meeting=self) & ( + Q(registration__attended=True) | + Q(registration__checkedin=True) ) ) attended_per_meeting_attended = ( @@ -260,11 +260,11 @@ def get_attendance(self): # 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 + attended_per_meeting_registration | 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')) + onsite = set(attended.filter(registration__meeting=self, registration__tickets__attendance_type__slug='onsite')) + remote = set(attended.filter(registration__meeting=self, registration__tickets__attendance_type__slug='remote')) remote.difference_update(onsite) return Attendance( @@ -1487,10 +1487,10 @@ def __str__(self): class RegistrationManager(models.Manager): def onsite(self): - return self.get_queryset().filter(registrationticket__attendance_type__slug='onsite') + return self.get_queryset().filter(tickets__attendance_type__slug='onsite') def remote(self): - return self.get_queryset().filter(registrationticket__attendance_type__slug='remote').exclude(registrationticket__attendance_type__slug='onsite') + return self.get_queryset().filter(tickets__attendance_type__slug='remote').exclude(tickets__attendance_type__slug='onsite') class Registration(models.Model): """Registration attendee records from the IETF registration system""" @@ -1513,6 +1513,14 @@ class Registration(models.Model): def __str__(self): return "{} {}".format(self.first_name, self.last_name) + @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 + class RegistrationTicket(models.Model): registration = ForeignKey(Registration, related_name='tickets') attendance_type = ForeignKey(AttendanceTypeName, on_delete=models.PROTECT) diff --git a/ietf/meeting/tests_models.py b/ietf/meeting/tests_models.py index 03b706e1d7..e333ddad9a 100644 --- a/ietf/meeting/tests_models.py +++ b/ietf/meeting/tests_models.py @@ -11,8 +11,8 @@ 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 RegistrationFactory from ietf.meeting.models import Session -from ietf.stats.factories import MeetingRegistrationFactory from ietf.utils.test_utils import TestCase from ietf.utils.timezone import date_today, datetime_today @@ -21,9 +21,9 @@ class MeetingTests(TestCase): def test_get_attendance_pre110(self): """Pre-110 meetings do not calculate attendance""" meeting = MeetingFactory(type_id='ietf', number='109') - MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='') - MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote') - MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person') + 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): @@ -31,31 +31,31 @@ def test_get_attendance_110(self): meeting = MeetingFactory(type_id='ietf', number='110') # start with attendees that should be ignored - MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='', attended=True) - MeetingRegistrationFactory(meeting=meeting, reg_type='', attended=False) + 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 - MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote', attended=True) - MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False) + 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 - MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='onsite', attended=True) - MeetingRegistrationFactory(meeting=meeting, reg_type='in_person', attended=False) + 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 - meeting.meetingregistration_set.filter(reg_type='remote').delete() + meeting.registration_set.remote().delete() attendance = meeting.get_attendance() self.assertIsNotNone(attendance) self.assertEqual(attendance.remote, 0) @@ -64,11 +64,11 @@ def test_get_attendance_110(self): 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 + 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 = MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False, checkedin=False).person + 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) @@ -82,9 +82,9 @@ def test_get_attendance_keeps_meetings_distinct(self): # 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 + p = RegistrationFactory(meeting=second_mtg, with_ticket={'attendance_type_id': '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) + 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() diff --git a/ietf/meeting/tests_utils.py b/ietf/meeting/tests_utils.py index 4bda3a65fa..3cb16202c8 100644 --- a/ietf/meeting/tests_utils.py +++ b/ietf/meeting/tests_utils.py @@ -1,11 +1,22 @@ # Copyright The IETF Trust 2025, All Rights Reserved # -*- coding: utf-8 -*- +import copy import datetime import debug # pyflakes: ignore -from ietf.meeting.factories import MeetingFactory # RegistrationFactory, RegistrationTicketFactory +import json +import jsonschema +from json import JSONDecodeError +from mock import patch, Mock + +from django.http import HttpResponse, JsonResponse +from ietf.meeting.factories import MeetingFactory, RegistrationFactory, RegistrationTicketFactory from ietf.meeting.models import Registration -from ietf.meeting.utils import migrate_registrations, get_preferred +from ietf.meeting.utils import (migrate_registrations, get_preferred, process_single_registration, + get_registration_data, sync_registration_data) +from ietf.nomcom.models import Volunteer +from ietf.nomcom.factories import NomComFactory, nomcom_kwargs_for_year +from ietf.person.factories import PersonFactory from ietf.stats.factories import MeetingRegistrationFactory from ietf.utils.test_utils import TestCase @@ -72,7 +83,7 @@ def test_additional_ticket(self): migrate_registrations(initial=True) # new.refresh_from_db() self.assertEqual(new.tickets.count(), 2) - + def test_cancelled_registration(self): # setup test initial conditions meeting = MeetingFactory(type_id='ietf', number='109') @@ -82,10 +93,251 @@ def test_cancelled_registration(self): # do test migrate_registrations(initial=True) self.assertEqual(Registration.objects.count(), 0) - + def test_get_preferred(self): meeting = MeetingFactory(type_id='ietf', number='109') onsite = MeetingRegistrationFactory(meeting=meeting, reg_type='onsite', ticket_type='week_pass') + remote = MeetingRegistrationFactory(meeting=meeting, reg_type='remote', ticket_type='week_pass') hackathon = MeetingRegistrationFactory(meeting=meeting, reg_type='hackathon_onsite', ticket_type='week_pass') - result = get_preferred([onsite, hackathon]) + result = get_preferred([remote, onsite, hackathon]) self.assertEqual(result, onsite) + result = get_preferred([hackathon, remote]) + self.assertEqual(result, remote) + result = get_preferred([hackathon]) + self.assertEqual(result, hackathon) + + +class JsonResponseWithJson(JsonResponse): + def json(self): + return json.loads(self.content) + + +class GetRegistrationsTests(TestCase): + + @patch('ietf.meeting.utils.requests.get') + def test_get_registation_data(self, mock_get): + meeting = MeetingFactory(type_id='ietf', number='122') + person = PersonFactory() + reg_details = dict( + first_name=person.first_name(), + last_name=person.last_name(), + email=person.email().address, + affiliation='Microsoft', + country_code='US', + meeting=meeting.number, + checkedin=True, + is_nomcom_volunteer=False, + cancelled=False, + tickets=[{'attendance_type': 'onsite', 'ticket_type': 'week_pass'}], + ) + reg_data = {'objects': {person.email().address: reg_details}} + reg_data_bad = copy.deepcopy(reg_data) + del reg_data_bad['objects'][person.email().address]['email'] + response1 = HttpResponse('Invalid apikey', status=403) + response2 = JsonResponseWithJson(reg_data) + response3 = Mock() + response3.status_code = 200 + response3.json.side_effect = JSONDecodeError("Expecting value", doc="", pos=0) + response4 = JsonResponseWithJson(reg_data_bad) + mock_get.side_effect = [response1, response2, response3, response4] + # test status 403 + with self.assertRaises(Exception): + get_registration_data(meeting) + # test status 200 good + returned_data = get_registration_data(meeting) + self.assertEqual(returned_data, reg_data) + # test decode error + with self.assertRaises(ValueError): + get_registration_data(meeting) + # test validation error + with self.assertRaises(jsonschema.exceptions.ValidationError): + get_registration_data(meeting) + + @patch('ietf.meeting.utils.get_registration_data') + def test_sync_registation_data(self, mock_get): + meeting = MeetingFactory(type_id='ietf', number='122') + person1 = PersonFactory() + person2 = PersonFactory() + items = [] + for person in [person1, person2]: + items.append(dict( + first_name=person.first_name(), + last_name=person.last_name(), + email=person.email().address, + affiliation='Microsoft', + country_code='US', + meeting=meeting.number, + checkedin=True, + is_nomcom_volunteer=False, + cancelled=False, + tickets=[{'attendance_type': 'onsite', 'ticket_type': 'week_pass'}], + )) + reg_data = {'objects': {items[0]['email']: items[0], items[1]['email']: items[1]}} + mock_get.return_value = reg_data + self.assertEqual(Registration.objects.filter(meeting=meeting).count(), 0) + stats = sync_registration_data(meeting) + self.assertEqual(Registration.objects.filter(meeting=meeting).count(), 2) + self.assertEqual(stats['created'], 2) + # test idempotent + stats = sync_registration_data(meeting) + self.assertEqual(Registration.objects.filter(meeting=meeting).count(), 2) + self.assertEqual(stats['created'], 0) + # test delete cancelled registration + del reg_data['objects'][items[1]['email']] + stats = sync_registration_data(meeting) + self.assertEqual(Registration.objects.filter(meeting=meeting).count(), 1) + self.assertEqual(stats['deleted'], 1) + + def test_process_single_registration(self): + # test new registration + meeting = MeetingFactory(type_id='ietf', number='122') + person = PersonFactory() + reg_data = dict( + first_name=person.first_name(), + last_name=person.last_name(), + email=person.email().address, + affiliation='Microsoft', + country_code='US', + meeting=meeting.number, + checkedin=True, + is_nomcom_volunteer=False, + cancelled=False, + tickets=[{'attendance_type': 'onsite', 'ticket_type': 'week_pass'}], + ) + self.assertEqual(meeting.registration_set.count(), 0) + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual(meeting.registration_set.count(), 1) + reg = meeting.registration_set.first() + self.assertEqual(new_reg, reg) + self.assertEqual(action, 'created') + self.assertEqual(reg.first_name, person.first_name()) + self.assertEqual(reg.last_name, person.last_name()) + self.assertEqual(reg.email, person.email().address) + self.assertEqual(reg.affiliation, 'Microsoft') + self.assertEqual(reg.meeting, meeting) + self.assertEqual(reg.checkedin, True) + self.assertEqual(reg.tickets.count(), 1) + ticket = reg.tickets.first() + self.assertEqual(ticket.attendance_type.slug, 'onsite') + self.assertEqual(ticket.ticket_type.slug, 'week_pass') + + # test no change + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual(meeting.registration_set.count(), 1) + reg = meeting.registration_set.first() + self.assertEqual(new_reg, reg) + self.assertEqual(action, None) + + # test update fields + reg_data['affiliation'] = 'Cisco' + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual(meeting.registration_set.count(), 1) + reg = meeting.registration_set.first() + self.assertEqual(new_reg, reg) + self.assertEqual(action, 'updated') + self.assertEqual(reg.affiliation, 'Cisco') + + # test update tickets + reg_data['tickets'] = [{'attendance_type': 'remote', 'ticket_type': 'week_pass'}] + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual(meeting.registration_set.count(), 1) + reg = meeting.registration_set.first() + self.assertEqual(new_reg, reg) + self.assertEqual(action, 'updated') + self.assertEqual(reg.tickets.count(), 1) + ticket = reg.tickets.first() + self.assertEqual(ticket.attendance_type.slug, 'remote') + + # test tickets, two of same + reg_data['tickets'] = [ + {'attendance_type': 'onsite', 'ticket_type': 'one_day'}, + {'attendance_type': 'onsite', 'ticket_type': 'one_day'}, + {'attendance_type': 'remote', 'ticket_type': 'week_pass'}, + ] + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual(meeting.registration_set.count(), 1) + reg = meeting.registration_set.first() + self.assertEqual(new_reg, reg) + self.assertEqual(action, 'updated') + self.assertEqual(reg.tickets.count(), 3) + self.assertEqual(reg.tickets.filter(attendance_type__slug='onsite', ticket_type__slug='one_day').count(), 2) + self.assertEqual(reg.tickets.filter(attendance_type__slug='remote', ticket_type__slug='week_pass').count(), 1) + + # test tickets, two of same, delete one + reg_data['tickets'] = [ + {'attendance_type': 'onsite', 'ticket_type': 'one_day'}, + {'attendance_type': 'remote', 'ticket_type': 'week_pass'}, + ] + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual(meeting.registration_set.count(), 1) + reg = meeting.registration_set.first() + self.assertEqual(new_reg, reg) + self.assertEqual(action, 'updated') + self.assertEqual(reg.tickets.count(), 2) + self.assertEqual(reg.tickets.filter(attendance_type__slug='onsite', ticket_type__slug='one_day').count(), 1) + self.assertEqual(reg.tickets.filter(attendance_type__slug='remote', ticket_type__slug='week_pass').count(), 1) + + def test_process_single_registration_nomcom(self): + '''Test that Volunteer is created if is_nomcom_volunteer=True''' + meeting = MeetingFactory(type_id='ietf', number='122') + person = PersonFactory() + reg_data = dict( + first_name=person.first_name(), + last_name=person.last_name(), + email=person.email().address, + affiliation='Microsoft', + country_code='US', + meeting=meeting.number, + checkedin=True, + is_nomcom_volunteer=True, + cancelled=False, + tickets=[{'attendance_type': 'onsite', 'ticket_type': 'week_pass'}], + ) + now = datetime.datetime.now() + if now.month > 10: + year = now.year + 1 + else: + year = now.year + # create appropriate group and nomcom objects + nomcom = NomComFactory.create(is_accepting_volunteers=True, **nomcom_kwargs_for_year(year)) + # assert no Volunteers exists + self.assertEqual(Volunteer.objects.count(), 0) + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual(action, 'created') + # assert Volunteer exists + self.assertEqual(Volunteer.objects.count(), 1) + volunteer = Volunteer.objects.last() + self.assertEqual(volunteer.person, person) + self.assertEqual(volunteer.nomcom, nomcom) + self.assertEqual(volunteer.origin, 'registration') + + def test_process_single_registration_cancelled(self): + # test cancelled registration, one of two tickets + meeting = MeetingFactory(type_id='ietf', number='122') + person = PersonFactory() + reg = RegistrationFactory(meeting=meeting, person=person, checkedin=False, with_ticket={'attendance_type_id': 'onsite'}) + RegistrationTicketFactory(registration=reg, attendance_type_id='remote', ticket_type_id='week_pass') + reg_data = dict( + first_name=person.first_name(), + last_name=person.last_name(), + email=person.email().address, + affiliation='Microsoft', + country_code='US', + meeting=meeting.number, + checkedin=False, + is_nomcom_volunteer=False, + cancelled=True, + tickets=[{'attendance_type': 'onsite', 'ticket_type': 'week_pass'}], + ) + self.assertEqual(meeting.registration_set.count(), 1) + self.assertEqual(reg.tickets.count(), 2) + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual((new_reg, action), (None, 'deleted')) + self.assertEqual(meeting.registration_set.count(), 1) + self.assertEqual(reg.tickets.count(), 1) + self.assertTrue(reg.tickets.filter(attendance_type__slug='remote').exists()) + # test cancelled registration, last ticket + reg_data['tickets'][0]['attendance_type'] = 'remote' + new_reg, action = process_single_registration(reg_data, meeting) + self.assertEqual((new_reg, action), (None, 'deleted')) + self.assertEqual(meeting.registration_set.count(), 0) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 889a1bc882..1aac2a6523 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -66,8 +66,7 @@ SessionPresentationFactory, MeetingFactory, FloorPlanFactory, TimeSlotFactory, SlideSubmissionFactory, RoomFactory, ConstraintFactory, MeetingHostFactory, ProceedingsMaterialFactory, - AttendedFactory) -from ietf.stats.factories import MeetingRegistrationFactory + AttendedFactory, RegistrationFactory) from ietf.doc.factories import DocumentFactory, WgDraftFactory from ietf.submit.tests import submission_file from ietf.utils.test_utils import assert_ical_response_is_valid @@ -8852,25 +8851,24 @@ def test_proceedings_attendees(self): - prefer onsite checkedin=True to remote attended when same person has both """ - meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number="118") + m = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number="118") person_a = PersonFactory(name='Person A') person_b = PersonFactory(name='Person B') person_c = PersonFactory(name='Person C') person_d = PersonFactory(name='Person D') - MeetingRegistrationFactory(meeting=meeting, person=person_a, reg_type='onsite', checkedin=True) - MeetingRegistrationFactory(meeting=meeting, person=person_b, reg_type='onsite', checkedin=False) - MeetingRegistrationFactory(meeting=meeting, person=person_a, reg_type='remote') - AttendedFactory(session__meeting=meeting, session__type_id='plenary', person=person_a) - MeetingRegistrationFactory(meeting=meeting, person=person_c, reg_type='remote') - AttendedFactory(session__meeting=meeting, session__type_id='plenary', person=person_c) - MeetingRegistrationFactory(meeting=meeting, person=person_d, reg_type='remote') + areg = RegistrationFactory(meeting=m, person=person_a, checkedin=True, with_ticket={'attendance_type_id': 'onsite'}) + RegistrationFactory(meeting=m, person=person_b, checkedin=False, with_ticket={'attendance_type_id': 'onsite'}) + creg = RegistrationFactory(meeting=m, person=person_c, with_ticket={'attendance_type_id': 'remote'}) + RegistrationFactory(meeting=m, person=person_d, with_ticket={'attendance_type_id': 'remote'}) + AttendedFactory(session__meeting=m, session__type_id='plenary', person=person_a) + AttendedFactory(session__meeting=m, session__type_id='plenary', person=person_c) url = urlreverse('ietf.meeting.views.proceedings_attendees',kwargs={'num': 118}) response = self.client.get(url) self.assertContains(response, 'Attendee list') q = PyQuery(response.content) self.assertEqual(2, len(q("#id_attendees tbody tr"))) text = q('#id_attendees tbody tr').text().replace('\n', ' ') - self.assertEqual(text, "A Person onsite C Person remote") + self.assertEqual(text, f"A Person {areg.affiliation} {areg.country_code} onsite C Person {creg.affiliation} {creg.country_code} remote") def test_proceedings_overview(self): '''Test proceedings IETF Overview page. @@ -9271,27 +9269,25 @@ def test_get_next_sequence(self): self.assertEqual(sequence,1) def test_participants_for_meeting(self): - person_a = PersonFactory() - person_b = PersonFactory() - person_c = PersonFactory() - person_d = PersonFactory() m = MeetingFactory.create(type_id='ietf') - MeetingRegistrationFactory(meeting=m, person=person_a, reg_type='onsite', checkedin=True) - MeetingRegistrationFactory(meeting=m, person=person_b, reg_type='onsite', checkedin=False) - MeetingRegistrationFactory(meeting=m, person=person_c, reg_type='remote') - MeetingRegistrationFactory(meeting=m, person=person_d, reg_type='remote') - AttendedFactory(session__meeting=m, session__type_id='plenary', person=person_c) + 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.assertTrue(person_a.pk in checked_in) - self.assertTrue(person_b.pk not in checked_in) - self.assertTrue(person_c.pk in attended) - self.assertTrue(person_d.pk not in attended) + 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) def test_session_attendance(self): meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number='118') make_meeting_test_data(meeting=meeting) session = Session.objects.filter(meeting=meeting, group__acronym='mars').first() - regs = MeetingRegistrationFactory.create_batch(3, meeting=meeting) + regs = RegistrationFactory.create_batch(3, meeting=meeting) persons = [reg.person for reg in regs] self.assertEqual(session.attended_set.count(), 0) @@ -9337,7 +9333,7 @@ def _test_button(person, expected): # person0 is already on the bluesheet _test_button(persons[0], False) # person3 attests he was there - persons.append(MeetingRegistrationFactory(meeting=meeting).person) + persons.append(RegistrationFactory(meeting=meeting).person) # button isn't shown if we're outside the corrections windows meeting.importantdate_set.create(name_id='revsub',date=date_today() - datetime.timedelta(days=20)) _test_button(persons[3], False) @@ -9395,12 +9391,12 @@ def _test_button(person, expected): def test_bluesheet_data(self): session = SessionFactory(meeting__type_id="ietf") - attended_with_affil = MeetingRegistrationFactory(meeting=session.meeting, affiliation="Somewhere") + attended_with_affil = RegistrationFactory(meeting=session.meeting, affiliation="Somewhere") AttendedFactory(session=session, person=attended_with_affil.person, time="2023-03-13T01:24:00Z") # joined 2nd - attended_no_affil = MeetingRegistrationFactory(meeting=session.meeting) + attended_no_affil = RegistrationFactory(meeting=session.meeting, affiliation="") AttendedFactory(session=session, person=attended_no_affil.person, time="2023-03-13T01:23:00Z") # joined 1st - MeetingRegistrationFactory(meeting=session.meeting) # did not attend - + RegistrationFactory(meeting=session.meeting) # did not attend + data = bluesheet_data(session) self.assertEqual( data, diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index af12b6a274..5e497f35f2 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -2,7 +2,9 @@ # -*- coding: utf-8 -*- import datetime import itertools +import jsonschema import os +import requests from hashlib import sha384 import pytz @@ -15,6 +17,7 @@ from django.contrib import messages 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.functions import Coalesce from django.template.loader import render_to_string @@ -27,7 +30,7 @@ from ietf.doc.storage_utils import store_bytes, store_str from ietf.meeting.models import (Session, SchedulingEvent, TimeSlot, Constraint, SchedTimeSessAssignment, SessionPresentation, Attended, - Registration, Meeting) + Registration, Meeting, RegistrationTicket) from ietf.doc.models import Document, State, NewRevisionDocEvent, StateDocEvent from ietf.doc.models import DocEvent from ietf.group.models import Group @@ -160,7 +163,7 @@ def bluesheet_data(session): .annotate( affiliation=Coalesce( Subquery( - MeetingRegistration.objects.filter( + Registration.objects.filter( Q(meeting=session.meeting), Q(person=OuterRef("person")) | Q(email=OuterRef("person__email")), ).values("affiliation")[:1] @@ -1008,20 +1011,25 @@ def participants_for_meeting(meeting): 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.meetingregistration_set.filter(reg_type='onsite', checkedin=True).values_list('person', flat=True).distinct() + 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 get_preferred(regs): - """ Return a preferred regular registration (non hackathon) from - a list of registrations if there is one, otherwise any. + """ If there are multiple registrations return preferred in + this order: onsite, remote, any (ie hackathon_onsite) """ - for reg in regs: - if reg.reg_type in ['onsite', 'remote']: - return reg - return reg + if len(regs) == 1: + return regs[0] + reg_types = [r.reg_type for r in regs] + if 'onsite' in reg_types: + return regs[reg_types.index('onsite')] + elif 'remote' in reg_types: + return regs[reg_types.index('remote')] + else: + return regs[0] def migrate_registrations(initial=False): @@ -1287,3 +1295,285 @@ def _format_materials(items): else: not_meeting_groups.append(entry) return meeting_groups, not_meeting_groups + + +import_registration_json_validator = jsonschema.Draft202012Validator( + schema={ + "type": "object", + "properties": { + "objects": { + "type": "object", + "patternProperties": { + # Email address as key (simplified pattern or just allow any key) + ".*": { + "type": "object", + "properties": { + "first_name": {"type": "string"}, + "last_name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "affiliation": {"type": "string"}, + "country_code": {"type": "string", "minLength": 2, "maxLength": 2}, + "meeting": {"type": "string"}, + "checkedin": {"type": "boolean"}, + "cancelled": {"type": "boolean"}, + "is_nomcom_volunteer": {"type": "boolean"}, + "tickets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "attendance_type": {"type": "string"}, + "ticket_type": {"type": "string"} + }, + "required": ["attendance_type", "ticket_type"] + } + } + }, + "required": [ + "first_name", "last_name", "email", + "country_code", "meeting", 'affiliation', + "checkedin", "is_nomcom_volunteer", "tickets", + "cancelled", + ] + } + }, + "additionalProperties": False + } + }, + "required": ["objects"] + } +) + + +def get_registration_data(meeting): + '''Retrieve data from registation system for meeting''' + url = settings.REGISTRATION_PARTICIPANTS_API_URL + key = settings.REGISTRATION_PARTICIPANTS_API_KEY + params = {'meeting': meeting.number, 'apikey': key} + try: + response = requests.get(url, params=params, timeout=settings.DEFAULT_REQUESTS_TIMEOUT) + except requests.Timeout as e: + log(f'GET request timed out for [{url}]: {e}') + raise Exception("Timeout retrieving data from registration API") from e + if response.status_code == 200: + try: + decoded = response.json() + except ValueError as e: + raise ValueError(f'Could not decode response from registration API: {e}') + else: + raise Exception(f'Bad response from registration API: {response.status_code}, {response.content[:64]}') + + # validate registration data + import_registration_json_validator.validate(decoded) + return decoded + + +def sync_registration_data(meeting): + """"Sync meeting.Registration with registration system. + + Registration records are created in realtime as people register for a + meeting. This function serves as an audit / reconciliation. Most records are + expected to already exist. The function has been optimized with this in mind. + + - Creates new registrations if they don't exist + - Updates existing registrations if fields differ + - Updates tickets as needed + - Deletes registrations that exist in the database but not in the JSON data + + Returns: + dict: Summary of changes made (created, updated, deleted counts) + """ + reg_data = get_registration_data(meeting) + + # Get the meeting ID from the first registration, the API only deals with one meeting at a time + first_email = next(iter(reg_data['objects'])) + meeting_number = reg_data['objects'][first_email]['meeting'] + try: + Meeting.objects.get(number=meeting_number) + except Meeting.DoesNotExist: + raise Exception(f'meeting does not exist {meeting_number}') + + # Get all existing registrations for this meeting + existing_registrations = meeting.registration_set.all() + existing_emails = set(reg.email for reg in existing_registrations if reg.email) + + # Track changes for reporting + stats = { + 'created': 0, + 'updated': 0, + 'deleted': 0, + } + + # Process registrations from reg_data + reg_emails = set() + for email, data in reg_data['objects'].items(): + reg_emails.add(email) + + # Process this registration + _, action_taken = process_single_registration(data, meeting) + + # Update stats + if action_taken == 'created': + stats['created'] += 1 + elif action_taken == 'updated': + stats['updated'] += 1 + + # Delete registrations that exist in the DB but not in registration data, they've been cancelled + emails_to_delete = existing_emails - reg_emails + if emails_to_delete: + result = Registration.objects.filter( + email__in=emails_to_delete, + meeting=meeting + ).delete() + if 'meeting.Registration' in result[1]: + deleted_count = result[1]['meeting.Registration'] + else: + deleted_count = 0 + stats['deleted'] = deleted_count + + # set meeting.attendees + count = Registration.objects.onsite().filter(meeting=meeting, checkedin=True).count() + if meeting.attendees != count: + meeting.attendees = count + meeting.save() + + return stats + + +def process_single_registration(reg_data, meeting): + """ + Process a single registration record - create, update, or leave unchanged as needed. + + Args: + reg_data (dict): Registration data + meeting (obj): The IETF meeting + + Returns: + tuple: (registration, action_taken) + - registration: Registration object + - action_taken: String indicating 'created', 'updated', or None + """ + # import here to avoid circular imports + from ietf.nomcom.models import Volunteer, NomCom + + action_taken = None + fields_updated = False + tickets_modified = False + + # handle deleted + # should not see cancelled records during nightly sync but can see + # them from realtime notifications + if reg_data['cancelled']: + try: + registration = Registration.objects.get(meeting=meeting, email=reg_data['email']) + except Registration.DoesNotExist: + return (None, None) + for ticket in reg_data['tickets']: + target = registration.tickets.filter( + attendance_type__slug=ticket['attendance_type'], + ticket_type__slug=ticket['ticket_type']).first() + target.delete() + if registration.tickets.count() == 0: + registration.delete() + return (None, 'deleted') + + person = Person.objects.filter(email__address=reg_data['email']).first() + if not person: + log.log(f"ERROR: meeting registration email unknown {reg_data['email']}") + + registration, created = Registration.objects.get_or_create( + email=reg_data['email'], + meeting=meeting, + defaults={ + 'first_name': reg_data['first_name'], + 'last_name': reg_data['last_name'], + 'person': person, + 'affiliation': reg_data['affiliation'], + 'country_code': reg_data['country_code'], + 'checkedin': reg_data['checkedin'], + } + ) + + # If not created, check if we need to update + if not created: + for field in ['first_name', 'last_name', 'affiliation', 'country_code', 'checkedin']: + if getattr(registration, field) != reg_data[field]: + setattr(registration, field, reg_data[field]) + fields_updated = True + + if fields_updated: + registration.save() + + # Process tickets - handle counting properly for multiple same-type tickets + # Build count dictionaries for existing and new tickets + existing_ticket_counts = {} + for ticket in registration.tickets.all(): + key = (ticket.attendance_type.slug, ticket.ticket_type.slug) + existing_ticket_counts[key] = existing_ticket_counts.get(key, 0) + 1 + + # Get new tickets from reg_data and count them + reg_data_ticket_counts = {} + for ticket_data in reg_data.get('tickets', []): + key = (ticket_data['attendance_type'], ticket_data['ticket_type']) + reg_data_ticket_counts[key] = reg_data_ticket_counts.get(key, 0) + 1 + + # Calculate tickets to add and remove + all_ticket_types = set(existing_ticket_counts.keys()) | set(reg_data_ticket_counts.keys()) + + for ticket_type in all_ticket_types: + existing_count = existing_ticket_counts.get(ticket_type, 0) + new_count = reg_data_ticket_counts.get(ticket_type, 0) + + # Delete excess tickets + if existing_count > new_count: + tickets_to_delete = existing_count - new_count + # Get all tickets of this type + matching_tickets = registration.tickets.filter( + attendance_type__slug=ticket_type[0], + ticket_type__slug=ticket_type[1] + ).order_by('id') # Use a consistent order for deterministic deletion + + # Delete the required number + for ticket in matching_tickets[:tickets_to_delete]: + ticket.delete() + tickets_modified = True + + # Add missing tickets + elif new_count > existing_count: + tickets_to_add = new_count - existing_count + + # Create the new tickets + for _ in range(tickets_to_add): + try: + RegistrationTicket.objects.create( + registration=registration, + attendance_type_id=ticket_type[0], + ticket_type_id=ticket_type[1], + ) + tickets_modified = True + except IntegrityError as e: + log(f"Error adding RegistrationTicket {e}") + + # handle nomcom volunteer + if reg_data['is_nomcom_volunteer'] and person: + try: + nomcom = NomCom.objects.get(is_accepting_volunteers=True) + except (NomCom.DoesNotExist, NomCom.MultipleObjectsReturned): + nomcom = None + if nomcom: + Volunteer.objects.get_or_create( + nomcom=nomcom, + person=person, + defaults={ + "affiliation": reg_data["affiliation"], + "origin": "registration" + } + ) + + # set action_taken + if created: + action_taken = 'created' + elif fields_updated or tickets_modified: + action_taken = 'updated' + + return registration, action_taken diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 591b934b58..8bd70a3733 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -61,6 +61,7 @@ from ietf.mailtrigger.utils import gather_address_lists from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission, Attended 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 @@ -98,7 +99,6 @@ from ietf.meeting.utils import participants_for_meeting, generate_bluesheet, bluesheet_data, save_bluesheet from ietf.message.utils import infer_message from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName -from ietf.stats.models import MeetingRegistration from ietf.utils import markdown from ietf.utils.decorators import require_api_key from ietf.utils.hedgedoc import Note, NoteError @@ -2710,7 +2710,7 @@ def session_attendance(request, session_id, num): was_there = Attended.objects.filter(session=session, person=person).exists() can_add = ( today_utc <= cor_cut_off_date - and MeetingRegistration.objects.filter( + and Registration.objects.filter( meeting=session.meeting, person=person ).exists() and not was_there @@ -4210,17 +4210,17 @@ def proceedings_attendees(request, num=None): return HttpResponseRedirect(f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}/attendee.html') template = None - meeting_registrations = None + registrations = None if int(meeting.number) >= 118: checked_in, attended = participants_for_meeting(meeting) - regs = list(MeetingRegistration.objects.filter(meeting__number=num, reg_type='onsite', checkedin=True)) + regs = list(Registration.objects.onsite().filter(meeting__number=num, checkedin=True)) - for mr in MeetingRegistration.objects.filter(meeting__number=num, reg_type='remote').select_related('person'): - if mr.person.pk in attended and mr.person.pk not in checked_in: - regs.append(mr) + 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: + regs.append(reg) - meeting_registrations = sorted(regs, key=lambda x: (x.last_name, x.first_name)) + registrations = sorted(regs, key=lambda x: (x.last_name, x.first_name)) else: overview_template = "/meeting/proceedings/%s/attendees.html" % meeting.number try: @@ -4230,7 +4230,7 @@ def proceedings_attendees(request, num=None): return render(request, "meeting/proceedings_attendees.html", { 'meeting': meeting, - 'meeting_registrations': meeting_registrations, + 'registrations': registrations, 'template': template, }) diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index 8f94cc7fc5..ea17da6707 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -31,7 +31,8 @@ NewRevisionDocEventFactory, DocumentAuthorFactory from ietf.group.factories import GroupFactory, GroupHistoryFactory, RoleFactory, RoleHistoryFactory from ietf.group.models import Group, Role -from ietf.meeting.factories import MeetingFactory, AttendedFactory +from ietf.meeting.factories import MeetingFactory, AttendedFactory, RegistrationFactory +from ietf.meeting.models import Registration from ietf.message.models import Message from ietf.nomcom.test_data import nomcom_test_data, generate_cert, check_comments, \ COMMUNITY_USER, CHAIR_USER, \ @@ -50,8 +51,6 @@ decorate_volunteers_with_qualifications, send_reminders, _is_time_to_send_reminder from ietf.person.factories import PersonFactory, EmailFactory from ietf.person.models import Email, Person -from ietf.stats.models import MeetingRegistration -from ietf.stats.factories import MeetingRegistrationFactory from ietf.utils.mail import outbox, empty_outbox, get_payload_text from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent from ietf.utils.timezone import date_today, datetime_today, datetime_from_date, DEADLINE_TZINFO @@ -2061,7 +2060,15 @@ def first_meeting_of_year(year): if not ' ' in ascii: continue first_name, last_name = ascii.rsplit(None, 1) - MeetingRegistration.objects.create(meeting=meeting, first_name=first_name, last_name=last_name, person=person, country_code='WO', email=email, attended=True) + RegistrationFactory( + meeting=meeting, + first_name=first_name, + last_name=last_name, + person=person, + country_code='WO', + email=email, + attended=True + ) for view in ('public_eligible','private_eligible'): url = reverse(f'ietf.nomcom.views.{view}',kwargs={'year':self.nc.year()}) for username in (self.chair.user.username,'secretary'): @@ -2084,7 +2091,7 @@ def first_meeting_of_year(year): for number in range(meeting_start, meeting_start+8): m = MeetingFactory.create(type_id='ietf', number=number) for p in people: - m.meetingregistration_set.create(person=p, reg_type="onsite", checkedin=True, attended=True) + RegistrationFactory(meeting=m, person=p, checkedin=True, attended=True) for p in people: self.nc.volunteer_set.create(person=p,affiliation='something') for view in ('public_volunteers','private_volunteers'): @@ -2110,10 +2117,6 @@ def first_meeting_of_year(year): self.assertContains(response, people[-1].plain_name(), status_code=200) self.assertNotContains(response, unqualified_person.plain_name()) - - - - class NomComIndexTests(TestCase): def setUp(self): super().setUp() @@ -2460,7 +2463,7 @@ def setUp(self): for combo in combinations(meetings,combo_len): p = PersonFactory() for m in combo: - MeetingRegistrationFactory(person=p, meeting=m, attended=True) + RegistrationFactory(person=p, meeting=m, attended=True) if combo_len<3: self.ineligible_people.append(p) else: @@ -2470,7 +2473,7 @@ def setUp(self): def ineligible_person_with_role(**kwargs): p = RoleFactory(**kwargs).person for m in meetings: - MeetingRegistrationFactory(person=p, meeting=m, attended=True) + RegistrationFactory(person=p, meeting=m, attended=True) self.ineligible_people.append(p) for group in ['isocbot', 'ietf-trust', 'llc-board', 'iab']: for role in ['member', 'chair']: @@ -2485,8 +2488,7 @@ def ineligible_person_with_role(**kwargs): 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', attended=True) - + RegistrationFactory(person=self.other_people[0], meeting__date=date, meeting__type_id='ietf', attended=True) def test_is_person_eligible(self): for person in self.eligible_people: @@ -2530,7 +2532,7 @@ def setUp(self): for combo in combinations(meetings,combo_len): p = PersonFactory() for m in combo: - MeetingRegistrationFactory(person=p, meeting=m, attended=True) + RegistrationFactory(person=p, meeting=m, attended=True) if combo_len<3: self.ineligible_people.append(p) else: @@ -2578,7 +2580,7 @@ def test_elig_by_meetings(self): for combo in combinations(prev_five,combo_len): p = PersonFactory() for m in combo: - MeetingRegistrationFactory(person=p, meeting=m, attended=True) # not checkedin because this forces looking at older meetings + RegistrationFactory(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) @@ -2593,8 +2595,9 @@ def test_elig_by_meetings(self): for person in ineligible_people: self.assertFalse(is_eligible(person,nomcom)) - Person.objects.filter(pk__in=[p.pk for p in eligible_people+ineligible_people]).delete() - + people = Person.objects.filter(pk__in=[p.pk for p in eligible_people + ineligible_people]) + Registration.objects.filter(person__in=people).delete() + people.delete() def test_elig_by_office_active_groups(self): @@ -2778,7 +2781,7 @@ def setUp(self): def test_registration_is_not_enough(self): p = PersonFactory() for meeting in self.meetings: - MeetingRegistrationFactory(person=p, meeting=meeting, checkedin=False) + RegistrationFactory(person=p, meeting=meeting, checkedin=False) self.assertFalse(is_eligible(p, self.nomcom)) def test_elig_by_meetings(self): @@ -2795,7 +2798,7 @@ def test_elig_by_meetings(self): for method in attendance_methods: p = PersonFactory() for meeting in combo: - MeetingRegistrationFactory(person=p, meeting=meeting, reg_type='onsite', checkedin=(method in ('checkedin', 'both'))) + RegistrationFactory(person=p, meeting=meeting, checkedin=(method in ('checkedin', 'both'))) if method in ('session', 'both'): AttendedFactory(session__meeting=meeting, session__type_id='plenary',person=p) if combo_len<3: @@ -2828,7 +2831,7 @@ def test_volunteer(self): 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', checkedin=True) + RegistrationFactory(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') @@ -2882,7 +2885,7 @@ def test_suggest_affiliation(self): nc = NomComFactory() nc.volunteer_set.create(person=person,affiliation='volunteer_affil') self.assertEqual(suggest_affiliation(person), 'volunteer_affil') - MeetingRegistrationFactory(person=person, affiliation='meeting_affil') + RegistrationFactory(person=person, affiliation='meeting_affil') self.assertEqual(suggest_affiliation(person), 'meeting_affil') class VolunteerDecoratorUnitTests(TestCase): @@ -2900,7 +2903,7 @@ def test_decorate_volunteers_with_qualifications(self): ('106', datetime.date(2019, 11, 16)), ]] for m in meetings: - MeetingRegistrationFactory(meeting=m, person=meeting_person, attended=True) + RegistrationFactory(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/nomcom/utils.py b/ietf/nomcom/utils.py index ab155ef1d5..10494d323f 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -666,14 +666,14 @@ def previous_five_meetings(date = None): return Meeting.objects.filter(type='ietf',date__lte=date).order_by('-date')[:5] def three_of_five_eligible_8713(previous_five, queryset=None): - """ Return a list of Person records who attended at least + """ Return a list of Person records who attended at least 3 of the 5 type_id='ietf' meetings before the given date. Does not disqualify anyone based on held roles. This variant bases the calculation on MeetingRegistration.attended """ if queryset is None: queryset = Person.objects.all() - return queryset.filter(meetingregistration__meeting__in=list(previous_five),meetingregistration__attended=True).annotate(mtg_count=Count('meetingregistration')).filter(mtg_count__gte=3) + return queryset.filter(registration__meeting__in=list(previous_five), registration__attended=True).annotate(mtg_count=Count('registration')).filter(mtg_count__gte=3) def three_of_five_eligible_9389(previous_five, queryset=None): """ Return a list of Person records who attended at least @@ -692,7 +692,7 @@ def three_of_five_eligible_9389(previous_five, queryset=None): return queryset.filter(pk__in=[id for id, count in counts.items() if count >= 3]) def suggest_affiliation(person): - recent_meeting = person.meetingregistration_set.order_by('-meeting__date').first() + recent_meeting = person.registration_set.order_by('-meeting__date').first() affiliation = recent_meeting.affiliation if recent_meeting else '' if not affiliation: recent_volunteer = person.volunteer_set.order_by('-nomcom__group__acronym').first() diff --git a/ietf/settings.py b/ietf/settings.py index c21120f77a..3f66e6abfe 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -1114,6 +1114,9 @@ def skip_unreadable_post(record): ] STATS_REGISTRATION_ATTENDEES_JSON_URL = 'https://registration.ietf.org/{number}/attendees/' +REGISTRATION_PARTICIPANTS_API_URL = 'https://registration.ietf.org/api/v1/participants-dt/' +REGISTRATION_PARTICIPANTS_API_KEY = 'changeme' + PROCEEDINGS_VERSION_CHANGES = [ 0, # version 1 97, # version 2: meeting 97 and later (was number was NEW_PROCEEDINGS_START) diff --git a/ietf/stats/management/commands/find_meetingregistration_person_issues.py b/ietf/stats/management/commands/find_meetingregistration_person_issues.py deleted file mode 100644 index 4eaf6ac238..0000000000 --- a/ietf/stats/management/commands/find_meetingregistration_person_issues.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright The IETF Trust 2021, All Rights Reserved - -import debug # pyflakes:ignore - -from django.core.management.base import BaseCommand - -from ietf.stats.utils import find_meetingregistration_person_issues - -class Command(BaseCommand): - help = "Find possible Person/Email objects to repair based on MeetingRegistration objects" - - def add_arguments(self, parser): - parser.add_argument('--meeting',action='append') - - def handle(self, *args, **options): - meetings = options['meeting'] or None - summary = find_meetingregistration_person_issues(meetings) - - print(f'{summary.ok_records} records are OK') - - for msg in summary.could_be_fixed: - print(msg) - - for msg in summary.maybe_address: - print(msg) - - for msg in summary.different_person: - print(msg) - - for msg in summary.no_person: - print(msg) - - for msg in summary.maybe_person: - print(msg) - - for msg in summary.no_email: - print(msg) diff --git a/ietf/stats/management/commands/repair_meetingregistration_person.py b/ietf/stats/management/commands/repair_meetingregistration_person.py deleted file mode 100644 index 74b1a807a6..0000000000 --- a/ietf/stats/management/commands/repair_meetingregistration_person.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright The IETF Trust 2021, All Rights Reserved - -import debug # pyflakes:ignore - -from django.core.management.base import BaseCommand - -from ietf.stats.utils import repair_meetingregistration_person - -class Command(BaseCommand): - help = "Repair MeetingRegistration objects that have no person but an email matching a person" - - def add_arguments(self, parser): - parser.add_argument('--meeting',action='append') - - def handle(self, *args, **options): - meetings = options['meeting'] or None - repaired = repair_meetingregistration_person(meetings) - print(f'Repaired {repaired} MeetingRegistration objects') \ No newline at end of file diff --git a/ietf/stats/utils.py b/ietf/stats/utils.py index 2e418eb0eb..f2e1d9801d 100644 --- a/ietf/stats/utils.py +++ b/ietf/stats/utils.py @@ -13,7 +13,7 @@ from ietf.stats.models import AffiliationAlias, AffiliationIgnoredEnding, CountryAlias, MeetingRegistration from ietf.name.models import CountryName -from ietf.person.models import Person, Email +from ietf.person.models import Email from ietf.utils.log import log import logging @@ -226,8 +226,8 @@ def compute_hirsch_index(citation_counts): def get_meeting_registration_data(meeting): """"Retrieve registration attendee data and summary statistics. Returns number of Registration records created. - - MeetingRegistration records are created in realtime as people register for a + + MeetingRegistration records are created in realtime as people register for a meeting. This function serves as an audit / reconciliation. Most records are expected to already exist. The function has been optimized with this in mind. """ @@ -329,60 +329,6 @@ def get_meeting_registration_data(meeting): meeting.save() return num_created, num_processed, num_total -def repair_meetingregistration_person(meetings=None): - repaired_records = 0 - qs = MeetingRegistration.objects.all() - if meetings: - qs = qs.filter(meeting__number__in=meetings) - for mr in qs: - if mr.email and not mr.person: - email_person = Person.objects.filter(email__address=mr.email).first() - if email_person: - mr.person = email_person - mr.save() - repaired_records += 1 - return repaired_records - -class MeetingRegistrationIssuesSummary(object): - pass - -def find_meetingregistration_person_issues(meetings=None): - summary = MeetingRegistrationIssuesSummary() - - summary.could_be_fixed = set() - summary.maybe_address = set() - summary.different_person = set() - summary.no_person = set() - summary.maybe_person = set() - summary.no_email = set() - summary.ok_records = 0 - - qs = MeetingRegistration.objects.all() - if meetings: - qs = qs.filter(meeting__number__in=meetings) - for mr in qs: - if mr.person and mr.email and mr.email in mr.person.email_set.values_list('address',flat=True): - summary.ok_records += 1 - elif mr.email: - email_person = Person.objects.filter(email__address=mr.email).first() - if mr.person: - if not email_person: - summary.maybe_address.add(f'{mr.email} is not present in any Email object. The MeetingRegistration object implies this is an address for {mr.person} ({mr.person.pk})') - elif email_person != mr.person: - summary.different_person.add(f'{mr} ({mr.pk}) has person {mr.person} ({mr.person.pk}) but an email {mr.email} attached to a different person {email_person} ({email_person.pk}).') - elif email_person: - summary.could_be_fixed.add(f'{mr} ({mr.pk}) has no person, but email {mr.email} matches {email_person} ({email_person.pk})') - else: - maybe_person_qs = Person.objects.filter(name__icontains=mr.last_name).filter(name__icontains=mr.first_name) - if maybe_person_qs.exists(): - summary.maybe_person.add(f'{mr} ({mr.pk}) has email address {mr.email} which cannot be associated with any Person. Consider these possible people {[(p,p.pk) for p in maybe_person_qs]}') - else: - summary.no_person.add(f'{mr} ({mr.pk}) has email address {mr.email} which cannot be associated with any Person') - else: - summary.no_email.add(f'{mr} ({mr.pk}) provides no email address') - - return summary - FetchStats = namedtuple("FetchStats", "added processed total") diff --git a/ietf/templates/meeting/proceedings_attendees.html b/ietf/templates/meeting/proceedings_attendees.html index 6c51a4546e..390ce00cad 100644 --- a/ietf/templates/meeting/proceedings_attendees.html +++ b/ietf/templates/meeting/proceedings_attendees.html @@ -29,13 +29,13 @@