Projet

Général

Profil

0001-add-connector-for-BigBlueButton-66156.patch

Benjamin Dauvergne, 15 juin 2022 12:12

Télécharger (48,6 ko)

Voir les différences:

Subject: [PATCH] add connector for BigBlueButton (#66156)

 passerelle/apps/bbb/__init__.py               |   0
 .../apps/bbb/migrations/0001_initial.py       | 112 ++++
 passerelle/apps/bbb/migrations/__init__.py    |   0
 passerelle/apps/bbb/models.py                 | 288 ++++++++++
 .../bbb/templates/bbb/resource_detail.html    |   2 +
 passerelle/apps/bbb/utils.py                  | 287 ++++++++++
 passerelle/settings.py                        |   1 +
 tests/test_bbb.py                             | 514 ++++++++++++++++++
 8 files changed, 1204 insertions(+)
 create mode 100644 passerelle/apps/bbb/__init__.py
 create mode 100644 passerelle/apps/bbb/migrations/0001_initial.py
 create mode 100644 passerelle/apps/bbb/migrations/__init__.py
 create mode 100644 passerelle/apps/bbb/models.py
 create mode 100644 passerelle/apps/bbb/templates/bbb/resource_detail.html
 create mode 100644 passerelle/apps/bbb/utils.py
 create mode 100644 tests/test_bbb.py
passerelle/apps/bbb/migrations/0001_initial.py
1
# Generated by Django 2.2.28 on 2022-06-15 08:22
2

  
3
import uuid
4

  
5
import django.db.models.deletion
6
from django.db import migrations, models
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    initial = True
12

  
13
    dependencies = [
14
        ('base', '0029_auto_20210202_1627'),
15
    ]
16

  
17
    operations = [
18
        migrations.CreateModel(
19
            name='Resource',
20
            fields=[
21
                (
22
                    'id',
23
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
24
                ),
25
                ('title', models.CharField(max_length=50, verbose_name='Title')),
26
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
27
                ('description', models.TextField(verbose_name='Description')),
28
                (
29
                    'basic_auth_username',
30
                    models.CharField(
31
                        blank=True, max_length=128, verbose_name='Basic authentication username'
32
                    ),
33
                ),
34
                (
35
                    'basic_auth_password',
36
                    models.CharField(
37
                        blank=True, max_length=128, verbose_name='Basic authentication password'
38
                    ),
39
                ),
40
                (
41
                    'client_certificate',
42
                    models.FileField(
43
                        blank=True, null=True, upload_to='', verbose_name='TLS client certificate'
44
                    ),
45
                ),
46
                (
47
                    'trusted_certificate_authorities',
48
                    models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'),
49
                ),
50
                (
51
                    'verify_cert',
52
                    models.BooleanField(blank=True, default=True, verbose_name='TLS verify certificates'),
53
                ),
54
                (
55
                    'http_proxy',
56
                    models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'),
57
                ),
58
                (
59
                    'bbb_url',
60
                    models.URLField(
61
                        help_text='Base URL of Big Blue Button (use "bbb-conf --secret" to get it)',
62
                        max_length=400,
63
                        verbose_name='BBB URL',
64
                    ),
65
                ),
66
                (
67
                    'shared_secret',
68
                    models.CharField(
69
                        help_text='Shared ecret (use "bbb-conf --secret" to get it)', max_length=128
70
                    ),
71
                ),
72
                (
73
                    'users',
74
                    models.ManyToManyField(
75
                        blank=True,
76
                        related_name='_resource_users_+',
77
                        related_query_name='+',
78
                        to='base.ApiUser',
79
                    ),
80
                ),
81
            ],
82
            options={
83
                'verbose_name': 'Big Blue Button',
84
            },
85
        ),
86
        migrations.CreateModel(
87
            name='Meeting',
88
            fields=[
89
                (
90
                    'id',
91
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
92
                ),
93
                ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
94
                ('updated', models.DateTimeField(auto_now=True, verbose_name='Updated')),
95
                ('guid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID')),
96
                ('name', models.TextField(verbose_name='Name')),
97
                ('idempotent_id', models.TextField(unique=True, verbose_name='Idempotent ID')),
98
                ('running', models.BooleanField(default=False, verbose_name='Is running?')),
99
                ('last_time_running', models.DateTimeField(null=True, verbose_name='Last time running')),
100
                ('create_parameters', models.TextField(null=True, verbose_name='Create parameters')),
101
                (
102
                    'resource',
103
                    models.ForeignKey(
104
                        on_delete=django.db.models.deletion.CASCADE,
105
                        related_name='meetings',
106
                        to='bbb.Resource',
107
                        verbose_name='Resource',
108
                    ),
109
                ),
110
            ],
111
        ),
112
    ]
passerelle/apps/bbb/models.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import json
18
import time
19
import uuid
20

  
21
from django.core.cache import cache
22
from django.db import models, transaction
23
from django.http import HttpResponseRedirect
24
from django.shortcuts import get_object_or_404
25
from django.urls import reverse
26
from django.utils.functional import cached_property
27
from django.utils.timezone import now
28
from django.utils.translation import gettext_lazy as _
29

  
30
from passerelle.base.models import BaseResource, HTTPResource
31
from passerelle.utils.api import endpoint
32
from passerelle.utils.jsonresponse import APIError
33

  
34
from . import utils
35

  
36

  
37
class Resource(BaseResource, HTTPResource):
38
    bbb_url = models.URLField(
39
        max_length=400,
40
        verbose_name=_('BBB URL'),
41
        help_text=_('Base URL of Big Blue Button (use "bbb-conf --secret" to get it)'),
42
    )
43
    shared_secret = models.CharField(
44
        max_length=128, help_text=_('Shared ecret (use "bbb-conf --secret" to get it)')
45
    )
46
    category = _('Business Process Connectors')
47

  
48
    class Meta:
49
        verbose_name = _('Big Blue Button')
50

  
51
    @cached_property
52
    def bbb(self):
53
        return utils.BBB(url=self.bbb_url, shared_secret=self.shared_secret, session=self.requests)
54

  
55
    def check_status(self):
56
        try:
