From 354df7b1d71ffde15399af96b4ca0b57696843bd Mon Sep 17 00:00:00 2001 From: Pedro Ferreira Date: Wed, 30 Sep 2020 09:24:13 +0200 Subject: [PATCH] VC/Zoom: Add webinar mode --- indico_vc_zoom/api/client.py | 49 +++++++- indico_vc_zoom/forms.py | 14 ++- indico_vc_zoom/plugin.py | 142 ++++++++++++++++------ indico_vc_zoom/templates/info_box.html | 2 +- indico_vc_zoom/templates/room_labels.html | 6 + 5 files changed, 170 insertions(+), 43 deletions(-) create mode 100644 indico_vc_zoom/templates/room_labels.html diff --git a/indico_vc_zoom/api/client.py b/indico_vc_zoom/api/client.py index 8dbc9e7..c8cf130 100644 --- a/indico_vc_zoom/api/client.py +++ b/indico_vc_zoom/api/client.py @@ -83,8 +83,35 @@ class MeetingComponent(BaseComponent): "{}/meetings/{}".format(self.base_uri, meeting_id), json=kwargs ) - def get_invitation(self, meeting_id, **kwargs): - return self.session.get("{}/meetings/{}/invitation".format(self.base_uri, meeting_id), json=kwargs) + +class WebinarComponent(BaseComponent): + def list(self, user_id, **kwargs): + return self.get( + "{}/users/{}/webinars".format(self.base_uri, user_id), params=kwargs + ) + + def create(self, user_id, **kwargs): + if kwargs.get("start_time"): + kwargs["start_time"] = format_iso_dt(kwargs["start_time"]) + return self.session.post( + "{}/users/{}/webinars".format(self.base_uri, user_id), + json=kwargs + ) + + def get(self, meeting_id, **kwargs): + return self.session.get("{}/webinars/{}".format(self.base_uri, meeting_id), json=kwargs) + + def update(self, meeting_id, **kwargs): + if kwargs.get("start_time"): + kwargs["start_time"] = format_iso_dt(kwargs["start_time"]) + return self.session.patch( + "{}/webinars/{}".format(self.base_uri, meeting_id), json=kwargs + ) + + def delete(self, meeting_id, **kwargs): + return self.session.delete( + "{}/webinars/{}".format(self.base_uri, meeting_id), json=kwargs + ) class UserComponent(BaseComponent): @@ -118,7 +145,8 @@ class ZoomClient(object): _components = { 'user': UserComponent, - 'meeting': MeetingComponent + 'meeting': MeetingComponent, + 'webinar': WebinarComponent } def __init__(self, api_key, api_secret, timeout=15): @@ -180,12 +208,21 @@ class ZoomIndicoClient(object): def update_meeting(self, meeting_id, data): return _handle_response(self.client.meeting.update(meeting_id, **data), 204, expects_json=False) - def get_meeting_invitation(self, meeting_id): - return _handle_response(self.client.meeting.get_invitation(meeting_id)) - def delete_meeting(self, meeting_id): return _handle_response(self.client.meeting.delete(meeting_id), 204, expects_json=False) + def create_webinar(self, user_id, **kwargs): + return _handle_response(self.client.webinar.create(user_id, **kwargs), 201) + + def get_webinar(self, webinar_id): + return _handle_response(self.client.webinar.get(webinar_id)) + + def update_webinar(self, webinar_id, data): + return _handle_response(self.client.webinar.update(webinar_id, **data), 204, expects_json=False) + + def delete_webinar(self, webinar_id): + return _handle_response(self.client.webinar.delete(webinar_id), 204, expects_json=False) + def check_user_meeting_time(self, user_id, start_dt, end_dt): pass diff --git a/indico_vc_zoom/forms.py b/indico_vc_zoom/forms.py index 1c19a0c..10ed183 100644 --- a/indico_vc_zoom/forms.py +++ b/indico_vc_zoom/forms.py @@ -32,6 +32,13 @@ class VCRoomForm(VCRoomFormBase): skip_fields = advanced_fields | VCRoomFormBase.conditional_fields + meeting_type = IndicoRadioField(_("Meeting Type"), + description=_("The type of Zoom meeting ot be created"), + orientation='horizontal', + choices=[ + ('regular', _('Regular Meeting')), + ('webinar', _('Webinar'))]) + host_choice = IndicoRadioField(_("Meeting Host"), [DataRequired()], choices=[('myself', _("Myself")), ('someone_else', _("Someone else"))]) @@ -47,6 +54,7 @@ class VCRoomForm(VCRoomFormBase): ('no_one', _("No one"))]) mute_audio = BooleanField(_('Mute audio'), + [HiddenUnless('meeting_type', 'regular')], widget=SwitchWidget(), description=_('Participants will join the VC room muted by default ')) @@ -55,14 +63,16 @@ class VCRoomForm(VCRoomFormBase): description=_('The host will join the VC room with video disabled')) mute_participant_video = BooleanField(_('Mute video (participants)'), + [HiddenUnless('meeting_type', 'regular')], widget=SwitchWidget(), description=_('Participants will join the VC room with video disabled')) waiting_room = BooleanField(_('Waiting room'), + [HiddenUnless('meeting_type', 'regular')], widget=SwitchWidget(), description=_('Participants may be kept in a waiting room by the host')) - description = TextAreaField(_('Description'), description=_('The description of the room')) + description = TextAreaField(_('Description'), description=_('Optional description for this room')) def __init__(self, *args, **kwargs): defaults = kwargs['obj'] @@ -74,6 +84,8 @@ class VCRoomForm(VCRoomFormBase): @generated_data def host(self): + if self.host_choice is None: + return None return session.user.identifier if self.host_choice.data == 'myself' else self.host_user.data.identifier def validate_host_user(self, field): diff --git a/indico_vc_zoom/plugin.py b/indico_vc_zoom/plugin.py index 6af14be..8cfbf62 100644 --- a/indico_vc_zoom/plugin.py +++ b/indico_vc_zoom/plugin.py @@ -15,12 +15,13 @@ from wtforms.validators import DataRequired, NumberRange from indico.core import signals from indico.core.config import config -from indico.core.plugins import IndicoPlugin, url_for_plugin +from indico.core.plugins import IndicoPlugin, render_plugin_template, url_for_plugin from indico.modules.events.views import WPSimpleEventDisplay from indico.modules.vc import VCPluginMixin, VCPluginSettingsFormBase from indico.modules.vc.exceptions import VCRoomError, VCRoomNotFoundError from indico.modules.vc.models.vc_rooms import VCRoom from indico.modules.vc.views import WPVCEventPage, WPVCManageEvent +from indico.util.date_time import now_utc from indico.util.user import principal_from_identifier from indico.web.forms.fields.simple import IndicoPasswordField from indico.web.forms.widgets import CKEditorWidget, SwitchWidget @@ -40,12 +41,14 @@ def _gen_random_password(): return ''.join(random.sample(string.ascii_lowercase + string.ascii_uppercase + string.digits, 10)) -def _fetch_zoom_meeting(vc_room, client=None): +def _fetch_zoom_meeting(vc_room, client=None, is_webinar=False): try: client = client or ZoomIndicoClient() + if is_webinar: + return client.get_webinar(vc_room.data['zoom_id']) return client.get_meeting(vc_room.data['zoom_id']) except HTTPError as e: - if e.response.status_code == 404: + if e.response.status_code in {400, 404}: # Indico will automatically mark this room as deleted raise VCRoomNotFoundError(_("This room has been deleted from Zoom")) else: @@ -53,19 +56,27 @@ def _fetch_zoom_meeting(vc_room, client=None): raise VCRoomError(_("Problem fetching room from Zoom. Please contact support if the error persists.")) -def _update_zoom_meeting(zoom_id, changes): +def _update_zoom_meeting(zoom_id, changes, is_webinar=False): client = ZoomIndicoClient() try: - client.update_meeting(zoom_id, changes) + if is_webinar: + client.update_webinar(zoom_id, changes) + else: + client.update_meeting(zoom_id, changes) except HTTPError as e: ZoomPlugin.logger.exception("Error updating meeting '%s': %s", zoom_id, e.response.content) raise VCRoomError(_("Can't update meeting. Please contact support if the error persists.")) def _get_schedule_args(obj): + duration = obj.end_dt - obj.start_dt + + if obj.start_dt < now_utc(): + return {} + return { 'start_time': obj.start_dt, - 'duration': (obj.end_dt - obj.start_dt).total_seconds() / 60, + 'duration': duration.total_seconds() / 60, } @@ -136,6 +147,7 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): super(ZoomPlugin, self).init() self.connect(signals.plugin.cli, self._extend_indico_cli) self.connect(signals.event.times_changed, self._times_changed) + self.template_hook('event-vc-room-list-item-labels', self._render_vc_room_labels) self.inject_bundle('main.js', WPSimpleEventDisplay) self.inject_bundle('main.js', WPVCEventPage) self.inject_bundle('main.js', WPVCManageEvent) @@ -169,6 +181,25 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): def icon_url(self): return url_for_plugin(self.name + '.static', filename='images/zoom_logo.png') + def create_form(self, event, existing_vc_room=None, existing_event_vc_room=None): + """Override the default room form creation mechanism.""" + form = super(ZoomPlugin, self).create_form( + event, + existing_vc_room=existing_vc_room, + existing_event_vc_room=existing_event_vc_room + ) + + if existing_vc_room: + # if we're editing a VC room, we will not allow the meeting type to be changed + form.meeting_type.render_kw = {'disabled': True} + + if form.data['meeting_type'] == 'webinar': + # webinar hosts cannot be changed through the API + form.host_choice.render_kw = {'disabled': True} + form.host_user.render_kw = {'disabled': True} + + return form + def _extend_indico_cli(self, sender, **kwargs): return cli @@ -200,11 +231,20 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): room_assoc.data['password_visibility'] = data.pop('password_visibility') flag_modified(room_assoc, 'data') - def update_data_vc_room(self, vc_room, data): + def update_data_vc_room(self, vc_room, data, is_new=False): super(ZoomPlugin, self).update_data_vc_room(vc_room, data) + fields = {'description'} + if data['meeting_type'] == 'webinar': + fields |= {'mute_host_video'} + if is_new: + fields |= {'host', 'meeting_type'} + else: + fields |= { + 'meeting_type', 'host', 'mute_audio', 'mute_participant_video', 'mute_host_video', 'join_before_host', + 'waiting_room' + } - for key in {'description', 'host', 'mute_audio', 'mute_participant_video', 'mute_host_video', - 'join_before_host', 'waiting_room'}: + for key in fields: if key in data: vc_room.data[key] = data.pop(key) @@ -237,32 +277,50 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): """ client = ZoomIndicoClient() host = principal_from_identifier(vc_room.data['host']) - host_id = find_enterprise_email(host) + host_email = find_enterprise_email(host) # get the object that this booking is linked to vc_room_assoc = vc_room.events[0] link_obj = vc_room_assoc.link_object - + is_webinar = vc_room.data['meeting_type'] == 'webinar' scheduling_args = _get_schedule_args(link_obj) if link_obj.start_dt else {} - self._check_indico_is_assistant(host_id) + self._check_indico_is_assistant(host_email) try: settings = { 'host_video': vc_room.data['mute_host_video'], - 'participant_video': not vc_room.data['mute_participant_video'], - 'join_before_host': self.settings.get('join_before_host'), - 'mute_upon_entry': vc_room.data['mute_audio'], - 'waiting_room': vc_room.data['waiting_room'] } - meeting_obj = client.create_meeting(self.settings.get('assistant_id'), - type=2 if scheduling_args else 3, # scheduled vs. recurring meeting - topic=vc_room.name, - password=_gen_random_password(), - schedule_for=host_id, - timezone=event.timezone, - settings=settings, - **scheduling_args) + + kwargs = {} + if is_webinar: + kwargs = { + 'type': 5 if scheduling_args else 6, + 'host_email': host_email + } + else: + kwargs = { + 'type': 2 if scheduling_args else 3, + 'schedule_for': host_email + } + settings.update({ + 'mute_upon_entry': vc_room.data['mute_audio'], + 'participant_video': not vc_room.data['mute_participant_video'], + 'waiting_room': vc_room.data['waiting_room'], + 'join_before_host': self.settings.get('join_before_host'), + }) + + kwargs.update({ + 'topic': vc_room.name, + 'password': _gen_random_password(), + 'timezone': event.timezone, + 'settings': settings + }) + kwargs.update(scheduling_args) + if is_webinar: + meeting_obj = client.create_webinar(self.settings.get('assistant_id'), **kwargs) + else: + meeting_obj = client.create_meeting(self.settings.get('assistant_id'), **kwargs) except HTTPError as e: self.logger.exception('Error creating Zoom Room: %s', e.response.content) raise VCRoomError(_("Could not create the room in Zoom. Please contact support if the error persists")) @@ -284,7 +342,8 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): def update_room(self, vc_room, event): client = ZoomIndicoClient() - zoom_meeting = _fetch_zoom_meeting(vc_room, client=client) + is_webinar = vc_room.data['meeting_type'] == 'webinar' + zoom_meeting = _fetch_zoom_meeting(vc_room, client=client, is_webinar=is_webinar) changes = {} host = principal_from_identifier(vc_room.data['host']) @@ -303,7 +362,7 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): if not email: raise Forbidden(_("This user doesn't seem to have an associated Zoom account")) - changes['schedule_for'] = email + changes['host_email' if is_webinar else 'schedule_for'] = email self._check_indico_is_assistant(email) notify_new_host(session.user, vc_room) @@ -311,20 +370,23 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): changes['topic'] = vc_room.name zoom_meeting_settings = zoom_meeting['settings'] - if vc_room.data['mute_audio'] != zoom_meeting_settings['mute_upon_entry']: - changes.setdefault('settings', {})['mute_upon_entry'] = vc_room.data['mute_audio'] - if vc_room.data['mute_participant_video'] == zoom_meeting_settings['participant_video']: - changes.setdefault('settings', {})['participant_video'] = not vc_room.data['mute_participant_video'] if vc_room.data['mute_host_video'] == zoom_meeting_settings['host_video']: changes.setdefault('settings', {})['host_video'] = not vc_room.data['mute_host_video'] - if vc_room.data['waiting_room'] != zoom_meeting_settings['waiting_room']: - changes.setdefault('settings', {})['waiting_room'] = vc_room.data['waiting_room'] + + if not is_webinar: + if vc_room.data['mute_audio'] != zoom_meeting_settings['mute_upon_entry']: + changes.setdefault('settings', {})['mute_upon_entry'] = vc_room.data['mute_audio'] + if vc_room.data['mute_participant_video'] == zoom_meeting_settings['participant_video']: + changes.setdefault('settings', {})['participant_video'] = not vc_room.data['mute_participant_video'] + if vc_room.data['waiting_room'] != zoom_meeting_settings['waiting_room']: + changes.setdefault('settings', {})['waiting_room'] = vc_room.data['waiting_room'] if changes: - _update_zoom_meeting(vc_room.data['zoom_id'], changes) + _update_zoom_meeting(vc_room.data['zoom_id'], changes, is_webinar=is_webinar) def refresh_room(self, vc_room, event): - zoom_meeting = _fetch_zoom_meeting(vc_room) + is_webinar = vc_room.data['meeting_type'] == 'webinar' + zoom_meeting = _fetch_zoom_meeting(vc_room, is_webinar=is_webinar) vc_room.name = zoom_meeting['topic'] vc_room.data.update({ 'url': zoom_meeting['join_url'], @@ -336,8 +398,12 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): def delete_room(self, vc_room, event): client = ZoomIndicoClient() zoom_id = vc_room.data['zoom_id'] + is_webinar = vc_room.data['meeting_type'] == 'webinar' try: - client.delete_meeting(zoom_id) + if is_webinar: + client.delete_webinar(zoom_id) + else: + client.delete_meeting(zoom_id) except HTTPError as e: # if there's a 404, there is no problem, since the room is supposed to be gone anyway if not e.response.status_code == 404: @@ -350,6 +416,7 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): def get_vc_room_form_defaults(self, event): defaults = super(ZoomPlugin, self).get_vc_room_form_defaults(event) defaults.update({ + 'meeting_type': 'regular', 'mute_audio': self.settings.get('mute_audio'), 'mute_host_video': self.settings.get('mute_host_video'), 'mute_participant_video': self.settings.get('mute_participant_video'), @@ -382,6 +449,11 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): def get_notification_cc_list(self, action, vc_room, event): return {principal_from_identifier(vc_room.data['host']).email} + def _render_vc_room_labels(self, event, vc_room, **kwargs): + if vc_room.plugin != self: + return + return render_plugin_template('room_labels.html', vc_room=vc_room) + def _times_changed(self, sender, obj, **kwargs): from indico.modules.events.models.events import Event from indico.modules.events.contributions.models.contributions import Contribution diff --git a/indico_vc_zoom/templates/info_box.html b/indico_vc_zoom/templates/info_box.html index acbb647..376b442 100644 --- a/indico_vc_zoom/templates/info_box.html +++ b/indico_vc_zoom/templates/info_box.html @@ -2,7 +2,7 @@ {% set host = vc_room.data.host %} {% set phone_link = settings.get('zoom_phone_link') %}
-
{% trans %}Meeting ID{% endtrans %}
+
{% trans %}Zoom Meeting ID{% endtrans %}
{{ vc_room.data.zoom_id }}
{% if host %}
{% trans %}Host{% endtrans %}
diff --git a/indico_vc_zoom/templates/room_labels.html b/indico_vc_zoom/templates/room_labels.html new file mode 100644 index 0000000..36d0865 --- /dev/null +++ b/indico_vc_zoom/templates/room_labels.html @@ -0,0 +1,6 @@ +{% if vc_room.data.meeting_type == 'webinar' %} +
+ {% trans %}Webinar{% endtrans %} +
+{% endif %}