Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions vc_zoom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
- Never use a PMI meeting ID when creating a new meeting
- Add automatic Zoom registration: event managers can opt-in to automatically register/unregister
Indico participants in the corresponding Zoom meeting or webinar
- Automatically check in Indico registrations when a participant joins the corresponding Zoom
meeting or webinar (opt-in per room, requires automatic registration to be enabled on the room)

### 3.3.5

Expand Down Expand Up @@ -166,6 +168,8 @@ Select the following "Event types":
* `Meeting has been deleted`
* `Webinar has been updated`
* `Webinar has been deleted`
* `Meeting participant joined` (optional, only needed for automatic check-in)
* `Webinar participant joined` (optional, only needed for automatic check-in)


## Plugin Configuration
Expand Down
18 changes: 18 additions & 0 deletions vc_zoom/indico_vc_zoom/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from webargs.flaskparser import use_kwargs
from werkzeug.exceptions import Forbidden, ServiceUnavailable

from indico.core import signals
from indico.core.db import db
from indico.core.errors import UserValueError
from indico.modules.vc.controllers import RHVCSystemEventBase
Expand Down Expand Up @@ -98,5 +99,22 @@ def _handle_zoom_event(self, event, payload):
elif event in ('meeting.deleted', 'webinar.deleted'):
current_plugin.logger.info('Zoom meeting deleted: %s', meeting_id)
vc_room.status = VCRoomStatus.deleted
elif event in ('meeting.participant_joined', 'webinar.participant_joined'):
if not vc_room.data.get('auto_register') or not vc_room.data.get('auto_checkin'):
return
email = payload['object'].get('participant', {}).get('email', '')
if not email:
current_plugin.logger.debug('participant_joined with no email for meeting %s', meeting_id)
return
registration = current_plugin._find_registration_by_participant_email(vc_room, email)
if registration is None:
current_plugin.logger.debug('No Indico registration for email %s in meeting %s', email, meeting_id)
return
if registration.checked_in:
return
registration.checked_in = True
signals.event.registration_checkin_updated.send(registration)
current_plugin.logger.info('Checked in registration %s via Zoom webhook (meeting %s)',
registration.id, meeting_id)
else:
current_plugin.logger.warning('Unhandled Zoom webhook payload: %s', event)
54 changes: 54 additions & 0 deletions vc_zoom/indico_vc_zoom/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@
# see the LICENSE file for more details.

import itertools
from datetime import datetime
from zoneinfo import ZoneInfo

import pytest

from indico.core.plugins import plugin_engine
from indico.modules.events.registration.models.forms import RegistrationForm
from indico.modules.events.registration.models.items import RegistrationFormItemType, RegistrationFormSection
from indico.modules.events.registration.util import create_personal_data_fields


TZ = ZoneInfo('Europe/Zurich')


@pytest.fixture
Expand All @@ -26,6 +34,27 @@ def zoom_plugin(app):
return plugin


@pytest.fixture
def zoom_user(zoom_api):
return zoom_api['user']


@pytest.fixture
def reg_form(create_event, db):
event = create_event(
start_dt=datetime(2024, 3, 1, 16, 0, tzinfo=TZ),
end_dt=datetime(2024, 3, 1, 18, 0, tzinfo=TZ),
)
regform = RegistrationForm(event=event, title='Test Form', currency='EUR')
section = RegistrationFormSection(registration_form=regform, title='Personal Data',
type=RegistrationFormItemType.section_pd)
regform.sections.append(section)
create_personal_data_fields(regform)
event.registration_forms.append(regform)
db.session.flush()
return regform


@pytest.fixture
def create_zoom_meeting(db, test_client, no_csrf_check, smtp, zoom_api):
def _create(obj, link_type, zoom_name='Zoom Meeting', **kwargs):
Expand Down Expand Up @@ -128,3 +157,28 @@ def _get_meeting(id_, *args, **kwargs):
'api_delete_meeting': api_delete_meeting,
'get_user': api_get_user,
}