57
            self.bbb.get_meetings()
58
        except utils.BBB.BBBError as e:
59
            raise APIError(e)
60

  
61
    @endpoint(
62
        methods=['post'],
63
        name='meeting',
64
        perm='can_access',
65
        description_post=_('Create a meeting'),
66
        post={
67
            'request_body': {
68
                'schema': {
69
                    'application/json': {
70
                        'type': 'object',
71
                        'properties': {
72
                            'name': {
73
                                'type': 'string',
74
                            },
75
                            'idempotent_id': {
76
                                'type': 'string',
77
                            },
78
                            'create_parameters': {
79
                                'type': 'object',
80
                                'properties': {
81
                                    'logoutUrl': {'type': 'string'},
82
                                },
83
                                'additionalProperties': True,
84
                            },
85
                        },
86
                        'required': ['name', 'idempotent_id'],
87
                        'unflattent': True,
88
                    }
89
                }
90
            }
91
        },
92
    )
93
    @transaction.atomic
94
    def meetings_endpoint(self, request, post_data):
95
        create_parameters = (
96
            json.dumps(post_data['create_parameters']) if post_data.get('create_parameters') else None
97
        )
98
        meeting, created = self.meetings.get_or_create(
99
            name=post_data['name'],
100
            idempotent_id=post_data['idempotent_id'],
101
            create_parameters=create_parameters,
102
        )
103
        try:
104
            meeting.create()
105
        except utils.BBB.BBBError as e:
106
            raise APIError(e)
107
        return {'data': meeting.to_json()}
108

  
109
    @endpoint(
110
        methods=['get'],
111
        name='meeting',
112
        perm='can_access',
113
        pattern=r'^(?P<guid>[0-9a-f]{32})/?$',
114
        example_pattern='{guid}/',
115
        description_post=_('Get a meeting'),
116
        parameters={
117
            'guid': {
118
                'description': _('Meeting guid'),
119
                'example_value': '7edb43abf2004f55a8a526ac4b1403e4',
120
            },
121
        },
122
    )
123
    def meeting_endpoint(self, request, guid):
124
        meeting = get_object_or_404(Meeting.objects.select_for_update(), guid=guid)
125
        return {'data': meeting.to_json()}
126

  
127
    @endpoint(
128
        methods=['get'],
129
        name='meeting',
130
        perm='can_access',
131
        pattern=r'^(?P<guid>[0-9a-f]{32})/is-running/?$',
132
        example_pattern='{guid}/is-running/',
133
        description_post=_('Report if meeting is running'),
134
        parameters={
135
            'guid': {
136
                'description': _('Meeting guid'),
137
                'example_value': '7edb43abf2004f55a8a526ac4b1403e4',
138
            },
139
        },
140
    )
141
    @transaction.atomic
142
    def is_running(self, request, guid):
143
        meeting = get_object_or_404(Meeting.objects.select_for_update(), guid=guid)
144
        if (now() - meeting.updated).total_seconds() > 5:
145
            meeting.update_is_running()
146
        return {'data': meeting.running}
147

  
148
    @endpoint(
149
        methods=['get'],
150
        name='meeting',
151
        perm='can_access',
152
        pattern=r'^(?P<guid>[0-9a-f]{32})/join/agent/?$',
153
        example_pattern='{guid}/join/agent/',
154
        description_post=_('Get a meeting'),
155
        parameters={
156
            'guid': {
157
                'description': _('Meeting guid'),
158
                'example_value': '7edb43abf2004f55a8a526ac4b1403e4',
159
            },
160
            'full_name': {
161
                'description': _('Agent full name'),
162
                'example_value': 'John Doe',
163
            },
164
        },
165
    )
166
    def join_agent(self, request, guid, full_name):
167
        meeting = get_object_or_404(Meeting, guid=guid)
168
        url = meeting.create().join_url(full_name, self.bbb.ROLE_MODERATOR)
169
        return HttpResponseRedirect(url)
170

  
171
    @endpoint(
172
        methods=['get'],
173
        name='meeting',
174
        perm='can_access',
175
        pattern=r'^(?P<guid>[0-9a-f]{32})/join/user/?$',
176
        example_pattern='{guid}/join/user/',
177
        description_post=_('Get a meeting'),
178
        parameters={
179
            'guid': {
180
                'description': _('Meeting guid'),
181
                'example_value': '7edb43abf2004f55a8a526ac4b1403e4',
182
            },
183
            'full_name': {
184
                'description': _('User full name'),
185
                'example_value': 'John Doe',
186
            },
187
        },
188
    )
189
    def join_user(self, request, guid, full_name):
190
        meeting = get_object_or_404(Meeting, guid=guid)
191
        url = meeting.create().join_url(full_name, self.bbb.ROLE_VIEWER)
192
        return HttpResponseRedirect(url)
193

  
194
    def _make_endpoint_url(self, endpoint, rest):
195
        return reverse(
196
            'generic-endpoint',
197
            kwargs={'connector': 'bbb', 'endpoint': endpoint, 'slug': self.slug, 'rest': rest},
198
        )
199

  
200

  
201
class Meeting(models.Model):
202
    created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
203
    updated = models.DateTimeField(verbose_name=_('Updated'), auto_now=True)
204
    resource = models.ForeignKey(
205
        Resource, verbose_name=_('Resource'), on_delete=models.CASCADE, related_name='meetings'
206
    )
207
    guid = models.UUIDField(verbose_name=_('UUID'), unique=True, default=uuid.uuid4)
208
    name = models.TextField(verbose_name=_('Name'))
209
    idempotent_id = models.TextField(verbose_name=_('Idempotent ID'), unique=True)
210
    running = models.BooleanField(verbose_name=_('Is running?'), default=False)
211
    last_time_running = models.DateTimeField(verbose_name=_('Last time running'), null=True)
212
    create_parameters = models.TextField('Create parameters', null=True)
213

  
214
    @property
215
    def join_user_url(self):
216
        return self.resource._make_endpoint_url(endpoint='meeting', rest=f'{self.guid.hex}/join/user/')
217

  
218
    @property
219
    def join_agent_url(self):
