Skip to content

Commit 0348dbd

Browse files
authored
feat: new meeting registration implementation (#8408)
* feat: new meeting registration implementation * fix: use on_delete=models.PROTECT for certain FKs * fix: convert outlier reg_types,ticket_types on initial migration * fix: fix initial migration mistake * fix: remove management command. add task * fix: move migration 0010 to 0011 to resolve conflict * fix: add missing migration for model changes * fix: add reg type names for unknown * fix: change migration to use 'unknown'. Add test function * fix: merge migrations * fix: rename test_migrate_registrations to check_migrate_registrations * fix: update names.json * fix: fix migration issue and add task * fix: fix broken migration dependency
1 parent a728cf2 commit 0348dbd

17 files changed

+1045
-10
lines changed

ietf/api/tests.py

+192-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright The IETF Trust 2015-2024, All Rights Reserved
22
# -*- coding: utf-8 -*-
33
import base64
4+
import copy
45
import datetime
56
import json
67
import html
@@ -31,7 +32,7 @@
3132
from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory
3233
from ietf.group.factories import RoleFactory
3334
from ietf.meeting.factories import MeetingFactory, SessionFactory
34-
from ietf.meeting.models import Session
35+
from ietf.meeting.models import Session, Registration
3536
from ietf.nomcom.models import Volunteer
3637
from ietf.nomcom.factories import NomComFactory, nomcom_kwargs_for_year
3738
from ietf.person.factories import PersonFactory, random_faker, EmailFactory, PersonalApiKeyFactory
@@ -828,6 +829,196 @@ def test_api_new_meeting_registration_nomcom_volunteer(self):
828829
self.assertEqual(volunteer.nomcom, nomcom)
829830
self.assertEqual(volunteer.origin, 'registration')
830831

832+
@override_settings(APP_API_TOKENS={"ietf.api.views.api_new_meeting_registration_v2": ["valid-token"]})
833+
def test_api_new_meeting_registration_v2(self):
834+
meeting = MeetingFactory(type_id='ietf')
835+
person = PersonFactory()
836+
regs = [
837+
{
838+
'affiliation': "Alguma Corporação",
839+
'country_code': 'PT',
840+
'email': person.email().address,
841+
'first_name': person.first_name(),
842+
'last_name': person.last_name(),
843+
'meeting': str(meeting.number),
844+
'reg_type': 'onsite',
845+
'ticket_type': 'week_pass',
846+
'checkedin': False,
847+
'is_nomcom_volunteer': False,
848+
'cancelled': False,
849+
}
850+
]
851+
852+
url = urlreverse('ietf.api.views.api_new_meeting_registration_v2')
853+
#
854+
# Test invalid key
855+
r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "invalid-token"})
856+
self.assertEqual(r.status_code, 403)
857+
#
858+
# Test invalid data
859+
bad_regs = copy.deepcopy(regs)
860+
del(bad_regs[0]['email'])
861+
r = self.client.post(url, data=json.dumps(bad_regs), content_type='application/json', headers={"X-Api-Key": "valid-token"})
862+
self.assertEqual(r.status_code, 400)
863+
#
864+
# Test valid POST
865+
r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"})
866+
self.assertContains(r, "Success", status_code=202)
867+
#
868+
# Check record
869+
reg = regs[0]
870+
objects = Registration.objects.filter(email=reg['email'], meeting__number=reg['meeting'])
871+
self.assertEqual(objects.count(), 1)
872+
obj = objects[0]
873+
for key in ['affiliation', 'country_code', 'first_name', 'last_name', 'checkedin']:
874+
self.assertEqual(getattr(obj, key), False if key=='checkedin' else reg.get(key) , "Bad data for field '%s'" % key)
875+
self.assertEqual(obj.tickets.count(), 1)
876+
ticket = obj.tickets.first()
877+
self.assertEqual(ticket.ticket_type.slug, regs[0]['ticket_type'])
878+
self.assertEqual(ticket.attendance_type.slug, regs[0]['reg_type'])
879+
self.assertEqual(obj.person, person)
880+
#
881+
# Test update (switch to remote)
882+
regs = [
883+
{
884+
'affiliation': "Alguma Corporação",
885+
'country_code': 'PT',
886+
'email': person.email().address,
887+
'first_name': person.first_name(),
888+
'last_name': person.last_name(),
889+
'meeting': str(meeting.number),
890+
'reg_type': 'remote',
891+
'ticket_type': 'week_pass',
892+
'checkedin': False,
893+
'is_nomcom_volunteer': False,
894+
'cancelled': False,
895+
}
896+
]
897+
r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"})
898+
self.assertContains(r, "Success", status_code=202)
899+
objects = Registration.objects.filter(email=reg['email'], meeting__number=reg['meeting'])
900+
self.assertEqual(objects.count(), 1)
901+
obj = objects[0]
902+
self.assertEqual(obj.tickets.count(), 1)
903+
ticket = obj.tickets.first()
904+
self.assertEqual(ticket.ticket_type.slug, regs[0]['ticket_type'])
905+
self.assertEqual(ticket.attendance_type.slug, regs[0]['reg_type'])
906+
#
907+
# Test multiple
908+
regs = [
909+
{
910+
'affiliation': "Alguma Corporação",
911+
'country_code': 'PT',
912+
'email': person.email().address,
913+
'first_name': person.first_name(),
914+
'last_name': person.last_name(),
915+
'meeting': str(meeting.number),
916+
'reg_type': 'onsite',
917+
'ticket_type': 'one_day',
918+
'checkedin': False,
919+
'is_nomcom_volunteer': False,
920+
'cancelled': False,
921+
},
922+
923+
{
924+
'affiliation': "Alguma Corporação",
925+
'country_code': 'PT',
926+
'email': person.email().address,
927+
'first_name': person.first_name(),
928+
'last_name': person.last_name(),
929+
'meeting': str(meeting.number),
930+
'reg_type': 'remote',
931+
'ticket_type': 'week_pass',
932+
'checkedin': False,
933+
'is_nomcom_volunteer': False,
934+
'cancelled': False,
935+
}
936+
]
937+
938+
r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"})
939+
self.assertContains(r, "Success", status_code=202)
940+
objects = Registration.objects.filter(email=reg['email'], meeting__number=reg['meeting'])
941+
self.assertEqual(objects.count(), 1)
942+
obj = objects[0]
943+
self.assertEqual(obj.tickets.count(), 2)
944+
self.assertEqual(obj.tickets.filter(attendance_type__slug='onsite').count(), 1)
945+
self.assertEqual(obj.tickets.filter(attendance_type__slug='remote').count(), 1)
946+
947+
@override_settings(APP_API_TOKENS={"ietf.api.views.api_new_meeting_registration_v2": ["valid-token"]})
948+
def test_api_new_meeting_registration_v2_cancelled(self):
949+
meeting = MeetingFactory(type_id='ietf')
950+
person = PersonFactory()
951+
regs = [
952+
{
953+
'affiliation': "Acme",
954+
'country_code': 'US',
955+
'email': person.email().address,
956+
'first_name': person.first_name(),
957+
'last_name': person.last_name(),
958+
'meeting': str(meeting.number),
959+
'reg_type': 'onsite',
960+
'ticket_type': 'week_pass',
961+
'checkedin': False,
962+
'is_nomcom_volunteer': False,
963+
'cancelled': False,
964+
}
965+
]
966+
url = urlreverse('ietf.api.views.api_new_meeting_registration_v2')
967+
self.assertEqual(Registration.objects.count(), 0)
968+
r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"})
969+
self.assertContains(r, "Success", status_code=202)
970+
self.assertEqual(Registration.objects.count(), 1)
971+
regs[0]['cancelled'] = True
972+
r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"})
973+
self.assertContains(r, "Success", status_code=202)
974+
self.assertEqual(Registration.objects.count(), 0)
975+
976+
@override_settings(APP_API_TOKENS={"ietf.api.views.api_new_meeting_registration_v2": ["valid-token"]})
977+
def test_api_new_meeting_registration_v2_nomcom(self):
978+
meeting = MeetingFactory(type_id='ietf')
979+
person = PersonFactory()
980+
regs = [
981+
{
982+
'affiliation': "Acme",
983+
'country_code': 'US',
984+
'email': person.email().address,
985+
'first_name': person.first_name(),
986+
'last_name': person.last_name(),
987+
'meeting': str(meeting.number),
988+
'reg_type': 'onsite',
989+
'ticket_type': 'week_pass',
990+
'checkedin': False,
991+
'is_nomcom_volunteer': False,
992+
'cancelled': False,
993+
}
994+
]
995+
996+
url = urlreverse('ietf.api.views.api_new_meeting_registration_v2')
997+
now = datetime.datetime.now()
998+
if now.month > 10:
999+
year = now.year + 1
1000+
else:
1001+
year = now.year
1002+
# create appropriate group and nomcom objects
1003+
nomcom = NomComFactory.create(is_accepting_volunteers=True, **nomcom_kwargs_for_year(year))
1004+
1005+
# first test is_nomcom_volunteer False
1006+
r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"})
1007+
self.assertContains(r, "Success", status_code=202)
1008+
# assert no Volunteers exists
1009+
self.assertEqual(Volunteer.objects.count(), 0)
1010+
1011+
# test is_nomcom_volunteer True
1012+
regs[0]['is_nomcom_volunteer'] = True
1013+
r = self.client.post(url, data=json.dumps(regs), content_type='application/json', headers={"X-Api-Key": "valid-token"})
1014+
self.assertContains(r, "Success", status_code=202)
1015+
# assert Volunteer exists
1016+
self.assertEqual(Volunteer.objects.count(), 1)
1017+
volunteer = Volunteer.objects.last()
1018+
self.assertEqual(volunteer.person, person)
1019+
self.assertEqual(volunteer.nomcom, nomcom)
1020+
self.assertEqual(volunteer.origin, 'registration')
1021+
8311022
def test_api_version(self):
8321023
DumpInfo.objects.create(date=timezone.datetime(2022,8,31,7,10,1,tzinfo=datetime.timezone.utc), host='testapi.example.com',tz='UTC')
8331024
url = urlreverse('ietf.api.views.version')

