Skip to content

Commit

Permalink
Zoom Rooms: new plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
pferreir committed Aug 12, 2024
1 parent d253510 commit 06bce5b
Show file tree
Hide file tree
Showing 18 changed files with 1,706 additions and 1 deletion.
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ jobs:
- plugin: cern_access
- plugin: payment_cern
- plugin: ravem
- plugin: zoom_rooms

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -212,7 +213,7 @@ jobs:
echo "$(pwd)/.venv/bin" >> $GITHUB_PATH
- name: Install extra dependencies
if: matrix.plugin == 'ravem'
if: matrix.plugin == 'ravem' || matrix.plugins == '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: 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
2 changes: 2 additions & 0 deletions zoom_rooms/.header.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
owner: CERN
start_year: 2024
3 changes: 3 additions & 0 deletions zoom_rooms/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
graft indico_zoom_rooms/templates

global-exclude *.pyc __pycache__ .keep
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://github.com/raw/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
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 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.

"""Add structures for zoom rooms queue
Revision ID: 546d4e9de960
Revises:
Create Date: 2024-08-07 10:44:37.218268
"""

from enum import Enum

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql.ddl import CreateSchema, DropSchema

from indico.core.db.sqlalchemy import PyIntEnum


# revision identifiers, used by Alembic.
revision = '546d4e9de960'
down_revision = None
branch_labels = None
depends_on = None


class _ZoomRoomsAction(int, Enum):
create = 0
update = 1
move = 2
delete = 3


def upgrade():
op.execute(CreateSchema('plugin_zoom_rooms'))
op.create_table(
'queue',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('entry_id', sa.String(), nullable=False),
sa.Column('zoom_room_id', sa.String(), nullable=False),
sa.Column('action', PyIntEnum(_ZoomRoomsAction), nullable=False),
sa.Column('entry_data', postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=True),
sa.Column('extra_args', postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=True),
sa.CheckConstraint(
'action != 3 OR (entry_data IS NULL AND extra_args IS NULL)', name=op.f('ck_queue_delete_has_no_args')
),
sa.CheckConstraint('action = 2 OR extra_args IS NULL', name=op.f('ck_queue_move_has_extra_args')),
sa.CheckConstraint('action = 2 OR extra_args IS NULL', name=op.f('ck_queue_other_actions_have_no_extra_args')),
sa.CheckConstraint('action = 3 OR entry_data IS NOT NULL', name=op.f('ck_queue_other_actions_have_args')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_queue')),
schema='plugin_zoom_rooms',
)


def downgrade():
op.drop_table('queue', schema='plugin_zoom_rooms')
op.execute(DropSchema('plugin_zoom_rooms'))
143 changes: 143 additions & 0 deletions zoom_rooms/indico_zoom_rooms/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# 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 sqlalchemy.dialects.postgresql import JSONB

from indico.core.db.sqlalchemy import PyIntEnum, db
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.vc.models.vc_rooms import VCRoom, VCRoomEventAssociation
from indico.util.enum import IndicoIntEnum

from indico_zoom_rooms.util import make_zoom_room_entry_id


class EntryData(t.TypedDict):
start_dt: int
end_dt: int
title: str
url: str


class OperationArgs(t.TypedDict):
start_dt: t.NotRequired[int]
end_dt: t.NotRequired[int]
new_zr_id: t.NotRequired[str]
title: t.NotRequired[str]


class ExtraArgs(t.TypedDict):
new_zr_id: t.NotRequired[str]


class ZoomRoomsAction(IndicoIntEnum):
create = 0
update = 1
move = 2
delete = 3


def get_entry_data(obj: Event | Contribution | SessionBlock, vc_room: VCRoom) -> EntryData:
if isinstance(obj, Event):
return {
'start_dt': int(obj.start_dt.timestamp()),
'end_dt': int(obj.end_dt.timestamp()),
'title': vc_room.name,
'url': vc_room.data['url'],
}
else:
entry = obj.timetable_entry
return {
'start_dt': int(entry.start_dt.timestamp()),
'end_dt': int(entry.end_dt.timestamp()),
'title': vc_room.name,
'url': vc_room.data['url'],
}


class ZoomRoomsQueueEntry(db.Model):
"""Pending calendar updates"""

__tablename__ = 'queue'
__table_args__ = (
db.CheckConstraint(
f'action != {ZoomRoomsAction.delete} OR (entry_data IS NULL AND extra_args IS NULL)', 'delete_has_no_args'
),
db.CheckConstraint(f'action = {ZoomRoomsAction.delete} OR entry_data IS NOT NULL', 'other_actions_have_args'),
db.CheckConstraint(f'action = {ZoomRoomsAction.move} OR extra_args IS NULL', 'move_has_extra_args'),
db.CheckConstraint(
f'action = {ZoomRoomsAction.move} OR extra_args IS NULL', 'other_actions_have_no_extra_args'
),
{'schema': 'plugin_zoom_rooms'},
)
#: Entry ID (mainly used to sort by insertion order)
id = db.Column(db.Integer, primary_key=True)
#: ID of the Entry (to be used on the server side)
entry_id = db.Column(db.String, nullable=False)
#: ID of the Zoom Room
zoom_room_id = db.Column(db.String, nullable=False)
#: :class:`ZoomRoomsAction` to perform
action = db.Column(PyIntEnum(ZoomRoomsAction), nullable=False)
#: The actual entry's data
entry_data: EntryData = db.Column(
JSONB(none_as_null=True),
)
#: Additional args to be sent with the request
extra_args: ExtraArgs = db.Column(
JSONB(none_as_null=True),
)

def __repr__(self):
action = ZoomRoomsAction(self.action).name
return f'<ZoomRoomsQueueEntry({self.id}, {self.entry_id}, {action})>'

@classmethod
def record(
cls,
action: int,
zoom_room_id: str,
assoc: VCRoomEventAssociation | None = None,
obj: Event | Contribution | SessionBlock | None = None,
vc_room: VCRoom | None = None,
args: OperationArgs | None = None,
):
if obj is None and assoc is not None:
obj = assoc.link_object
vc_room = vc_room or assoc.vc_room
elif assoc is None and (obj is None or vc_room is None):
raise ValueError('Either assoc or obj + vc_room must be provided')

args = args or {}

if new_zr_id := args.pop('new_zr_id', None):
extra_args = {'new_zr_id': new_zr_id}
else:
extra_args = None

entry = cls(
action=action,
entry_id=make_zoom_room_entry_id(zoom_room_id, obj, vc_room),
# DELETE operations have no additional parameters, only the ID
entry_data=None if action == ZoomRoomsAction.delete else EntryData(get_entry_data(obj, vc_room), **args),
extra_args=extra_args,
zoom_room_id=zoom_room_id,
)
db.session.add(entry)
db.session.flush()

@property
def data(self) -> dict:
return {
'type': self.action,
'entry_id': self.entry_id,
'entry_data': self.entry_data,
'zoom_room_id': self.zoom_room_id,
'extra_args': self.extra_args,
}
Loading

0 comments on commit 06bce5b

Please sign in to comment.