220
        return self.resource._make_endpoint_url(endpoint='meeting', rest=f'{self.guid.hex}/join/agent/')
221

  
222
    @property
223
    def url(self):
224
        return self.resource._make_endpoint_url(endpoint='meeting', rest=f'{self.guid.hex}/')
225

  
226
    @property
227
    def is_running_url(self):
228
        return self.resource._make_endpoint_url(endpoint='meeting', rest=f'{self.guid.hex}/')
229

  
230
    def to_json(self):
231
        return {
232
            'guid': self.guid.hex,
233
            'created': self.created,
234
            'updated': self.updated,
235
            'name': self.name,
236
            'idempotent_id': self.idempotent_id,
237
            'running': self.running,
238
            'last_time_running': self.last_time_running,
239
            'url': self.url,
240
            'join_user_url': self.join_user_url,
241
            'join_agent_url': self.join_agent_url,
242
            'is_running_url': self.is_running_url,
243
            'bbb_meeting_info': self.meeting_info(),
244
        }
245

  
246
    @property
247
    def meeting_id(self):
248
        return self.guid.hex
249

  
250
    def update_is_running(self):
251
        try:
252
            running = self.resource.bbb.is_meeting_running(self.meeting_id)['running'] == 'true'
253
        except self.resource.bbb.BBBError:
254
            return
255
        if self.running != running:
256
            self.running = running
257
            if running:
258
                self.last_time_running = now()
259
                self.save(update_fields=['updated', 'last_time_running', 'running'])
260
            else:
261
                self.save(update_fields=['updated', 'running'])
262

  
263
    @property
264
    def create_kwargs(self):
265
        return json.loads(self.create_parameters) if self.create_parameters else {}
266

  
267
    def create(self):
268
        return self.resource.bbb.meetings.create(
269
            name=self.name, meeting_id=self.meeting_id, **self.create_kwargs
270
        )
271

  
272
    @property
273
    def cache_key(self):
274
        return f'bbb_{self.resource.slug}_{self.guid.hex}'
275

  
276
    def meeting_info(self):
277
        start = time.time()
278
        try:
279
            data, timestamp = cache.get(self.cache_key)
280
        except TypeError:
281
            data = None
282
        if data is None or (start - timestamp) > 30:
283
            try:
284
                data = self.resource.bbb.meetings.get(meeting_id=self.meeting_id).attributes
285
            except utils.BBB.BBBError:
286
                data = data or {}
287
            cache.set(self.cache_key, (data, start))
288
        return data
passerelle/apps/bbb/templates/bbb/resource_detail.html
1
{% extends "passerelle/manage/service_view.html" %}
2
{% load i18n passerelle %}
passerelle/apps/bbb/utils.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import hashlib
18
import re
19
import xml.etree.ElementTree as ET
20

  
21
import requests
22
from django.utils.http import urlencode
23

  
24

  
25
class BBB:
26
    CALL_CREATE = 'create'
27
    CALL_END = 'end'
28
    CALL_GET_MEETING_INFO = 'getMeetingInfo'
29
    CALL_GET_MEETINGS = 'getMeetings'
30
    CALL_IS_MEETING_RUNNING = 'isMeetingRunning'
31
    CALL_JOIN = 'join'
32

  
33
    PARAM_ATTENDEE_PW = 'attendeePW'
34
    PARAM_CREATE_TIME = 'createTime'
35
    PARAM_FULL_NAME = 'fullName'
36
    PARAM_MEETING_ID = 'meetingID'
37
    PARAM_MODERATOR_PW = 'moderatorPW'
38
    PARAM_NAME = 'name'
39
    PARAM_ROLE = 'role'
40

  
41
    TAG_RETURN_CODE = 'returncode'
42
    TAG_MESSAGE_KEY = 'messageKey'
43
    TAG_MESSAGE = 'message'
44

  
45
    RETURN_CODE_SUCCESS = 'SUCCESS'
46
    RETURN_CODE_FAILED = 'FAILED'
47
    RETURN_CODES = [RETURN_CODE_SUCCESS, RETURN_CODE_FAILED]
48

  
49
    ROLE_MODERATOR = 'MODERATOR'
50
    ROLE_VIEWER = 'VIEWER'
51
    ROLES = [ROLE_MODERATOR, ROLE_VIEWER]
52

  
53
    MESSAGE_KEY_ID_NOT_UNIQUE = 'idNotUnique'
54
    MESSAGE_KEY_NOT_FOUND = 'notFound'
55
    MESSAGE_KEY_CHECKSUM_ERROR = 'checksumError'
56
    MESSAGE_KEY_SENT_END_MEETING_REQUEST = 'sentEndMeetingRequest'
57

  
58
    class BBBError(Exception):
59
        pass
60

  
61
    class FailedError(BBBError):
62
        def __init__(self, message, message_key=None):
63
            self.message_key = message_key
64
            super().__init__(message)
65

  
66
        def __repr__(self):
67
            return f'<{self.__class__.__name__} "{self}" message_key={self.message_key}>'
68

  
69
    class NotFoundError(FailedError):
70
        pass
71

  
72
    class IdNotUniqueError(FailedError):
73
        pass
74

  
75
    class ChecksumError(FailedError):
76
        pass
77

  
78
    MESSAGE_KEY_TO_CLASS = {
79
        MESSAGE_KEY_ID_NOT_UNIQUE: IdNotUniqueError,
80
        MESSAGE_KEY_NOT_FOUND: NotFoundError,
81
        MESSAGE_KEY_CHECKSUM_ERROR: ChecksumError,
82
    }
83

  
84
    @staticmethod
85
    def camel_to_snake(string):
86
        string = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', string)
87
        string = re.sub('(.)([0-9]+)', r'\1_\2', string)
88
        return re.sub('([a-z0-9])([A-Z])', r'\1_\2', string).lower()
89

  
90
    def __init__(self, *, url, shared_secret, session=None):
91
        if not url.endswith('/'):
92
            raise self.BBBError(f'expected url ending with a slash, got {url!r}')
93
        self.url = url
94
        self.shared_secret = shared_secret
95
        self.http_session = session or requests.Session()
