Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zoom Rooms Plugin #173

Merged
merged 13 commits into from
Nov 4, 2024
Merged
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ jobs:
- plugin: cern_access
- plugin: payment_cern
- plugin: ravem
- plugin: zoom_rooms

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -202,7 +203,7 @@ jobs:
echo "$(pwd)/.venv/bin" >> $GITHUB_PATH

- name: Install extra dependencies
if: matrix.plugin == 'ravem'
if: matrix.plugin == 'ravem' || matrix.plugin == 'zoom_rooms'
run: |
pip install responses
pip install "indico-plugin-vc-zoom @ git+https://github.com/${PLUGINS_REPO}.git@${PLUGINS_BRANCH}#subdirectory=vc_zoom"
Expand Down
2 changes: 1 addition & 1 deletion audiovisual/indico_audiovisual/templates/event_header.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</div>
<div class="event-service-toolbar toolbar right">
<div class="group">
<a class="i-button i-button-small event-service-right-button highlight join-button" href="{{ url }}"><strong>{% trans %}Watch{% endtrans %}</strong></a>
<a class="i-button i-button-small highlight" href="{{ url }}"><strong>{% trans %}Watch{% endtrans %}</strong></a>
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[mypy]
disable_error_code = misc, import-untyped, import-not-found
11 changes: 3 additions & 8 deletions ravem/indico_ravem/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
const ravemButton = (function makeRavemButton() {
const states = {
connected: {
icon: 'icon-no-camera',
icon: 'x',
tooltip: $t.gettext('Disconnect {0} from the videoconference room {1}'),
action: 'disconnect',
handler: function disconnectHandler(data, btn) {
Expand Down Expand Up @@ -45,7 +45,7 @@
},
},
disconnected: {
icon: 'icon-camera',
icon: 'video',
tooltip: $t.gettext('Connect {0} to the videoconference room {1}'),
action: 'connect',
handler: function connectHandler(data, btn) {
Expand Down Expand Up @@ -197,12 +197,7 @@

const name = btn.data('roomName');
const vcRoomName = btn.data('vcRoomName');
const html = [
'<span class="',
states[newState].icon,
'"><strong style="margin-left: 0.4em;">{0}</strong></span>'.format(name),
].join('');

const html = `<i class="icon ${states[newState].icon}"></i> ${name}`;
btn.html(html);
btn.toggleClass('disabled', !states[newState].action); // Whether the button should be disabled

Expand Down
4 changes: 2 additions & 2 deletions ravem/indico_ravem/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ def init(self):
self.template_hook('manage-event-vc-extra-buttons',
partial(self.inject_connect_button, 'ravem_button.html'))
self.template_hook('event-vc-extra-buttons',
partial(self.inject_connect_button, 'ravem_button_group.html'))
partial(self.inject_connect_button, 'ravem_button.html'))
self.template_hook('event-timetable-vc-extra-buttons',
partial(self.inject_connect_button, 'ravem_button_group.html'))
partial(self.inject_connect_button, 'ravem_button.html'))

self.inject_bundle('main.js', WPSimpleEventDisplay)
self.inject_bundle('main.js', WPVCEventPage)
Expand Down
9 changes: 0 additions & 9 deletions ravem/indico_ravem/templates/_ravem_button.html

This file was deleted.

10 changes: 8 additions & 2 deletions ravem/indico_ravem/templates/ravem_button.html
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
{% from 'ravem:_ravem_button.html' import ravem_button %}
{{ ravem_button(room_name, event_vc_room) }}
<a href="#" class="ui mini button js-ravem-button"
style="margin: 0;"
data-room-name="{{ room_name }}"
data-vc-room-name="{{ event_vc_room.vc_room.name }}"
data-status-url="{{ url_for_plugin('ravem.room_status', event_vc_room.event, event_vc_room_id=event_vc_room.id) }}"
data-connect-url="{{ url_for_plugin('ravem.connect_room', event_vc_room.event, event_vc_room_id=event_vc_room.id) }}"
data-disconnect-url="{{ url_for_plugin('ravem.disconnect_room', event_vc_room.event, event_vc_room_id=event_vc_room.id) }}">
</a>
4 changes: 0 additions & 4 deletions ravem/indico_ravem/templates/ravem_button_group.html

This file was deleted.

1 change: 1 addition & 0 deletions zoom_rooms/.header.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
start_year: 2024
52 changes: 52 additions & 0 deletions zoom_rooms/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Zoom Rooms Plugin

## Features

- Synchronizes the calendars of Zoom Rooms-powered devices with the corresponding room occupancy/Zoom meeting

## Changelog

### 3.3

First version

## Details

[**Zoom Rooms**](https://www.zoom.com/en/products/meeting-rooms/) manages its "bookings" through entries in an Exchange calendar. This plugin synchronizes with Exchange a representation of every Indico time slot which fulfils the following criteria:

- Is either a Contribution, Session Block or Event;
- Takes place in a room which has a Zoom Rooms-enabled device (i.e. has a `zoom-rooms-calendar-id` attribute)

![Screenshot of device screen](https://raw.githubusercontent.com/indico/indico-plugins-cern/master/zoom_rooms/assets/logi_screen.png)

This plugin relies on an external custom-built REST API endpoint (not provided) which interfaces on our behalf with the Exchange Graph API.
The logic is very similar to livesync or the exchange sync plugin: a queue of operations is kept in a database table and rolled back in case the request fails.

Objects which are direct tracked by the plugin, through signals, are:

- Events
- Session Blocks
- Contributions
- VC Rooms (associations)

The available operations are:

- CREATE - a new calendar slot should be created, with a given start/end date, location and title
- UPDATE - change the start/end time or title of a calendar slot
- MOVE - change the room ID of a given slot (which practically means deleting it and recreating it in another room's calendar)
- DELETE - delete the slot

This is a summary of the events handled by the plugin and the actions it takes:

| Object | Change in `{start, end}_dt` | Change in `location` | Change in `block` | Create |
| -------------- | --------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------- | -------- |
| `Event` | `UPDATE {start_dt, end_dt}` | `CREATE/MOVE/DELETE` depending on the original/target room; trigger change in objects inheriting location | Check change in location for object | `CREATE` |
| `SessionBlock` | `UPDATE {start_dt, end_dt}` | `CREATE/MOVE/DELETE` depending on the original/target room; trigger change in objects inheriting location | Check change in location for object | `CREATE` |
| `Contribution` | `UPDATE {start_dt, end_dt}` | `CREATE/MOVE/DELETE` depending on the original/target room | Check change in location for object | `CREATE` |

| Object | Change in name | Create | Detach | Attach | Clone |
| ------------------------ | ----------------------------------------------- | -------------------- | -------------------- | -------------------- | -------------------- |
| `VCRoom` | `UPDATE title` in all `VCRoomEventAssociations` | `CREATE link_object` | N/A | N/A | N/A |
| `VCRoomEventAssociation` | N/A | N/A | `DELETE link_object` | `CREATE link_object` | `CREATE link_object` |

This plugins relies on the `vc_zoom` plugin being available and enabled.
Binary file added zoom_rooms/assets/logi_screen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions zoom_rooms/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This file is part of the CERN Indico plugins.
# Copyright (C) 2024 CERN
#
# The CERN 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.

pytest_plugins = ('indico', 'indico_vc_zoom.fixtures')
17 changes: 17 additions & 0 deletions zoom_rooms/indico_zoom_rooms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# This file is part of the CERN Indico plugins.
# Copyright (C) 2024 CERN
#
# The CERN 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.

from indico.core import signals
from indico.util.i18n import make_bound_gettext


_ = make_bound_gettext('zoom_rooms')


@signals.core.import_tasks.connect
def _import_tasks(sender, **kwargs):
import indico_zoom_rooms.tasks # noqa: F401
207 changes: 207 additions & 0 deletions zoom_rooms/indico_zoom_rooms/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# This file is part of the CERN Indico plugins.
# Copyright (C) 2024 CERN
#
# The CERN 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.

import typing as t
from functools import wraps

from indico.modules.events.contributions.models.contributions import Contribution
from indico.modules.events.models.events import Event
from indico.modules.events.sessions.models.blocks import SessionBlock
from indico.modules.events.sessions.models.sessions import Session
from indico.modules.events.timetable.models.entries import TimetableEntry
from indico.modules.vc.models.vc_rooms import VCRoom, VCRoomEventAssociation

from indico_zoom_rooms.models import OperationArgs, ZoomRoomsAction, ZoomRoomsQueueEntry
from indico_zoom_rooms.util import get_vc_room_associations, get_zoom_room_id


def only_zoom_rooms(f):
"""
Limit the wrapped handler to only objects which are somehow tied to a VCRoom and a Room with
ZoomRooms enabled.
"""

@wraps(f)
def _wrapper(obj: Event | Contribution | SessionBlock | VCRoomEventAssociation, *args: tuple, **kwargs: dict):
link_obj = obj.link_object if isinstance(obj, VCRoomEventAssociation) else obj

if not link_obj.room:
return

# do not bother doing anything if the room is not ZR-enabled
if get_zoom_room_id(link_obj.room):
f(obj, *args, **kwargs)

return _wrapper


@only_zoom_rooms
def _delete_zoom_association(old_link: SessionBlock | Contribution | Event, vc_room: VCRoom):
"""Handle deleted VC room associations (given the corresponding object)."""
if zr_id := get_zoom_room_id(old_link.room):
ZoomRoomsQueueEntry.record(ZoomRoomsAction.delete, zr_id, obj=old_link, vc_room=vc_room)


@only_zoom_rooms
def _handle_link_object_created(assoc: VCRoomEventAssociation):
"""Handle the creation of a new object (given the corresponding association)."""
if assoc.vc_room.type == 'zoom':
if zr_id := get_zoom_room_id(assoc.link_object.room):
ZoomRoomsQueueEntry.record(ZoomRoomsAction.create, zr_id, assoc=assoc)


@only_zoom_rooms
def _handle_link_object_dt_change(
obj: SessionBlock | Contribution | Event,
changes: dict[str, t.Any],
):
"""Handle the changes of date/time of an object."""
if not (zr_id := get_zoom_room_id(obj.room)):
return

# do not handle sub-objects, as the corresponding signals are called directly
for assoc in obj.vc_room_associations:
if assoc.vc_room.type == 'zoom':
args = OperationArgs()
if 'start_dt' in changes:
args['start_dt'] = int(changes['start_dt'][1].timestamp())
if 'end_dt' in changes:
args['end_dt'] = int(changes['end_dt'][1].timestamp())

ZoomRoomsQueueEntry.record(ZoomRoomsAction.update, zr_id, assoc=assoc, args=args)


def _handle_contribution_move(obj: Contribution, original_block: SessionBlock, new_block: SessionBlock):
"""Handle contributions moved between blocks."""
# block is None -> attached to the event
if (orig_zr_id := get_zoom_room_id(original_block.room if original_block else obj.event.room)) != (
new_zr_id := get_zoom_room_id(new_block.room if new_block else obj.event.room)
):
_handle_linked_obj_location_change(obj, orig_zr_id, new_zr_id)


def _handle_linked_obj_location_change(
obj: SessionBlock | Contribution | Event,
old_zr_id: str | None,
new_zr_id: str | None,
):
"""Handle changes of location in an object (given the old/new ZoomRoom ID)."""
match old_zr_id, new_zr_id:
case None, None:
# this doesn't concern us as there are no zoom rooms involved
return
case None, zr_id:
make_op = lambda assoc: ZoomRoomsQueueEntry.record(ZoomRoomsAction.create, t.cast(str, zr_id), assoc=assoc)
case zr_id, None:
make_op = lambda assoc: ZoomRoomsQueueEntry.record(ZoomRoomsAction.delete, t.cast(str, zr_id), assoc=assoc)
case old_zr_id, new_zr_id:
make_op = lambda assoc: ZoomRoomsQueueEntry.record(
ZoomRoomsAction.move, t.cast(str, old_zr_id), assoc=assoc, args={'new_zr_id': t.cast(str, new_zr_id)}
)

# go over all associations, incl. nested ones
for assoc in get_vc_room_associations(obj):
if assoc.vc_room.type == 'zoom':
make_op(assoc)


def _check_link_object_for_updates(
obj: Session | SessionBlock | Contribution | Event,
changes: dict[str, t.Any] | None = None,
):
"""Check "newsworthy" updates in an object, given a set of changes. Call the appropriate handling functions."""
if changes is None:
changes = {}

# contribution move between session blocks / top level, and it is inheriting its location
if session_block_data := changes.get('session_block'):
old_block, new_block = session_block_data
_handle_contribution_move(
obj,
old_block,
new_block,
)

# event/contribution/session/block location changed explicitly
if location_data := changes.get('location_data'):
old_data, new_data = location_data

# if the room wasn't updated, nothing to do
if 'room' in old_data:
old_room = old_data['room']
# this won't fail since there is a 'room 'in either dict
new_room = new_data['room']

old_zr_id = get_zoom_room_id(old_room) if old_room else None
new_zr_id = get_zoom_room_id(new_room) if new_room else None

_handle_linked_obj_location_change(obj, old_zr_id, new_zr_id)

# start or end date changed
if set(changes) & {'start_dt', 'end_dt'}:
_handle_link_object_dt_change(obj, {k: v for k, v in changes.items() if k in ('start_dt, end_dt')})


def signal_link_object_updated(obj: Session | SessionBlock | Contribution, changes: dict | None = None):
_check_link_object_for_updates(obj, changes)


def signal_event_updated(event: Event, changes: dict[str, t.Any]):
_check_link_object_for_updates(event, changes)


def signal_tt_entry_updated(obj_type, entry: TimetableEntry, obj: Event | Contribution | SessionBlock, changes: dict):
_check_link_object_for_updates(obj, changes)


def signal_zoom_meeting_created(vc_room: VCRoom, assoc: VCRoomEventAssociation, event: Event):
_handle_link_object_created(assoc)


def signal_zoom_meeting_cloned(
old_assoc: VCRoomEventAssociation,
new_assoc: VCRoomEventAssociation,
vc_room: VCRoom,
link_object: SessionBlock | Contribution | Event,
):
# on clone, clone also the zoom room calendar entry
if new_assoc.link_object.room:
_handle_link_object_created(new_assoc)


def signal_zoom_meeting_association_attached(
assoc: VCRoomEventAssociation,
vc_room: VCRoom,
event: Event,
data: dict,
old_link: VCRoomEventAssociation | None,
new_room: bool = False,
):
# ignore associations to new rooms, since that's already handled by `vc_room_created`
if not new_room:
# this is a new association to an existing room, hence a new slot has to be created
_handle_link_object_created(assoc)


def signal_zoom_meeting_association_detached(
assoc: VCRoomEventAssociation,
vc_room: VCRoom,
old_link: Event | Contribution | SessionBlock,
event: Event,
data: dict | None = None,
):
if vc_room.type == 'zoom':
# when a room is detached from an event/contribution/session block, delete the corresponding zoom room entry
_delete_zoom_association(old_link, vc_room)


def signal_zoom_meeting_data_updated(vc_room: VCRoom, data: dict):
if vc_room.type == 'zoom' and (name := data.get('name')) and name != vc_room.name:
for assoc in vc_room.events:
if zr_id := get_zoom_room_id(assoc.link_object):
ZoomRoomsQueueEntry.record(ZoomRoomsAction.update, zr_id, assoc=assoc, args=OperationArgs(title=name))

Loading
Loading