SEBWIN-449: Implemented basic functionality of Jitsi Meet.
This commit is contained in:
parent
e72456b79e
commit
52217fa477
13 changed files with 358 additions and 113 deletions
|
@ -249,7 +249,7 @@ namespace SafeExamBrowser.Client
|
|||
|
||||
private IOperation BuildProctoringOperation()
|
||||
{
|
||||
var controller = new ProctoringController(uiFactory);
|
||||
var controller = new ProctoringController(ModuleLogger(nameof(ProctoringController)), uiFactory);
|
||||
var operation = new ProctoringOperation(context, logger, controller);
|
||||
|
||||
context.ProctoringController = controller;
|
||||
|
|
|
@ -17,7 +17,50 @@ namespace SafeExamBrowser.Configuration.ConfigurationData.DataMapping
|
|||
{
|
||||
switch (key)
|
||||
{
|
||||
// TODO
|
||||
case Keys.Proctoring.JitsiMeet.RoomName:
|
||||
MapJitsiMeetRoomName(settings, value);
|
||||
break;
|
||||
case Keys.Proctoring.JitsiMeet.ServerUrl:
|
||||
MapJitsiMeetServerUrl(settings, value);
|
||||
break;
|
||||
case Keys.Proctoring.JitsiMeet.Subject:
|
||||
MapJitsiMeetSubject(settings, value);
|
||||
break;
|
||||
case Keys.Proctoring.JitsiMeet.Token:
|
||||
MapJitsiMeetToken(settings, value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void MapJitsiMeetRoomName(AppSettings settings, object value)
|
||||
{
|
||||
if (value is string name)
|
||||
{
|
||||
settings.Proctoring.JitsiMeet.RoomName = name;
|
||||
}
|
||||
}
|
||||
|
||||
private void MapJitsiMeetServerUrl(AppSettings settings, object value)
|
||||
{
|
||||
if (value is string url)
|
||||
{
|
||||
settings.Proctoring.JitsiMeet.ServerUrl = url;
|
||||
}
|
||||
}
|
||||
|
||||
private void MapJitsiMeetSubject(AppSettings settings, object value)
|
||||
{
|
||||
if (value is string subject)
|
||||
{
|
||||
settings.Proctoring.JitsiMeet.Subject = subject;
|
||||
}
|
||||
}
|
||||
|
||||
private void MapJitsiMeetToken(AppSettings settings, object value)
|
||||
{
|
||||
if (value is string token)
|
||||
{
|
||||
settings.Proctoring.JitsiMeet.Token = token;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -219,6 +219,10 @@ namespace SafeExamBrowser.Configuration.ConfigurationData
|
|||
internal static class JitsiMeet
|
||||
{
|
||||
internal const string Enabled = "jitsiMeetEnable";
|
||||
internal const string RoomName = "jitsiMeetRoom";
|
||||
internal const string ServerUrl = "jitsiMeetServerURL";
|
||||
internal const string Subject = "jitsiMeetSubject";
|
||||
internal const string Token = "jitsiMeetToken";
|
||||
}
|
||||
|
||||
internal static class Zoom
|
||||
|
|
22
SafeExamBrowser.Proctoring/JitsiMeet/index.html
Normal file
22
SafeExamBrowser.Proctoring/JitsiMeet/index.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="placeholder" />
|
||||
<script src='https://meet.jit.si/external_api.js'></script>
|
||||
<script type="text/javascript">
|
||||
var domain = "%%_DOMAIN_%%";
|
||||
var options = {
|
||||
height: "100%",
|
||||
jwt: "%%_TOKEN_%%",
|
||||
parentNode: document.querySelector('#placeholder'),
|
||||
roomName: "%%_ROOM_NAME_%%",
|
||||
width: "100%"
|
||||
};
|
||||
var api = new JitsiMeetExternalAPI(domain, options);
|
||||
|
||||
api.executeCommand("subject", "%%_SUBJECT_%%");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -6,17 +6,46 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
using System;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Web.WebView2.Wpf;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.UserInterface.Contracts.Proctoring;
|
||||
|
||||
namespace SafeExamBrowser.Proctoring
|
||||
{
|
||||
internal class ProctoringControl : WebView2, IProctoringControl
|
||||
{
|
||||
internal ProctoringControl()
|
||||
private readonly ILogger logger;
|
||||
|
||||
internal ProctoringControl(ILogger logger)
|
||||
{
|
||||
Source = new Uri("https://www.microsoft.com");
|
||||
this.logger = logger;
|
||||
CoreWebView2InitializationCompleted += ProctoringControl_CoreWebView2InitializationCompleted;
|
||||
}
|
||||
|
||||
private void CoreWebView2_PermissionRequested(object sender, CoreWebView2PermissionRequestedEventArgs e)
|
||||
{
|
||||
if (e.PermissionKind == CoreWebView2PermissionKind.Camera || e.PermissionKind == CoreWebView2PermissionKind.Microphone)
|
||||
{
|
||||
logger.Info($"Granted access to {e.PermissionKind}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"Denied access to {e.PermissionKind}.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ProctoringControl_CoreWebView2InitializationCompleted(object sender, CoreWebView2InitializationCompletedEventArgs e)
|
||||
{
|
||||
if (e.IsSuccess)
|
||||
{
|
||||
CoreWebView2.PermissionRequested += CoreWebView2_PermissionRequested;
|
||||
logger.Info("Successfully initialized.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to initialize!", e.InitializationException);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Proctoring.Contracts;
|
||||
using SafeExamBrowser.Settings.Proctoring;
|
||||
using SafeExamBrowser.UserInterface.Contracts;
|
||||
|
@ -15,31 +19,72 @@ namespace SafeExamBrowser.Proctoring
|
|||
{
|
||||
public class ProctoringController : IProctoringController
|
||||
{
|
||||
private readonly IModuleLogger logger;
|
||||
private readonly IUserInterfaceFactory uiFactory;
|
||||
|
||||
private IProctoringWindow window;
|
||||
|
||||
public ProctoringController(IUserInterfaceFactory uiFactory)
|
||||
public ProctoringController(IModuleLogger logger, IUserInterfaceFactory uiFactory)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.uiFactory = uiFactory;
|
||||
}
|
||||
|
||||
public void Initialize(ProctoringSettings settings)
|
||||
{
|
||||
var control = new ProctoringControl();
|
||||
if (settings.JitsiMeet.Enabled || settings.Zoom.Enabled)
|
||||
{
|
||||
var control = new ProctoringControl(logger.CloneFor(nameof(ProctoringControl)));
|
||||
|
||||
control.EnsureCoreWebView2Async().ContinueWith(_ =>
|
||||
{
|
||||
control.Dispatcher.Invoke(() => control.NavigateToString(LoadContent(settings)));
|
||||
});
|
||||
|
||||
window = uiFactory.CreateProctoringWindow(control);
|
||||
window.Show();
|
||||
|
||||
// TODO
|
||||
//var content = load Zoom page, replace //INDEX_JS//;
|
||||
|
||||
//control.NavigateToString(content);
|
||||
logger.Info($"Initialized proctoring with {(settings.JitsiMeet.Enabled ? "Jitsi Meet" : "Zoom")}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Warn("Failed to initialize remote proctoring because no provider is enabled in the active configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Terminate()
|
||||
{
|
||||
window?.Close();
|
||||
}
|
||||
|
||||
private string LoadContent(ProctoringSettings settings)
|
||||
{
|
||||
var provider = settings.JitsiMeet.Enabled ? "JitsiMeet" : "Zoom";
|
||||
var assembly = Assembly.GetAssembly(typeof(ProctoringController));
|
||||
var path = $"{typeof(ProctoringController).Namespace}.{provider}.index.html";
|
||||
|
||||
using (var stream = assembly.GetManifestResourceStream(path))
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
var html = reader.ReadToEnd();
|
||||
|
||||
if (settings.JitsiMeet.Enabled)
|
||||
{
|
||||
html = html.Replace("%%_DOMAIN_%%", settings.JitsiMeet.ServerUrl);
|
||||
html = html.Replace("%%_ROOM_NAME_%%", settings.JitsiMeet.RoomName);
|
||||
html = html.Replace("%%_SUBJECT_%%", settings.JitsiMeet.Subject);
|
||||
html = html.Replace("%%_TOKEN_%%", settings.JitsiMeet.Token);
|
||||
}
|
||||
else if (settings.Zoom.Enabled)
|
||||
{
|
||||
html = html.Replace("%%_API_KEY_%%", settings.Zoom.ApiKey);
|
||||
html = html.Replace("%%_API_SECRET_%%", settings.Zoom.ApiSecret);
|
||||
html = html.Replace("123456789", Convert.ToString(settings.Zoom.MeetingNumber));
|
||||
html = html.Replace("%%_USER_NAME_%%", settings.Zoom.UserName);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,12 +95,10 @@
|
|||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="Zoom\index.html" />
|
||||
<Content Include="Zoom\index.js" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Jitsi\" />
|
||||
<EmbeddedResource Include="JitsiMeet\index.html" />
|
||||
<EmbeddedResource Include="Zoom\index.html" />
|
||||
</ItemGroup>
|
||||
<ItemGroup />
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
<Import Project="..\packages\Microsoft.Web.WebView2.1.0.705.50\build\Microsoft.Web.WebView2.targets" Condition="Exists('..\packages\Microsoft.Web.WebView2.1.0.705.50\build\Microsoft.Web.WebView2.targets')" />
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
|
|
|
@ -14,7 +14,100 @@
|
|||
<script src="https://source.zoom.us/zoom-meeting-1.8.1.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9/crypto-js.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
//INDEX_JS//
|
||||
const API_KEY = "%%_API_KEY_%%";
|
||||
const API_SECRET = "%%_API_SECRET_%%";
|
||||
|
||||
console.log("Checking system requirements...");
|
||||
console.log(JSON.stringify(ZoomMtg.checkSystemRequirements()));
|
||||
|
||||
console.log("Initializing Zoom...");
|
||||
ZoomMtg.setZoomJSLib('https://source.zoom.us/1.8.1/lib', '/av');
|
||||
ZoomMtg.preLoadWasm();
|
||||
ZoomMtg.prepareJssdk();
|
||||
|
||||
const config = {
|
||||
meetingNumber: 123456789,
|
||||
leaveUrl: 'https://google.ch',
|
||||
userName: '%%_USER_NAME_%%',
|
||||
/* passWord: 'password', // if required */
|
||||
role: 0 // 1 for host; 0 for attendee
|
||||
};
|
||||
|
||||
const signature = ZoomMtg.generateSignature({
|
||||
meetingNumber: config.meetingNumber,
|
||||
apiKey: API_KEY,
|
||||
apiSecret: API_SECRET,
|
||||
role: config.role,
|
||||
error: function (res) {
|
||||
console.error("FAILED TO GENERATE SIGNATURE: " + res)
|
||||
},
|
||||
success: function (res) {
|
||||
console.log("Successfully generated signature.");
|
||||
console.log(res.result);
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Initializing meeting...");
|
||||
|
||||
// See documentation: https://zoom.github.io/sample-app-web/ZoomMtg.html#init
|
||||
ZoomMtg.init({
|
||||
debug: true, //optional
|
||||
leaveUrl: config.leaveUrl, //required
|
||||
// webEndpoint: 'PSO web domain', // PSO option
|
||||
showMeetingHeader: true, //option
|
||||
disableInvite: false, //optional
|
||||
disableCallOut: false, //optional
|
||||
disableRecord: false, //optional
|
||||
disableJoinAudio: false, //optional
|
||||
audioPanelAlwaysOpen: true, //optional
|
||||
showPureSharingContent: false, //optional
|
||||
isSupportAV: true, //optional,
|
||||
isSupportChat: false, //optional,
|
||||
isSupportQA: true, //optional,
|
||||
isSupportCC: true, //optional,
|
||||
screenShare: true, //optional,
|
||||
rwcBackup: '', //optional,
|
||||
videoDrag: true, //optional,
|
||||
sharingMode: 'both', //optional,
|
||||
videoHeader: true, //optional,
|
||||
isLockBottom: true, // optional,
|
||||
isSupportNonverbal: true, // optional,
|
||||
isShowJoiningErrorDialog: true, // optional,
|
||||
inviteUrlFormat: '', // optional
|
||||
loginWindow: { // optional,
|
||||
width: 400,
|
||||
height: 380
|
||||
},
|
||||
// meetingInfo: [ // optional
|
||||
// 'topic',
|
||||
// 'host',
|
||||
// 'mn',
|
||||
// 'pwd',
|
||||
// 'telPwd',
|
||||
// 'invite',
|
||||
// 'participant',
|
||||
// 'dc'
|
||||
// ],
|
||||
disableVoIP: false, // optional
|
||||
disableReport: false, // optional
|
||||
error: function (res) {
|
||||
console.warn("INIT ERROR")
|
||||
console.log(res)
|
||||
},
|
||||
success: function () {
|
||||
ZoomMtg.join({
|
||||
signature: signature,
|
||||
apiKey: API_KEY,
|
||||
meetingNumber: config.meetingNumber,
|
||||
userName: config.userName,
|
||||
/* passWord: meetConfig.passWord, */
|
||||
error(res) {
|
||||
console.warn("JOIN ERROR")
|
||||
console.log(res)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,95 +0,0 @@
|
|||
const API_KEY = "...";
|
||||
const API_SECRET = "...";
|
||||
|
||||
console.log("Checking system requirements...");
|
||||
console.log(JSON.stringify(ZoomMtg.checkSystemRequirements()));
|
||||
|
||||
console.log("Initializing Zoom...");
|
||||
ZoomMtg.setZoomJSLib('https://source.zoom.us/1.8.1/lib', '/av');
|
||||
ZoomMtg.preLoadWasm();
|
||||
ZoomMtg.prepareJssdk();
|
||||
|
||||
const config = {
|
||||
meetingNumber: 123456,
|
||||
leaveUrl: 'https://google.ch',
|
||||
userName: 'Firstname Lastname',
|
||||
userEmail: 'firstname.lastname@yoursite.com',
|
||||
/* passWord: 'password', // if required */
|
||||
role: 0 // 1 for host; 0 for attendee
|
||||
};
|
||||
|
||||
const signature = ZoomMtg.generateSignature({
|
||||
meetingNumber: config.meetingNumber,
|
||||
apiKey: API_KEY,
|
||||
apiSecret: API_SECRET,
|
||||
role: config.role,
|
||||
error: function (res) {
|
||||
console.error("FAILED TO GENERATE SIGNATURE: " + res)
|
||||
},
|
||||
success: function (res) {
|
||||
console.log("Successfully generated signature.");
|
||||
console.log(res.result);
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Initializing meeting...");
|
||||
|
||||
// See documentation: https://zoom.github.io/sample-app-web/ZoomMtg.html#init
|
||||
ZoomMtg.init({
|
||||
debug: true, //optional
|
||||
leaveUrl: config.leaveUrl, //required
|
||||
// webEndpoint: 'PSO web domain', // PSO option
|
||||
showMeetingHeader: true, //option
|
||||
disableInvite: false, //optional
|
||||
disableCallOut: false, //optional
|
||||
disableRecord: false, //optional
|
||||
disableJoinAudio: false, //optional
|
||||
audioPanelAlwaysOpen: true, //optional
|
||||
showPureSharingContent: false, //optional
|
||||
isSupportAV: true, //optional,
|
||||
isSupportChat: false, //optional,
|
||||
isSupportQA: true, //optional,
|
||||
isSupportCC: true, //optional,
|
||||
screenShare: true, //optional,
|
||||
rwcBackup: '', //optional,
|
||||
videoDrag: true, //optional,
|
||||
sharingMode: 'both', //optional,
|
||||
videoHeader: true, //optional,
|
||||
isLockBottom: true, // optional,
|
||||
isSupportNonverbal: true, // optional,
|
||||
isShowJoiningErrorDialog: true, // optional,
|
||||
inviteUrlFormat: '', // optional
|
||||
loginWindow: { // optional,
|
||||
width: 400,
|
||||
height: 380
|
||||
},
|
||||
// meetingInfo: [ // optional
|
||||
// 'topic',
|
||||
// 'host',
|
||||
// 'mn',
|
||||
// 'pwd',
|
||||
// 'telPwd',
|
||||
// 'invite',
|
||||
// 'participant',
|
||||
// 'dc'
|
||||
// ],
|
||||
disableVoIP: false, // optional
|
||||
disableReport: false, // optional
|
||||
error: function(res) {
|
||||
console.warn("INIT ERROR")
|
||||
console.log(res)
|
||||
},
|
||||
success: function() {
|
||||
ZoomMtg.join({
|
||||
signature: signature,
|
||||
apiKey: API_KEY,
|
||||
meetingNumber: config.meetingNumber,
|
||||
userName: config.userName,
|
||||
/* passWord: meetConfig.passWord, */
|
||||
error(res) {
|
||||
console.warn("JOIN ERROR")
|
||||
console.log(res)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
44
SafeExamBrowser.Settings/Proctoring/JitsiMeetSettings.cs
Normal file
44
SafeExamBrowser.Settings/Proctoring/JitsiMeetSettings.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
using System;
|
||||
|
||||
namespace SafeExamBrowser.Settings.Proctoring
|
||||
{
|
||||
/// <summary>
|
||||
/// All settings for the meeting provider Jitsi Meet.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class JitsiMeetSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether proctoring with Jitsi Meet is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the meeting room.
|
||||
/// </summary>
|
||||
public string RoomName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The URL of the Jitsi Meet server.
|
||||
/// </summary>
|
||||
public string ServerUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject of the meeting.
|
||||
/// </summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authentication token for the meeting.
|
||||
/// </summary>
|
||||
public string Token { get; set; }
|
||||
}
|
||||
}
|
|
@ -20,5 +20,21 @@ namespace SafeExamBrowser.Settings.Proctoring
|
|||
/// Determines whether the entire remote proctoring feature is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All settings for remote proctoring with Jitsi Meet.
|
||||
/// </summary>
|
||||
public JitsiMeetSettings JitsiMeet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All settings for remote proctoring with Zoom.
|
||||
/// </summary>
|
||||
public ZoomSettings Zoom { get; set; }
|
||||
|
||||
public ProctoringSettings()
|
||||
{
|
||||
JitsiMeet = new JitsiMeetSettings();
|
||||
Zoom = new ZoomSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
44
SafeExamBrowser.Settings/Proctoring/ZoomSettings.cs
Normal file
44
SafeExamBrowser.Settings/Proctoring/ZoomSettings.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
using System;
|
||||
|
||||
namespace SafeExamBrowser.Settings.Proctoring
|
||||
{
|
||||
/// <summary>
|
||||
/// All settings for the meeting provider Zoom.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ZoomSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// The API key to be used for authentication.
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The API secret to be used for authentication.
|
||||
/// </summary>
|
||||
public string ApiSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether proctoring with Zoom is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of the meeting.
|
||||
/// </summary>
|
||||
public int MeetingNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user name to be used for the meeting.
|
||||
/// </summary>
|
||||
public string UserName { get; set; }
|
||||
}
|
||||
}
|
|
@ -71,7 +71,9 @@
|
|||
<Compile Include="Browser\Proxy\ProxyProtocol.cs" />
|
||||
<Compile Include="Browser\Proxy\ProxyConfiguration.cs" />
|
||||
<Compile Include="ConfigurationMode.cs" />
|
||||
<Compile Include="Proctoring\JitsiMeetSettings.cs" />
|
||||
<Compile Include="Proctoring\ProctoringSettings.cs" />
|
||||
<Compile Include="Proctoring\ZoomSettings.cs" />
|
||||
<Compile Include="SessionMode.cs" />
|
||||
<Compile Include="Security\KioskMode.cs" />
|
||||
<Compile Include="Logging\LogLevel.cs" />
|
||||
|
|
Loading…
Add table
Reference in a new issue