96
        self.meetings = self.Meetings(bbb=self)
97

  
98
    def _build_query(self, name, parameters):
99
        """Method to create valid API query
100
        to call on BigBlueButton. Because each query
101
        should have a encrypted checksum based on request Data.
102
        """
103
        query = urlencode(parameters)
104
        prepared = f'{name}{query}{self.shared_secret}'
105
        checksum = hashlib.sha1(prepared.encode('utf-8')).hexdigest()
106
        sep = '&' if query else ''
107
        result = f'{query}{sep}checksum={checksum}'
108
        return result
109

  
110
    def _http_get(self, url):
111
        try:
112
            raw_response = self.http_session.get(url)
113
            raw_response.raise_for_status()
114
        except requests.RequestException as e:
115
            raise self.BBBError('transport error', e)
116
        return raw_response
117

  
118
    @classmethod
119
    def _parse_xml_doc(cls, root, data):
120
        for child in root:
121
            if len(child) == 0:
122
                data[child.tag.split('}')[-1]] = child.text
123
            elif max(len(sub) for sub in child) == 0:
124
                # subobject case
125
                data[child.tag] = cls._parse_xml_doc(child, {})
126
            else:
127
                # array case
128
                data[child.tag] = [cls._parse_xml_doc(sub, {}) for sub in child]
129
        return data
130

  
131
    @classmethod
132
    def _parse_response(cls, response):
133
        try:
134
            root = ET.fromstring(response.content)
135
            if root.tag != 'response':
136
                raise ValueError('root tag is not response')
137
            data = {}
138
            cls._parse_xml_doc(root, data)
139
            if cls.TAG_RETURN_CODE not in data:
140
                raise ValueError(f'expected a return code, got {data!r}')
141
            return_code = data.get(cls.TAG_RETURN_CODE)
142
            if return_code not in cls.RETURN_CODES:
143
                raise ValueError(f'expected a return code, got {data!r}')
144
            data.pop(cls.TAG_RETURN_CODE)
145
            return return_code, data
146
        except Exception as e:
147
            raise cls.BBBError('invalid response', e)
148

  
149
    def _make_url(self, name, parameters):
150
        query = self._build_query(name, parameters)
151
        return f'{self.url}api/{name}?{query}'
152

  
153
    def _api_call(self, name, parameters):
154
        url = self._make_url(name, parameters)
155
        raw_response = self._http_get(url)
156
        return_code, data = self._parse_response(raw_response)
157
        if return_code == self.RETURN_CODE_FAILED:
158
            exception_class = self.MESSAGE_KEY_TO_CLASS.get(data.get(self.TAG_MESSAGE_KEY), self.FailedError)
159
            raise exception_class(
160
                message=data.get(self.TAG_MESSAGE, 'NO_MESSAGE_KEY'),
161
                message_key=data.get(self.TAG_MESSAGE_KEY),
162
            )
163
        return data
164

  
165
    def create_meeting(self, name, meeting_id, attendee_pw=None, moderator_pw=None, **kwargs):
166
        parameters = {
167
            self.PARAM_NAME: name,
168
            self.PARAM_MEETING_ID: meeting_id,
169
            self.PARAM_ATTENDEE_PW: (
170
                attendee_pw
171
                or hashlib.sha1((self.shared_secret + meeting_id + 'attendee').encode()).hexdigest()
172
            ),
173
            self.PARAM_MODERATOR_PW: (
174
                moderator_pw
175
                or hashlib.sha1((self.shared_secret + meeting_id + 'moderator').encode()).hexdigest()
176
            ),
177
        }
178
        return self._api_call(self.CALL_CREATE, dict(parameters, **kwargs))
179

  
180
    def get_meeting_info(self, meeting_id):
181
        parameters = {
182
            self.PARAM_MEETING_ID: meeting_id,
183
        }
184
        return self._api_call(self.CALL_GET_MEETING_INFO, parameters)
185

  
186
    def is_meeting_running(self, meeting_id):
187
        parameters = {
188
            self.PARAM_MEETING_ID: meeting_id,
189
        }
190
        try:
191
            return self._api_call(self.CALL_IS_MEETING_RUNNING, parameters)
192
        except self.NotFoundError:
193
            return False
194

  
195
    def end_meeting(self, meeting_id):
196
        parameters = {
197
            self.PARAM_MEETING_ID: meeting_id,
198
        }
199
        try:
200
            response = self._api_call(self.CALL_END, parameters)
201
        except self.NotFoundError:
202
            return False
203
        if response[self.TAG_MESSAGE_KEY] == self.MESSAGE_KEY_SENT_END_MEETING_REQUEST:
204
            return True
205
        raise self.BBBError(f'expected notFound or sentEndMeetingRequest, got {response!r}')
206

  
207
    def get_meetings(self):
208
        return self._api_call(self.CALL_GET_MEETINGS, {})
209

  
210
    def make_join_url(self, full_name, meeting_id, role, create_time=None, **kwargs):
211
        if role not in self.ROLES:
212
            raise self.BBBError(f'expected a role value, got {role!r}')
213
        parameters = {
214
            self.PARAM_FULL_NAME: full_name,
215
            self.PARAM_MEETING_ID: meeting_id,
216
            self.PARAM_ROLE: role,
217
        }
218
        if create_time is not None:
219
            parameters[self.PARAM_CREATE_TIME] = create_time
220
        return self._make_url(self.CALL_JOIN, parameters)
221

  
222
    class Meeting:
223
        is_running = False
224
        meeting_name = None
225
        meeting_id = None
226

  
227
        def __init__(self, bbb: 'BBB', **kwargs):
228
            self.bbb = bbb
229
            # came to snake case
230
            parameters = {BBB.camel_to_snake(key): value for key, value in kwargs.items()}
231
            self.message = {k: v for k, v in parameters.items() if k.startswith('message')}
232
            self.meeting_id = parameters.pop('meeting_id')
233
            self.meeting_name = parameters.pop('meeting_name')
234
            self.attributes = {k: v for k, v in parameters.items() if not k.startswith('message')}
235

  
236
        def update(self):
237
            data = self.bbb.get_meeting_info(self.meeting_id)