@pytest.fixture
def zoom_api_registrants(zoom_api, mocker):
zoom_api['add_meeting_registrant'] = mocker.patch('indico_vc_zoom.plugin.ZoomIndicoClient.add_meeting_registrant')
zoom_api['add_webinar_registrant'] = mocker.patch('indico_vc_zoom.plugin.ZoomIndicoClient.add_webinar_registrant')
zoom_api['update_meeting_registrants_status'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.update_meeting_registrants_status')
zoom_api['update_webinar_registrants_status'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.update_webinar_registrants_status')
zoom_api['list_meeting_registrants'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.list_meeting_registrants')
zoom_api['list_webinar_registrants'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.list_webinar_registrants')
zoom_api['batch_meeting_registrants'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.batch_meeting_registrants')
zoom_api['batch_webinar_registrants'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.batch_webinar_registrants')

zoom_api['add_meeting_registrant'].return_value = {}
zoom_api['add_webinar_registrant'].return_value = {}
zoom_api['list_meeting_registrants'].return_value = {'registrants': [{'id': 'reg123', 'email': 'test@example.com'}]}
zoom_api['list_webinar_registrants'].return_value = {'registrants': [{'id': 'reg123', 'email': 'test@example.com'}]}

return zoom_api
9 changes: 8 additions & 1 deletion vc_zoom/indico_vc_zoom/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ class VCRoomForm(VCRoomFormBase):
description=_('Automatically register Indico registrants in this Zoom '
'meeting/webinar. Consider setting "Passcode visibility" '
'to "No one" so participants only receive their '
'personalized join link.'))
'personalized join link'))

auto_checkin = BooleanField(_('Automatic check-in'),
[HiddenUnless('auto_register')],
widget=SwitchWidget(),
description=_('Automatically check in registrants when they join this '
'Zoom meeting/webinar'))

description = TextAreaField(_('Description'), description=_('Optional description for this meeting'))

Expand Down Expand Up @@ -162,6 +168,7 @@ def __init__(self, *args, **kwargs):

if not current_plugin.settings.get('allow_auto_register'):
del self.auto_register
del self.auto_checkin

if not current_plugin.settings.get('allow_language_interpretation'):
del self.language_interpretation
Expand Down
15 changes: 14 additions & 1 deletion vc_zoom/indico_vc_zoom/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ def update_data_association(self, event, vc_room, room_assoc, data):
def update_data_vc_room(self, vc_room, data, is_new=False):
auto_register_before = vc_room.data.get('auto_register') if not is_new else None
super().update_data_vc_room(vc_room, data, is_new=is_new)
fields = {'description', 'password', 'auto_register'}
fields = {'description', 'password', 'auto_register', 'auto_checkin'}

# we may end up not getting a meeting_type from the form
# (i.e. webinars are disabled)
Expand Down Expand Up @@ -989,6 +989,19 @@ def _add_batch_registrants(self, client, zoom_id, entries, is_webinar):
self.logger.info(f'Batch-added registrants to Zoom {zoom_type} %s: %s', # noqa: G004
zoom_id, emails)

def _find_registration_by_participant_email(self, vc_room, email):
event_ids = {assoc.event_id for assoc in vc_room.events}
if not event_ids:
return None
candidates = (Registration.query
.join(Registration.registration_form)
.filter(RegistrationForm.event_id.in_(event_ids),
Registration.state.in_([RegistrationState.complete, RegistrationState.unpaid]),
~Registration.is_deleted,
~RegistrationForm.is_deleted))
email_lower = email.lower()
return next((c for c in candidates if self._get_registrant_email(c).lower() == email_lower), None)

def _remove_registrants(self, client, zoom_id, vc_room, email_ids, is_webinar):
if not email_ids:
return
Expand Down
55 changes: 3 additions & 52 deletions vc_zoom/tests/registration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
# them and/or modify them under the terms of the MIT License;
# see the LICENSE file for more details.

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from datetime import timedelta

import pytest
from flask import session
Expand All @@ -21,55 +20,6 @@
from indico.modules.vc.models.vc_rooms import VCRoom, VCRoomEventAssociation, VCRoomStatus


TZ = ZoneInfo('Europe/Zurich')


@pytest.fixture
def zoom_user(create_user):
return create_user(1, email='don.orange@megacorp.xyz')


@pytest.fixture
def reg_form(create_event, db):
event = create_event(
start_dt=datetime(2024, 3, 1, 16, 0, tzinfo=TZ),
end_dt=datetime(2024, 3, 1, 18, 0, tzinfo=TZ),
)
regform = RegistrationForm(event=event, title='Test Form', currency='EUR')
# Add personal data section
section = RegistrationFormSection(registration_form=regform, title='Personal Data',
type=RegistrationFormItemType.section_pd)
regform.sections.append(section)
create_personal_data_fields(regform)
event.registration_forms.append(regform)
db.session.flush()
return regform


@pytest.fixture
def zoom_api_registrants(zoom_api, mocker):
zoom_api['add_meeting_registrant'] = mocker.patch('indico_vc_zoom.plugin.ZoomIndicoClient.add_meeting_registrant')
zoom_api['add_webinar_registrant'] = mocker.patch('indico_vc_zoom.plugin.ZoomIndicoClient.add_webinar_registrant')
zoom_api['update_meeting_registrants_status'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.update_meeting_registrants_status')
zoom_api['update_webinar_registrants_status'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.update_webinar_registrants_status')
zoom_api['list_meeting_registrants'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.list_meeting_registrants')
zoom_api['list_webinar_registrants'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.list_webinar_registrants')

zoom_api['batch_meeting_registrants'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.batch_meeting_registrants')
zoom_api['batch_webinar_registrants'] = mocker.patch(
'indico_vc_zoom.plugin.ZoomIndicoClient.batch_webinar_registrants')

zoom_api['list_meeting_registrants'].return_value = {'registrants': [{'id': 'reg123', 'email': 'test@example.com'}]}
zoom_api['list_webinar_registrants'].return_value = {'registrants': [{'id': 'reg123', 'email': 'test@example.com'}]}

return zoom_api


def test_registration_sync_meeting(db, zoom_plugin, zoom_api_registrants, reg_form, create_zoom_meeting, test_client,
zoom_user):
zoom_plugin.settings.set('allow_auto_register', True)
Expand Down Expand Up @@ -321,14 +271,15 @@ def test_registration_form_deleted_batches_removals(db, zoom_plugin, zoom_api_re
assert cancelled_emails == {'alice@example.com', 'bob@example.com'}


def _create_vc_room_with_assoc(db, event, zoom_user, *, auto_register=True):
def _create_vc_room_with_assoc(db, event, zoom_user, *, auto_register=True, auto_checkin=False):
"""Create a Zoom VCRoom + association for an event without going through the HTTP endpoint."""
vc_room = VCRoom(name='Test Meeting', type='zoom', status=VCRoomStatus.created, created_by_user=zoom_user)
vc_room.data = {
'zoom_id': 'zmeeting_manual',
'meeting_type': 'regular',
'host': 'User:1',
'auto_register': auto_register,
'auto_checkin': auto_checkin,
}
assoc = VCRoomEventAssociation(link_object=event, vc_room=vc_room, show=True,
data={'password_visibility': 'everyone'})
Expand Down
Loading