2020-11-25 15:49:44 +01:00

479 lines
21 KiB
Python

from __future__ import unicode_literals
import random
import string
from flask import flash, session
from requests.exceptions import HTTPError
from sqlalchemy.orm.attributes import flag_modified
from werkzeug.exceptions import Forbidden, NotFound
from wtforms.fields.core import BooleanField
from wtforms.fields import IntegerField, TextAreaField
from wtforms.fields.html5 import EmailField, URLField
from wtforms.fields.simple import StringField
from wtforms.validators import DataRequired, NumberRange
from indico.core import signals
from indico.core.config import config
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
from indico.web.http_api.hooks.base import HTTPAPIHook
from indico_vc_zoom import _
from indico_vc_zoom.api import ZoomIndicoClient
from indico_vc_zoom.blueprint import blueprint
from indico_vc_zoom.cli import cli
from indico_vc_zoom.forms import VCRoomAttachForm, VCRoomForm
from indico_vc_zoom.http_api import DeleteVCRoomAPI
from indico_vc_zoom.notifications import notify_new_host, notify_host_start_url
from indico_vc_zoom.util import find_enterprise_email
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, 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 in {400, 404}:
# Indico will automatically mark this room as deleted
raise VCRoomNotFoundError(_("This room has been deleted from Zoom"))
else:
ZoomPlugin.logger.exception('Error getting Zoom Room: %s', e.response.content)
raise VCRoomError(_("Problem fetching room from Zoom. Please contact support if the error persists."))
def _update_zoom_meeting(zoom_id, changes, is_webinar=False):
client = ZoomIndicoClient()
try:
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': duration.total_seconds() / 60,
}
class PluginSettingsForm(VCPluginSettingsFormBase):
support_email = EmailField(_('Zoom email support'))
api_key = StringField(_('API Key'), [DataRequired()])
api_secret = IndicoPasswordField(_('API Secret'), [DataRequired()], toggle=True)
email_domains = StringField(_('E-mail domains'), [DataRequired()],
description=_("Comma-separated list of e-mail domains which can use the Zoom API."))
assistant_id = StringField(_('Assistant Zoom ID'), [DataRequired()],
description=_('Account to be used as owner of all rooms. It will get "assistant" '
'privileges on all accounts for which it books rooms'))
mute_audio = BooleanField(_('Mute audio'),
widget=SwitchWidget(),
description=_('Participants will join the VC room muted by default '))
mute_host_video = BooleanField(_('Mute video (host)'),
widget=SwitchWidget(),
description=_('The host will join the VC room with video disabled'))
mute_participant_video = BooleanField(_('Mute video (participants)'),
widget=SwitchWidget(),
description=_('Participants will join the VC room with video disabled'))
join_before_host = BooleanField(_('Join Before Host'),
widget=SwitchWidget(),
description=_('Allow participants to join the meeting before the host starts the '
'meeting. Only used for scheduled or recurring meetings.'))
waiting_room = BooleanField(_('Waiting room'),
widget=SwitchWidget(),
description=_('Participants may be kept in a waiting room by the host'))
num_days_old = IntegerField(_('VC room age threshold'), [NumberRange(min=1), DataRequired()],
description=_('Number of days after an Indico event when a videoconference room is '
'considered old'))
max_rooms_warning = IntegerField(_('Max. num. VC rooms before warning'), [NumberRange(min=1), DataRequired()],
description=_('Maximum number of rooms until a warning is sent to the managers'))
zoom_phone_link = URLField(_('ZoomVoice phone number'),
description=_('Link to the list of ZoomVoice phone numbers'))
creation_email_footer = TextAreaField(_('Creation email footer'), widget=CKEditorWidget(),
description=_('Footer to append to emails sent upon creation of a VC room'))
send_host_url = BooleanField(_('Send host URL'),
widget=SwitchWidget(),
description=_('Whether to send an e-mail with the Host URL to the meeting host upon '
'creation of a meeting'))
class ZoomPlugin(VCPluginMixin, IndicoPlugin):
"""Zoom
Zoom Plugin for Indico."""
configurable = True
settings_form = PluginSettingsForm
vc_room_form = VCRoomForm
vc_room_attach_form = VCRoomAttachForm
friendly_name = 'Zoom'
def init(self):
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)
HTTPAPIHook.register(DeleteVCRoomAPI)
@property
def default_settings(self):
return dict(VCPluginMixin.default_settings, **{
'support_email': config.SUPPORT_EMAIL,
'assistant_id': config.SUPPORT_EMAIL,
'api_key': '',
'api_secret': '',
'email_domains': '',
'mute_host_video': True,
'mute_audio': True,
'mute_participant_video': True,
'join_before_host': True,
'waiting_room': False,
'num_days_old': 5,
'max_rooms_warning': 5000,
'zoom_phone_link': None,
'creation_email_footer': None,
'send_host_url': False
})
@property
def logo_url(self):
return url_for_plugin(self.name + '.static', filename='images/zoom_logo.png')
@property
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
def update_data_association(self, event, vc_room, room_assoc, data):
# XXX: This feels slightly hacky. Maybe we should change the API on the core?
association_is_new = room_assoc.vc_room is None
old_link = room_assoc.link_object
super(ZoomPlugin, self).update_data_association(event, vc_room, room_assoc, data)
if vc_room.data:
# this is not a new room
if association_is_new:
# this means we are updating an existing meeting with a new vc_room-event association
_update_zoom_meeting(vc_room.data['zoom_id'], {
'start_time': None,
'duration': None,
'type': 3
})
elif room_assoc.link_object != old_link:
# the booking should now be linked to something else
new_schedule_args = _get_schedule_args(room_assoc.link_object)
meeting = _fetch_zoom_meeting(vc_room)
current_schedule_args = {k: meeting[k] for k in {'start_time', 'duration'}}
# check whether the start time / duration of the scheduled meeting differs
if new_schedule_args != current_schedule_args:
_update_zoom_meeting(vc_room.data['zoom_id'], new_schedule_args)
room_assoc.data['password_visibility'] = data.pop('password_visibility')
flag_modified(room_assoc, '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 fields:
if key in data:
vc_room.data[key] = data.pop(key)
flag_modified(vc_room, 'data')
def _check_indico_is_assistant(self, user_id):
client = ZoomIndicoClient()
assistant_id = self.settings.get('assistant_id')
if user_id != assistant_id:
try:
assistants = {assist['email'] for assist in client.get_assistants_for_user(user_id)['assistants']}
except HTTPError as e:
if e.response.status_code == 404:
raise NotFound(_("No Zoom account found for this user"))
self.logger.exception('Error getting assistants for account %s: %s', user_id, e.response.content)
raise VCRoomError(_("Problem getting information about Zoom account"))
if assistant_id not in assistants:
client.add_assistant_to_user(user_id, assistant_id)
def create_room(self, vc_room, event):
"""Create a new Zoom room for an event, given a VC room.
In order to create the Zoom room, the function will try to get
a valid e-mail address for the user in question, which can be
use with the Zoom API.
:param vc_room: the VC room from which to create the Zoom room
:param event: the event to the Zoom room will be attached
"""
client = ZoomIndicoClient()
host = principal_from_identifier(vc_room.data['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_email)
try:
settings = {
'host_video': vc_room.data['mute_host_video'],
}
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"))
vc_room.data.update({
'zoom_id': unicode(meeting_obj['id']),
'url': meeting_obj['join_url'],
'public_url': meeting_obj['join_url'].split('?')[0],
'start_url': meeting_obj['start_url'],
'password': meeting_obj['password'],
'host': host.identifier
})
flag_modified(vc_room, 'data')
# e-mail Host URL to meeting host
if self.settings.get('send_host_url'):
notify_host_start_url(vc_room)
def update_room(self, vc_room, event):
client = ZoomIndicoClient()
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'])
host_id = zoom_meeting['host_id']
try:
host_data = client.get_user(host_id)
except HTTPError as e:
self.logger.exception("Error retrieving user '%s': %s", host_id, e.response.content)
raise VCRoomError(_("Can't get information about user. Please contact support if the error persists."))
# host changed
if host_data['email'] not in host.all_emails:
email = find_enterprise_email(host)
if not email:
raise Forbidden(_("This user doesn't seem to have an associated Zoom account"))
changes['host_email' if is_webinar else 'schedule_for'] = email
self._check_indico_is_assistant(email)
notify_new_host(session.user, vc_room)
if vc_room.name != zoom_meeting['topic']:
changes['topic'] = vc_room.name
zoom_meeting_settings = zoom_meeting['settings']
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 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, is_webinar=is_webinar)
def refresh_room(self, vc_room, event):
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'],
'public_url': zoom_meeting['join_url'].split('?')[0],
'zoom_id': zoom_meeting['id']
})
flag_modified(vc_room, 'data')
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:
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:
self.logger.exception('Error getting Zoom Room: %s', e.response.content)
raise VCRoomError(_("Problem fetching room from Zoom. Please contact support if the error persists."))
def get_blueprints(self):
return blueprint
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'),
'waiting_room': self.settings.get('waiting_room'),
'host_choice': 'myself',
'host_user': None,
'password_visibility': 'logged_in'
})
return defaults
def get_vc_room_attach_form_defaults(self, event):
defaults = super(ZoomPlugin, self).get_vc_room_attach_form_defaults(event)
defaults['password_visibility'] = 'logged_in'
return defaults
def can_manage_vc_room(self, user, room):
return (
user == principal_from_identifier(room.data['host']) or
super(ZoomPlugin, self).can_manage_vc_room(user, room)
)
def _merge_users(self, target, source, **kwargs):
super(ZoomPlugin, self)._merge_users(target, source, **kwargs)
for room in VCRoom.query.filter(
VCRoom.type == self.service_name, VCRoom.data.contains({'host': source.identifier})
):
room.data['host'] = target.id
flag_modified(room, 'data')
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
from indico.modules.events.sessions.models.blocks import SessionBlock
if not hasattr(obj, 'vc_room_associations'):
return
if any(assoc.vc_room.type == 'zoom' and len(assoc.vc_room.events) == 1 for assoc in obj.vc_room_associations):
if sender == Event:
message = _("There are one or more scheduled Zoom meetings associated with this event which were not "
"automatically updated.")
elif sender == Contribution:
message = _("There are one or more scheduled Zoom meetings associated with contribution '{}' which "
" were not automatically updated.").format(obj.title)
elif sender == SessionBlock:
message = _("There are one or more scheduled Zoom meetings associated with this session block which "
"were not automatically updated.")
else:
return
flash(message, 'warning')