238
            parameters = {
239
                BBB.camel_to_snake(key): value
240
                for key, value in data.items()
241
                if key not in ['meetingID', 'meetingName']
242
            }
243
            self.message = {k: v for k, v in parameters.items() if k.startswith('message')}
244
            self.attributes = {k: v for k, v in parameters.items() if not k.startswith('message')}
245

  
246
        def update_is_running(self):
247
            self.is_running = self.bbb.is_meeting_running(self.meeting_id)['running'] == 'true'
248
            return self.is_running
249

  
250
        def end(self):
251
            try:
252
                return self.bbb.end_meeting(self.meeting_id)
253
            finally:
254
                self.is_running = False
255

  
256
        def join_url(self, full_name, role):
257
            return self.bbb.make_join_url(
258
                full_name=full_name,
259
                role=role,
260
                meeting_id=self.meeting_id,
261
                create_time=self.attributes['create_time'],
262
            )
263

  
264
        def to_dict(self):
265
            return {k: v for k, v in self.__dict__.items() if k not in ['bbb']}
266

  
267
        def __repr__(self):
268
            return f'<Meeting name={self.meeting_name} id={self.meeting_id}>'
269

  
270
    class Meetings:
271
        def __init__(self, bbb: 'BBB'):
272
            self.bbb = bbb
273

  
274
        def create(self, name, meeting_id, **kwargs):
275
            return BBB.Meeting(
276
                self.bbb,
277
                meeting_name=name,
278
                **self.bbb.create_meeting(name=name, meeting_id=meeting_id, **kwargs),
279
            )
280

  
281
        def get(self, meeting_id):
282
            return BBB.Meeting(self.bbb, **self.bbb.get_meeting_info(meeting_id=meeting_id))
283

  
284
        def all(self):
285
            response = self.bbb.get_meetings()
286
            for response_meeting in response.get('meetings', []):
287
                yield BBB.Meeting(self.bbb, **response_meeting)
passerelle/settings.py
132 132
    'passerelle.apps.atal',
133 133
    'passerelle.apps.atos_genesys',
134 134
    'passerelle.apps.base_adresse',
135
    'passerelle.apps.bbb',
135 136
    'passerelle.apps.bdp',
136 137
    'passerelle.apps.cartads_cs',
137 138
    'passerelle.apps.choosit',
tests/test_bbb.py
1
# Copyright (C) 2021  Entr'ouvert
2
#
3
# This program is free software: you can redistribute it and/or modify it
4
# under the terms of the GNU Affero General Public License as published
5
# by the Free Software Foundation, either version 3 of the License, or
6
# (at your option) any later version.
7
#
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
# GNU Affero General Public License for more details.
12
#
13
# You should have received a copy of the GNU Affero General Public License
14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15

  
16
import datetime
17
import json
18
import uuid
19

  
20
import mock
21
import pytest
22

  
23
import passerelle.apps.bbb.utils as bbb_utils
24

  
25
from . import utils
26

  
27
BBB_URL = 'https://example.com/bigbluebutton/'
28
SHARED_SECRET = 'ABCD'
29
SLUG = 'test'
30
MEETING_NAME = 'RDV'
31
MEETING_ID = 'a' * 32
32
UUID = uuid.UUID(MEETING_ID)
33
IDEMPOTENT_ID = '10-1999'
34
LOGOUT_URL = 'https://portal/'
35

  
36

  
37
@pytest.fixture
38
def connector(db):
39
    from passerelle.apps.bbb.models import Resource
40

  
41
    return utils.setup_access_rights(
42
        Resource.objects.create(
43
            slug=SLUG,
44
            bbb_url=BBB_URL,
45
            shared_secret=SHARED_SECRET,
46
        )
47
    )
48

  
49

  
50
class TestManage:
51
    pytestmark = pytest.mark.django_db
52

  
53
    @pytest.fixture
54
    def app(self, app, admin_user):
55
        from .test_manager import login
56

  
57
        login(app)
58
        return app
59

  
60
    def test_homepage(self, app, connector):
61
        app.get(f'/bbb/{SLUG}/')
62

  
63

  
64
class TestBBB:
65
    @pytest.fixture
66
    def bbb(self):
67
        return bbb_utils.BBB(url=BBB_URL, shared_secret=SHARED_SECRET)
68

  
69
    @pytest.fixture
70
    def mock(self):
71
        with mock.patch('requests.Session.send') as requests_send:
72
            requests_send.return_value = mock.Mock()
73
            yield requests_send
74

  
75
    def test_create_failure(self, bbb, mock):
76
        mock.return_value.status_code = 200
77
        mock.return_value.content = '<response/>'
78

  
79
        with pytest.raises(bbb.BBBError):
80
            bbb.create_meeting(name=MEETING_NAME, meeting_id=MEETING_ID)
81

  
82
    class TestCreate:
83
        @pytest.fixture
84
        def mock(self, mock):
85
            mock.return_value.content = '''<response>
86
    <returncode>SUCCESS</returncode>
87
    <message>ok</message>
88
    <meetingID>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</meetingID>
89
</response>'''
90
            return mock
91

  
92
        def test_create(self, bbb, mock):
93
            result = bbb.create_meeting(name=MEETING_NAME, meeting_id=MEETING_ID)
94
            prepared_request = mock.call_args[0][0]
95
            assert prepared_request.url == (
96
                'https://example.com/bigbluebutton/api/create?'
97
                'name=RDV&meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&attendeePW=6256459f2baead794d599a1ad21a07fd4bc5743a'
98
                '&moderatorPW=2ebb4732fcbdd5a9f0751c3ffa7a5f51f755f980&checksum=4ac7d65fe6beb6f86fa5a13d6d8e0d1fadee0b50'
99
            )
100
            assert result == {'meetingID': MEETING_ID, 'message': 'ok'}
101

  
102
        def test_meetings_create(self, bbb, mock):
103
            meeting = bbb.meetings.create(name=MEETING_NAME, meeting_id=MEETING_ID)
104
            prepared_request = mock.call_args[0][0]
