datatracker/ietf/meeting/tests_helpers.py
2022-12-07 15:06:43 -06:00

640 lines
29 KiB
Python

# Copyright The IETF Trust 2020-2022, All Rights Reserved
# -*- coding: utf-8 -*-
import datetime
import debug # pyflakes:ignore
from unittest.mock import patch, Mock
from django.conf import settings
from django.contrib.messages.storage.fallback import FallbackStorage
from django.test import override_settings, RequestFactory
from ietf.group.factories import GroupFactory, GroupHistoryFactory
from ietf.group.models import Group
from ietf.meeting.factories import SessionFactory, MeetingFactory, TimeSlotFactory
from ietf.meeting.helpers import (AgendaFilterOrganizer, AgendaKeywordTagger,
delete_interim_session_conferences, sessions_post_save, sessions_post_cancel,
create_interim_session_conferences, get_ietf_meeting)
from ietf.meeting.models import SchedTimeSessAssignment, Session
from ietf.meeting.test_data import make_meeting_test_data
from ietf.utils.meetecho import Conference
from ietf.utils.test_utils import TestCase
from ietf.utils.timezone import date_today
# override the legacy office hours setting to guarantee consistency with the tests
@override_settings(MEETING_LEGACY_OFFICE_HOURS_END=111)
class AgendaKeywordTaggerTests(TestCase):
def do_test_tag_assignments_with_filter_keywords(self, meeting_num, bof=False, historic=None):
"""Assignments should be tagged properly
The historic param can be None, group, or parent, to specify whether to test
with no GroupHistory active at the time of the Session's meeting,
with such a GroupHistory active, no GroupHistory for the parent, or both.
"""
# decide whether meeting should use legacy keywords (for office hours)
legacy_keywords = meeting_num <= 111
# create meeting and groups
meeting = MeetingFactory(type_id='ietf', number=meeting_num)
group_state_id = 'bof' if bof else 'active'
group = GroupFactory(state_id=group_state_id)
# Set up the historic group and parent if needed. Keep track of these as expected_*
# for later reference. If not using historic group or parent, fall back to the non-historic
# groups.
if historic:
history_time = meeting.tz().localize(
datetime.datetime.combine(meeting.date, datetime.time())
- datetime.timedelta(days=1)
)
expected_group = GroupHistoryFactory(group=group, time=history_time)
if historic == 'parent':
expected_area = GroupHistoryFactory(group=group.parent,time=history_time)
else:
expected_area = expected_group.parent
else:
expected_group = group
expected_area = group.parent
# create sessions, etc
session_data = [
{
'description': 'regular wg session',
'session': SessionFactory(
group=group, meeting=meeting, add_to_schedule=False,
purpose_id='none' if legacy_keywords else 'regular',
type_id='regular',
),
'expected_keywords': {
expected_group.acronym,
expected_area.acronym,
# if legacy_keywords, next line repeats a previous entry to avoid adding anything to the set
expected_group.acronym if legacy_keywords else 'regular',
f'{expected_group.acronym}-sessa',
},
},
{
'description': 'plenary session',
'session': SessionFactory(
group=group, meeting=meeting, add_to_schedule=False,
name=f'{group.acronym} plenary',
purpose_id='none' if legacy_keywords else 'plenary',
type_id='plenary',
),
'expected_keywords': {
expected_group.acronym,
expected_area.acronym,
f'{expected_group.acronym}-sessb',
'plenary',
f'{group.acronym}-plenary',
},
},
{
'description': 'office hours session',
'session': SessionFactory(
group=group, meeting=meeting, add_to_schedule=False,
name=f'{group.acronym} office hours',
purpose_id='none' if legacy_keywords else 'officehours',
type_id='other',
),
'expected_keywords': {
expected_group.acronym,
expected_area.acronym,
f'{expected_group.acronym}-sessc',
'officehours',
f'{group.acronym}-officehours' if legacy_keywords else 'officehours',
# officehours in prev line is a repeated value - since this is a set, it will be ignored
f'{group.acronym}-office-hours',
},
}
]
for sd in session_data:
sd['session'].timeslotassignments.create(
timeslot=TimeSlotFactory(meeting=meeting, type=sd['session'].type),
schedule=meeting.schedule,
)
assignments = meeting.schedule.assignments.all()
# Execute the method under test
AgendaKeywordTagger(assignments=assignments).apply()
# Assert expected results
# check the assignment count - paranoid, but the method mutates its input so let's be careful
self.assertEqual(len(assignments), len(session_data), 'Should not change number of assignments')
assignment_by_session_pk = {a.session.pk: a for a in assignments}
for sd in session_data:
assignment = assignment_by_session_pk[sd['session'].pk]
expected_filter_keywords = sd['expected_keywords']
if bof:
expected_filter_keywords.add('bof')
self.assertCountEqual(
assignment.filter_keywords,
expected_filter_keywords,
f'Assignment for "{sd["description"]}" has incorrect filter keywords'
)
@override_settings(MEETING_LEGACY_OFFICE_HOURS_END=111)
def test_tag_assignments_with_filter_keywords(self):
# use distinct meeting numbers > 111 for non-legacy keyword tests
self.do_test_tag_assignments_with_filter_keywords(112)
self.do_test_tag_assignments_with_filter_keywords(113, historic='group')
self.do_test_tag_assignments_with_filter_keywords(114, historic='parent')
self.do_test_tag_assignments_with_filter_keywords(115, bof=True)
self.do_test_tag_assignments_with_filter_keywords(116, bof=True, historic='group')
self.do_test_tag_assignments_with_filter_keywords(117, bof=True, historic='parent')
@override_settings(MEETING_LEGACY_OFFICE_HOURS_END=111)
def test_tag_assignments_with_filter_keywords_legacy(self):
# use distinct meeting numbers <= 111 for legacy keyword tests
self.do_test_tag_assignments_with_filter_keywords(101)
self.do_test_tag_assignments_with_filter_keywords(102, historic='group')
self.do_test_tag_assignments_with_filter_keywords(103, historic='parent')
self.do_test_tag_assignments_with_filter_keywords(104, bof=True)
self.do_test_tag_assignments_with_filter_keywords(105, bof=True, historic='group')
self.do_test_tag_assignments_with_filter_keywords(106, bof=True, historic='parent')
class AgendaFilterOrganizerTests(TestCase):
def test_get_filter_categories(self):
self.do_get_filter_categories_test(False)
def test_get_legacy_filter_categories(self):
self.do_get_filter_categories_test(True)
def do_get_filter_categories_test(self, legacy):
# set up
meeting = make_meeting_test_data()
if legacy:
meeting.session_set.all().update(purpose_id='none') # legacy meetings did not have purposes
else:
meeting.number = str(settings.MEETING_LEGACY_OFFICE_HOURS_END + 1)
meeting.save()
# create extra groups for testing
iab = Group.objects.get(acronym='iab')
iab_child = GroupFactory(type_id='iab', parent=iab)
irtf = Group.objects.get(acronym='irtf')
irtf_child = GroupFactory(parent=irtf, state_id='bof')
# non-area group sessions
SessionFactory(group=iab_child, meeting=meeting)
SessionFactory(group=irtf_child, meeting=meeting)
# office hours session
SessionFactory(
group=Group.objects.get(acronym='farfut'),
purpose_id='officehours' if not legacy else 'none',
type_id='other',
name='FARFUT office hours',
meeting=meeting
)
if legacy:
expected = [
[
# area category
{'label': 'FARFUT', 'keyword': 'farfut', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'ames', 'keyword': 'ames', 'is_bof': False, 'toggled_by': ['farfut']},
{'label': 'mars', 'keyword': 'mars', 'is_bof': False, 'toggled_by': ['farfut']},
]},
],
[
# non-area category
{'label': 'IAB', 'keyword': 'iab', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': iab_child.acronym, 'keyword': iab_child.acronym, 'is_bof': False, 'toggled_by': ['iab']},
]},
{'label': 'IRTF', 'keyword': 'irtf', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': irtf_child.acronym, 'keyword': irtf_child.acronym, 'is_bof': True, 'toggled_by': ['bof', 'irtf']},
]},
],
[
# non-group category
{'label': 'Office Hours', 'keyword': 'officehours', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'FARFUT', 'keyword': 'farfut-officehours', 'is_bof': False, 'toggled_by': ['officehours', 'farfut']}
]},
{'label': None, 'keyword': None,'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'BoF', 'keyword': 'bof', 'is_bof': False, 'toggled_by': []},
{'label': 'Plenary', 'keyword': 'plenary', 'is_bof': False, 'toggled_by': []},
]},
],
]
else:
expected = [
[
# area category
{'label': 'FARFUT', 'keyword': 'farfut', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'ames', 'keyword': 'ames', 'is_bof': False, 'toggled_by': ['farfut']},
{'label': 'mars', 'keyword': 'mars', 'is_bof': False, 'toggled_by': ['farfut']},
]},
],
[
# non-area category
{'label': 'IAB', 'keyword': 'iab', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': iab_child.acronym, 'keyword': iab_child.acronym, 'is_bof': False, 'toggled_by': ['iab']},
]},
{'label': 'IRTF', 'keyword': 'irtf', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': irtf_child.acronym, 'keyword': irtf_child.acronym, 'is_bof': True, 'toggled_by': ['bof', 'irtf']},
]},
],
[
# non-group category
{'label': 'Administrative', 'keyword': 'admin', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'Registration', 'keyword': 'registration', 'is_bof': False, 'toggled_by': ['admin', 'secretariat']},
]},
{'label': 'Closed meeting', 'keyword': 'closed_meeting', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'IESG Breakfast', 'keyword': 'iesg-breakfast', 'is_bof': False, 'toggled_by': ['closed_meeting', 'iesg']},
]},
{'label': 'Office hours', 'keyword': 'officehours', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'FARFUT office hours', 'keyword': 'farfut-office-hours', 'is_bof': False, 'toggled_by': ['officehours', 'farfut']}
]},
{'label': 'Plenary', 'keyword': 'plenary', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'IETF Plenary', 'keyword': 'ietf-plenary', 'is_bof': False, 'toggled_by': ['plenary', 'ietf']},
]},
{'label': 'Social', 'keyword': 'social', 'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'Morning Break', 'keyword': 'morning-break', 'is_bof': False, 'toggled_by': ['social', 'secretariat']},
]},
{'label': None, 'keyword': None,'is_bof': False, 'toggled_by': [],
'children': [
{'label': 'BoF', 'keyword': 'bof', 'is_bof': False, 'toggled_by': []},
]},
],
]
# put all the above together for single-column tests
expected_single_category = [sum(expected, [])]
###
# test using sessions
sessions = meeting.session_set.all()
AgendaKeywordTagger(sessions=sessions).apply()
# default
filter_organizer = AgendaFilterOrganizer(sessions=sessions)
self.assertEqual(filter_organizer.get_filter_categories(), expected)
# single-column
filter_organizer = AgendaFilterOrganizer(sessions=sessions, single_category=True)
self.assertEqual(filter_organizer.get_filter_categories(), expected_single_category)
###
# test again using assignments
assignments = SchedTimeSessAssignment.objects.filter(
schedule__in=(meeting.schedule, meeting.schedule.base)
)
AgendaKeywordTagger(assignments=assignments).apply()
# default
filter_organizer = AgendaFilterOrganizer(assignments=assignments)
self.assertEqual(filter_organizer.get_filter_categories(), expected)
# single-column
filter_organizer = AgendaFilterOrganizer(assignments=assignments, single_category=True)
self.assertEqual(filter_organizer.get_filter_categories(), expected_single_category)
def test_get_non_area_keywords(self):
# set up
meeting = make_meeting_test_data()
# create a session in a 'special' group, which should then appear in the non-area keywords
team = GroupFactory(type_id='team')
SessionFactory(group=team, meeting=meeting)
# and a BoF
bof = GroupFactory(state_id='bof')
SessionFactory(group=bof, meeting=meeting)
expected = sorted(['bof', 'plenary', team.acronym.lower()])
###
# by sessions
sessions = meeting.session_set.all()
AgendaKeywordTagger(sessions=sessions).apply()
filter_organizer = AgendaFilterOrganizer(sessions=sessions)
self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
filter_organizer = AgendaFilterOrganizer(sessions=sessions, single_category=True)
self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
###
# by assignments
assignments = meeting.schedule.assignments.all()
AgendaKeywordTagger(assignments=assignments).apply()
filter_organizer = AgendaFilterOrganizer(assignments=assignments)
self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
filter_organizer = AgendaFilterOrganizer(assignments=assignments, single_category=True)
self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
@override_settings(
MEETECHO_API_CONFIG={
'api_base': 'https://example.com',
'client_id': 'datatracker',
'client_secret': 'secret',
'request_timeout': 3.01,
}
)
class InterimTests(TestCase):
@patch('ietf.utils.meetecho.ConferenceManager')
def test_delete_interim_session_conferences(self, mock):
mock_conf_mgr = mock.return_value # "instance" seen by the internals
sessions = [
SessionFactory(meeting__type_id='interim', remote_instructions='fake-meetecho-url'),
SessionFactory(meeting__type_id='interim', remote_instructions='other-fake-meetecho-url'),
]
timeslots = [
session.official_timeslotassignment().timeslot for session in sessions
]
conferences = [
Conference(
manager=mock_conf_mgr, id=1, public_id='some-uuid', description='desc',
start_time=timeslots[0].time, duration=timeslots[0].duration, url='fake-meetecho-url',
deletion_token='please-delete-me',
),
Conference(
manager=mock_conf_mgr, id=2, public_id='some-uuid-2', description='desc',
start_time=timeslots[1].time, duration=timeslots[1].duration, url='other-fake-meetecho-url',
deletion_token='please-delete-me-as-well',
),
]
# should not call the API if MEETECHO_API_CONFIG is not defined
with override_settings(): # will undo any changes to settings in the block
del settings.MEETECHO_API_CONFIG
delete_interim_session_conferences([sessions[0], sessions[1]])
self.assertFalse(mock.called)
# no conferences, no sessions being deleted -> no conferences deleted
mock.reset_mock()
mock_conf_mgr.fetch.return_value = []
delete_interim_session_conferences([])
self.assertFalse(mock_conf_mgr.delete_conference.called)
# two conferences, no sessions being deleted -> no conferences deleted
mock_conf_mgr.fetch.return_value = [conferences[0], conferences[1]]
mock_conf_mgr.delete_conference.reset_mock()
delete_interim_session_conferences([])
self.assertFalse(mock_conf_mgr.delete_conference.called)
mock_conf_mgr.delete_conference.reset_mock()
# one conference, other session being deleted -> no conferences deleted
mock_conf_mgr.fetch.return_value = [conferences[0]]
delete_interim_session_conferences([sessions[1]])
self.assertFalse(mock_conf_mgr.delete_conference.called)
# one conference, same session being deleted -> conference deleted
mock.reset_mock()
mock_conf_mgr.fetch.return_value = [conferences[0]]
delete_interim_session_conferences([sessions[0]])
self.assertTrue(mock_conf_mgr.delete_conference.called)
self.assertCountEqual(
mock_conf_mgr.delete_conference.call_args[0],
(conferences[0],)
)
# two conferences, one being deleted -> correct conference deleted
mock.reset_mock()
mock_conf_mgr.fetch.return_value = [conferences[0], conferences[1]]
delete_interim_session_conferences([sessions[1]])
self.assertTrue(mock_conf_mgr.delete_conference.called)
self.assertEqual(mock_conf_mgr.delete_conference.call_count, 1)
self.assertEqual(
mock_conf_mgr.delete_conference.call_args[0],
(conferences[1],)
)
# two conferences, both being deleted -> both conferences deleted
mock.reset_mock()
mock_conf_mgr.fetch.return_value = [conferences[0], conferences[1]]
delete_interim_session_conferences([sessions[0], sessions[1]])
self.assertTrue(mock_conf_mgr.delete_conference.called)
self.assertEqual(mock_conf_mgr.delete_conference.call_count, 2)
args_list = [call_args[0] for call_args in mock_conf_mgr.delete_conference.call_args_list]
self.assertCountEqual(
args_list,
((conferences[0],), (conferences[1],)),
)
@patch('ietf.meeting.helpers.delete_interim_session_conferences')
def test_sessions_post_cancel(self, mock):
sessions_post_cancel(RequestFactory().post('/some/url'), 'sessions arg')
self.assertTrue(mock.called)
self.assertEqual(mock.call_args[0], ('sessions arg',))
@patch('ietf.meeting.helpers.delete_interim_session_conferences')
def test_sessions_post_cancel_delete_exception(self, mock):
"""sessions_post_cancel prevents exceptions percolating up"""
mock.side_effect = RuntimeError('oops')
sessions = SessionFactory.create_batch(3, meeting__type_id='interim')
# create mock request with session / message storage
request = RequestFactory().post('/some/url')
setattr(request, 'session', 'session')
messages = FallbackStorage(request)
setattr(request, '_messages', messages)
sessions_post_cancel(request, sessions)
self.assertTrue(mock.called)
self.assertEqual(mock.call_args[0], (sessions,))
msgs = [str(msg) for msg in messages]
self.assertEqual(len(msgs), 1)
self.assertIn('An error occurred', msgs[0])
@patch('ietf.utils.meetecho.ConferenceManager')
def test_create_interim_session_conferences(self, mock):
mock_conf_mgr = mock.return_value # "instance" seen by the internals
sessions = [
SessionFactory(meeting__type_id='interim', meeting__time_zone='america/halifax', remote_instructions='junk'),
SessionFactory(meeting__type_id='interim', meeting__time_zone='asia/kuala_lumpur', remote_instructions=''),
]
timeslots = [
session.official_timeslotassignment().timeslot for session in sessions
]
with override_settings(): # will undo any changes to settings in the block
del settings.MEETECHO_API_CONFIG
create_interim_session_conferences([sessions[0], sessions[1]])
self.assertFalse(mock.called)
# create for 0 sessions
mock.reset_mock()
create_interim_session_conferences([])
self.assertFalse(mock_conf_mgr.create.called)
self.assertEqual(
Session.objects.get(pk=sessions[0].pk).remote_instructions,
'junk',
)
# create for 1 session
mock.reset_mock()
mock_conf_mgr.create.return_value = [
Conference(
manager=mock_conf_mgr, id=1, public_id='some-uuid', description='desc',
start_time=timeslots[0].utc_start_time(), duration=timeslots[0].duration, url='fake-meetecho-url',
deletion_token='please-delete-me',
),
]
create_interim_session_conferences([sessions[0]])
self.assertTrue(mock_conf_mgr.create.called)
self.assertEqual(
mock_conf_mgr.create.call_args[1],
{
'group': sessions[0].group,
'description': str(sessions[0]),
'start_time': timeslots[0].utc_start_time(),
'duration': timeslots[0].duration,
}
)
self.assertEqual(
Session.objects.get(pk=sessions[0].pk).remote_instructions,
'fake-meetecho-url',
)
# create for 2 sessions
mock.reset_mock()
mock_conf_mgr.create.side_effect = [
[Conference(
manager=mock_conf_mgr, id=1, public_id='some-uuid', description='desc',
start_time=timeslots[0].utc_start_time(), duration=timeslots[0].duration, url='different-fake-meetecho-url',
deletion_token='please-delete-me',
)],
[Conference(
manager=mock_conf_mgr, id=2, public_id='another-uuid', description='desc',
start_time=timeslots[1].utc_start_time(), duration=timeslots[1].duration, url='another-fake-meetecho-url',
deletion_token='please-delete-me-too',
)],
]
create_interim_session_conferences([sessions[0], sessions[1]])
self.assertTrue(mock_conf_mgr.create.called)
self.assertEqual(
mock_conf_mgr.create.call_args_list,
[
({
'group': sessions[0].group,
'description': str(sessions[0]),
'start_time': timeslots[0].utc_start_time(),
'duration': timeslots[0].duration,
},),
({
'group': sessions[1].group,
'description': str(sessions[1]),
'start_time': timeslots[1].utc_start_time(),
'duration': timeslots[1].duration,
},),
]
)
self.assertEqual(
Session.objects.get(pk=sessions[0].pk).remote_instructions,
'different-fake-meetecho-url',
)
self.assertEqual(
Session.objects.get(pk=sessions[1].pk).remote_instructions,
'another-fake-meetecho-url',
)
@patch('ietf.utils.meetecho.ConferenceManager')
def test_create_interim_session_conferences_errors(self, mock):
mock_conf_mgr = mock.return_value
session = SessionFactory(meeting__type_id='interim')
timeslot = session.official_timeslotassignment().timeslot
mock_conf_mgr.create.return_value = []
with self.assertRaises(RuntimeError):
create_interim_session_conferences([session])
mock.reset_mock()
mock_conf_mgr.create.return_value = [
Conference(
manager=mock_conf_mgr, id=1, public_id='some-uuid', description='desc',
start_time=timeslot.time, duration=timeslot.duration, url='different-fake-meetecho-url',
deletion_token='please-delete-me',
),
Conference(
manager=mock_conf_mgr, id=2, public_id='another-uuid', description='desc',
start_time=timeslot.time, duration=timeslot.duration, url='another-fake-meetecho-url',
deletion_token='please-delete-me-too',
),
]
with self.assertRaises(RuntimeError):
create_interim_session_conferences([session])
mock.reset_mock()
mock_conf_mgr.create.side_effect = ValueError('some error')
with self.assertRaises(RuntimeError):
create_interim_session_conferences([session])
@patch('ietf.meeting.helpers.create_interim_session_conferences')
def test_sessions_post_save_creates_meetecho_conferences(self, mock_create_method):
session = SessionFactory(meeting__type_id='interim')
mock_form = Mock()
mock_form.instance = session
mock_form.has_changed.return_value = True
mock_form.changed_data = []
mock_form.requires_approval = True
mock_form.cleaned_data = {'date': date_today(), 'time': datetime.time(1, 23), 'remote_participation': None}
sessions_post_save(RequestFactory().post('/some/url'), [mock_form])
self.assertTrue(mock_create_method.called)
self.assertCountEqual(mock_create_method.call_args[0][0], [])
mock_create_method.reset_mock()
mock_form.cleaned_data['remote_participation'] = 'manual'
sessions_post_save(RequestFactory().post('/some/url'), [mock_form])
self.assertTrue(mock_create_method.called)
self.assertCountEqual(mock_create_method.call_args[0][0], [])
mock_create_method.reset_mock()
mock_form.cleaned_data['remote_participation'] = 'meetecho'
sessions_post_save(RequestFactory().post('/some/url'), [mock_form])
self.assertTrue(mock_create_method.called)
self.assertCountEqual(mock_create_method.call_args[0][0], [session])
# Check that an exception does not percolate through sessions_post_save
mock_create_method.side_effect = RuntimeError('some error')
mock_form.cleaned_data['remote_participation'] = 'meetecho'
# create mock request with session / message storage
request = RequestFactory().post('/some/url')
setattr(request, 'session', 'session')
messages = FallbackStorage(request)
setattr(request, '_messages', messages)
sessions_post_save(request, [mock_form])
self.assertTrue(mock_create_method.called)
self.assertCountEqual(mock_create_method.call_args[0][0], [session])
msgs = [str(msg) for msg in messages]
self.assertEqual(len(msgs), 1)
self.assertIn('An error occurred', msgs[0])
class HelperTests(TestCase):
def test_get_ietf_meeting(self):
"""get_ietf_meeting() should only return IETF meetings"""
# put the IETF far in the past so it's not "current"
today = date_today()
ietf = MeetingFactory(type_id='ietf', date=today- datetime.timedelta(days=5 * 365))
# put the interim meeting now so it will be picked up as "current" if there's a bug
interim = MeetingFactory(type_id='interim', date=today)
self.assertEqual(get_ietf_meeting(ietf.number), ietf, 'Return IETF meeting by number')
self.assertIsNone(get_ietf_meeting(interim.number), 'Ignore non-IETF meetings')
self.assertIsNone(get_ietf_meeting(), 'Return None if there is no current IETF meeting')
ietf.date = today
ietf.save()
self.assertEqual(get_ietf_meeting(), ietf, 'Return current meeting if there is one')