feat: explicitly model session attendance (#4025)
* feat: add model to track session attendance * feat: add model to track session attendance * feat: add api to set session attendees * fix: use user pk instead off person pk in the attended api. * feat: calculate three of five from attended * feat: management utility to populate Attended model history * docs: document why nomcom calculations don't use Attended yet. * fix: add migration to add new personalapikey endpoint to choices * test: verify very old last login prevents api key use, * chore: address review nits * chore: comment on some idiosyncracies of the expected input to populate_attended * fix: add unique_together constraint for the Attended model * fix: correctly handle empty querysets passed to three_of_five_eligible functions.
This commit is contained in:
parent
86e548c952
commit
07bfa68a75
|
@ -1,7 +1,7 @@
|
||||||
# Copyright The IETF Trust 2015-2020, All Rights Reserved
|
# Copyright The IETF Trust 2015-2020, All Rights Reserved
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import html
|
import html
|
||||||
import os
|
import os
|
||||||
|
@ -24,7 +24,9 @@ import ietf
|
||||||
from ietf.group.factories import RoleFactory
|
from ietf.group.factories import RoleFactory
|
||||||
from ietf.meeting.factories import MeetingFactory, SessionFactory
|
from ietf.meeting.factories import MeetingFactory, SessionFactory
|
||||||
from ietf.meeting.test_data import make_meeting_test_data
|
from ietf.meeting.test_data import make_meeting_test_data
|
||||||
|
from ietf.meeting.models import Session
|
||||||
from ietf.person.factories import PersonFactory, random_faker
|
from ietf.person.factories import PersonFactory, random_faker
|
||||||
|
from ietf.person.models import User
|
||||||
from ietf.person.models import PersonalApiKey
|
from ietf.person.models import PersonalApiKey
|
||||||
from ietf.stats.models import MeetingRegistration
|
from ietf.stats.models import MeetingRegistration
|
||||||
from ietf.utils.mail import outbox, get_payload_text
|
from ietf.utils.mail import outbox, get_payload_text
|
||||||
|
@ -144,6 +146,72 @@ class CustomApiTests(TestCase):
|
||||||
event = doc.latest_event()
|
event = doc.latest_event()
|
||||||
self.assertEqual(event.by, recman)
|
self.assertEqual(event.by, recman)
|
||||||
|
|
||||||
|
def test_api_add_session_attendees(self):
|
||||||
|
url = urlreverse('ietf.meeting.views.api_add_session_attendees')
|
||||||
|
otherperson = PersonFactory()
|
||||||
|
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')
|
||||||
|
recman = recmanrole.person
|
||||||
|
meeting = MeetingFactory(type_id='ietf')
|
||||||
|
session = SessionFactory(group__type_id='wg', meeting=meeting)
|
||||||
|
apikey = PersonalApiKey.objects.create(endpoint=url, person=recman)
|
||||||
|
|
||||||
|
badrole = RoleFactory(group__type_id='ietf', name_id='ad')
|
||||||
|
badapikey = PersonalApiKey.objects.create(endpoint=url, person=badrole.person)
|
||||||
|
badrole.person.user.last_login = timezone.now()
|
||||||
|
badrole.person.user.save()
|
||||||
|
|
||||||
|
# Improper credentials, or method
|
||||||
|
r = self.client.post(url, {})
|
||||||
|
self.assertContains(r, "Missing apikey parameter", status_code=400)
|
||||||
|
|
||||||
|
r = self.client.post(url, {'apikey': badapikey.hash()} )
|
||||||
|
self.assertContains(r, "Restricted to role: Recording Manager", status_code=403)
|
||||||
|
|
||||||
|
r = self.client.post(url, {'apikey': apikey.hash()} )
|
||||||
|
self.assertContains(r, "Too long since last regular login", status_code=400)
|
||||||
|
|
||||||
|
recman.user.last_login = timezone.now()-datetime.timedelta(days=365)
|
||||||
|
recman.user.save()
|
||||||
|
r = self.client.post(url, {'apikey': apikey.hash()} )
|
||||||
|
self.assertContains(r, "Too long since last regular login", status_code=400)
|
||||||
|
|
||||||
|
recman.user.last_login = timezone.now()
|
||||||
|
recman.user.save()
|
||||||
|
r = self.client.get(url, {'apikey': apikey.hash()} )
|
||||||
|
self.assertContains(r, "Method not allowed", status_code=405)
|
||||||
|
|
||||||
|
recman.user.last_login = timezone.now()
|
||||||
|
recman.user.save()
|
||||||
|
|
||||||
|
# Malformed requests
|
||||||
|
r = self.client.post(url, {'apikey': apikey.hash()} )
|
||||||
|
self.assertContains(r, "Missing attended parameter", status_code=400)
|
||||||
|
|
||||||
|
for baddict in (
|
||||||
|
'{}',
|
||||||
|
'{"bogons;drop table":"bogons;drop table"}',
|
||||||
|
'{"session_id":"Not an integer;drop table"}',
|
||||||
|
f'{{"session_id":{session.pk},"attendees":"not a list;drop table"}}',
|
||||||
|
f'{{"session_id":{session.pk},"attendees":"not a list;drop table"}}',
|
||||||
|
f'{{"session_id":{session.pk},"attendees":[1,2,"not an int;drop table",4]}}',
|
||||||
|
):
|
||||||
|
r = self.client.post(url, {'apikey': apikey.hash(), 'attended': baddict})
|
||||||
|
self.assertContains(r, "Malformed post", status_code=400)
|
||||||
|
|
||||||
|
bad_session_id = Session.objects.order_by('-pk').first().pk + 1
|
||||||
|
r = self.client.post(url, {'apikey': apikey.hash(), 'attended': f'{{"session_id":{bad_session_id},"attendees":[]}}'})
|
||||||
|
self.assertContains(r, "Invalid session", status_code=400)
|
||||||
|
bad_user_id = User.objects.order_by('-pk').first().pk + 1
|
||||||
|
r = self.client.post(url, {'apikey': apikey.hash(), 'attended': f'{{"session_id":{session.pk},"attendees":[{bad_user_id}]}}'})
|
||||||
|
self.assertContains(r, "Invalid attendee", status_code=400)
|
||||||
|
|
||||||
|
# Reasonable request
|
||||||
|
r = self.client.post(url, {'apikey':apikey.hash(), 'attended': f'{{"session_id":{session.pk},"attendees":[{recman.user.pk}, {otherperson.user.pk}]}}'})
|
||||||
|
|
||||||
|
self.assertEqual(session.attended_set.count(),2)
|
||||||
|
self.assertTrue(session.attended_set.filter(person=recman).exists())
|
||||||
|
self.assertTrue(session.attended_set.filter(person=otherperson).exists())
|
||||||
|
|
||||||
def test_api_upload_bluesheet(self):
|
def test_api_upload_bluesheet(self):
|
||||||
url = urlreverse('ietf.meeting.views.api_upload_bluesheet')
|
url = urlreverse('ietf.meeting.views.api_upload_bluesheet')
|
||||||
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')
|
recmanrole = RoleFactory(group__type_id='ietf', name_id='recman')
|
||||||
|
|
|
@ -29,8 +29,10 @@ urlpatterns = [
|
||||||
url(r'^meeting/session/video/url$', meeting_views.api_set_session_video_url),
|
url(r'^meeting/session/video/url$', meeting_views.api_set_session_video_url),
|
||||||
# Let Meetecho trigger recording imports
|
# Let Meetecho trigger recording imports
|
||||||
url(r'^notify/meeting/import_recordings/(?P<number>[a-z0-9-]+)/?$', meeting_views.api_import_recordings),
|
url(r'^notify/meeting/import_recordings/(?P<number>[a-z0-9-]+)/?$', meeting_views.api_import_recordings),
|
||||||
# Let the registration system notify us about registrations
|
# Let MeetEcho upload bluesheets
|
||||||
url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet),
|
url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet),
|
||||||
|
# Let MeetEcho tell us about session attendees
|
||||||
|
url(r'^notify/session/attendees/?$', meeting_views.api_add_session_attendees),
|
||||||
# Let the registration system notify us about registrations
|
# Let the registration system notify us about registrations
|
||||||
url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration),
|
url(r'^notify/meeting/registration/?', api_views.api_new_meeting_registration),
|
||||||
# OpenID authentication provider
|
# OpenID authentication provider
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from ietf.meeting.models import (Meeting, Room, Session, TimeSlot, Constraint, Schedule,
|
from ietf.meeting.models import (Attended, Meeting, Room, Session, TimeSlot, Constraint, Schedule,
|
||||||
SchedTimeSessAssignment, ResourceAssociation, FloorPlan, UrlResource,
|
SchedTimeSessAssignment, ResourceAssociation, FloorPlan, UrlResource,
|
||||||
SessionPresentation, ImportantDate, SlideSubmission, SchedulingEvent, BusinessConstraint,
|
SessionPresentation, ImportantDate, SlideSubmission, SchedulingEvent, BusinessConstraint,
|
||||||
ProceedingsMaterial, MeetingHost)
|
ProceedingsMaterial, MeetingHost)
|
||||||
|
@ -204,3 +204,8 @@ class MeetingHostAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'meeting']
|
list_display = ['name', 'meeting']
|
||||||
raw_id_fields = ['meeting']
|
raw_id_fields = ['meeting']
|
||||||
admin.site.register(MeetingHost, MeetingHostAdmin)
|
admin.site.register(MeetingHost, MeetingHostAdmin)
|
||||||
|
|
||||||
|
class AttendedAdmin(admin.ModelAdmin):
|
||||||
|
model = Attended
|
||||||
|
search_fields = ["person__name", "session__group__acronym", "session__meeting__number"]
|
||||||
|
admin.site.register(Attended, AttendedAdmin)
|
||||||
|
|
80
ietf/meeting/management/commands/populate_attended.py
Normal file
80
ietf/meeting/management/commands/populate_attended.py
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
# Copyright The IETF Trust 2022, All Rights Reserved
|
||||||
|
|
||||||
|
import debug # pyflakes: ignore
|
||||||
|
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from ietf.meeting.models import Session
|
||||||
|
from ietf.meeting.utils import sort_sessions
|
||||||
|
from ietf.person.models import Person, Email
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = 'Populates the meeting Attended table based on bluesheets and registration information'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('filename', nargs='+', type=str)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
session_cache = dict()
|
||||||
|
skipped = 0
|
||||||
|
for filename in options['filename']:
|
||||||
|
records = json.loads(open(filename,'r').read())
|
||||||
|
for record in tqdm(records):
|
||||||
|
user = record['sub']
|
||||||
|
session_acronym = record['group']
|
||||||
|
meeting_number = record['meeting']
|
||||||
|
email = record['email']
|
||||||
|
# In the expected dumps from MeetEcho, if there was only one session for group foo, it would just be named 'foo'.
|
||||||
|
# If there were _three_, we would see 'foo' for the first, 'foo_2' for the second, and 'foo_3' for the third.
|
||||||
|
# order below is the index into what is returned from sort_sessions -- 0 is the first session for a group at that meeting.
|
||||||
|
# There is brutal fixup below for older meetings where we had special arrangements where meetecho reported the non-existant
|
||||||
|
# group of 'plenary', mapping it into the appropriate 'ietf' group session.
|
||||||
|
# A bug in the export scripts at MeetEcho trimmed the '-t' from 'model-t'.
|
||||||
|
order = 0
|
||||||
|
if session_acronym in ['anrw_test', 'demoanrw', 'hostspeaker']:
|
||||||
|
skipped = skipped + 1
|
||||||
|
continue
|
||||||
|
if session_acronym=='model':
|
||||||
|
session_acronym='model-t'
|
||||||
|
if '_' in session_acronym:
|
||||||
|
session_acronym, order = session_acronym.split('_')
|
||||||
|
order = int(order)-1
|
||||||
|
if session_acronym == 'plenary':
|
||||||
|
session_acronym = 'ietf'
|
||||||
|
if meeting_number == '111':
|
||||||
|
order = 4
|
||||||
|
elif meeting_number == '110':
|
||||||
|
order = 3
|
||||||
|
elif meeting_number == '109':
|
||||||
|
order = 6
|
||||||
|
elif meeting_number == '108':
|
||||||
|
order = 13
|
||||||
|
if not (meeting_number, session_acronym) in session_cache:
|
||||||
|
session_cache[(meeting_number, session_acronym)] = sort_sessions([s for s in Session.objects.filter(meeting__number=meeting_number,group__acronym=session_acronym) if s.official_timeslotassignment()])
|
||||||
|
sessions = session_cache[(meeting_number, session_acronym)]
|
||||||
|
try:
|
||||||
|
session = sessions[order]
|
||||||
|
except IndexError:
|
||||||
|
issues.append(('session not found',record))
|
||||||
|
continue
|
||||||
|
person = None
|
||||||
|
email = Email.objects.filter(address=email).first()
|
||||||
|
if email:
|
||||||
|
person = email.person
|
||||||
|
else:
|
||||||
|
person = Person.objects.filter(user__pk=user).first()
|
||||||
|
if not person:
|
||||||
|
issues.append(('person not found',record))
|
||||||
|
continue
|
||||||
|
obj, created = session.attended_set.get_or_create(person=person)
|
||||||
|
for issue in issues:
|
||||||
|
print(issue)
|
||||||
|
print(f'{len(issues)} issues encountered')
|
||||||
|
print(f'{skipped} records intentionally skipped')
|
28
ietf/meeting/migrations/0053_attended.py
Normal file
28
ietf/meeting/migrations/0053_attended.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# Copyright The IETF Trust 2022, All Rights Reserved
|
||||||
|
# Generated by Django 2.2.28 on 2022-06-17 08:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import ietf.utils.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('person', '0023_auto_20220615_1006'),
|
||||||
|
('meeting', '0052_auto_20220503_1815'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Attended',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('person', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='person.Person')),
|
||||||
|
('session', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meeting.Session')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('person', 'session')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1379,4 +1379,14 @@ class MeetingHost(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (('meeting', 'name'),)
|
unique_together = (('meeting', 'name'),)
|
||||||
ordering = ('pk',)
|
ordering = ('pk',)
|
||||||
|
|
||||||
|
class Attended(models.Model):
|
||||||
|
person = ForeignKey(Person)
|
||||||
|
session = ForeignKey(Session)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (('person', 'session'),)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.person} at {self.session}'
|
||||||
|
|
|
@ -14,7 +14,7 @@ from ietf import api
|
||||||
from ietf.meeting.models import ( Meeting, ResourceAssociation, Constraint, Room, Schedule, Session,
|
from ietf.meeting.models import ( Meeting, ResourceAssociation, Constraint, Room, Schedule, Session,
|
||||||
TimeSlot, SchedTimeSessAssignment, SessionPresentation, FloorPlan,
|
TimeSlot, SchedTimeSessAssignment, SessionPresentation, FloorPlan,
|
||||||
UrlResource, ImportantDate, SlideSubmission, SchedulingEvent,
|
UrlResource, ImportantDate, SlideSubmission, SchedulingEvent,
|
||||||
BusinessConstraint, ProceedingsMaterial, MeetingHost)
|
BusinessConstraint, ProceedingsMaterial, MeetingHost, Attended)
|
||||||
|
|
||||||
from ietf.name.resources import MeetingTypeNameResource
|
from ietf.name.resources import MeetingTypeNameResource
|
||||||
class MeetingResource(ModelResource):
|
class MeetingResource(ModelResource):
|
||||||
|
@ -414,3 +414,21 @@ class MeetingHostResource(ModelResource):
|
||||||
"meeting": ALL_WITH_RELATIONS,
|
"meeting": ALL_WITH_RELATIONS,
|
||||||
}
|
}
|
||||||
api.meeting.register(MeetingHostResource())
|
api.meeting.register(MeetingHostResource())
|
||||||
|
|
||||||
|
|
||||||
|
from ietf.person.resources import PersonResource
|
||||||
|
class AttendedResource(ModelResource):
|
||||||
|
person = ToOneField(PersonResource, 'person')
|
||||||
|
session = ToOneField(SessionResource, 'session')
|
||||||
|
class Meta:
|
||||||
|
queryset = Attended.objects.all()
|
||||||
|
serializer = api.Serializer()
|
||||||
|
cache = SimpleCache()
|
||||||
|
#resource_name = 'attended'
|
||||||
|
ordering = ['id', ]
|
||||||
|
filtering = {
|
||||||
|
"id": ALL,
|
||||||
|
"person": ALL_WITH_RELATIONS,
|
||||||
|
"session": ALL_WITH_RELATIONS,
|
||||||
|
}
|
||||||
|
api.meeting.register(AttendedResource())
|
||||||
|
|
|
@ -50,7 +50,7 @@ from ietf.doc.fields import SearchableDocumentsField
|
||||||
from ietf.doc.models import Document, State, DocEvent, NewRevisionDocEvent, DocAlias
|
from ietf.doc.models import Document, State, DocEvent, NewRevisionDocEvent, DocAlias
|
||||||
from ietf.group.models import Group
|
from ietf.group.models import Group
|
||||||
from ietf.group.utils import can_manage_session_materials, can_manage_some_groups, can_manage_group
|
from ietf.group.utils import can_manage_session_materials, can_manage_some_groups, can_manage_group
|
||||||
from ietf.person.models import Person
|
from ietf.person.models import Person, User
|
||||||
from ietf.ietfauth.utils import role_required, has_role, user_is_person
|
from ietf.ietfauth.utils import role_required, has_role, user_is_person
|
||||||
from ietf.mailtrigger.utils import gather_address_lists
|
from ietf.mailtrigger.utils import gather_address_lists
|
||||||
from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission
|
from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission
|
||||||
|
@ -3710,6 +3710,38 @@ def api_set_session_video_url(request):
|
||||||
|
|
||||||
return HttpResponse("Done", status=200, content_type='text/plain')
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
||||||
|
|
||||||
|
@require_api_key
|
||||||
|
@role_required('Recording Manager') # TODO : Rework how Meetecho interacts via APIs. There may be better paths to pursue than Personal API keys as they are currently defined.
|
||||||
|
@csrf_exempt
|
||||||
|
def api_add_session_attendees(request):
|
||||||
|
|
||||||
|
def err(code, text):
|
||||||
|
return HttpResponse(text, status=code, content_type='text/plain')
|
||||||
|
|
||||||
|
if request.method != 'POST':
|
||||||
|
return err(405, "Method not allowed")
|
||||||
|
attended_post = request.POST.get('attended')
|
||||||
|
if not attended_post:
|
||||||
|
return err(400, "Missing attended parameter")
|
||||||
|
try:
|
||||||
|
attended = json.loads(attended_post)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
return err(400, "Malformed post")
|
||||||
|
if not ( 'session_id' in attended and type(attended['session_id']) is int ):
|
||||||
|
return err(400, "Malformed post")
|
||||||
|
session_id = attended['session_id']
|
||||||
|
if not ( 'attendees' in attended and type(attended['attendees']) is list and all([type(el) is int for el in attended['attendees']]) ):
|
||||||
|
return err(400, "Malformed post")
|
||||||
|
session = Session.objects.filter(pk=session_id).first()
|
||||||
|
if not session:
|
||||||
|
return err(400, "Invalid session")
|
||||||
|
users = User.objects.filter(pk__in=attended['attendees'])
|
||||||
|
if users.count() != len(attended['attendees']):
|
||||||
|
return err(400, "Invalid attendee")
|
||||||
|
for user in users:
|
||||||
|
session.attended_set.get_or_create(person=user.person)
|
||||||
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
||||||
|
|
||||||
|
|
||||||
@require_api_key
|
@require_api_key
|
||||||
@role_required('Recording Manager', 'Secretariat')
|
@role_required('Recording Manager', 'Secretariat')
|
||||||
|
|
|
@ -608,10 +608,24 @@ def three_of_five_eligible(previous_five, queryset=None):
|
||||||
3 of the 5 type_id='ietf' meetings before the given
|
3 of the 5 type_id='ietf' meetings before the given
|
||||||
date. Does not disqualify anyone based on held roles.
|
date. Does not disqualify anyone based on held roles.
|
||||||
"""
|
"""
|
||||||
if not queryset:
|
if queryset is None:
|
||||||
queryset = Person.objects.all()
|
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(meetingregistration__meeting__in=list(previous_five),meetingregistration__attended=True).annotate(mtg_count=Count('meetingregistration')).filter(mtg_count__gte=3)
|
||||||
|
|
||||||
|
def new_three_of_five_eligible(previous_five, queryset=None):
|
||||||
|
""" 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 'new' variant bases the calculation on the Meeting.Session model rather than Stats.MeetingRegistration
|
||||||
|
Leadership will have to create a new RFC specifying eligibility (RFC8989 is timing out) before it can be used.
|
||||||
|
"""
|
||||||
|
if queryset is None:
|
||||||
|
queryset = Person.objects.all()
|
||||||
|
return queryset.filter(
|
||||||
|
Q(attended__session__meeting__in=list(previous_five)),
|
||||||
|
Q(attended__session__type='plenary')|Q(attended__session__group__type__in=['wg','rg'])
|
||||||
|
).annotate(mtg_count=Count('attended__session__meeting',distinct=True)).filter(mtg_count__gte=3)
|
||||||
|
|
||||||
def suggest_affiliation(person):
|
def suggest_affiliation(person):
|
||||||
recent_meeting = person.meetingregistration_set.order_by('-meeting__date').first()
|
recent_meeting = person.meetingregistration_set.order_by('-meeting__date').first()
|
||||||
affiliation = recent_meeting.affiliation if recent_meeting else ''
|
affiliation = recent_meeting.affiliation if recent_meeting else ''
|
||||||
|
|
18
ietf/person/migrations/0023_auto_20220615_1006.py
Normal file
18
ietf/person/migrations/0023_auto_20220615_1006.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 2.2.28 on 2022-06-15 10:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('person', '0022_auto_20220513_1456'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='personalapikey',
|
||||||
|
name='endpoint',
|
||||||
|
field=models.CharField(choices=[('/api/appauth/authortools', '/api/appauth/authortools'), ('/api/appauth/bibxml', '/api/appauth/bibxml'), ('/api/iesg/position', '/api/iesg/position'), ('/api/meeting/session/video/url', '/api/meeting/session/video/url'), ('/api/notify/meeting/bluesheet', '/api/notify/meeting/bluesheet'), ('/api/notify/meeting/registration', '/api/notify/meeting/registration'), ('/api/notify/session/attendees', '/api/notify/session/attendees'), ('/api/v2/person/person', '/api/v2/person/person')], max_length=128),
|
||||||
|
),
|
||||||
|
]
|
|
@ -355,7 +355,8 @@ PERSON_API_KEY_VALUES = [
|
||||||
("/api/v2/person/person", "/api/v2/person/person", "Robot"),
|
("/api/v2/person/person", "/api/v2/person/person", "Robot"),
|
||||||
("/api/meeting/session/video/url", "/api/meeting/session/video/url", "Recording Manager"),
|
("/api/meeting/session/video/url", "/api/meeting/session/video/url", "Recording Manager"),
|
||||||
("/api/notify/meeting/registration", "/api/notify/meeting/registration", "Robot"),
|
("/api/notify/meeting/registration", "/api/notify/meeting/registration", "Robot"),
|
||||||
("/api/notify/meeting/bluesheet", "/api/notify/meeting/bluesheet", "Recording Manager"),
|
("/api/notify/meeting/bluesheet", "/api/notify/meeting/bluesheet", "Recording Manager"),
|
||||||
|
("/api/notify/session/attendees", "/api/notify/session/attendees", "Recording Manager"),
|
||||||
("/api/appauth/authortools", "/api/appauth/authortools", None),
|
("/api/appauth/authortools", "/api/appauth/authortools", None),
|
||||||
("/api/appauth/bibxml", "/api/appauth/bibxml", None),
|
("/api/appauth/bibxml", "/api/appauth/bibxml", None),
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in a new issue