105
            assert prepared_request.url == (
106
                'https://example.com/bigbluebutton/api/create?'
107
                'name=RDV&meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&attendeePW=6256459f2baead794d599a1ad21a07fd4bc5743a'
108
                '&moderatorPW=2ebb4732fcbdd5a9f0751c3ffa7a5f51f755f980&checksum=4ac7d65fe6beb6f86fa5a13d6d8e0d1fadee0b50'
109
            )
110
            assert meeting.meeting_name == MEETING_NAME
111
            assert meeting.meeting_id == MEETING_ID
112
            assert meeting.attributes == {}
113
            assert meeting.message == {'message': 'ok'}
114

  
115
    class TestGetMeetingInfo:
116
        @pytest.fixture
117
        def mock(self, mock):
118
            mock.return_value.content = '''<response>
119
    <returncode>SUCCESS</returncode>
120
    <meetingName>RDV</meetingName>
121
    <message>ok</message>
122
    <meetingID>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</meetingID>
123
    <metadata>
124
      <coin>1</coin>
125
    </metadata>
126
    <others>
127
      <other>
128
        <a>1</a>
129
      </other>
130
    </others>
131
</response>'''
132
            return mock
133

  
134
        def test_get_meeting_info(self, bbb, mock):
135
            result = bbb.get_meeting_info(meeting_id=MEETING_ID)
136
            prepared_request = mock.call_args[0][0]
137
            assert (
138
                prepared_request.url == 'https://example.com/bigbluebutton/api/getMeetingInfo'
139
                '?meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&checksum=4d71906c30e6e62d8b9f1a3e57233bb64126e0da'
140
            )
141
            assert result == {
142
                'meetingName': MEETING_NAME,
143
                'meetingID': MEETING_ID,
144
                'message': 'ok',
145
                'metadata': {'coin': '1'},
146
                'others': [{'a': '1'}],
147
            }
148

  
149
        def test_meetings_get(self, bbb, mock):
150
            meeting = bbb.meetings.get(meeting_id=MEETING_ID)
151
            prepared_request = mock.call_args[0][0]
152
            assert (
153
                prepared_request.url == 'https://example.com/bigbluebutton/api/getMeetingInfo'
154
                '?meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&checksum=4d71906c30e6e62d8b9f1a3e57233bb64126e0da'
155
            )
156
            assert meeting.meeting_name == MEETING_NAME
157
            assert meeting.meeting_id == MEETING_ID
158
            assert meeting.attributes == {
159
                'metadata': {'coin': '1'},
160
                'others': [{'a': '1'}],
161
            }
162
            assert meeting.message == {'message': 'ok'}
163

  
164
    class TestMakeJoinURL:
165
        def test_make_join_url(self, bbb):
166
            assert bbb.make_join_url(
167
                meeting_id=MEETING_ID, full_name='John Doe', role=bbb.ROLE_MODERATOR
168
            ) == (
169
                'https://example.com/bigbluebutton/api/join?fullName=John+Doe'
170
                '&meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&role=MODERATOR'
171
                '&checksum=838762e70a6fe3257ea81f7b27acbde9945acaf9'
172
            )
173

  
174
        def test_meeting_join_url(self, bbb):
175
            meeting = bbb.Meeting(bbb, meeting_name=None, meeting_id=MEETING_ID, create_time=1234)
176
            assert meeting.join_url(full_name='John Doe', role=bbb.ROLE_MODERATOR) == (
177
                'https://example.com/bigbluebutton/api/join?fullName=John+Doe'
178
                '&meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&role=MODERATOR'
179
                '&createTime=1234&checksum=07a7e7fd233dd213bd6dfc5332cc842afac56ba5'
180
            )
181

  
182
    class TestEndMeeting:
183
        @pytest.fixture
184
        def mock(self, mock):
185
            mock.return_value.content = '''<response>
186
    <returncode>SUCCESS</returncode>
187
    <messageKey>sentEndMeetingRequest</messageKey>
188
</response>'''
189
            return mock
190

  
191
        def test_end_meeting(self, bbb, mock):
192
            result = bbb.end_meeting(meeting_id=MEETING_ID)
193
            prepared_request = mock.call_args[0][0]
194
            assert (
195
                prepared_request.url == 'https://example.com/bigbluebutton/api/end'
196
                '?meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&checksum=ed94e9d091f0a105e47d91f7f0633b6f7b881698'
197
            )
198
            assert result is True
199

  
200
        def test_meeting_end(self, bbb, mock):
201
            meeting = bbb.Meeting(bbb, meeting_name=None, meeting_id=MEETING_ID)
202
            result = meeting.end()
203
            prepared_request = mock.call_args[0][0]
204
            assert (
205
                prepared_request.url == 'https://example.com/bigbluebutton/api/end'
206
                '?meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&checksum=ed94e9d091f0a105e47d91f7f0633b6f7b881698'
207
            )
208
            assert result is True
209

  
210
    class TestEndMeetingIfNotFound:
211
        @pytest.fixture
212
        def mock(self, mock):
213
            mock.return_value.content = '''<response>
214
    <returncode>FAILED</returncode>
215
    <messageKey>notFound</messageKey>
216
</response>'''
217
            return mock
218

  
219
        def test_end_meeting(self, bbb, mock):
220
            result = bbb.end_meeting(meeting_id=MEETING_ID)
221
            prepared_request = mock.call_args[0][0]
222
            assert (
223
                prepared_request.url == 'https://example.com/bigbluebutton/api/end'
224
                '?meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&checksum=ed94e9d091f0a105e47d91f7f0633b6f7b881698'
225
            )
226
            assert result is False
227

  
228
    class TestGetMeetings:
229
        @pytest.fixture
230
        def mock(self, mock):
231
            mock.return_value.content = '''<response>
232
    <returncode>SUCCESS</returncode>
233
    <meetings>
234
      <meeting>
235
        <meetingName>RDV</meetingName>
236
        <message>ok</message>
237
        <meetingID>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</meetingID>
238
        <metadata>
239
          <coin>1</coin>
240
        </metadata>
241
        <others>
242
          <other>
243
            <a>1</a>
244
          </other>
245
        </others>
246
      </meeting>
247
    </meetings>
248
</response>'''
249
            return mock
