Merged in [19917] and [19930] from jennifer@painless-security.com:

Create/delete Meetecho conferences when requesting/canceling interim sessions. Fixes #3507. Fixes #3508.
 - Legacy-Id: 19934
Note: SVN reference [19917] has been migrated to Git commit 81cd64da2bc0122f733df02f7db634665c9b309a

Note: SVN reference [19930] has been migrated to Git commit c64297e495010f3c147726ad61c24ca436c324da
This commit is contained in:
Robert Sparks 2022-02-14 19:08:10 +00:00
parent 04df65c4ff
commit f8c7be6df9
12 changed files with 1238 additions and 24 deletions

View file

@ -16,6 +16,7 @@ from django.core import validators
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms import BaseInlineFormSet
from django.utils.functional import cached_property
import debug # pyflakes:ignore
@ -207,7 +208,8 @@ class InterimSessionModelForm(forms.ModelForm):
time = forms.TimeField(widget=forms.TimeInput(format='%H:%M'), required=True)
requested_duration = CustomDurationField(required=True)
end_time = forms.TimeField(required=False)
remote_instructions = forms.CharField(max_length=1024, required=True)
remote_participation = forms.ChoiceField(choices=(), required=False)
remote_instructions = forms.CharField(max_length=1024, required=False)
agenda = forms.CharField(required=False, widget=forms.Textarea, strip=False)
agenda_note = forms.CharField(max_length=255, required=False)
@ -233,7 +235,13 @@ class InterimSessionModelForm(forms.ModelForm):
doc = self.instance.agenda()
content = doc.text_or_error()
self.initial['agenda'] = content
# set up remote participation choices
choices = []
if hasattr(settings, 'MEETECHO_API_CONFIG'):
choices.append(('meetecho', 'Automatically create Meetecho conference'))
choices.append(('manual', 'Manually specify remote instructions...'))
self.fields['remote_participation'].choices = choices
def clean_date(self):
'''Date field validator. We can't use required on the input because
@ -251,6 +259,21 @@ class InterimSessionModelForm(forms.ModelForm):
raise forms.ValidationError('Provide a duration, %s-%smin.' % (min_minutes, max_minutes))
return duration
def clean(self):
if self.cleaned_data.get('remote_participation', None) == 'meetecho':
self.cleaned_data['remote_instructions'] = '' # blank this out if we're creating a Meetecho conference
elif not self.cleaned_data['remote_instructions']:
self.add_error('remote_instructions', 'This field is required')
return self.cleaned_data
# Override to ignore the non-model 'remote_participation' field when computing has_changed()
@cached_property
def changed_data(self):
data = super().changed_data
if 'remote_participation' in data:
data.remove('remote_participation')
return data
def save(self, *args, **kwargs):
"""NOTE: as the baseform of an inlineformset self.save(commit=True)
never gets called"""

View file

@ -12,6 +12,7 @@ from tempfile import mkstemp
from django.http import Http404
from django.db.models import F, Prefetch
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.models import AnonymousUser
from django.urls import reverse
from django.shortcuts import get_object_or_404
@ -29,7 +30,7 @@ from ietf.person.models import Person
from ietf.meeting.models import Meeting, Schedule, TimeSlot, SchedTimeSessAssignment, ImportantDate, SchedulingEvent, Session
from ietf.meeting.utils import session_requested_by, add_event_info_to_session_qs
from ietf.name.models import ImportantDateName, SessionPurposeName
from ietf.utils import log
from ietf.utils import log, meetecho
from ietf.utils.history import find_history_replacements_active_at
from ietf.utils.mail import send_mail
from ietf.utils.pipe import pipe
@ -1074,6 +1075,76 @@ def sessions_post_save(request, forms):
if 'agenda' in form.changed_data:
form.save_agenda()
try:
create_interim_session_conferences(
form.instance for form in forms
if form.cleaned_data.get('remote_participation', None) == 'meetecho'
)
except RuntimeError:
messages.warning(
request,
'An error occurred while creating a Meetecho conference. The interim meeting request '
'has been created without complete remote participation information. '
'Please edit the request to add this or contact the secretariat if you require assistance.',
)
def create_interim_session_conferences(sessions):
error_occurred = False
if hasattr(settings, 'MEETECHO_API_CONFIG'): # do nothing if not configured
meetecho_manager = meetecho.ConferenceManager(settings.MEETECHO_API_CONFIG)
for session in sessions:
ts = session.official_timeslotassignment().timeslot
try:
confs = meetecho_manager.create(
group=session.group,
description=str(session),
start_time=ts.time,
duration=ts.duration,
)
except Exception as err:
log.log(f'Exception creating Meetecho conference for {session}: {err}')
confs = []
if len(confs) == 1:
session.remote_instructions = confs[0].url
session.save()
else:
error_occurred = True
if error_occurred:
raise RuntimeError('error creating meetecho conferences')
def delete_interim_session_conferences(sessions):
"""Delete Meetecho conference for the session, if any"""
if hasattr(settings, 'MEETECHO_API_CONFIG'): # do nothing if Meetecho API not configured
meetecho_manager = meetecho.ConferenceManager(settings.MEETECHO_API_CONFIG)
for session in sessions:
if session.remote_instructions:
for conference in meetecho_manager.fetch(session.group):
if conference.url == session.remote_instructions:
conference.delete()
break
def sessions_post_cancel(request, sessions):
"""Clean up after session cancellation
When this is called, the session has already been canceled, so exceptions should
not be raised.
"""
try:
delete_interim_session_conferences(sessions)
except Exception as err:
sess_pks = ', '.join(str(s.pk) for s in sessions)
log.log(f'Exception deleting Meetecho conferences for sessions [{sess_pks}]: {err}')
messages.warning(
request,
'An error occurred while cleaning up Meetecho conferences for the canceled sessions. '
'The session or sessions have been canceled, but Meetecho conferences may not have been cleaned '
'up properly.',
)
def update_interim_session_assignment(form):
"""Helper function to create / update timeslot assigned to interim session"""

View file

@ -0,0 +1,91 @@
# Copyright The IETF Trust 2022, All Rights Reserved
# -*- coding: utf-8 -*-
import datetime
from textwrap import dedent
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from ietf.meeting.models import Session
from ietf.utils.meetecho import ConferenceManager, MeetechoAPIError
class Command(BaseCommand):
help = 'Manage Meetecho conferences'
def add_arguments(self, parser) -> None:
parser.add_argument('group', type=str)
parser.add_argument('-d', '--delete', type=int, action='append',
metavar='SESSION_PK',
help='Delete the conference associated with the specified Session')
def handle(self, group, delete, *args, **options):
conf_mgr = ConferenceManager(settings.MEETECHO_API_CONFIG)
if delete:
self.handle_delete_conferences(conf_mgr, group, delete)
else:
self.handle_list_conferences(conf_mgr, group)
def handle_list_conferences(self, conf_mgr, group):
confs, conf_sessions = self.fetch_conferences(conf_mgr, group)
self.stdout.write(f'Meetecho conferences for {group}:\n\n')
for conf in confs:
sessions_desc = ', '.join(str(s.pk) for s in conf_sessions[conf.id]) or None
self.stdout.write(
dedent(f'''\
* {conf.description}
Start time: {conf.start_time}
Duration: {int(conf.duration.total_seconds() // 60)} minutes
URL: {conf.url}
Associated session PKs: {sessions_desc}
''')
)
def handle_delete_conferences(self, conf_mgr, group, session_pks_to_delete):
sessions_to_delete = Session.objects.filter(pk__in=session_pks_to_delete)
confs, conf_sessions = self.fetch_conferences(conf_mgr, group)
confs_to_delete = []
descriptions = []
for session in sessions_to_delete:
for conf in confs:
associated = conf_sessions[conf.id]
if session in associated:
confs_to_delete.append(conf)
sessions_desc = ', '.join(str(s.pk) for s in associated) or None
descriptions.append(
f'{conf.description} ({conf.start_time}, {int(conf.duration.total_seconds() // 60)} mins) - used by {sessions_desc}'
)
if len(confs_to_delete) > 0:
self.stdout.write('Will delete:')
for desc in descriptions:
self.stdout.write(f'* {desc}')
try:
proceed = input('Proceed [y/N]? ').lower()
except EOFError:
proceed = 'n'
if proceed in ['y', 'yes']:
for conf, desc in zip(confs_to_delete, descriptions):
conf.delete()
self.stdout.write(f'Deleted {desc}')
else:
self.stdout.write('Nothing deleted.')
else:
self.stdout.write('No associated Meetecho conferences found')
def fetch_conferences(self, conf_mgr, group):
try:
confs = conf_mgr.fetch(group)
except MeetechoAPIError as err:
raise CommandError('API error fetching Meetecho conference data') from err
conf_sessions = {}
for conf in confs:
conf_sessions[conf.id] = Session.objects.filter(
group__acronym=group,
meeting__date__gte=datetime.date.today(),
remote_instructions__contains=conf.url,
)
return confs, conf_sessions

View file

@ -1,10 +1,11 @@
# Copyright The IETF Trust 2021, All Rights Reserved
# -*- coding: utf-8 -*-
"""Tests of forms in the Meeting application"""
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from ietf.meeting.forms import FileUploadForm, ApplyToAllFileUploadForm
from ietf.meeting.forms import FileUploadForm, ApplyToAllFileUploadForm, InterimSessionModelForm
from ietf.utils.test_utils import TestCase
@ -102,3 +103,19 @@ class ApplyToAllFileUploadFormTests(TestCase):
def test_no_show_apply_to_all_field(self):
form = ApplyToAllFileUploadFormTests.TestClass(show_apply_to_all_checkbox=False)
self.assertNotIn('apply_to_all', form.fields)
class InterimSessionModelFormTests(TestCase):
@override_settings(MEETECHO_API_CONFIG={}) # setting needs to exist, don't care about its value in this test
def test_remote_participation_options(self):
"""Only offer Meetecho conference creation when configured"""
form = InterimSessionModelForm()
choice_vals = [choice[0] for choice in form.fields['remote_participation'].choices]
self.assertIn('meetecho', choice_vals)
self.assertIn('manual', choice_vals)
del settings.MEETECHO_API_CONFIG
form = InterimSessionModelForm()
choice_vals = [choice[0] for choice in form.fields['remote_participation'].choices]
self.assertNotIn('meetecho', choice_vals)
self.assertIn('manual', choice_vals)

View file

@ -1,15 +1,20 @@
# Copyright The IETF Trust 2020, All Rights Reserved
# -*- coding: utf-8 -*-
from unittest.mock import patch, Mock
from django.conf import settings
from django.test import override_settings
from django.contrib.messages.storage.fallback import FallbackStorage
from django.test import override_settings, RequestFactory
from ietf.group.factories import GroupFactory
from ietf.group.models import Group
from ietf.meeting.factories import SessionFactory, MeetingFactory, TimeSlotFactory
from ietf.meeting.helpers import AgendaFilterOrganizer, AgendaKeywordTagger
from ietf.meeting.models import SchedTimeSessAssignment
from ietf.meeting.helpers import (AgendaFilterOrganizer, AgendaKeywordTagger,
delete_interim_session_conferences, sessions_post_save, sessions_post_cancel,
create_interim_session_conferences)
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
@ -332,4 +337,281 @@ class AgendaFilterOrganizerTests(TestCase):
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)
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', remote_instructions='junk'),
SessionFactory(meeting__type_id='interim', 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].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.assertCountEqual(
mock_conf_mgr.create.call_args[1],
{
'group': sessions[0].group,
'description': str(sessions[0]),
'start_time': timeslots[0].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].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].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.assertCountEqual(
mock_conf_mgr.create.call_args_list,
[
({
'group': sessions[0].group,
'description': str(sessions[0]),
'start_time': timeslots[0].time,
'duration': timeslots[0].duration,
},),
({
'group': sessions[1].group,
'description': str(sessions[1]),
'start_time': timeslots[1].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 = {'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])

View file

@ -45,7 +45,7 @@ from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignm
from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data
from ietf.meeting.utils import finalize, condition_slide_order
from ietf.meeting.utils import add_event_info_to_session_qs
from ietf.meeting.views import session_draft_list, parse_agenda_filter_params
from ietf.meeting.views import session_draft_list, parse_agenda_filter_params, sessions_post_save
from ietf.name.models import SessionStatusName, ImportantDateName, RoleName, ProceedingsMaterialTypeName
from ietf.utils.decorators import skip_coverage
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
@ -4456,7 +4456,9 @@ class InterimTests(TestCase):
'session_set-MIN_NUM_FORMS':0,
'session_set-MAX_NUM_FORMS':1000}
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
with patch('ietf.meeting.views.sessions_post_save', wraps=sessions_post_save) as mock:
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
self.assertTrue(mock.called)
self.assertRedirects(r,urlreverse('ietf.meeting.views.upcoming'))
meeting = Meeting.objects.order_by('id').last()
self.assertEqual(meeting.type_id,'interim')
@ -4525,7 +4527,9 @@ class InterimTests(TestCase):
'session_set-TOTAL_FORMS':1,
'session_set-INITIAL_FORMS':0}
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
with patch('ietf.meeting.views.sessions_post_save', wraps=sessions_post_save) as mock:
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
self.assertTrue(mock.called)
self.assertRedirects(r,urlreverse('ietf.meeting.views.upcoming'))
meeting = Meeting.objects.order_by('id').last()
self.assertEqual(meeting.type_id,'interim')
@ -4579,7 +4583,9 @@ class InterimTests(TestCase):
'session_set-TOTAL_FORMS':2,
'session_set-INITIAL_FORMS':0}
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
with patch('ietf.meeting.views.sessions_post_save', wraps=sessions_post_save) as mock:
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
self.assertTrue(mock.called)
self.assertRedirects(r,urlreverse('ietf.meeting.views.upcoming'))
meeting = Meeting.objects.order_by('id').last()
@ -4715,8 +4721,9 @@ class InterimTests(TestCase):
'session_set-TOTAL_FORMS':2,
'session_set-INITIAL_FORMS':0}
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
with patch('ietf.meeting.views.sessions_post_save', wraps=sessions_post_save) as mock:
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
self.assertTrue(mock.called)
self.assertRedirects(r,urlreverse('ietf.meeting.views.upcoming'))
meeting_count_after = Meeting.objects.filter(type='interim').count()
self.assertEqual(meeting_count_after,meeting_count_before + 2)
@ -5052,7 +5059,8 @@ class InterimTests(TestCase):
def test_interim_request_disapprove_with_extra_and_canceled_sessions(self):
self.do_interim_request_disapprove_test(extra_session=True, canceled_session=True)
def test_interim_request_cancel(self):
@patch('ietf.meeting.views.sessions_post_cancel')
def test_interim_request_cancel(self, mock):
"""Test that interim request cancel function works
Does not test that UI buttons are present, that is handled elsewhere.
@ -5071,6 +5079,7 @@ class InterimTests(TestCase):
self.client.login(username="ameschairman", password="ameschairman+password")
r = self.client.post(url, {'comments': comments})
self.assertEqual(r.status_code, 403)
self.assertFalse(mock.called, 'Should not cancel sessions if request rejected')
# test cancelling before announcement
self.client.login(username="marschairman", password="marschairman+password")
@ -5081,8 +5090,11 @@ class InterimTests(TestCase):
self.assertEqual(session.current_status,'canceledpa')
self.assertEqual(session.agenda_note, comments)
self.assertEqual(len(outbox), length_before) # no email notice
self.assertTrue(mock.called, 'Should cancel sessions if request handled')
self.assertCountEqual(mock.call_args[0][1], meeting.session_set.all())
# test cancelling after announcement
mock.reset_mock()
meeting = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='sched').first().meeting
url = urlreverse('ietf.meeting.views.interim_request_cancel', kwargs={'number': meeting.number})
r = self.client.post(url, {'comments': comments})
@ -5092,8 +5104,11 @@ class InterimTests(TestCase):
self.assertEqual(session.agenda_note, comments)
self.assertEqual(len(outbox), length_before + 1)
self.assertIn('Interim Meeting Cancelled', outbox[-1]['Subject'])
self.assertTrue(mock.called, 'Should cancel sessions if request handled')
self.assertCountEqual(mock.call_args[0][1], meeting.session_set.all())
def test_interim_request_session_cancel(self):
@patch('ietf.meeting.views.sessions_post_cancel')
def test_interim_request_session_cancel(self, mock):
"""Test that interim meeting session cancellation functions
Does not test that UI buttons are present, that is handled elsewhere.
@ -5109,6 +5124,7 @@ class InterimTests(TestCase):
url = urlreverse('ietf.meeting.views.interim_request_session_cancel', kwargs={'sessionid': session.pk})
r = self.client.post(url, {'comments': comments})
self.assertEqual(r.status_code, 409)
self.assertFalse(mock.called, 'Should not cancel sessions if request rejected')
# Add a second session
SessionFactory(meeting=meeting, status_id='apprw')
@ -5118,7 +5134,8 @@ class InterimTests(TestCase):
self.client.login(username="ameschairman", password="ameschairman+password")
r = self.client.post(url, {'comments': comments})
self.assertEqual(r.status_code, 403)
self.assertFalse(mock.called, 'Should not cancel sessions if request rejected')
# test cancelling before announcement
self.client.login(username="marschairman", password="marschairman+password")
length_before = len(outbox)
@ -5127,6 +5144,9 @@ class InterimTests(TestCase):
r = self.client.post(url, {'comments': comments})
self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_request_details',
kwargs={'number': meeting.number}))
self.assertTrue(mock.called, 'Should cancel sessions if request handled')
self.assertCountEqual(mock.call_args[0][1], [session])
# This session should be canceled...
sessions = meeting.session_set.with_current_status()
session = sessions.filter(id=session.pk).first() # reload our session info
@ -5140,6 +5160,7 @@ class InterimTests(TestCase):
self.assertEqual(len(outbox), length_before) # no email notice
# test cancelling after announcement
mock.reset_mock()
session = Session.objects.with_current_status().filter(
meeting__type='interim', group__acronym='mars', current_status='sched').first()
meeting = session.meeting
@ -5148,6 +5169,7 @@ class InterimTests(TestCase):
url = urlreverse('ietf.meeting.views.interim_request_session_cancel', kwargs={'sessionid': session.pk})
r = self.client.post(url, {'comments': comments})
self.assertEqual(r.status_code, 409)
self.assertFalse(mock.called, 'Should not cancel sessions if request rejected')
# Add another session
SessionFactory(meeting=meeting, status_id='sched') # two sessions so canceling a session makes sense
@ -5157,6 +5179,9 @@ class InterimTests(TestCase):
r = self.client.post(url, {'comments': comments})
self.assertRedirects(r, urlreverse('ietf.meeting.views.interim_request_details',
kwargs={'number': meeting.number}))
self.assertTrue(mock.called, 'Should cancel sessions if request handled')
self.assertCountEqual(mock.call_args[0][1], [session])
# This session should be canceled...
sessions = meeting.session_set.with_current_status()
session = sessions.filter(id=session.pk).first() # reload our session info

View file

@ -71,7 +71,7 @@ from ietf.meeting.helpers import sessions_post_save, is_interim_meeting_approved
from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice
from ietf.meeting.helpers import send_interim_approval
from ietf.meeting.helpers import send_interim_approval_request
from ietf.meeting.helpers import send_interim_announcement_request
from ietf.meeting.helpers import send_interim_announcement_request, sessions_post_cancel
from ietf.meeting.utils import finalize, sort_accept_tuple, condition_slide_order
from ietf.meeting.utils import add_event_info_to_session_qs
from ietf.meeting.utils import session_time_for_sorting
@ -3184,7 +3184,9 @@ def interim_request_cancel(request, number):
was_scheduled = session_status.slug == 'sched'
result_status = SessionStatusName.objects.get(slug='canceled' if was_scheduled else 'canceledpa')
for session in meeting.session_set.not_canceled():
sessions_to_cancel = meeting.session_set.not_canceled()
for session in sessions_to_cancel:
SchedulingEvent.objects.create(
session=session,
status=result_status,
@ -3194,6 +3196,8 @@ def interim_request_cancel(request, number):
if was_scheduled:
send_interim_meeting_cancellation_notice(meeting)
sessions_post_cancel(request, sessions_to_cancel)
messages.success(request, 'Interim meeting cancelled')
return redirect(upcoming)
else:
@ -3241,6 +3245,8 @@ def interim_request_session_cancel(request, sessionid):
if was_scheduled:
send_interim_session_cancellation_notice(session)
sessions_post_cancel(request, [session])
messages.success(request, 'Interim meeting session cancelled')
return redirect(interim_request_details, number=session.meeting.number)
else:

View file

@ -1236,6 +1236,18 @@ qvNU+qRWi+YXrITsgn92/gVxX5AoK0n+s5Lx7fpjxkARVi66SF6zTJnX
# Default timeout for HTTP requests via the requests library
DEFAULT_REQUESTS_TIMEOUT = 20 # seconds
# Meetecho API setup: Uncomment this and provide real credentials to enable
# Meetecho conference creation for interim session requests
#
# MEETECHO_API_CONFIG = {
# 'api_base': 'https://meetings.conf.meetecho.com/api/v1/',
# 'client_id': 'datatracker',
# 'client_secret': 'some secret',
# 'request_timeout': 3.01, # python-requests doc recommend slightly > a multiple of 3 seconds
# }
# Put the production SECRET_KEY in settings_local.py, and also any other
# sensitive or site-specific changes. DO NOT commit settings_local.py to svn.
from ietf.settings_local import * # pyflakes:ignore pylint: disable=wildcard-import

View file

@ -26,6 +26,11 @@ var interimRequest = {
$('input[name$="-time"]').each(interimRequest.calculateEndTime);
$('input[name$="-time"]').each(interimRequest.updateInfo);
$('#id_country').select2({placeholder:"Country"});
const remoteParticipations = $('select[id$="-remote_participation"]');
remoteParticipations.change(
evt => interimRequest.updateRemoteInstructionsVisibility(evt.target)
);
remoteParticipations.each((index, elt) => interimRequest.updateRemoteInstructionsVisibility(elt));
},
addSession : function() {
@ -226,6 +231,22 @@ var interimRequest = {
} else {
$(".location").prop('disabled', true);
}
},
updateRemoteInstructionsVisibility : function(elt) {
const sessionSetPrefix = elt.id.replace('-remote_participation', '');
const remoteInstructionsId = sessionSetPrefix + '-remote_instructions';
const remoteInstructions = $('#' + remoteInstructionsId);
switch (elt.value) {
case 'meetecho':
remoteInstructions.closest('.form-group').hide();
break;
default:
remoteInstructions.closest('.form-group').show();
break;
}
}
}

View file

@ -121,13 +121,27 @@
</div>
</div>
<div class="form-group">
<label for="id_session_set-{{ forloop.counter0 }}-remote_participation" class="col-md-2 control-label">
Remote Participation
</label>
<div class="col-md-10">
<div class="row">
<div class="col-md-12">
{% render_field form.remote_participation class="form-control" %}
</div>
</div>
</div>
</div>
<div class="form-group{% if form.remote_instructions.errors %} alert alert-danger{% endif %}">
<label for="id_session_set-{{ forloop.counter0 }}-remote_instructions" class="col-md-2 control-label required">Remote Instructions</label>
<div class="col-md-10">{% render_field form.remote_instructions class="form-control" placeholder="Webex (or other) URL or descriptive information (see below)" %}
<label for="id_session_set-{{ forloop.counter0 }}-remote_instructions" class="control-label col-md-2 required">Instructions</label>
<div class="col-md-10">
{% render_field form.remote_instructions class="form-control" placeholder="Webex (or other) URL or descriptive information (see below)" %}
<p class="help-block">
For virtual interims, a conference link <b>should be provided in the original request</b> in all but the most unusual circumstances.
Otherwise, "Remote participation is not supported" or "Remote participation information will be obtained at the time of approval" are acceptable values.
See <a href="https://www.ietf.org/forms/wg-webex-account-request/">here</a> for more on remote participation support.
For virtual interims, a conference link <b>should be provided in the original request</b> in all but the most unusual circumstances.
Otherwise, "Remote participation is not supported" or "Remote participation information will be obtained at the time of approval" are acceptable values.
See <a href="https://www.ietf.org/forms/wg-webex-account-request/">here</a> for more on remote participation support.
</p>
</div>
{% if form.remote_instructions.errors %}<span class="help-inline">{{ form.remote_instructions.errors }}</span>{% endif %}

260
ietf/utils/meetecho.py Normal file
View file

@ -0,0 +1,260 @@
# Copyright The IETF Trust 2021, All Rights Reserved
#
"""Meetecho interim meeting scheduling API
Implements the v1 API described in email from alex@meetecho.com
on 2021-12-09.
API methods return Python objects equivalent to the JSON structures
specified in the API documentation. Times and durations are represented
in the Python API by datetime and timedelta objects, respectively.
"""
import requests
import debug # pyflakes: ignore
from datetime import datetime, timedelta
from json import JSONDecodeError
from typing import Dict, Sequence, Union
from urllib.parse import urljoin
class MeetechoAPI:
def __init__(self, api_base: str, client_id: str, client_secret: str, request_timeout=3.01):
self.client_id = client_id
self.client_secret = client_secret
self.request_timeout = request_timeout # python-requests doc recommend slightly > a multiple of 3 seconds
self._session = requests.Session()
# if needed, add a trailing slash so urljoin won't eat the trailing path component
self.api_base = api_base if api_base.endswith('/') else f'{api_base}/'
def _request(self, method, url, api_token=None, json=None):
"""Execute an API request"""
headers = {'Accept': 'application/json'}
if api_token is not None:
headers['Authorization'] = f'bearer {api_token}'
try:
response = self._session.request(
method,
urljoin(self.api_base, url),
headers=headers,
json=json,
timeout=self.request_timeout,
)
except requests.RequestException as err:
raise MeetechoAPIError(str(err)) from err
if response.status_code != 200:
raise MeetechoAPIError(f'API request failed (HTTP status code = {response.status_code})')
# try parsing the result as JSON in case the server failed to set the Content-Type header
try:
return response.json()
except JSONDecodeError as err:
if response.headers['Content-Type'].startswith('application/json'):
# complain if server told us to expect JSON and it was invalid
raise MeetechoAPIError('Error decoding response as JSON') from err
return None
def _deserialize_time(self, s: str) -> datetime:
return datetime.strptime(s, '%Y-%m-%d %H:%M:%S')
def _serialize_time(self, dt: datetime) -> str:
return dt.strftime('%Y-%m-%d %H:%M:%S')
def _deserialize_duration(self, minutes: int) -> timedelta:
return timedelta(minutes=minutes)
def _serialize_duration(self, td: timedelta) -> int:
return int(td.total_seconds() // 60)
def _deserialize_meetings_response(self, response):
"""In-place deserialization of response data structure
Deserializes data in the structure where needed (currently, that's time-related structures)
"""
for session_data in response['rooms'].values():
session_data['room']['start_time'] = self._deserialize_time(session_data['room']['start_time'])
session_data['room']['duration'] = self._deserialize_duration(session_data['room']['duration'])
return response
def retrieve_wg_tokens(self, acronyms: Union[str, Sequence[str]]):
"""Retrieve API tokens for one or more WGs
:param acronyms: list of WG acronyms for which tokens are requested
:return: {'tokens': {acronym0: token0, acronym1: token1, ...}}
"""
return self._request(
'POST', 'auth/ietfservice/tokens',
json={
'client': self.client_id,
'secret': self.client_secret,
'wgs': [acronyms] if isinstance(acronyms, str) else acronyms,
}
)
def schedule_meeting(self, wg_token: str, description: str, start_time: datetime, duration: timedelta,
extrainfo=''):
"""Schedule a meeting session
Return structure is:
{
"rooms": {
"<session UUID>": {
"room": {
"id": int,
"start_time": datetime,
"duration": timedelta
description: str,
},
"url": str,
"deletion_token": str
}
}
}
:param wg_token: token retrieved via retrieve_wg_tokens()
:param description: str describing the meeting
:param start_time: starting time as a datetime
:param duration: duration as a timedelta
:param extrainfo: str with additional information for Meetecho staff
:return: scheduled meeting data dict
"""
return self._deserialize_meetings_response(
self._request(
'POST', 'meeting/interim/createRoom',
api_token=wg_token,
json={
'description': description,
'start_time': self._serialize_time(start_time),
'duration': self._serialize_duration(duration),
'extrainfo': extrainfo,
},
)
)
def fetch_meetings(self, wg_token: str):
"""Fetch all meetings scheduled for a given wg
Return structure is:
{
"rooms": {
"<session UUID>": {
"room": {
"id": int,
"start_time": datetime,
"duration": timedelta
"description": str,
},
"url": str,
"deletion_token": str
}
}
}
As of 2022-01-31, the return structure also includes a 'group' key whose
value is the group acronym. This is not shown in the documentation.
:param wg_token: token from retrieve_wg_tokens()
:return: meeting data dict
"""
return self._deserialize_meetings_response(
self._request('GET', 'meeting/interim/fetchRooms', api_token=wg_token)
)
def delete_meeting(self, deletion_token: str):
"""Remove a scheduled meeting
:param deletion_token: deletion_key from fetch_meetings() or schedule_meeting() return data
:return: {}
"""
return self._request('POST', 'meeting/interim/deleteRoom', api_token=deletion_token)
class MeetechoAPIError(Exception):
"""Base class for MeetechoAPI exceptions"""
class Conference:
"""Scheduled session/room representation"""
def __init__(self, manager, id, public_id, description, start_time, duration, url, deletion_token):
self._manager = manager
self.id = id # Meetecho system ID
self.public_id = public_id # public session UUID
self.description = description
self.start_time = start_time
self.duration = duration
self.url = url
self.deletion_token = deletion_token
@classmethod
def from_api_dict(cls, manager, api_dict):
# Returns a list of Conferences
return [
cls(
**val['room'],
public_id=public_id,
url=val['url'],
deletion_token=val['deletion_token'],
manager=manager,
) for public_id, val in api_dict.items()
]
def __str__(self):
return f'Meetecho conference {self.description}'
def __repr__(self):
props = [
f'description="{self.description}"',
f'start_time={repr(self.start_time)}',
f'duration={repr(self.duration)}',
]
return f'Conference({", ".join(props)})'
def __eq__(self, other):
return isinstance(other, type(self)) and all(
getattr(self, attr) == getattr(other, attr)
for attr in [
'id', 'public_id', 'description', 'start_time',
'duration', 'url', 'deletion_token'
]
)
def delete(self):
self._manager.delete_conference(self)
class ConferenceManager:
def __init__(self, api_config: dict):
self.api = MeetechoAPI(**api_config)
self.wg_tokens: Dict[str, str] = {}
def wg_token(self, group):
group_acronym = group.acronym if hasattr(group, 'acronym') else group
if group_acronym not in self.wg_tokens:
self.wg_tokens[group_acronym] = self.api.retrieve_wg_tokens(
group_acronym
)['tokens'][group_acronym]
return self.wg_tokens[group_acronym]
def fetch(self, group):
response = self.api.fetch_meetings(self.wg_token(group))
return Conference.from_api_dict(self, response['rooms'])
def create(self, group, description, start_time, duration, extrainfo=''):
response = self.api.schedule_meeting(
wg_token=self.wg_token(group),
description=description,
start_time=start_time,
duration=duration,
extrainfo=extrainfo,
)
return Conference.from_api_dict(self, response['rooms'])
def delete_by_url(self, group, url):
for conf in self.fetch(group):
if conf.url == url:
self.api.delete_meeting(conf.deletion_token)
def delete_conference(self, conf: Conference):
self.api.delete_meeting(conf.deletion_token)

View file

@ -0,0 +1,392 @@
# Copyright The IETF Trust 2021, All Rights Reserved
# -*- coding: utf-8 -*-
import datetime
import requests
import requests_mock
from unittest.mock import patch
from urllib.parse import urljoin
from django.conf import settings
from django.test import override_settings
from ietf.utils.tests import TestCase
from .meetecho import Conference, ConferenceManager, MeetechoAPI, MeetechoAPIError
API_BASE = 'https://meetecho-api.example.com'
CLIENT_ID = 'datatracker'
CLIENT_SECRET = 'very secret'
API_CONFIG={
'api_base': API_BASE,
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET,
}
@override_settings(MEETECHO_API_CONFIG=API_CONFIG)
class APITests(TestCase):
retrieve_token_url = urljoin(API_BASE, 'auth/ietfservice/tokens')
schedule_meeting_url = urljoin(API_BASE, 'meeting/interim/createRoom')
fetch_meetings_url = urljoin(API_BASE, 'meeting/interim/fetchRooms')
delete_meetings_url = urljoin(API_BASE, 'meeting/interim/deleteRoom')
def setUp(self):
super().setUp()
self.requests_mock = requests_mock.Mocker()
self.requests_mock.start()
def tearDown(self):
self.requests_mock.stop()
super().tearDown()
def test_retrieve_wg_tokens(self):
data_to_fetch = {
'tokens': {
'acro': 'wg-token-value-for-acro',
'beta': 'different-token',
'gamma': 'this-is-not-the-same',
}
}
self.requests_mock.post(self.retrieve_token_url, status_code=200, json=data_to_fetch)
api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET)
api_response = api.retrieve_wg_tokens(['acro', 'beta', 'gamma'])
self.assertTrue(self.requests_mock.called)
request = self.requests_mock.last_request
self.assertEqual(
request.headers['Content-Type'],
'application/json',
'Incorrect request content-type',
)
self.assertEqual(
request.json(),
{
'client': CLIENT_ID,
'secret': CLIENT_SECRET,
'wgs': ['acro', 'beta', 'gamma'],
}
)
self.assertEqual(api_response, data_to_fetch)
def test_schedule_meeting(self):
self.requests_mock.post(
self.schedule_meeting_url,
status_code=200,
json={
'rooms': {
'3d55bce0-535e-4ba8-bb8e-734911cf3c32': {
'room': {
'id': 18,
'start_time': '2021-09-14 10:00:00',
'duration': 130,
'description': 'interim-2021-wgname-01',
},
'url': 'https://meetings.conf.meetecho.com/interim/?short=3d55bce0-535e-4ba8-bb8e-734911cf3c32',
'deletion_token': 'session-deletion-token',
},
}
},
)
api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET)
api_response = api.schedule_meeting(
wg_token='my-token',
start_time=datetime.datetime(2021, 9, 14, 10, 0, 0),
duration=datetime.timedelta(minutes=130),
description='interim-2021-wgname-01',
extrainfo='message for staff',
)
self.assertTrue(self.requests_mock.called)
request = self.requests_mock.last_request
self.assertIn('Authorization', request.headers)
self.assertEqual(
request.headers['Content-Type'],
'application/json',
'Incorrect request content-type',
)
self.assertEqual(request.headers['Authorization'], 'bearer my-token',
'Incorrect request authorization header')
self.assertEqual(
request.json(),
{
'duration': 130,
'start_time': '2021-09-14 10:00:00',
'extrainfo': 'message for staff',
'description': 'interim-2021-wgname-01',
},
'Incorrect request content'
)
self.assertEqual(
api_response,
{
'rooms': {
'3d55bce0-535e-4ba8-bb8e-734911cf3c32': {
'room': {
'id': 18,
'start_time': datetime.datetime(2021, 9, 14, 10, 0, 0),
'duration': datetime.timedelta(minutes=130),
'description': 'interim-2021-wgname-01',
},
'url': 'https://meetings.conf.meetecho.com/interim/?short=3d55bce0-535e-4ba8-bb8e-734911cf3c32',
'deletion_token': 'session-deletion-token',
},
}
},
)
def test_fetch_meetings(self):
self.maxDiff = 2048
self.requests_mock.get(
self.fetch_meetings_url,
status_code=200,
json={
'rooms': {
'3d55bce0-535e-4ba8-bb8e-734911cf3c32': {
'room': {
'id': 18,
'start_time': '2021-09-14 10:00:00',
'duration': 130,
'description': 'interim-2021-wgname-01',
},
'url': 'https://meetings.conf.meetecho.com/interim/?short=3d55bce0-535e-4ba8-bb8e-734911cf3c32',
'deletion_token': 'session-deletion-token-01',
},
'e68e96d4-d38f-475b-9073-ecab46ca96a5': {
'room': {
'id': 23,
'start_time': '2021-09-15 14:30:00',
'duration': 30,
'description': 'interim-2021-wgname-02',
},
'url': 'https://meetings.conf.meetecho.com/interim/?short=e68e96d4-d38f-475b-9073-ecab46ca96a5',
'deletion_token': 'session-deletion-token-02',
},
}
},
)
api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET)
api_response = api.fetch_meetings(wg_token='my-token')
self.assertTrue(self.requests_mock.called)
request = self.requests_mock.last_request
self.assertIn('Authorization', request.headers)
self.assertEqual(request.headers['Authorization'], 'bearer my-token',
'Incorrect request authorization header')
self.assertEqual(
api_response,
{
'rooms': {
'3d55bce0-535e-4ba8-bb8e-734911cf3c32': {
'room': {
'id': 18,
'start_time': datetime.datetime(2021, 9, 14, 10, 0, 0),
'duration': datetime.timedelta(minutes=130),
'description': 'interim-2021-wgname-01',
},
'url': 'https://meetings.conf.meetecho.com/interim/?short=3d55bce0-535e-4ba8-bb8e-734911cf3c32',
'deletion_token': 'session-deletion-token-01',
},
'e68e96d4-d38f-475b-9073-ecab46ca96a5': {
'room': {
'id': 23,
'start_time': datetime.datetime(2021, 9, 15, 14, 30, 0),
'duration': datetime.timedelta(minutes=30),
'description': 'interim-2021-wgname-02',
},
'url': 'https://meetings.conf.meetecho.com/interim/?short=e68e96d4-d38f-475b-9073-ecab46ca96a5',
'deletion_token': 'session-deletion-token-02',
},
}
},
)
def test_delete_meeting(self):
data_to_fetch = {}
self.requests_mock.post(self.delete_meetings_url, status_code=200, json=data_to_fetch)
api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET)
api_response = api.delete_meeting(deletion_token='delete-this-meeting-please')
self.assertTrue(self.requests_mock.called)
request = self.requests_mock.last_request
self.assertIn('Authorization', request.headers)
self.assertEqual(request.headers['Authorization'], 'bearer delete-this-meeting-please',
'Incorrect request authorization header')
self.assertIsNone(request.body, 'Delete meeting request has no body')
self.assertCountEqual(api_response, data_to_fetch)
def test_request_helper_failed_requests(self):
self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'unauthorized/url/endpoint'), status_code=401)
self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'forbidden/url/endpoint'), status_code=403)
self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'notfound/url/endpoint'), status_code=404)
api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET)
for method in ['POST', 'GET']:
for code, endpoint in ((401, 'unauthorized/url/endpoint'), (403, 'forbidden/url/endpoint'), (404, 'notfound/url/endpoint')):
with self.assertRaises(Exception) as context:
api._request(method, endpoint)
self.assertIsInstance(context.exception, MeetechoAPIError)
self.assertIn(str(code), str(context.exception))
def test_request_helper_exception(self):
self.requests_mock.register_uri(requests_mock.ANY, urljoin(API_BASE, 'exception/url/endpoint'), exc=requests.exceptions.RequestException)
api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET)
for method in ['POST', 'GET']:
with self.assertRaises(Exception) as context:
api._request(method, 'exception/url/endpoint')
self.assertIsInstance(context.exception, MeetechoAPIError)
def test_time_serialization(self):
"""Time de/serialization should be consistent"""
time = datetime.datetime.now().replace(microsecond=0) # cut off to 0 microseconds
api = MeetechoAPI(API_BASE, CLIENT_ID, CLIENT_SECRET)
self.assertEqual(api._deserialize_time(api._serialize_time(time)), time)
@override_settings(MEETECHO_API_CONFIG=API_CONFIG)
class ConferenceManagerTests(TestCase):
def test_conference_from_api_dict(self):
confs = Conference.from_api_dict(
None,
{
'session-1-uuid': {
'room': {
'id': 1,
'start_time': datetime.datetime(2022,2,4,1,2,3),
'duration': datetime.timedelta(minutes=45),
'description': 'some-description',
},
'url': 'https://example.com/some/url',
'deletion_token': 'delete-me',
},
'session-2-uuid': {
'room': {
'id': 2,
'start_time': datetime.datetime(2022,2,5,4,5,6),
'duration': datetime.timedelta(minutes=90),
'description': 'another-description',
},
'url': 'https://example.com/another/url',
'deletion_token': 'delete-me-too',
},
}
)
self.assertCountEqual(
confs,
[
Conference(
manager=None,
id=1,
public_id='session-1-uuid',
description='some-description',
start_time=datetime.datetime(2022,2,4,1,2,3),
duration=datetime.timedelta(minutes=45),
url='https://example.com/some/url',
deletion_token='delete-me',
),
Conference(
manager=None,
id=2,
public_id='session-2-uuid',
description='another-description',
start_time=datetime.datetime(2022,2,5,4,5,6),
duration=datetime.timedelta(minutes=90),
url='https://example.com/another/url',
deletion_token='delete-me-too',
),
]
)
@patch.object(ConferenceManager, 'wg_token', return_value='atoken')
@patch('ietf.utils.meetecho.MeetechoAPI.fetch_meetings')
def test_fetch(self, mock_fetch, _):
mock_fetch.return_value = {
'rooms': {
'session-1-uuid': {
'room': {
'id': 1,
'start_time': datetime.datetime(2022,2,4,1,2,3),
'duration': datetime.timedelta(minutes=45),
'description': 'some-description',
},
'url': 'https://example.com/some/url',
'deletion_token': 'delete-me',
},
}
}
cm = ConferenceManager(settings.MEETECHO_API_CONFIG)
fetched = cm.fetch('acronym')
self.assertEqual(
fetched,
[Conference(
manager=cm,
id=1,
public_id='session-1-uuid',
description='some-description',
start_time=datetime.datetime(2022,2,4,1,2,3),
duration=datetime.timedelta(minutes=45),
url='https://example.com/some/url',
deletion_token='delete-me',
)],
)
self.assertEqual(mock_fetch.call_args[0], ('atoken',))
@patch.object(ConferenceManager, 'wg_token', return_value='atoken')
@patch('ietf.utils.meetecho.MeetechoAPI.schedule_meeting')
def test_create(self, mock_schedule, _):
mock_schedule.return_value = {
'rooms': {
'session-1-uuid': {
'room': {
'id': 1,
'start_time': datetime.datetime(2022,2,4,1,2,3),
'duration': datetime.timedelta(minutes=45),
'description': 'some-description',
},
'url': 'https://example.com/some/url',
'deletion_token': 'delete-me',
},
},
}
cm = ConferenceManager(settings.MEETECHO_API_CONFIG)
result = cm.create('group', 'desc', 'starttime', 'dur', 'extra')
self.assertEqual(
result,
[Conference(
manager=cm,
id=1,
public_id='session-1-uuid',
description='some-description',
start_time=datetime.datetime(2022,2,4,1,2,3),
duration=datetime.timedelta(minutes=45),
url='https://example.com/some/url',
deletion_token='delete-me',
)]
)
args, kwargs = mock_schedule.call_args
self.assertEqual(
kwargs,
{
'wg_token': 'atoken',
'description': 'desc',
'start_time': 'starttime',
'duration': 'dur',
'extrainfo': 'extra',
})
@patch('ietf.utils.meetecho.MeetechoAPI.delete_meeting')
def test_delete_conference(self, mock_delete):
cm = ConferenceManager(settings.MEETECHO_API_CONFIG)
cm.delete_conference(Conference(None, None, None, None, None, None, None, 'delete-this'))
args, kwargs = mock_delete.call_args
self.assertEqual(args, ('delete-this',))
@patch('ietf.utils.meetecho.MeetechoAPI.delete_meeting')
def test_delete_by_url(self, mock_delete):
cm = ConferenceManager(settings.MEETECHO_API_CONFIG)
cm.delete_conference(Conference(None, None, None, None, None, None, 'the-url', 'delete-this'))
args, kwargs = mock_delete.call_args
self.assertEqual(args, ('delete-this',))