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:
parent
04df65c4ff
commit
f8c7be6df9
|
@ -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"""
|
||||
|
|
|
@ -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"""
|
||||
|
|
91
ietf/meeting/management/commands/meetecho_conferences.py
Normal file
91
ietf/meeting/management/commands/meetecho_conferences.py
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
260
ietf/utils/meetecho.py
Normal 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)
|
392
ietf/utils/tests_meetecho.py
Normal file
392
ietf/utils/tests_meetecho.py
Normal 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',))
|
Loading…
Reference in a new issue