250

  
251
        def test_get_meetings(self, bbb, mock):
252
            result = bbb.get_meetings()
253
            prepared_request = mock.call_args[0][0]
254
            assert (
255
                prepared_request.url
256
                == 'https://example.com/bigbluebutton/api/getMeetings?checksum=64bf453f633be1f9d2c7b0f6bed4a4702dbcc41e'
257
            )
258
            assert result == {
259
                'meetings': [
260
                    {
261
                        'meetingID': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
262
                        'meetingName': 'RDV',
263
                        'message': 'ok',
264
                        'metadata': {'coin': '1'},
265
                        'others': [{'a': '1'}],
266
                    }
267
                ]
268
            }
269

  
270
        def test_meetings_all(self, bbb, mock):
271
            result = list(bbb.meetings.all())
272
            prepared_request = mock.call_args[0][0]
273
            assert (
274
                prepared_request.url
275
                == 'https://example.com/bigbluebutton/api/getMeetings?checksum=64bf453f633be1f9d2c7b0f6bed4a4702dbcc41e'
276
            )
277
            assert len(result) == 1
278
            meeting = result[0]
279
            assert meeting.meeting_name == MEETING_NAME
280
            assert meeting.meeting_id == MEETING_ID
281
            assert meeting.attributes == {
282
                'metadata': {'coin': '1'},
283
                'others': [{'a': '1'}],
284
            }
285
            assert meeting.message == {'message': 'ok'}
286

  
287

  
288
class TestAPI:
289
    @pytest.fixture(autouse=True)
290
    def stable(self, freezer):
291
        from passerelle.apps.bbb.models import Meeting
292

  
293
        freezer.move_to('2022-01-01T12:00:00Z')
294
        with mock.patch.object(Meeting._meta.get_field('guid'), 'default', UUID):
295
            yield None
296

  
297
    @pytest.fixture
298
    def meetings_create(self):
299
        with mock.patch('passerelle.apps.bbb.utils.BBB.Meetings.create') as create:
300
            yield create
301

  
302
    @pytest.fixture
303
    def meetings_get(self):
304
        with mock.patch('passerelle.apps.bbb.utils.BBB.Meetings.get') as get:
305
            yield get
306

  
307
    @pytest.fixture
308
    def is_meeting_running(self):
309
        with mock.patch('passerelle.apps.bbb.utils.BBB.is_meeting_running') as is_running:
310
            yield is_running
311

  
312
    class TestCreate:
313
        def test_ok(self, app, connector, meetings_create, meetings_get):
314
            meeting = bbb_utils.BBB.Meeting(
315
                None, meeting_id=MEETING_ID, meeting_name=MEETING_NAME, logout_url=LOGOUT_URL
316
            )
317
            meetings_create.return_value = meeting
318
            meetings_get.return_value = meeting
319
            response = app.post_json(
320
                f'/bbb/{SLUG}/meeting',
321
                params={
322
                    'name': MEETING_NAME,
323
                    'idempotent_id': IDEMPOTENT_ID,
324
                    'create_parameters': {
325
                        'logoutUrl': LOGOUT_URL,
326
                    },
327
                },
328
            )
329
            assert meetings_create.call_args == mock.call(
330
                logoutUrl=LOGOUT_URL, meeting_id=MEETING_ID, name=MEETING_NAME
331
            )
332
            assert response.json['err'] == 0
333
            assert response.json['data'] == {
334
                'created': '2022-01-01T12:00:00Z',
335
                'updated': '2022-01-01T12:00:00Z',
336
                'guid': MEETING_ID,
337
                'idempotent_id': IDEMPOTENT_ID,
338
                'url': f'/bbb/test/meeting/{MEETING_ID}/',
339
                'is_running_url': f'/bbb/test/meeting/{MEETING_ID}/',
340
                'join_agent_url': f'/bbb/test/meeting/{MEETING_ID}/join/agent/',
341
                'join_user_url': f'/bbb/test/meeting/{MEETING_ID}/join/user/',
342
                'last_time_running': None,
343
                'name': 'RDV',
344
                'running': False,
345
                'bbb_meeting_info': {
346
                    'logout_url': LOGOUT_URL,
347
                },
348
            }
349
            meeting = connector.meetings.get()
350
            assert meeting.meeting_id == MEETING_ID
351
            assert meeting.name == MEETING_NAME
352
            assert meeting.create_kwargs == {'logoutUrl': LOGOUT_URL}
353

  
354
        def test_error(self, app, connector, meetings_create):
355
            meetings_create.side_effect = bbb_utils.BBB.BBBError('coin')
356
            response = app.post_json(
357
                f'/bbb/{SLUG}/meeting',
358
                params={
359
                    'name': MEETING_NAME,
360
                    'idempotent_id': IDEMPOTENT_ID,
361
                    'create_parameters': {
362
                        'logoutUrl': LOGOUT_URL,
363
                    },
364
                },
365
            )
366
            assert response.json['err'] == 1
367
            assert response.json['err_desc'] == 'coin'
368

  
369
    class TestGet:
370
        def test_ok(self, app, connector, meetings_get, freezer):
371
            connector.meetings.create(
372
                guid=UUID,
373
                idempotent_id=IDEMPOTENT_ID,
374
                name=MEETING_NAME,
375
                create_parameters=json.dumps({'logoutUrl': LOGOUT_URL}),
376
            )
377
            meeting = bbb_utils.BBB.Meeting(
378
                bbb=connector.bbb,
379
                meeting_id=MEETING_ID,
380
                meeting_name=MEETING_NAME,
381
                logout_url=LOGOUT_URL,
382
                create_time=1234,
383
            )
384
            meetings_get.return_value = meeting
385
            response = app.get(f'/bbb/{SLUG}/meeting/{MEETING_ID}/')
386
            assert response.json['err'] == 0