ietf/api/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
# Let MeetEcho upload session polls
6767
url(r'^notify/session/polls/?$', meeting_views.api_upload_polls),
6868
# Let the registration system notify us about registrations
69+
url(r'^notify/meeting/registration/v2/?', api_views.api_new_meeting_registration_v2),
6970
url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration),
7071
# OpenID authentication provider
7172
url(r'^openid/$', TemplateView.as_view(template_name='api/openid-issuer.html'), name='ietf.api.urls.oidc_issuer'),

ietf/api/views.py

+142-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from ietf.ietfauth.utils import role_required
4646
from ietf.ietfauth.views import send_account_creation_email
4747
from ietf.ipr.utils import ingest_response_email as ipr_ingest_response_email
48-
from ietf.meeting.models import Meeting
48+
from ietf.meeting.models import Meeting, Registration
4949
from ietf.nomcom.models import Volunteer, NomCom
5050
from ietf.nomcom.utils import ingest_feedback_email as nomcom_ingest_feedback_email
5151
from ietf.person.models import Person, Email
@@ -236,6 +236,147 @@ def err(code, text):
236236
return HttpResponse(status=405)
237237

238238

239+
_new_registration_json_validator = jsonschema.Draft202012Validator(
240+
schema={
241+
"type": "array",
242+
"items": {
243+
"type": "object",
244+
"properties": {
245+
"meeting": {"type": "string"},
246+
"first_name": {"type": "string"},
247+
"last_name": {"type": "string"},
248+
"affiliation": {"type": "string"},
249+
"country_code": {"type": "string"},
250+
"email": {"type": "string"},
251+
"reg_type": {"type": "string"},
252+
"ticket_type": {"type": "string"},
253+
"checkedin": {"type": "boolean"},
254+
"is_nomcom_volunteer": {"type": "boolean"},
255+
"cancelled": {"type": "boolean"},
256+
},
257+
"required": ["meeting", "first_name", "last_name", "affiliation", "country_code", "email", "reg_type", "ticket_type", "checkedin", "is_nomcom_volunteer", "cancelled"],
258+
"additionalProperties": "false"
259+
}
260+
}
261+
)
262+
263+
264+
@requires_api_token
265+
@csrf_exempt
266+
def api_new_meeting_registration_v2(request):
267+
'''REST API to notify the datatracker about a new meeting registration'''
268+
def _http_err(code, text):
269+
return HttpResponse(text, status=code, content_type="text/plain")
270+
271+
def _api_response(result):
272+
return JsonResponse(data={"result": result})
273+
274+
if request.method != "POST":
275+
return _http_err(405, "Method not allowed")
276+
277+
if request.content_type != "application/json":
278+
return _http_err(415, "Content-Type must be application/json")
279+
280+
# Validate
281+
try:
282+
payload = json.loads(request.body)
283+
_new_registration_json_validator.validate(payload)
284+
except json.decoder.JSONDecodeError as err:
285+
return _http_err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}")
286+
except jsonschema.exceptions.ValidationError as err:
287+
return _http_err(400, f"JSON schema error at {err.json_path}: {err.message}")
288+
except Exception:
289+
return _http_err(400, "Invalid request format")
290+
291+
# Validate consistency
292+
# - if receive multiple records they should be for same meeting, same person (email)
293+
if len(payload) > 1:
294+
if len(set([r['meeting'] for r in payload])) != 1:
295+
return _http_err(400, "Different meeting values")
296+
if len(set([r['email'] for r in payload])) != 1:
297+
return _http_err(400, "Different email values")
298+
299+
# Validate meeting
300+
number = payload[0]['meeting']
301+
try:
302+
meeting = Meeting.objects.get(number=number)
303+
except Meeting.DoesNotExist:
304+
return _http_err(400, "Invalid meeting value: '%s'" % (number, ))
305+
306+
# Validate email
307+
email = payload[0]['email']
308+
try:
309+
validate_email(email)
310+
except ValidationError:
311+
return _http_err(400, "Invalid email value: '%s'" % (email, ))
312+
313+
# get person
314+
person = Person.objects.filter(email__address=email).first()
315+
if not person:
316+
log.log(f"api_new_meeting_registration_v2 no Person found for {email}")
317+
318+
registration = payload[0]
319+
# handle cancelled
320+
if registration['cancelled']:
321+
if len(payload) > 1:
322+
return _http_err(400, "Error. Received cancelled registration notification with more than one record. ({})".format(email))
323+
try:
324+
obj = Registration.objects.get(meeting=meeting, email=email)
325+
except Registration.DoesNotExist:
326+
return _http_err(400, "Error. Received cancelled registration notification for non-existing registration. ({})".format(email))
327+
if obj.tickets.count() == 1:
328+
obj.delete()
329+
else:
330+
obj.tickets.filter(
331+
attendance_type__slug=registration.reg_type,
332+
ticket_type__slug=registration.ticket_type).delete()
333+
return HttpResponse('Success', status=202, content_type='text/plain')
334+
335+
# create or update MeetingRegistration
336+
update_fields = ['first_name', 'last_name', 'affiliation', 'country_code', 'checkedin', 'is_nomcom_volunteer']
337+
try:
338+
reg = Registration.objects.get(meeting=meeting, email=email)
339+
for key, value in registration.items():
340+
if key in update_fields:
341+
setattr(reg, key, value)
342+
reg.save()
343+
except Registration.DoesNotExist:
344+
reg = Registration.objects.create(
345+
meeting_id=meeting.pk,
346+
person=person,
347+
email=email,
348+
first_name=registration['first_name'],
349+
last_name=registration['last_name'],
350+
affiliation=registration['affiliation'],
351+
country_code=registration['country_code'],
352+
checkedin=registration['checkedin'])
353+
354+
# handle registration tickets
355+
reg.tickets.all().delete()
356+
for registration in payload:
357+
reg.tickets.create(
358+
attendance_type_id=registration['reg_type'],
359+
ticket_type_id=registration['ticket_type'],
360+
)
361+
# handle nomcom volunteer
362+
if registration['is_nomcom_volunteer'] and person:
363+
try:
364+
nomcom = NomCom.objects.get(is_accepting_volunteers=True)
365+
except (NomCom.DoesNotExist, NomCom.MultipleObjectsReturned):
366+
nomcom = None
367+
if nomcom:
368+
Volunteer.objects.get_or_create(
369+
nomcom=nomcom,
370+
person=person,
371+
defaults={
372+
"affiliation": registration["affiliation"],
373+
"origin": "registration"
374+
}
375+
)
376+
377+
return HttpResponse('Success', status=202, content_type='text/plain')
378+
379+
239380
def version(request):
240381
dumpdate = None
241382
dumpinfo = DumpInfo.objects.order_by('-date').first()

0 commit comments

Comments
 (0)