diff --git a/vc_zoom/.flake8 b/vc_zoom/.flake8 new file mode 100644 index 0000000..aff3b92 --- /dev/null +++ b/vc_zoom/.flake8 @@ -0,0 +1,37 @@ +[flake8] +max-line-length = 120 + +# colored output +format = ${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s + +# decent quote styles +inline-quotes = single +multiline-quotes = single +docstring-quotes = double +avoid-escape = true + +exclude = + build + dist + docs + ext_modules + htmlcov + indico.egg-info + node_modules + .*/ + # TODO: remove the next two entries and use extend-exclude once flake8 3.8.0 is out + .git + __pycache__ + +ignore = + # allow omitting whitespace around arithmetic operators + E226 + # don't require specific wrapping before/after binary operators + W503 + W504 + # allow assigning lambdas (it's useful for single-line functions defined inside other functions) + E731 + # while single quotes are nicer, we have double quotes in way too many places + Q000 + # for non-docstring multiline strings we don't really enforce a quote style + Q001 diff --git a/vc_zoom/.gitignore b/vc_zoom/.gitignore new file mode 100755 index 0000000..4a45354 --- /dev/null +++ b/vc_zoom/.gitignore @@ -0,0 +1,85 @@ +.vscode/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/build/ +docs/source/generated/ + +# pytest +.pytest_cache/ + +# PyBuilder +target/ + +# Editor files +#mac +.DS_Store +*~ + +#vim +*.swp +*.swo + +#pycharm +.idea/* + + +#Ipython Notebook +.ipynb_checkpoints + + +webpack-build-config.json +url_map.json diff --git a/vc_zoom/MANIFEST.in b/vc_zoom/MANIFEST.in new file mode 100644 index 0000000..facfb9e --- /dev/null +++ b/vc_zoom/MANIFEST.in @@ -0,0 +1,6 @@ +graft indico_vc_zoom/static +graft indico_vc_zoom/migrations +graft indico_vc_zoom/templates +graft indico_vc_zoom/translations + +global-exclude *.pyc __pycache__ .keep diff --git a/vc_zoom/README.md b/vc_zoom/README.md new file mode 100644 index 0000000..4b220a0 --- /dev/null +++ b/vc_zoom/README.md @@ -0,0 +1,7 @@ +Indico Plugin for Zoom based on Vidyo plugin. + +To obtain Api key and api secret, please visit [https://marketplace.zoom.us/docs/guides/auth/jwt](https://marketplace.zoom.us/docs/guides/auth/jwt) + +Not ready for production. + +Developed by Giovanni Mariano @ ENEA Frascati diff --git a/vc_zoom/conftest.py b/vc_zoom/conftest.py new file mode 100644 index 0000000..7c568e8 --- /dev/null +++ b/vc_zoom/conftest.py @@ -0,0 +1 @@ +pytest_plugins = 'indico' diff --git a/vc_zoom/indico_vc_zoom/__init__.py b/vc_zoom/indico_vc_zoom/__init__.py new file mode 100644 index 0000000..823742f --- /dev/null +++ b/vc_zoom/indico_vc_zoom/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals + +from indico.util.i18n import make_bound_gettext + + +_ = make_bound_gettext('vc_zoom') diff --git a/vc_zoom/indico_vc_zoom/api/__init__.py b/vc_zoom/indico_vc_zoom/api/__init__.py new file mode 100644 index 0000000..bd7f914 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/api/__init__.py @@ -0,0 +1,4 @@ +from .client import ZoomIndicoClient, ZoomClient + + +__all__ = ['ZoomIndicoClient', 'ZoomClient'] diff --git a/vc_zoom/indico_vc_zoom/api/client.py b/vc_zoom/indico_vc_zoom/api/client.py new file mode 100644 index 0000000..c8cf130 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/api/client.py @@ -0,0 +1,236 @@ +from __future__ import absolute_import, unicode_literals + +import time + +import jwt +from requests import Session +from requests.exceptions import HTTPError +from pytz import utc + + +def format_iso_dt(d): + """Convertdatetime objects to a UTC-based string. + + :param d: The :class:`datetime.datetime` to convert to a string + :returns: The string representation of the date + """ + return d.astimezone(utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _handle_response(resp, expected_code=200, expects_json=True): + resp.raise_for_status() + if resp.status_code != expected_code: + raise HTTPError("Unexpected status code {}".format(resp.status_code), response=resp) + if expects_json: + return resp.json() + else: + return resp + + +class APIException(Exception): + pass + + +class BaseComponent(object): + def __init__(self, base_uri, config, timeout): + self.base_uri = base_uri + self.config = config + self.timeout = timeout + + @property + def token(self): + header = {"alg": "HS256", "typ": "JWT"} + payload = {"iss": self.config['api_key'], "exp": int(time.time() + 3600)} + token = jwt.encode(payload, self.config['api_secret'], algorithm="HS256", headers=header) + return token.decode("utf-8") + + @property + def session(self): + session = Session() + session.headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {}'.format(self.token) + } + return session + + +class MeetingComponent(BaseComponent): + def list(self, user_id, **kwargs): + return self.get( + "{}/users/{}/meetings".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/{}/meetings".format(self.base_uri, user_id), + json=kwargs + ) + + def get(self, meeting_id, **kwargs): + return self.session.get("{}/meetings/{}".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( + "{}/meetings/{}".format(self.base_uri, meeting_id), json=kwargs + ) + + def delete(self, meeting_id, **kwargs): + return self.session.delete( + "{}/meetings/{}".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): + def me(self): + return self.get('me') + + def list(self, **kwargs): + return self.session.get("{}/users".format(self.base_uri), params=kwargs) + + def create(self, **kwargs): + return self.session.post("{}/users".format(self.base_uri), params=kwargs) + + def update(self, user_id, **kwargs): + return self.session.patch("{}/users/{}".format(self.base_uri, user_id), params=kwargs) + + def delete(self, user_id, **kwargs): + return self.session.delete("{}/users/{}".format(self.base_uri, user_id), params=kwargs) + + def add_assistant(self, user_id, **kwargs): + return self.session.post("{}/users/{}/assistants".format(self.base_uri, user_id), json=kwargs) + + def get_assistants(self, user_id, **kwargs): + return self.session.get("{}/users/{}/assistants".format(self.base_uri, user_id), params=kwargs) + + def get(self, user_id, **kwargs): + return self.session.get("{}/users/{}".format(self.base_uri, user_id), params=kwargs) + + +class ZoomClient(object): + """Zoom REST API Python Client.""" + + _components = { + 'user': UserComponent, + 'meeting': MeetingComponent, + 'webinar': WebinarComponent + } + + def __init__(self, api_key, api_secret, timeout=15): + """Create a new Zoom client. + + :param api_key: the Zoom JWT API key + :param api_secret: the Zoom JWT API Secret + :param timeout: the time out to use for API requests + """ + BASE_URI = "https://api.zoom.us/v2" + + # Setup the config details + config = { + "api_key": api_key, + "api_secret": api_secret + } + + # Instantiate the components + + self.components = { + key: component(base_uri=BASE_URI, config=config, timeout=timeout) + for key, component in self._components.viewitems() + } + + @property + def meeting(self): + """Get the meeting component.""" + return self.components.get("meeting") + + @property + def user(self): + """Get the user component.""" + return self.components.get("user") + + @property + def webinar(self): + """Get the user component.""" + return self.components.get("webinar") + + +class ZoomIndicoClient(object): + def __init__(self): + self._refresh_client() + + def _refresh_client(self): + from indico_vc_zoom.plugin import ZoomPlugin + settings = ZoomPlugin.settings + self.client = ZoomClient( + settings.get('api_key'), + settings.get('api_secret') + ) + + def create_meeting(self, user_id, **kwargs): + return _handle_response(self.client.meeting.create(user_id, **kwargs), 201) + + def get_meeting(self, meeting_id): + return _handle_response(self.client.meeting.get(meeting_id)) + + def update_meeting(self, meeting_id, data): + return _handle_response(self.client.meeting.update(meeting_id, **data), 204, expects_json=False) + + 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 + + def get_user(self, user_id): + return _handle_response(self.client.user.get(user_id)) + + def get_assistants_for_user(self, user_id): + return _handle_response(self.client.user.get_assistants(user_id)) + + def add_assistant_to_user(self, user_id, assistant_email): + return _handle_response(self.client.user.add_assistant(user_id, assistants=[{'email': assistant_email}]), 201) diff --git a/vc_zoom/indico_vc_zoom/blueprint.py b/vc_zoom/indico_vc_zoom/blueprint.py new file mode 100644 index 0000000..bcb7103 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/blueprint.py @@ -0,0 +1,14 @@ +from __future__ import unicode_literals + +from indico.core.plugins import IndicoPluginBlueprint + +from indico_vc_zoom.controllers import RHRoomHost + + +blueprint = IndicoPluginBlueprint('vc_zoom', 'indico_vc_zoom') + +# Room management +# using any(zoom) instead of defaults since the event vc room locator +# includes the service and normalization skips values provided in 'defaults' +blueprint.add_url_rule('/event//manage/videoconference/zoom//room-host', + 'set_room_host', RHRoomHost, methods=('POST',)) diff --git a/vc_zoom/indico_vc_zoom/cli.py b/vc_zoom/indico_vc_zoom/cli.py new file mode 100644 index 0000000..b414b30 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/cli.py @@ -0,0 +1,34 @@ + + +from __future__ import unicode_literals + +import click +from terminaltables import AsciiTable + +from indico.cli.core import cli_group +from indico.modules.vc.models.vc_rooms import VCRoom, VCRoomStatus + + +@cli_group(name='zoom') +def cli(): + """Manage the Zoom plugin.""" + + +@cli.command() +@click.option('--status', type=click.Choice(['deleted', 'created'])) +def rooms(status=None): + """Lists all Zoom rooms""" + + room_query = VCRoom.find(type='zoom') + table_data = [['ID', 'Name', 'Status', 'Zoom ID']] + + if status: + room_query = room_query.filter(VCRoom.status == VCRoomStatus.get(status)) + + for room in room_query: + table_data.append([unicode(room.id), room.name, room.status.name, unicode(room.data['zoom_id'])]) + + table = AsciiTable(table_data) + for col in (0, 3, 4): + table.justify_columns[col] = 'right' + print table.table diff --git a/vc_zoom/indico_vc_zoom/client/index.js b/vc_zoom/indico_vc_zoom/client/index.js new file mode 100644 index 0000000..361049b --- /dev/null +++ b/vc_zoom/indico_vc_zoom/client/index.js @@ -0,0 +1,34 @@ +// This file is part of the Indico plugins. +// Copyright (C) 2002 - 2019 CERN +// +// The Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; +// see the LICENSE file for more details. + +$(function() { + $('.vc-toolbar').dropdown({ + positioning: { + level1: {my: 'right top', at: 'right bottom', offset: '0px 0px'}, + }, + }); + + $('.vc-toolbar .action-make-owner').click(function() { + const $this = $(this); + + $.ajax({ + url: $this.data('href'), + method: 'POST', + complete: IndicoUI.Dialogs.Util.progress(), + }) + .done(function(result) { + if (handleAjaxError(result)) { + return; + } else { + location.reload(); + } + }) + .fail(function(error) { + handleAjaxError(error); + }); + }); +}); diff --git a/vc_zoom/indico_vc_zoom/controllers.py b/vc_zoom/indico_vc_zoom/controllers.py new file mode 100644 index 0000000..0a0c8c1 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/controllers.py @@ -0,0 +1,26 @@ + + +from __future__ import unicode_literals + +from flask import flash, jsonify, session + +from indico.core.db import db +from indico.modules.vc.controllers import RHVCSystemEventBase +from indico.modules.vc.exceptions import VCRoomError +from indico.util.i18n import _ + + +class RHRoomHost(RHVCSystemEventBase): + def _process(self): + result = {} + self.vc_room.data['host'] = session.user.identifier + try: + self.plugin.update_room(self.vc_room, self.event) + except VCRoomError as err: + result['error'] = {'message': err.message} + result['success'] = False + db.session.rollback() + else: + flash(_("You are now the host of room '{room.name}'".format(room=self.vc_room)), 'success') + result['success'] = True + return jsonify(result) diff --git a/vc_zoom/indico_vc_zoom/forms.py b/vc_zoom/indico_vc_zoom/forms.py new file mode 100644 index 0000000..10ed183 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/forms.py @@ -0,0 +1,93 @@ +from __future__ import unicode_literals +from flask import session + +from wtforms.fields.core import BooleanField +from wtforms.fields.simple import TextAreaField +from wtforms.validators import DataRequired, ValidationError + +from indico.modules.vc.forms import VCRoomAttachFormBase, VCRoomFormBase +from indico.util.user import principal_from_identifier +from indico.web.forms.base import generated_data +from indico.web.forms.fields import IndicoRadioField, PrincipalField +from indico.web.forms.validators import HiddenUnless +from indico.web.forms.widgets import SwitchWidget + +from indico_vc_zoom import _ + + +class VCRoomAttachForm(VCRoomAttachFormBase): + password_visibility = IndicoRadioField(_("Password visibility"), + description=_("Who should be able to know this meeting's password"), + orientation='horizontal', + choices=[ + ('everyone', _('Everyone')), + ('logged_in', _('Logged-in users')), + ('no_one', _("No one"))]) + + +class VCRoomForm(VCRoomFormBase): + """Contains all information concerning a Zoom booking.""" + + advanced_fields = {'mute_audio', 'mute_host_video', 'mute_participant_video'} | VCRoomFormBase.advanced_fields + + 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"))]) + + host_user = PrincipalField(_("User"), + [HiddenUnless('host_choice', 'someone_else'), DataRequired()]) + + password_visibility = IndicoRadioField(_("Password visibility"), + description=_("Who should be able to know this meeting's password"), + orientation='horizontal', + choices=[ + ('everyone', _('Everyone')), + ('logged_in', _('Logged-in users')), + ('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 ')) + + 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)'), + [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=_('Optional description for this room')) + + def __init__(self, *args, **kwargs): + defaults = kwargs['obj'] + if defaults.host_user is None and defaults.host is not None: + host = principal_from_identifier(defaults.host) + defaults.host_choice = 'myself' if host == session.user else 'someone_else' + defaults.host_user = None if host == session.user else host + super(VCRoomForm, self).__init__(*args, **kwargs) + + @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): + if not field.data: + raise ValidationError(_("Unable to find this user in Indico.")) diff --git a/vc_zoom/indico_vc_zoom/http_api.py b/vc_zoom/indico_vc_zoom/http_api.py new file mode 100644 index 0000000..7c53d69 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/http_api.py @@ -0,0 +1,51 @@ + + +from flask import request + +from indico.modules.vc.models.vc_rooms import VCRoom, VCRoomStatus +from indico.web.http_api.hooks.base import HTTPAPIHook + + +class DeleteVCRoomAPI(HTTPAPIHook): + PREFIX = 'api' + TYPES = ('deletevcroom',) + RE = r'zoom' + GUEST_ALLOWED = False + VALID_FORMATS = ('json',) + COMMIT = True + HTTP_POST = True + + def _has_access(self, user): + from indico_vc_zoom.plugin import ZoomPlugin + return user in ZoomPlugin.settings.acls.get('managers') + + def _getParams(self): + super(DeleteVCRoomAPI, self)._getParams() + self._room_ids = map(int, request.form.getlist('rid')) + + def api_deletevcroom(self, user): + from indico_vc_zoom.plugin import ZoomPlugin + from indico_vc_zoom.api import APIException + + success = [] + failed = [] + not_in_db = [] + + for rid in self._room_ids: + room = VCRoom.query.filter(VCRoom.type == 'zoom', + VCRoom.status == VCRoomStatus.created, + VCRoom.data.contains({'zoom_id': str(rid)})).first() + if not room: + not_in_db.append(rid) + continue + try: + room.plugin.delete_meeting(room, None) + except APIException: + failed.append(rid) + ZoomPlugin.logger.exception('Could not delete VC room %s', room) + else: + room.status = VCRoomStatus.deleted + success.append(rid) + ZoomPlugin.logger.info('%s deleted', room) + + return {'success': success, 'failed': failed, 'missing': not_in_db} diff --git a/vc_zoom/indico_vc_zoom/notifications.py b/vc_zoom/indico_vc_zoom/notifications.py new file mode 100644 index 0000000..f04885e --- /dev/null +++ b/vc_zoom/indico_vc_zoom/notifications.py @@ -0,0 +1,37 @@ +from __future__ import unicode_literals + +from indico.web.flask.templating import get_template_module +from indico.core.notifications import make_email, send_email +from indico.util.user import principal_from_identifier + + +def notify_host_start_url(vc_room): + from indico_vc_zoom.plugin import ZoomPlugin + + user = principal_from_identifier(vc_room.data['host']) + to_list = {user.email} + + template_module = get_template_module( + 'vc_zoom:emails/notify_start_url.html', + plugin=ZoomPlugin.instance, + vc_room=vc_room, + user=user + ) + + email = make_email(to_list, template=template_module, html=True) + send_email(email, None, 'Zoom') + + +def notify_new_host(actor, vc_room): + from indico_vc_zoom.plugin import ZoomPlugin + + template_module = get_template_module( + 'vc_zoom:emails/notify_new_host.html', + plugin=ZoomPlugin.instance, + vc_room=vc_room, + actor=actor + ) + + new_host = principal_from_identifier(vc_room.data['host']) + email = make_email({new_host.email}, cc_list={actor.email}, template=template_module, html=True) + send_email(email, None, 'Zoom') diff --git a/vc_zoom/indico_vc_zoom/plugin.py b/vc_zoom/indico_vc_zoom/plugin.py new file mode 100644 index 0000000..8cfbf62 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/plugin.py @@ -0,0 +1,478 @@ +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') diff --git a/vc_zoom/indico_vc_zoom/static/images/zoom_logo.png b/vc_zoom/indico_vc_zoom/static/images/zoom_logo.png new file mode 100644 index 0000000..c8347b4 Binary files /dev/null and b/vc_zoom/indico_vc_zoom/static/images/zoom_logo.png differ diff --git a/vc_zoom/indico_vc_zoom/templates/buttons.html b/vc_zoom/indico_vc_zoom/templates/buttons.html new file mode 100644 index 0000000..c5cef9a --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/buttons.html @@ -0,0 +1,21 @@ +{% macro render_join_button(vc_room, event_vc_room, extra_classes="", is_manager=false) %} + {% if event_vc_room.data.password_visibility == 'everyone' or is_manager or + (session.user and event_vc_room.data.password_visibility == 'logged_in') %} + + {% trans %}Join{% endtrans %} + + {% elif event_vc_room.data.password_visibility == 'no_one' %} + + {% trans %}Join{% endtrans %} + + {% else %} + + {% trans %}Please log in{% endtrans %} + + {% endif %} +{% endmacro %} diff --git a/vc_zoom/indico_vc_zoom/templates/emails/created.html b/vc_zoom/indico_vc_zoom/templates/emails/created.html new file mode 100644 index 0000000..1dbafeb --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/emails/created.html @@ -0,0 +1,20 @@ +{% extends 'vc/emails/created.html' %} + +{% block plugin_specific_info %} +
  • + Host: + {{ (vc_room.data.host|decodeprincipal).full_name }} +
  • +
  • + Zoom URL: + {{ vc_room.data.url }} +
  • + +{% endblock %} + +{% block custom_footer %} + {% if plugin.settings.get('creation_email_footer') %} +
    + {{ plugin.settings.get('creation_email_footer') | sanitize_html }} + {% endif %} +{% endblock %} diff --git a/vc_zoom/indico_vc_zoom/templates/emails/deleted.html b/vc_zoom/indico_vc_zoom/templates/emails/deleted.html new file mode 100644 index 0000000..a67c1f1 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/emails/deleted.html @@ -0,0 +1 @@ +{% extends 'vc/emails/deleted.html' %} diff --git a/vc_zoom/indico_vc_zoom/templates/emails/notify_new_host.html b/vc_zoom/indico_vc_zoom/templates/emails/notify_new_host.html new file mode 100644 index 0000000..225aa88 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/emails/notify_new_host.html @@ -0,0 +1,25 @@ +{% extends 'emails/base.html' %} + +{% block subject -%} + [{{ plugin.friendly_name }}] You are now hosting '{{ vc_room.name }}' +{%- endblock %} + +{% block header -%} +

    + {{ actor.full_name }} has just made you the host of Zoom Meeting '{{ vc_room.name }}'. +

    + {% if plugin.settings.get('send_host_url') %} +

    + ATTENTION: + You should not share this URL with anyone, since it will allow them to become meeting hosts! +

    + + {% endif %} + + {% block custom_footer %}{% endblock %} +{%- endblock %} diff --git a/vc_zoom/indico_vc_zoom/templates/emails/notify_start_url.html b/vc_zoom/indico_vc_zoom/templates/emails/notify_start_url.html new file mode 100644 index 0000000..320a3af --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/emails/notify_start_url.html @@ -0,0 +1,20 @@ +{% extends 'emails/base.html' %} + +{% block subject -%} + [{{ plugin.friendly_name }}] Host URL - {{ vc_room.name }} +{%- endblock %} + +{% block header -%} +

    + ATTENTION: + You should not share this URL with anyone, since it will allow them to become meeting hosts! +

    + + + {% block custom_footer %}{% endblock %} +{%- endblock %} diff --git a/vc_zoom/indico_vc_zoom/templates/emails/remote_deleted.html b/vc_zoom/indico_vc_zoom/templates/emails/remote_deleted.html new file mode 100644 index 0000000..1c0c580 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/emails/remote_deleted.html @@ -0,0 +1,16 @@ +{% extends 'emails/base.html' %} + +{% block subject -%} + [{{ plugin.friendly_name }}] Room deleted from server: {{ vc_room.name }} +{%- endblock %} + +{% block header -%} +

    + The Zoom room "{{ vc_room.name }}" has been deleted from the Zoom server since it has not been used by any recent event. +

    +

    + You won't be able to attach it to any future events. If you need to do so, please create a new room. +

    +{% block custom_footer %}{% endblock %} + +{%- endblock %} diff --git a/vc_zoom/indico_vc_zoom/templates/event_buttons.html b/vc_zoom/indico_vc_zoom/templates/event_buttons.html new file mode 100644 index 0000000..fd9fcf1 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/event_buttons.html @@ -0,0 +1,6 @@ +{% extends 'vc/event_buttons.html' %} +{% from 'vc_zoom:buttons.html' import render_join_button %} + +{% block buttons %} + {{ render_join_button(vc_room, event_vc_room, "i-button-small event-service-right-button join-button") }} +{% endblock %} diff --git a/vc_zoom/indico_vc_zoom/templates/info_box.html b/vc_zoom/indico_vc_zoom/templates/info_box.html new file mode 100644 index 0000000..376b442 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/info_box.html @@ -0,0 +1,32 @@ +{% from '_clipboard_input.html' import clipboard_input %} +{% set host = vc_room.data.host %} +{% set phone_link = settings.get('zoom_phone_link') %} +
    +
    {% trans %}Zoom Meeting ID{% endtrans %}
    +
    {{ vc_room.data.zoom_id }}
    + {% if host %} +
    {% trans %}Host{% endtrans %}
    +
    {{ (host|decodeprincipal).full_name }}
    + {% endif %} + {% if event_vc_room.data.password_visibility == 'everyone' or is_manager or + (session.user and event_vc_room.data.password_visibility == 'logged_in') %} +
    {% trans %}Password{% endtrans %}
    +
    {{ vc_room.data.password }}
    + {% endif %} + {% if event_vc_room.data.show_autojoin %} +
    {% trans %}Zoom URL{% endtrans %}
    +
    + {{ clipboard_input(vc_room.data.url, name="vc-room-url-%s"|format(event_vc_room.id)) }} +
    + {% endif %} + {% if event_vc_room.data.show_phone_numbers and phone_link %} +
    + {% trans %}Useful links{% endtrans %} +
    +
    + + {% trans %}Phone numbers{% endtrans %} + +
    + {% endif %} +
    diff --git a/vc_zoom/indico_vc_zoom/templates/manage_event_info_box.html b/vc_zoom/indico_vc_zoom/templates/manage_event_info_box.html new file mode 100644 index 0000000..ebbffcf --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/manage_event_info_box.html @@ -0,0 +1,37 @@ +{% from '_password.html' import password %} +{% from '_clipboard_input.html' import clipboard_input %} +{% set host = vc_room.data.host %} +{% set phone_link = settings.get('zoom_phone_link') %} +
    +
    {% trans %}Zoom Meeting ID{% endtrans %}
    +
    {{ vc_room.data.zoom_id }}
    +
    {% trans %}Host{% endtrans %}
    +
    + {{ (host|decodeprincipal).full_name }} +
    +
    {% trans %}Linked to{% endtrans %}
    +
    + {% set obj = event_vc_room.link_object %} + {% if obj is none %} + (missing {{ event_vc_room.link_type.name }}) + {% elif event_vc_room.link_type.name == 'event' %} + {% trans %}the whole event{% endtrans %} + {% elif event_vc_room.link_type.name == 'contribution' %} + {% trans %}Contribution{% endtrans %}: {{ obj.title }} + {% elif event_vc_room.link_type.name == 'block' %} + {% trans %}Session{% endtrans %}: {{ obj.full_title }} + {% endif %} +
    +
    {% trans %}Password{% endtrans %}
    +
    {{ vc_room.data.password }}
    +
    {% trans %}Zoom URL{% endtrans %}
    +
    + {{ clipboard_input(vc_room.data.url, name="vc-room-url") }} +
    +
    {% trans %}Created on{% endtrans %}
    +
    {{ vc_room.created_dt | format_datetime(timezone=event.tzinfo) }}
    + {% if vc_room.modified_dt %} +
    {% trans %}Modified on{% endtrans %}
    +
    {{ vc_room.modified_dt | format_datetime(timezone=event.tzinfo) }}
    + {% endif %} +
    diff --git a/vc_zoom/indico_vc_zoom/templates/management_buttons.html b/vc_zoom/indico_vc_zoom/templates/management_buttons.html new file mode 100644 index 0000000..ac8a8b6 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/management_buttons.html @@ -0,0 +1,6 @@ +{% extends 'vc/management_buttons.html' %} +{% from 'vc_zoom:buttons.html' import render_join_button %} + +{% block buttons %} + {{ render_join_button(vc_room, event_vc_room, extra_classes="icon-play", is_manager=true) }} +{% endblock %} diff --git a/vc_zoom/indico_vc_zoom/templates/room_labels.html b/vc_zoom/indico_vc_zoom/templates/room_labels.html new file mode 100644 index 0000000..36d0865 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/room_labels.html @@ -0,0 +1,6 @@ +{% if vc_room.data.meeting_type == 'webinar' %} +
    + {% trans %}Webinar{% endtrans %} +
    +{% endif %} diff --git a/vc_zoom/indico_vc_zoom/templates/vc_room_timetable_buttons.html b/vc_zoom/indico_vc_zoom/templates/vc_room_timetable_buttons.html new file mode 100644 index 0000000..c1db6a8 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/templates/vc_room_timetable_buttons.html @@ -0,0 +1,7 @@ +{% extends 'vc/vc_room_timetable_buttons.html' %} +{% from 'vc_zoom:buttons.html' import render_join_button %} +{% set vc_room = event_vc_room.vc_room %} + +{% block buttons %} + {{ render_join_button(vc_room, event_vc_room, "i-button-small event-service-right-button join-button") }} +{% endblock %} diff --git a/vc_zoom/indico_vc_zoom/translations/fr_FR/LC_MESSAGES/messages-js.po b/vc_zoom/indico_vc_zoom/translations/fr_FR/LC_MESSAGES/messages-js.po new file mode 100644 index 0000000..fe16c6c --- /dev/null +++ b/vc_zoom/indico_vc_zoom/translations/fr_FR/LC_MESSAGES/messages-js.po @@ -0,0 +1,23 @@ +# Translations template for PROJECT. +# Copyright (C) 2015 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# +# Translators: +# Thomas Baron , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Indico\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2015-03-11 16:21+0100\n" +"PO-Revision-Date: 2015-03-12 12:52+0000\n" +"Last-Translator: Thomas Baron \n" +"Language-Team: French (France) (http://www.transifex.com/projects/p/indico/language/fr_FR/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 1.3\n" +"Language: fr_FR\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "Indico" +msgstr "Indico" diff --git a/vc_zoom/indico_vc_zoom/translations/fr_FR/LC_MESSAGES/messages.po b/vc_zoom/indico_vc_zoom/translations/fr_FR/LC_MESSAGES/messages.po new file mode 100644 index 0000000..652e3e6 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/translations/fr_FR/LC_MESSAGES/messages.po @@ -0,0 +1,289 @@ +# Translations template for PROJECT. +# Copyright (C) 2017 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# +# Translators: +# Thomas Baron , 2015,2017 +msgid "" +msgstr "" +"Project-Id-Version: Indico\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2017-10-18 11:55+0200\n" +"PO-Revision-Date: 2017-10-30 11:04+0000\n" +"Last-Translator: Thomas Baron \n" +"Language-Team: French (France) (http://www.transifex.com/indico/indico/language/fr_FR/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.5.1\n" +"Language: fr_FR\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: indico_vc_zoom/controllers.py:38 +msgid "You are now the owner of the room '{room.name}'" +msgstr "Vous êtes maintenant responsable de la salle '{room.name}'" + +#: indico_vc_zoom/forms.py:32 +msgid "The PIN must be a number" +msgstr "Le code confidentiel doit être un nombre entier" + +#: indico_vc_zoom/forms.py:37 +msgid "Show PIN" +msgstr "Afficher le code confidentiel" + +#: indico_vc_zoom/forms.py:39 +msgid "Show the VC Room PIN on the event page (insecure!)" +msgstr "Afficher le code confidentiel de la salle Vidyo sur la page de l'événement (peu sûr!)" + +#: indico_vc_zoom/forms.py:40 +msgid "Show Auto-join URL" +msgstr "Afficher l'URL de connexion" + +#: indico_vc_zoom/forms.py:42 +msgid "Show the auto-join URL on the event page" +msgstr "Afficher l'URL de connexion sur la page de l'événement" + +#: indico_vc_zoom/forms.py:43 +msgid "Show Phone Access numbers" +msgstr "Afficher les numéros d'accès téléphonique" + +#: indico_vc_zoom/forms.py:45 +msgid "Show a link to the list of phone access numbers" +msgstr "Afficher un lien vers la liste des numéros d'accès téléphonique" + +#: indico_vc_zoom/forms.py:58 indico_vc_zoom/templates/info_box.html:7 +#: indico_vc_zoom/templates/manage_event_info_box.html:6 +msgid "Description" +msgstr "Description" + +#: indico_vc_zoom/forms.py:58 +msgid "The description of the room" +msgstr "La description de la salle" + +#: indico_vc_zoom/forms.py:59 indico_vc_zoom/templates/info_box.html:14 +#: indico_vc_zoom/templates/manage_event_info_box.html:10 +msgid "Owner" +msgstr "Responsable" + +#: indico_vc_zoom/forms.py:59 +msgid "The owner of the room" +msgstr "Le responsable de la salle" + +#: indico_vc_zoom/forms.py:60 +#: indico_vc_zoom/templates/manage_event_info_box.html:39 +msgid "Moderation PIN" +msgstr "Code confidentiel de modération" + +#: indico_vc_zoom/forms.py:61 +msgid "Used to moderate the VC Room. Only digits allowed." +msgstr "Utilisé pour modérer la salle de VC. Seuls les chiffres sont autorisés." + +#: indico_vc_zoom/forms.py:62 indico_vc_zoom/templates/info_box.html:18 +#: indico_vc_zoom/templates/manage_event_info_box.html:32 +msgid "Room PIN" +msgstr "Code confidentiel de la salle" + +#: indico_vc_zoom/forms.py:63 +msgid "" +"Used to protect the access to the VC Room (leave blank for open access). " +"Only digits allowed." +msgstr "Utilisé pour protéger l'accès à la salle de VC (laisser vide pour un accès ouvert). Seuls les chiffres sont autorisés." + +#: indico_vc_zoom/forms.py:65 +msgid "Auto mute" +msgstr "Coupure automatique des périphériques d'entrée" + +#: indico_vc_zoom/forms.py:66 +msgid "On" +msgstr "Activé" + +#: indico_vc_zoom/forms.py:66 +msgid "Off" +msgstr "Désactivé" + +#: indico_vc_zoom/forms.py:67 +msgid "" +"The VidyoDesktop clients will join the VC room muted by default (audio and " +"video)" +msgstr "Les clients VidyoDesktop rejoindront la salle Vidyo avec le micro et la caméra coupés par défaut" + +#: indico_vc_zoom/forms.py:82 +msgid "Unable to find this user in Indico." +msgstr "Impossible de trouver cet utilisateur dans Indico." + +#: indico_vc_zoom/forms.py:84 +msgid "This user does not have a suitable account to use Vidyo." +msgstr "Cet utilisateur n'a pas de compte qui lui permet d'utiliser Vidyo." + +#: indico_vc_zoom/plugin.py:49 +msgid "Vidyo email support" +msgstr "Adresse électronique de l'assistance Vidyo" + +#: indico_vc_zoom/plugin.py:50 +msgid "Username" +msgstr "Identifiant" + +#: indico_vc_zoom/plugin.py:50 +msgid "Indico username for Vidyo" +msgstr "Identifiant Indico pour Vidyo" + +#: indico_vc_zoom/plugin.py:51 +msgid "Password" +msgstr "Mot de passe" + +#: indico_vc_zoom/plugin.py:52 +msgid "Indico password for Vidyo" +msgstr "Mot de passe utilisateur pour Vidyo" + +#: indico_vc_zoom/plugin.py:53 +msgid "Admin API WSDL URL" +msgstr "URL WSDL pour l'API admin" + +#: indico_vc_zoom/plugin.py:54 +msgid "User API WSDL URL" +msgstr "URL WSDL pour l'API utilisateur" + +#: indico_vc_zoom/plugin.py:55 +msgid "Indico tenant prefix" +msgstr "Préfixe de tenant pour Indico" + +#: indico_vc_zoom/plugin.py:56 +msgid "The tenant prefix for Indico rooms created on this server" +msgstr "Le préfixe de tenant pour les salles Vidyo créées sur ce serveur Indico" + +#: indico_vc_zoom/plugin.py:57 +msgid "Public rooms' group name" +msgstr "Nom du groupe Vidyo pour les salles Vidyo" + +#: indico_vc_zoom/plugin.py:58 +msgid "Group name for public videoconference rooms created by Indico" +msgstr "Nom du groupe pour les salles publiques de visioconférence créées par Indico" + +#: indico_vc_zoom/plugin.py:59 +msgid "Authenticators" +msgstr "Services d'authentification" + +#: indico_vc_zoom/plugin.py:60 +msgid "Identity providers to convert Indico users to Vidyo accounts" +msgstr "Fournisseurs d'identité pour convertir des utilisateurs Indico en comptes Vidyo" + +#: indico_vc_zoom/plugin.py:61 +msgid "VC room age threshold" +msgstr "Limite d'âge pour les salles Vidyo" + +#: indico_vc_zoom/plugin.py:62 +msgid "" +"Number of days after an Indico event when a videoconference room is " +"considered old" +msgstr "Nombre de jours à partir de la fin d'un événement dans Indico après lesquels une salle de visioconférence est considérée comme agée" + +#: indico_vc_zoom/plugin.py:64 +msgid "Max. num. VC rooms before warning" +msgstr "Nombre maximum de salles Vidyo avant un message d'alerte" + +#: indico_vc_zoom/plugin.py:65 +msgid "Maximum number of rooms until a warning is sent to the managers" +msgstr "Nombre maximum de salles Vidyo créées sur ce serveur avant qu'un message d'alerte soit envoyé aux administrateurs" + +#: indico_vc_zoom/plugin.py:66 +msgid "VidyoVoice phone number" +msgstr "Numéros de téléphone VidyoVoice" + +#: indico_vc_zoom/plugin.py:67 +msgid "Link to the list of VidyoVoice phone numbers" +msgstr "Lien vers la liste des numéros de téléphones VidyoVoice" + +#: indico_vc_zoom/plugin.py:68 +msgid "Client Chooser URL" +msgstr "URL de sélection du client" + +#: indico_vc_zoom/plugin.py:69 +msgid "" +"URL for client chooser interface. The room key will be passed as a 'url' GET" +" query argument" +msgstr "L'URL pour l'interface de sélection du client. Le code de la salle sera passé comme argument de requête GET." + +#: indico_vc_zoom/plugin.py:71 +msgid "Creation email footer" +msgstr "Pied de page du courriel de création" + +#: indico_vc_zoom/plugin.py:72 +msgid "Footer to append to emails sent upon creation of a VC room" +msgstr "Pied de page ajouté au courriel envoyé à la création d'une nouvelle salle Vidyo" + +#: indico_vc_zoom/plugin.py:162 indico_vc_zoom/plugin.py:202 +#: indico_vc_zoom/plugin.py:240 indico_vc_zoom/plugin.py:269 +msgid "No valid Vidyo account found for this user" +msgstr "Pas de compte Vidyo valide pour cet utilisateur" + +#: indico_vc_zoom/plugin.py:198 indico_vc_zoom/plugin.py:263 +msgid "Room name already in use" +msgstr "Ce nom de salle est déjà utilisé" + +#: indico_vc_zoom/plugin.py:213 +msgid "Could not find newly created room in Vidyo" +msgstr "Impossible de trouver la nouvelle salle dans Vidyo" + +#: indico_vc_zoom/plugin.py:232 indico_vc_zoom/plugin.py:259 +#: indico_vc_zoom/plugin.py:288 +msgid "This room has been deleted from Vidyo" +msgstr "Cette salle a été supprimée de Vidyo" + +#: indico_vc_zoom/templates/buttons.html:7 +#, python-format +msgid "You will be the owner of this Vidyo room, replacing %(name)s." +msgstr "Vous deviendrez le responsable de cette salle Vidyo, à la place de %(name)s." + +#: indico_vc_zoom/templates/buttons.html:9 +msgid "Make me owner" +msgstr "Nommez moi responsable" + +#: indico_vc_zoom/templates/buttons.html:19 +msgid "Join" +msgstr "Rejoindre" + +#: indico_vc_zoom/templates/info_box.html:5 +msgid "Name" +msgstr "Nom" + +#: indico_vc_zoom/templates/info_box.html:10 +#: indico_vc_zoom/templates/manage_event_info_box.html:8 +msgid "Extension" +msgstr "Extension" + +#: indico_vc_zoom/templates/info_box.html:22 +#: indico_vc_zoom/templates/manage_event_info_box.html:45 +msgid "Auto-join URL" +msgstr "URL pour connexion à la salle" + +#: indico_vc_zoom/templates/info_box.html:29 +msgid "Useful links" +msgstr "Liens utiles" + +#: indico_vc_zoom/templates/info_box.html:33 +msgid "Phone numbers" +msgstr "Numéros de téléphone" + +#: indico_vc_zoom/templates/manage_event_info_box.html:18 +msgid "Linked to" +msgstr "attachée à" + +#: indico_vc_zoom/templates/manage_event_info_box.html:24 +msgid "the whole event" +msgstr "l'événement entier" + +#: indico_vc_zoom/templates/manage_event_info_box.html:26 +msgid "Contribution" +msgstr "La contribution" + +#: indico_vc_zoom/templates/manage_event_info_box.html:28 +msgid "Session" +msgstr "La session" + +#: indico_vc_zoom/templates/manage_event_info_box.html:49 +msgid "Created on" +msgstr "Créée le " + +#: indico_vc_zoom/templates/manage_event_info_box.html:52 +msgid "Modified on" +msgstr "Modifiée le " diff --git a/vc_zoom/indico_vc_zoom/util.py b/vc_zoom/indico_vc_zoom/util.py new file mode 100644 index 0000000..cfb4ba5 --- /dev/null +++ b/vc_zoom/indico_vc_zoom/util.py @@ -0,0 +1,23 @@ +from __future__ import unicode_literals + + +from indico.core.db import db +from indico.modules.users.models.emails import UserEmail +from indico.modules.users.models.users import User + + +def find_enterprise_email(user): + """Find a user's first e-mail address which can be used by the Zoom API. + + :param user: the `User` in question + :return: the e-mail address if it exists, otherwise `None` + """ + from indico_vc_zoom.plugin import ZoomPlugin + providers = [auth.strip() for auth in ZoomPlugin.settings.get('email_domains').split(',')] + result = UserEmail.query.filter( + UserEmail.user == user, + ~User.is_blocked, + ~User.is_deleted, + db.or_(UserEmail.email.ilike("%%@{}".format(provider)) for provider in providers) + ).join(User).first() + return result.email if result else None diff --git a/vc_zoom/pytest.ini b/vc_zoom/pytest.ini new file mode 100644 index 0000000..cba3f73 --- /dev/null +++ b/vc_zoom/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +; more verbose summary (include skip/fail/error/warning), coverage +addopts = -rsfEw --cov . --cov-report html --no-cov-on-fail +; only check for tests in suffixed files +python_files = *_test.py +; we need the livesync plugin to be loaded +indico_plugins = vc_zoom diff --git a/vc_zoom/setup.cfg b/vc_zoom/setup.cfg new file mode 100644 index 0000000..a5814c8 --- /dev/null +++ b/vc_zoom/setup.cfg @@ -0,0 +1,2 @@ +[pydocstyle] +ignore = D100,D101,D102,D103,D104,D105,D107,D203,D213 diff --git a/vc_zoom/setup.py b/vc_zoom/setup.py new file mode 100644 index 0000000..62f4b20 --- /dev/null +++ b/vc_zoom/setup.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +from setuptools import find_packages, setup + + +setup( + name='indico-plugin-vc-zoom', + version='0.3-dev', + description='Zoom video-conferencing plugin for Indico', + url='', + license='MIT', + author='Giovanni Mariano - ENEA', + author_email='giovanni.mariano@enea.it', + packages=find_packages(), + zip_safe=False, + include_package_data=True, + install_requires=[ + 'indico>=2', + 'PyJWT' + ], + classifiers=[ + 'Environment :: Plugins', + 'Environment :: Web Environment', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 2.7' + ], + entry_points={'indico.plugins': {'vc_zoom = indico_vc_zoom.plugin:ZoomPlugin'}} +) diff --git a/vc_zoom/tests/task_test.py b/vc_zoom/tests/task_test.py new file mode 100644 index 0000000..06f10b4 --- /dev/null +++ b/vc_zoom/tests/task_test.py @@ -0,0 +1,22 @@ + +from datetime import datetime + +import pytest +from pytz import utc + +from indico.modules.vc.models.vc_rooms import VCRoom, VCRoomEventAssociation, VCRoomStatus + + +@pytest.fixture +def create_dummy_room(db, dummy_user): + """Returns a callable which lets you create dummy Zoom room occurrences""" + pass + + +def test_room_cleanup(create_event, create_dummy_room, freeze_time, db): + """Test that 'old' Zoom rooms are correctly detected""" + freeze_time(datetime(2015, 2, 1)) + + pass + + assert {r.id for r in find_old_zoom_rooms(180)} == {2, 3, 5} diff --git a/vc_zoom/webpack-bundles.json b/vc_zoom/webpack-bundles.json new file mode 100644 index 0000000..5f28835 --- /dev/null +++ b/vc_zoom/webpack-bundles.json @@ -0,0 +1,5 @@ +{ + "entry": { + "main": "./index.js" + } +}