387
            assert response.json['data'] == {
388
                'created': '2022-01-01T12:00:00Z',
389
                'updated': '2022-01-01T12:00:00Z',
390
                'guid': MEETING_ID,
391
                'idempotent_id': IDEMPOTENT_ID,
392
                'url': f'/bbb/test/meeting/{MEETING_ID}/',
393
                'is_running_url': f'/bbb/test/meeting/{MEETING_ID}/',
394
                'join_agent_url': f'/bbb/test/meeting/{MEETING_ID}/join/agent/',
395
                'join_user_url': f'/bbb/test/meeting/{MEETING_ID}/join/user/',
396
                'last_time_running': None,
397
                'name': 'RDV',
398
                'running': False,
399
                'bbb_meeting_info': {
400
                    'create_time': 1234,
401
                    'logout_url': LOGOUT_URL,
402
                },
403
            }
404
            # test cache
405
            freezer.move_to(datetime.timedelta(seconds=100))
406
            meetings_get.side_effect = bbb_utils.BBB.BBBError('boom!')
407
            response = app.get(f'/bbb/{SLUG}/meeting/{MEETING_ID}/')
408
            assert response.json['err'] == 0
409
            assert response.json['data'] == {
410
                'created': '2022-01-01T12:00:00Z',
411
                'updated': '2022-01-01T12:00:00Z',
412
                'guid': MEETING_ID,
413
                'idempotent_id': IDEMPOTENT_ID,
414
                'url': f'/bbb/test/meeting/{MEETING_ID}/',
415
                'is_running_url': f'/bbb/test/meeting/{MEETING_ID}/',
416
                'join_agent_url': f'/bbb/test/meeting/{MEETING_ID}/join/agent/',
417
                'join_user_url': f'/bbb/test/meeting/{MEETING_ID}/join/user/',
418
                'last_time_running': None,
419
                'name': 'RDV',
420
                'running': False,
421
                'bbb_meeting_info': {
422
                    'create_time': 1234,
423
                    'logout_url': LOGOUT_URL,
424
                },
425
            }
426

  
427
    class TestIsRunning:
428
        def test_ok(self, app, connector, meetings_get, is_meeting_running, freezer):
429
            connector.meetings.create(
430
                guid=UUID,
431
                idempotent_id=IDEMPOTENT_ID,
432
                name=MEETING_NAME,
433
                create_parameters=json.dumps({'logoutUrl': LOGOUT_URL}),
434
            )
435
            meeting = bbb_utils.BBB.Meeting(
436
                bbb=connector.bbb,
437
                meeting_id=MEETING_ID,
438
                meeting_name=MEETING_NAME,
439
                logout_url=LOGOUT_URL,
440
                create_time=1234,
441
            )
442
            freezer.move_to(datetime.timedelta(seconds=6))
443
            meetings_get.return_value = meeting
444
            is_meeting_running.return_value = {'running': 'true'}
445
            response = app.get(f'/bbb/{SLUG}/meeting/{MEETING_ID}/is-running/')
446
            assert response.json['err'] == 0
447
            assert response.json['data'] is True
448
            assert connector.meetings.get().running is True
449
            # test cache
450
            is_meeting_running.return_value = {'running': 'false'}
451
            is_meeting_running.side_effect = bbb_utils.BBB.BBBError('boom!')
452
            response = app.get(f'/bbb/{SLUG}/meeting/{MEETING_ID}/is-running/')
453
            assert response.json['err'] == 0
454
            assert response.json['data'] is True
455
            # test cache expired
456
            freezer.move_to(datetime.timedelta(seconds=6))
457
            response = app.get(f'/bbb/{SLUG}/meeting/{MEETING_ID}/is-running/')
458
            assert response.json['err'] == 0
459
            assert response.json['data'] is True
460
            is_meeting_running.side_effect = None
461
            response = app.get(f'/bbb/{SLUG}/meeting/{MEETING_ID}/is-running/')
462
            assert response.json['err'] == 0
463
            assert response.json['data'] is False
464

  
465
    class TestJoin:
466
        def test_join_user(self, app, connector, meetings_create):
467
            connector.meetings.create(
468
                guid=UUID, name=MEETING_NAME, create_parameters=json.dumps({'logoutUrl': LOGOUT_URL})
469
            )
470
            meeting = bbb_utils.BBB.Meeting(
471
                bbb=connector.bbb,
472
                meeting_id=MEETING_ID,
473
                meeting_name=MEETING_NAME,
474
                logout_url=LOGOUT_URL,
475
                create_time=1234,
476
            )
477
            meetings_create.return_value = meeting
478

  
479
            response = app.get(
480
                f'/bbb/{SLUG}/meeting/{MEETING_ID}/join/user/', params={'full_name': 'John Doe'}
481
            )
482
            assert meetings_create.call_args == mock.call(
483
                logoutUrl=LOGOUT_URL, meeting_id=MEETING_ID, name=MEETING_NAME
484
            )
485
            assert response.location == (
486
                'https://example.com/bigbluebutton/api/join'
487
                '?fullName=John+Doe&meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
488
                '&role=VIEWER&createTime=1234&checksum=1851e34e269a5063605bc64589ea53ded2f9744d'
489
            )
490

  
491
        def test_join_agent(self, app, connector, meetings_create):
492
            connector.meetings.create(
493
                guid=UUID, name=MEETING_NAME, create_parameters=json.dumps({'logoutUrl': LOGOUT_URL})
494
            )
495
            meeting = bbb_utils.BBB.Meeting(
496
                bbb=connector.bbb,
497
                meeting_id=MEETING_ID,
498
                meeting_name=MEETING_NAME,
499
                logout_url=LOGOUT_URL,
500
                create_time=1234,
501
            )
502
            meetings_create.return_value = meeting
503

  
504
            response = app.get(
505
                f'/bbb/{SLUG}/meeting/{MEETING_ID}/join/agent/', params={'full_name': 'John Doe'}
506
            )
507
            assert meetings_create.call_args == mock.call(
508
                logoutUrl=LOGOUT_URL, meeting_id=MEETING_ID, name=MEETING_NAME
509
            )
510
            assert response.location == (
511
                'https://example.com/bigbluebutton/api/join'
512
                '?fullName=John+Doe&meetingID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
513
                '&role=MODERATOR&createTime=1234&checksum=07a7e7fd233dd213bd6dfc5332cc842afac56ba5'
514
            )
0
-