mirror of
https://git.deluge-torrent.org/deluge
synced 2025-04-19 19:14:55 +00:00
[Tests] [Web] Make JSON independent of Web component
* Implement JSONTestCase in test_json_api.py * Implement WebAPITestCase test case in test_web_api.py
This commit is contained in:
parent
bcc1db12e5
commit
d58960d723
9 changed files with 634 additions and 164 deletions
|
@ -86,10 +86,10 @@ class ProcessOutputHandler(protocol.ProcessProtocol):
|
|||
else:
|
||||
self.quit_d.errback(status)
|
||||
|
||||
def check_callbacks(self, data, type="stdout"):
|
||||
def check_callbacks(self, data, cb_type="stdout"):
|
||||
ret = False
|
||||
for c in self.callbacks:
|
||||
if type not in c["types"] or c["deferred"].called:
|
||||
if cb_type not in c["types"] or c["deferred"].called:
|
||||
continue
|
||||
for trigger in c["triggers"]:
|
||||
if trigger["expr"] in data:
|
||||
|
@ -118,7 +118,7 @@ class ProcessOutputHandler(protocol.ProcessProtocol):
|
|||
"""Process output from stderr"""
|
||||
self.log_output += data
|
||||
self.stderr_out += data
|
||||
self.check_callbacks(data, type="stderr")
|
||||
self.check_callbacks(data, cb_type="stderr")
|
||||
if not self.print_stderr:
|
||||
return
|
||||
data = "\n%s" % data.strip()
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
|
||||
# the additional special exception to link portions of this program with the OpenSSL library.
|
||||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
from twisted.internet import defer
|
||||
|
||||
import deluge.component as component
|
||||
|
@ -78,9 +85,7 @@ class ClientTestCase(BaseTestCase, DaemonBase):
|
|||
return d
|
||||
|
||||
def test_connect_no_credentials(self):
|
||||
d = client.connect(
|
||||
"localhost", self.listen_port, username="", password=""
|
||||
)
|
||||
d = client.connect("localhost", self.listen_port, username="", password="")
|
||||
|
||||
def on_connect(result):
|
||||
self.assertEqual(client.get_auth_level(), AUTH_LEVEL_ADMIN)
|
||||
|
@ -92,9 +97,7 @@ class ClientTestCase(BaseTestCase, DaemonBase):
|
|||
|
||||
def test_connect_localclient(self):
|
||||
username, password = deluge.ui.common.get_localhost_auth()
|
||||
d = client.connect(
|
||||
"localhost", self.listen_port, username=username, password=password
|
||||
)
|
||||
d = client.connect("localhost", self.listen_port, username=username, password=password)
|
||||
|
||||
def on_connect(result):
|
||||
self.assertEqual(client.get_auth_level(), AUTH_LEVEL_ADMIN)
|
||||
|
@ -106,15 +109,29 @@ class ClientTestCase(BaseTestCase, DaemonBase):
|
|||
|
||||
def test_connect_bad_password(self):
|
||||
username, password = deluge.ui.common.get_localhost_auth()
|
||||
d = client.connect(
|
||||
"localhost", self.listen_port, username=username, password=password + "1"
|
||||
)
|
||||
d = client.connect("localhost", self.listen_port, username=username, password=password + "1")
|
||||
|
||||
def on_failure(failure):
|
||||
self.assertEqual(
|
||||
failure.trap(error.BadLoginError),
|
||||
error.BadLoginError
|
||||
)
|
||||
self.assertEquals(failure.value.message, "Password does not match")
|
||||
self.addCleanup(client.disconnect)
|
||||
|
||||
d.addCallbacks(self.fail, on_failure)
|
||||
return d
|
||||
|
||||
def test_connect_invalid_user(self):
|
||||
username, password = deluge.ui.common.get_localhost_auth()
|
||||
d = client.connect("localhost", self.listen_port, username="invalid-user")
|
||||
|
||||
def on_failure(failure):
|
||||
self.assertEqual(
|
||||
failure.trap(error.BadLoginError),
|
||||
error.BadLoginError
|
||||
)
|
||||
self.assertEquals(failure.value.message, "Username does not exist")
|
||||
self.addCleanup(client.disconnect)
|
||||
|
||||
d.addCallbacks(self.fail, on_failure)
|
||||
|
@ -122,9 +139,7 @@ class ClientTestCase(BaseTestCase, DaemonBase):
|
|||
|
||||
def test_connect_without_password(self):
|
||||
username, password = deluge.ui.common.get_localhost_auth()
|
||||
d = client.connect(
|
||||
"localhost", self.listen_port, username=username
|
||||
)
|
||||
d = client.connect("localhost", self.listen_port, username=username)
|
||||
|
||||
def on_failure(failure):
|
||||
self.assertEqual(
|
||||
|
@ -137,11 +152,19 @@ class ClientTestCase(BaseTestCase, DaemonBase):
|
|||
d.addCallbacks(self.fail, on_failure)
|
||||
return d
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_connect_with_password(self):
|
||||
username, password = deluge.ui.common.get_localhost_auth()
|
||||
yield client.connect("localhost", self.listen_port, username=username, password=password)
|
||||
yield client.core.create_account("testuser", "testpw", "DEFAULT")
|
||||
yield client.disconnect()
|
||||
ret = yield client.connect("localhost", self.listen_port, username="testuser", password="testpw")
|
||||
self.assertEquals(ret, deluge.common.AUTH_LEVEL_NORMAL)
|
||||
yield
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_invalid_rpc_method_call(self):
|
||||
yield client.connect(
|
||||
"localhost", self.listen_port, username="", password=""
|
||||
)
|
||||
yield client.connect("localhost", self.listen_port, username="", password="")
|
||||
d = client.core.invalid_method()
|
||||
|
||||
def on_failure(failure):
|
||||
|
|
277
deluge/tests/test_json_api.py
Normal file
277
deluge/tests/test_json_api.py
Normal file
|
@ -0,0 +1,277 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2016 bendikro <bro.devel+deluge@gmail.com>
|
||||
#
|
||||
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
|
||||
# the additional special exception to link portions of this program with the OpenSSL library.
|
||||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
import json as json_lib
|
||||
|
||||
from mock import MagicMock
|
||||
from twisted.internet import defer
|
||||
from twisted.web import server
|
||||
from twisted.web.http import Request
|
||||
|
||||
import deluge.common
|
||||
import deluge.component as component
|
||||
import deluge.ui.web.auth
|
||||
import deluge.ui.web.json_api
|
||||
from deluge.error import DelugeError
|
||||
from deluge.ui.client import client
|
||||
from deluge.ui.web.auth import Auth
|
||||
from deluge.ui.web.json_api import JSON, JSONException
|
||||
|
||||
from . import common
|
||||
from .basetest import BaseTestCase
|
||||
from .daemon_base import DaemonBase
|
||||
|
||||
common.disable_new_release_check()
|
||||
|
||||
|
||||
class JSONBase(BaseTestCase, DaemonBase):
|
||||
|
||||
def connect_client(self, *args, **kwargs):
|
||||
return client.connect(
|
||||
"localhost", self.listen_port, username=kwargs.get("user", ""),
|
||||
password=kwargs.get("password", "")
|
||||
)
|
||||
|
||||
def disconnect_client(self, *args):
|
||||
return client.disconnect()
|
||||
|
||||
def tear_down(self):
|
||||
d = component.shutdown()
|
||||
d.addCallback(self.disconnect_client)
|
||||
d.addCallback(self.terminate_core)
|
||||
return d
|
||||
|
||||
|
||||
class JSONTestCase(JSONBase):
|
||||
|
||||
def set_up(self):
|
||||
d = self.common_set_up()
|
||||
d.addCallback(self.start_core)
|
||||
d.addCallbacks(self.connect_client, self.terminate_core)
|
||||
return d
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_get_remote_methods(self):
|
||||
json = JSON()
|
||||
methods = yield json.get_remote_methods()
|
||||
self.assertEquals(type(methods), tuple)
|
||||
self.assertTrue(len(methods) > 0)
|
||||
|
||||
def test_render_fail_disconnected(self):
|
||||
json = JSON()
|
||||
request = MagicMock()
|
||||
request.method = "POST"
|
||||
request._disconnected = True
|
||||
# When disconnected, returns empty string
|
||||
self.assertEquals(json.render(request), "")
|
||||
|
||||
def test_render_fail(self):
|
||||
json = JSON()
|
||||
request = MagicMock()
|
||||
request.method = "POST"
|
||||
|
||||
def compress(contents, request):
|
||||
return contents
|
||||
self.patch(deluge.ui.web.json_api, "compress", compress)
|
||||
|
||||
def write(response_str):
|
||||
request.write_was_called = True
|
||||
response = json_lib.loads(response_str)
|
||||
self.assertEquals(response["result"], None)
|
||||
self.assertEquals(response["id"], None)
|
||||
self.assertEquals(response["error"]["message"], "JSONException: JSON not decodable")
|
||||
self.assertEquals(response["error"]["code"], 5)
|
||||
|
||||
request.write = write
|
||||
request.write_was_called = False
|
||||
request._disconnected = False
|
||||
self.assertEquals(json.render(request), server.NOT_DONE_YET)
|
||||
self.assertTrue(request.write_was_called)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_handle_request_invalid_method(self):
|
||||
json = JSON()
|
||||
request = MagicMock()
|
||||
json_data = {"method": "no-existing-module.test", "id": 0, "params": []}
|
||||
request.json = json_lib.dumps(json_data)
|
||||
request_id, result, error = json._handle_request(request)
|
||||
self.assertEquals(error, {'message': 'Unknown method', 'code': 2})
|
||||
yield
|
||||
return
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_handle_request_invalid_json_request(self):
|
||||
json = JSON()
|
||||
request = MagicMock()
|
||||
request.json = json_lib.dumps({"id": 0, "params": []})
|
||||
self.assertRaises(JSONException, json._handle_request, request)
|
||||
request.json = json_lib.dumps({"method": "some.method", "params": []})
|
||||
self.assertRaises(JSONException, json._handle_request, request)
|
||||
request.json = json_lib.dumps({"method": "some.method", "id": 0})
|
||||
self.assertRaises(JSONException, json._handle_request, request)
|
||||
yield
|
||||
|
||||
|
||||
class JSONCustomUserTestCase(JSONBase):
|
||||
|
||||
def set_up(self):
|
||||
d = self.common_set_up()
|
||||
d.addCallback(self.start_core)
|
||||
return d
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_handle_request_auth_error(self):
|
||||
yield self.connect_client()
|
||||
json = JSON()
|
||||
auth_conf = {"session_timeout": 10, "sessions": []}
|
||||
Auth(auth_conf) # Must create the component
|
||||
|
||||
# Must be called to update remote methods in json object
|
||||
yield json.get_remote_methods()
|
||||
|
||||
request = MagicMock()
|
||||
request.getCookie = MagicMock(return_value="bad_value")
|
||||
json_data = {"method": "core.get_libtorrent_version", "id": 0, "params": []}
|
||||
request.json = json_lib.dumps(json_data)
|
||||
request_id, result, error = json._handle_request(request)
|
||||
self.assertEquals(error, {'message': 'Not authenticated', 'code': 1})
|
||||
return
|
||||
|
||||
|
||||
class RPCRaiseDelugeErrorJSONTestCase(JSONBase):
|
||||
|
||||
def set_up(self):
|
||||
d = self.common_set_up()
|
||||
custom_script = """
|
||||
from deluge.error import DelugeError
|
||||
from deluge.core.rpcserver import export
|
||||
class TestClass(object):
|
||||
@export()
|
||||
def test(self):
|
||||
raise DelugeError("DelugeERROR")
|
||||
|
||||
test = TestClass()
|
||||
daemon.rpcserver.register_object(test)
|
||||
"""
|
||||
d.addCallback(self.start_core, custom_script=custom_script)
|
||||
d.addCallbacks(self.connect_client, self.terminate_core)
|
||||
return d
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_handle_request_method_raise_delugeerror(self):
|
||||
json = JSON()
|
||||
|
||||
def get_session_id(s_id):
|
||||
return s_id
|
||||
self.patch(deluge.ui.web.auth, "get_session_id", get_session_id)
|
||||
auth_conf = {"session_timeout": 10, "sessions": []}
|
||||
auth = Auth(auth_conf)
|
||||
request = Request(MagicMock(), False)
|
||||
request.base = ""
|
||||
auth._create_session(request)
|
||||
methods = yield json.get_remote_methods()
|
||||
# Verify the function has been registered
|
||||
self.assertTrue("testclass.test" in methods)
|
||||
|
||||
request = MagicMock()
|
||||
request.getCookie = MagicMock(return_value=auth.config["sessions"].keys()[0])
|
||||
json_data = {"method": "testclass.test", "id": 0, "params": []}
|
||||
request.json = json_lib.dumps(json_data)
|
||||
request_id, result, error = json._handle_request(request)
|
||||
result.addCallback(self.fail)
|
||||
|
||||
def on_error(error):
|
||||
self.assertEquals(error.type, DelugeError)
|
||||
result.addErrback(on_error)
|
||||
yield
|
||||
return
|
||||
|
||||
|
||||
class JSONRequestFailedTestCase(JSONBase):
|
||||
|
||||
def set_up(self):
|
||||
d = self.common_set_up()
|
||||
custom_script = """
|
||||
from deluge.error import DelugeError
|
||||
from deluge.core.rpcserver import export
|
||||
from twisted.internet import reactor, task
|
||||
class TestClass(object):
|
||||
@export()
|
||||
def test(self):
|
||||
def test_raise_error():
|
||||
raise DelugeError("DelugeERROR")
|
||||
|
||||
return task.deferLater(reactor, 1, test_raise_error)
|
||||
|
||||
test = TestClass()
|
||||
daemon.rpcserver.register_object(test)
|
||||
"""
|
||||
from twisted.internet.defer import Deferred
|
||||
extra_callback = {"deferred": Deferred(), "types": ["stderr"],
|
||||
"timeout": 10,
|
||||
"triggers": [{"expr": "in test_raise_error",
|
||||
"value": lambda reader, data, data_all: "Test"}]}
|
||||
|
||||
def on_test_raise(*args):
|
||||
self.assertTrue("in test_raise_error" in self.core.stderr_out)
|
||||
|
||||
extra_callback["deferred"].addCallback(on_test_raise)
|
||||
self.d_test_raise_error_log = extra_callback["deferred"]
|
||||
|
||||
d.addCallback(self.start_core, custom_script=custom_script, print_stderr=False,
|
||||
timeout=5, extra_callbacks=[extra_callback])
|
||||
d.addCallbacks(self.connect_client, self.terminate_core)
|
||||
return d
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_render_on_rpc_request_failed(self):
|
||||
json = JSON()
|
||||
|
||||
def get_session_id(s_id):
|
||||
return s_id
|
||||
self.patch(deluge.ui.web.auth, "get_session_id", get_session_id)
|
||||
auth_conf = {"session_timeout": 10, "sessions": []}
|
||||
auth = Auth(auth_conf)
|
||||
request = Request(MagicMock(), False)
|
||||
request.base = ""
|
||||
auth._create_session(request)
|
||||
methods = yield json.get_remote_methods()
|
||||
# Verify the function has been registered
|
||||
self.assertTrue("testclass.test" in methods)
|
||||
|
||||
request = MagicMock()
|
||||
request.getCookie = MagicMock(return_value=auth.config["sessions"].keys()[0])
|
||||
|
||||
def compress(contents, request):
|
||||
return contents
|
||||
# Patch compress to avoid having to decompress output
|
||||
self.patch(deluge.ui.web.json_api, "compress", compress)
|
||||
|
||||
def write(response_str):
|
||||
request.write_was_called = True
|
||||
response = json_lib.loads(response_str)
|
||||
self.assertEquals(response["result"], None, "BAD RESULT")
|
||||
self.assertEquals(response["id"], 0)
|
||||
self.assertEquals(response["error"]["message"],
|
||||
"Failure: [Failure instance: Traceback (failure with no frames):"
|
||||
" <class 'deluge.error.DelugeError'>: DelugeERROR\n]"),
|
||||
self.assertEquals(response["error"]["code"], 4)
|
||||
|
||||
request.write = write
|
||||
request.write_was_called = False
|
||||
request._disconnected = False
|
||||
json_data = {"method": "testclass.test", "id": 0, "params": []}
|
||||
request.json = json_lib.dumps(json_data)
|
||||
d = json._on_json_request(request)
|
||||
|
||||
def on_success(arg):
|
||||
self.assertEquals(arg, server.NOT_DONE_YET)
|
||||
return True
|
||||
d.addCallbacks(on_success, self.fail)
|
||||
yield d
|
223
deluge/tests/test_web_api.py
Normal file
223
deluge/tests/test_web_api.py
Normal file
|
@ -0,0 +1,223 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2016 bendikro <bro.devel+deluge@gmail.com>
|
||||
#
|
||||
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
|
||||
# the additional special exception to link portions of this program with the OpenSSL library.
|
||||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
import os
|
||||
from StringIO import StringIO
|
||||
|
||||
from twisted.internet import defer, reactor
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.web.client import Agent, FileBodyProducer
|
||||
from twisted.web.http_headers import Headers
|
||||
from twisted.web.static import File
|
||||
|
||||
import deluge.common
|
||||
import deluge.component as component
|
||||
import deluge.ui.web.auth
|
||||
import deluge.ui.web.server
|
||||
from deluge import configmanager
|
||||
from deluge.ui.client import client
|
||||
from deluge.ui.web.server import DelugeWeb
|
||||
|
||||
from . import common
|
||||
from .basetest import BaseTestCase
|
||||
from .daemon_base import DaemonBase
|
||||
|
||||
common.disable_new_release_check()
|
||||
|
||||
|
||||
class ReactorOverride(object):
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr == "run":
|
||||
return self._run
|
||||
if attr == "stop":
|
||||
return self._stop
|
||||
return getattr(reactor, attr)
|
||||
|
||||
def _run(self):
|
||||
pass
|
||||
|
||||
def _stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class WebAPITestCase(BaseTestCase, DaemonBase):
|
||||
|
||||
def set_up(self):
|
||||
self.host_id = None
|
||||
deluge.ui.web.server.reactor = ReactorOverride()
|
||||
d = self.common_set_up()
|
||||
d.addCallback(self.start_core)
|
||||
d.addCallback(self.start_webapi)
|
||||
return d
|
||||
|
||||
def start_webapi(self, arg):
|
||||
self.webserver_listen_port = 8999
|
||||
|
||||
config_defaults = deluge.ui.web.server.CONFIG_DEFAULTS.copy()
|
||||
config_defaults["port"] = self.webserver_listen_port
|
||||
self.config = configmanager.ConfigManager("web.conf", config_defaults)
|
||||
|
||||
self.deluge_web = DelugeWeb()
|
||||
|
||||
host = list(self.deluge_web.web_api.host_list["hosts"][0])
|
||||
host[2] = self.listen_port
|
||||
self.deluge_web.web_api.host_list["hosts"][0] = tuple(host)
|
||||
self.host_id = host[0]
|
||||
self.deluge_web.start()
|
||||
|
||||
def tear_down(self):
|
||||
d = component.shutdown()
|
||||
d.addCallback(self.terminate_core)
|
||||
return d
|
||||
|
||||
def test_connect_invalid_host(self):
|
||||
d = self.deluge_web.web_api.connect("id")
|
||||
d.addCallback(self.fail)
|
||||
d.addErrback(self.assertIsInstance, Failure)
|
||||
return d
|
||||
|
||||
def test_connect(self):
|
||||
d = self.deluge_web.web_api.connect(self.host_id)
|
||||
|
||||
def on_connect(result):
|
||||
self.assertEquals(type(result), tuple)
|
||||
self.assertTrue(len(result) > 0)
|
||||
self.addCleanup(client.disconnect)
|
||||
return result
|
||||
|
||||
d.addCallback(on_connect)
|
||||
d.addErrback(self.fail)
|
||||
return d
|
||||
|
||||
def test_disconnect(self):
|
||||
d = self.deluge_web.web_api.connect(self.host_id)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def on_connect(result):
|
||||
self.assertTrue(self.deluge_web.web_api.connected())
|
||||
yield self.deluge_web.web_api.disconnect()
|
||||
self.assertFalse(self.deluge_web.web_api.connected())
|
||||
|
||||
d.addCallback(on_connect)
|
||||
d.addErrback(self.fail)
|
||||
return d
|
||||
|
||||
def test_get_config(self):
|
||||
config = self.deluge_web.web_api.get_config()
|
||||
self.assertEquals(self.webserver_listen_port, config["port"])
|
||||
|
||||
def test_set_config(self):
|
||||
config = self.deluge_web.web_api.get_config()
|
||||
config["pwd_salt"] = "new_salt"
|
||||
config["pwd_sha1"] = 'new_sha'
|
||||
config["sessions"] = {
|
||||
"233f23632af0a74748bc5dd1d8717564748877baa16420e6898e17e8aa365e6e": {
|
||||
"login": "skrot",
|
||||
"expires": 1460030877.0,
|
||||
"level": 10
|
||||
}
|
||||
}
|
||||
self.deluge_web.web_api.set_config(config)
|
||||
web_config = component.get("DelugeWeb").config.config
|
||||
self.assertNotEquals(config["pwd_salt"], web_config["pwd_salt"])
|
||||
self.assertNotEquals(config["pwd_sha1"], web_config["pwd_sha1"])
|
||||
self.assertNotEquals(config["sessions"], web_config["sessions"])
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def get_host_status(self):
|
||||
host = list(self.deluge_web.web_api._get_host(self.host_id))
|
||||
host[3] = 'Online'
|
||||
host[4] = u'2.0.0.dev562'
|
||||
status = yield self.deluge_web.web_api.get_host_status(self.host_id)
|
||||
self.assertEquals(status, tuple(status))
|
||||
|
||||
def test_get_host(self):
|
||||
self.assertEquals(self.deluge_web.web_api._get_host("invalid_id"), None)
|
||||
conn = self.deluge_web.web_api.host_list["hosts"][0]
|
||||
self.assertEquals(self.deluge_web.web_api._get_host(conn[0]), conn)
|
||||
|
||||
def test_add_host(self):
|
||||
conn = [None, '', 0, '', '']
|
||||
self.assertEquals(self.deluge_web.web_api._get_host(conn[0]), None)
|
||||
# Add valid host
|
||||
ret = self.deluge_web.web_api.add_host(conn[1], conn[2], conn[3], conn[4])
|
||||
self.assertEquals(ret[0], True)
|
||||
conn[0] = ret[1]
|
||||
self.assertEquals(self.deluge_web.web_api._get_host(conn[0]), conn)
|
||||
|
||||
# Add already existing host
|
||||
ret = self.deluge_web.web_api.add_host(conn[1], conn[2], conn[3], conn[4])
|
||||
self.assertEquals(ret, (False, "Host already in the list"))
|
||||
|
||||
# Add invalid port
|
||||
conn[2] = "bad port"
|
||||
ret = self.deluge_web.web_api.add_host(conn[1], conn[2], conn[3], conn[4])
|
||||
self.assertEquals(ret, (False, "Port is invalid"))
|
||||
|
||||
def test_remove_host(self):
|
||||
conn = ['connection_id', '', 0, '', '']
|
||||
self.deluge_web.web_api.host_list["hosts"].append(conn)
|
||||
self.assertEquals(self.deluge_web.web_api._get_host(conn[0]), conn)
|
||||
# Remove valid host
|
||||
self.assertTrue(self.deluge_web.web_api.remove_host(conn[0]))
|
||||
self.assertEquals(self.deluge_web.web_api._get_host(conn[0]), None)
|
||||
# Remove non-existing host
|
||||
self.assertFalse(self.deluge_web.web_api.remove_host(conn[0]))
|
||||
|
||||
def test_get_torrent_info(self):
|
||||
filename = os.path.join(os.path.dirname(__file__), "test.torrent")
|
||||
ret = self.deluge_web.web_api.get_torrent_info(filename)
|
||||
self.assertEquals(ret["name"], "azcvsupdater_2.6.2.jar")
|
||||
self.assertEquals(ret["info_hash"], "ab570cdd5a17ea1b61e970bb72047de141bce173")
|
||||
self.assertTrue("files_tree" in ret)
|
||||
|
||||
def test_get_magnet_info(self):
|
||||
ret = self.deluge_web.web_api.get_magnet_info("magnet:?xt=urn:btih:SU5225URMTUEQLDXQWRB2EQWN6KLTYKN")
|
||||
self.assertEquals(ret["name"], "953bad769164e8482c7785a21d12166f94b9e14d")
|
||||
self.assertEquals(ret["info_hash"], "953bad769164e8482c7785a21d12166f94b9e14d")
|
||||
self.assertTrue("files_tree" in ret)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_get_torrent_files(self):
|
||||
yield self.deluge_web.web_api.connect(self.host_id)
|
||||
filename = os.path.join(os.path.dirname(__file__), "test.torrent")
|
||||
torrents = [{"path": filename, "options": {"download_location": "/home/deluge/"}}]
|
||||
self.deluge_web.web_api.add_torrents(torrents)
|
||||
ret = yield self.deluge_web.web_api.get_torrent_files("ab570cdd5a17ea1b61e970bb72047de141bce173")
|
||||
self.assertEquals(ret["type"], "dir")
|
||||
self.assertEquals(ret["contents"], {u'azcvsupdater_2.6.2.jar':
|
||||
{'priority': 1, u'index': 0, u'offset': 0, 'progress': 0.0, u'path':
|
||||
u'azcvsupdater_2.6.2.jar', 'type': 'file', u'size': 307949}})
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_download_torrent_from_url(self):
|
||||
filename = "ubuntu-9.04-desktop-i386.iso.torrent"
|
||||
self.deluge_web.top_level.putChild(filename, File(common.rpath(filename)))
|
||||
url = "http://localhost:%d/%s" % (self.webserver_listen_port, filename)
|
||||
res = yield self.deluge_web.web_api.download_torrent_from_url(url)
|
||||
self.assertTrue(res.endswith(filename))
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def test_invalid_json(self):
|
||||
"""
|
||||
If json_api._send_response does not return server.NOT_DONE_YET
|
||||
this error is thrown when json is invalid:
|
||||
exceptions.RuntimeError: Request.write called on a request after Request.finish was called.
|
||||
|
||||
"""
|
||||
agent = Agent(reactor)
|
||||
bad_body = '{ method": "auth.login" }'
|
||||
d = yield agent.request(
|
||||
'POST',
|
||||
'http://127.0.0.1:%s/json' % self.webserver_listen_port,
|
||||
Headers({'User-Agent': ['Twisted Web Client Example'],
|
||||
'Content-Type': ['application/json']}),
|
||||
FileBodyProducer(StringIO(bad_body)))
|
||||
yield d
|
|
@ -16,7 +16,6 @@ from email.utils import formatdate
|
|||
|
||||
from twisted.internet.task import LoopingCall
|
||||
|
||||
from deluge import component
|
||||
from deluge.common import utf8_encoded
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
@ -79,9 +78,10 @@ class Auth(JSONComponent):
|
|||
The component that implements authentification into the JSON interface.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, config):
|
||||
super(Auth, self).__init__("Auth")
|
||||
self.worker = LoopingCall(self._clean_sessions)
|
||||
self.config = config
|
||||
|
||||
def start(self):
|
||||
self.worker.start(5)
|
||||
|
@ -90,19 +90,18 @@ class Auth(JSONComponent):
|
|||
self.worker.stop()
|
||||
|
||||
def _clean_sessions(self):
|
||||
config = component.get("DelugeWeb").config
|
||||
session_ids = config["sessions"].keys()
|
||||
session_ids = self.config["sessions"].keys()
|
||||
|
||||
now = time.gmtime()
|
||||
for session_id in session_ids:
|
||||
session = config["sessions"][session_id]
|
||||
session = self.config["sessions"][session_id]
|
||||
|
||||
if "expires" not in session:
|
||||
del config["sessions"][session_id]
|
||||
del self.config["sessions"][session_id]
|
||||
continue
|
||||
|
||||
if time.gmtime(session["expires"]) < now:
|
||||
del config["sessions"][session_id]
|
||||
del self.config["sessions"][session_id]
|
||||
continue
|
||||
|
||||
def _create_session(self, request, login='admin'):
|
||||
|
@ -117,21 +116,18 @@ class Auth(JSONComponent):
|
|||
m.update(os.urandom(32))
|
||||
session_id = m.hexdigest()
|
||||
|
||||
config = component.get("DelugeWeb").config
|
||||
|
||||
expires, expires_str = make_expires(config["session_timeout"])
|
||||
expires, expires_str = make_expires(self.config["session_timeout"])
|
||||
checksum = str(make_checksum(session_id))
|
||||
|
||||
request.addCookie('_session_id', session_id + checksum,
|
||||
path=request.base + "json", expires=expires_str)
|
||||
|
||||
log.debug("Creating session for %s", login)
|
||||
config = component.get("DelugeWeb").config
|
||||
|
||||
if isinstance(config["sessions"], list):
|
||||
config.config["sessions"] = {}
|
||||
if isinstance(self.config["sessions"], list):
|
||||
self.config["sessions"] = {}
|
||||
|
||||
config["sessions"][session_id] = {
|
||||
self.config["sessions"][session_id] = {
|
||||
"login": login,
|
||||
"level": AUTH_LEVEL_ADMIN,
|
||||
"expires": expires
|
||||
|
@ -139,7 +135,7 @@ class Auth(JSONComponent):
|
|||
return True
|
||||
|
||||
def check_password(self, password):
|
||||
config = component.get("DelugeWeb").config
|
||||
config = self.config
|
||||
if "pwd_md5" in config.config:
|
||||
# We are using the 1.2-dev auth method
|
||||
log.debug("Received a password via the 1.2-dev auth method")
|
||||
|
@ -206,16 +202,15 @@ class Auth(JSONComponent):
|
|||
:raises: Exception
|
||||
"""
|
||||
|
||||
config = component.get("DelugeWeb").config
|
||||
session_id = get_session_id(request.getCookie("_session_id"))
|
||||
|
||||
if session_id not in config["sessions"]:
|
||||
if session_id not in self.config["sessions"]:
|
||||
auth_level = AUTH_LEVEL_NONE
|
||||
session_id = None
|
||||
else:
|
||||
session = config["sessions"][session_id]
|
||||
session = self.config["sessions"][session_id]
|
||||
auth_level = session["level"]
|
||||
expires, expires_str = make_expires(config["session_timeout"])
|
||||
expires, expires_str = make_expires(self.config["session_timeout"])
|
||||
session["expires"] = expires
|
||||
|
||||
_session_id = request.getCookie("_session_id")
|
||||
|
@ -253,9 +248,8 @@ class Auth(JSONComponent):
|
|||
salt = hashlib.sha1(os.urandom(32)).hexdigest()
|
||||
s = hashlib.sha1(salt)
|
||||
s.update(utf8_encoded(new_password))
|
||||
config = component.get("DelugeWeb").config
|
||||
config["pwd_salt"] = salt
|
||||
config["pwd_sha1"] = s.hexdigest()
|
||||
self.config["pwd_salt"] = salt
|
||||
self.config["pwd_sha1"] = s.hexdigest()
|
||||
return True
|
||||
|
||||
@export
|
||||
|
@ -290,8 +284,7 @@ class Auth(JSONComponent):
|
|||
:param session_id: the id for the session to remove
|
||||
:type session_id: string
|
||||
"""
|
||||
config = component.get("DelugeWeb").config
|
||||
del config["sessions"][__request__.session_id]
|
||||
del self.config["sessions"][__request__.session_id]
|
||||
return True
|
||||
|
||||
@export(AUTH_LEVEL_NONE)
|
||||
|
|
|
@ -16,14 +16,12 @@ import shutil
|
|||
import tempfile
|
||||
import time
|
||||
from types import FunctionType
|
||||
from urllib import unquote_plus
|
||||
|
||||
from twisted.internet import defer, reactor
|
||||
from twisted.internet.defer import Deferred, DeferredList
|
||||
from twisted.web import http, resource, server
|
||||
|
||||
from deluge import component, httpdownloader
|
||||
from deluge.common import is_magnet
|
||||
from deluge import common, component, httpdownloader
|
||||
from deluge.configmanager import ConfigManager, get_config_dir
|
||||
from deluge.ui import common as uicommon
|
||||
from deluge.ui.client import Client, client
|
||||
|
@ -104,47 +102,6 @@ class JSON(resource.Resource, component.Component):
|
|||
return methods
|
||||
return client.daemon.get_method_list().addCallback(on_get_methods)
|
||||
|
||||
def connect(self, host="localhost", port=58846, username="", password=""):
|
||||
"""
|
||||
Connects the client to a daemon
|
||||
"""
|
||||
d = client.connect(host, port, username, password)
|
||||
|
||||
def on_client_connected(connection_id):
|
||||
"""
|
||||
Handles the client successfully connecting to the daemon and
|
||||
invokes retrieving the method names.
|
||||
"""
|
||||
d = self.get_remote_methods()
|
||||
component.get("Web.PluginManager").start()
|
||||
component.get("Web").start()
|
||||
return d
|
||||
|
||||
return d.addCallback(on_client_connected)
|
||||
|
||||
def disable(self):
|
||||
if not client.is_classicmode():
|
||||
client.disconnect()
|
||||
client.set_disconnect_callback(None)
|
||||
|
||||
def enable(self):
|
||||
if not client.is_classicmode():
|
||||
client.set_disconnect_callback(self._on_client_disconnect)
|
||||
client.register_event_handler("PluginEnabledEvent", self.get_remote_methods)
|
||||
client.register_event_handler("PluginDisabledEvent", self.get_remote_methods)
|
||||
if component.get("DelugeWeb").config["default_daemon"]:
|
||||
# Sort out getting the default daemon here
|
||||
default = component.get("DelugeWeb").config["default_daemon"]
|
||||
host = component.get("Web").get_host(default)
|
||||
if host:
|
||||
self.connect(*host[1:])
|
||||
else:
|
||||
self.connect()
|
||||
|
||||
def _on_client_disconnect(self, *args):
|
||||
component.get("Web.PluginManager").stop()
|
||||
component.get("Web").stop()
|
||||
|
||||
def _exec_local(self, method, params, request):
|
||||
"""
|
||||
Handles executing all local methods.
|
||||
|
@ -180,9 +137,8 @@ class JSON(resource.Resource, component.Component):
|
|||
"""
|
||||
try:
|
||||
request.json = json.loads(request.json)
|
||||
except ValueError:
|
||||
except (ValueError, TypeError):
|
||||
raise JSONException("JSON not decodable")
|
||||
|
||||
if "method" not in request.json or "id" not in request.json or \
|
||||
"params" not in request.json:
|
||||
raise JSONException("Invalid JSON request")
|
||||
|
@ -257,12 +213,12 @@ class JSON(resource.Resource, component.Component):
|
|||
request.setHeader("content-type", "application/x-json")
|
||||
request.write(compress(response, request))
|
||||
request.finish()
|
||||
return server.NOT_DONE_YET
|
||||
|
||||
def render(self, request):
|
||||
"""
|
||||
Handles all the POST requests made to the /json controller.
|
||||
"""
|
||||
|
||||
if request.method != "POST":
|
||||
request.setResponseCode(http.NOT_ALLOWED)
|
||||
request.finish()
|
||||
|
@ -421,7 +377,30 @@ class WebApi(JSONComponent):
|
|||
except KeyError:
|
||||
self.sessionproxy = SessionProxy()
|
||||
|
||||
def get_host(self, host_id):
|
||||
def disable(self):
|
||||
if not client.is_classicmode():
|
||||
client.disconnect()
|
||||
client.set_disconnect_callback(None)
|
||||
|
||||
def enable(self):
|
||||
if not client.is_classicmode():
|
||||
client.set_disconnect_callback(self._on_client_disconnect)
|
||||
client.register_event_handler("PluginEnabledEvent", self._json.get_remote_methods)
|
||||
client.register_event_handler("PluginDisabledEvent", self._json.get_remote_methods)
|
||||
if component.get("DelugeWeb").config["default_daemon"]:
|
||||
# Sort out getting the default daemon here
|
||||
default = component.get("DelugeWeb").config["default_daemon"]
|
||||
host = component.get("Web")._get_host(default)
|
||||
if host:
|
||||
self._connect_daemon(*host[1:])
|
||||
else:
|
||||
self._connect_daemon()
|
||||
|
||||
def _on_client_disconnect(self, *args):
|
||||
component.get("Web.PluginManager").stop()
|
||||
self.stop()
|
||||
|
||||
def _get_host(self, host_id):
|
||||
"""
|
||||
Return the information about a host
|
||||
|
||||
|
@ -442,6 +421,24 @@ class WebApi(JSONComponent):
|
|||
self.core_config.stop()
|
||||
self.sessionproxy.stop()
|
||||
|
||||
def _connect_daemon(self, host="localhost", port=58846, username="", password=""):
|
||||
"""
|
||||
Connects the client to a daemon
|
||||
"""
|
||||
d = client.connect(host, port, username, password)
|
||||
|
||||
def on_client_connected(connection_id):
|
||||
"""
|
||||
Handles the client successfully connecting to the daemon and
|
||||
invokes retrieving the method names.
|
||||
"""
|
||||
d = self._json.get_remote_methods()
|
||||
component.get("Web.PluginManager").start()
|
||||
self.start()
|
||||
return d
|
||||
|
||||
return d.addCallback(on_client_connected)
|
||||
|
||||
@export
|
||||
def connect(self, host_id):
|
||||
"""
|
||||
|
@ -452,16 +449,10 @@ class WebApi(JSONComponent):
|
|||
:returns: the methods the daemon supports
|
||||
:rtype: list
|
||||
"""
|
||||
d = Deferred()
|
||||
|
||||
def on_connected(methods):
|
||||
d.callback(methods)
|
||||
host = self.get_host(host_id)
|
||||
host = self._get_host(host_id)
|
||||
if host:
|
||||
self._json.connect(*host[1:]).addCallback(on_connected)
|
||||
else:
|
||||
return defer.fail(Exception("Bad host id"))
|
||||
return d
|
||||
return self._connect_daemon(*host[1:])
|
||||
return defer.fail(Exception("Bad host id"))
|
||||
|
||||
@export
|
||||
def connected(self):
|
||||
|
@ -478,8 +469,12 @@ class WebApi(JSONComponent):
|
|||
"""
|
||||
Disconnect the web interface from the connected daemon.
|
||||
"""
|
||||
client.disconnect()
|
||||
return True
|
||||
d = client.disconnect()
|
||||
|
||||
def on_disconnect(reason):
|
||||
return str(reason)
|
||||
d.addCallback(on_disconnect)
|
||||
return d
|
||||
|
||||
@export
|
||||
def update_ui(self, keys, filter_dict):
|
||||
|
@ -677,47 +672,7 @@ class WebApi(JSONComponent):
|
|||
|
||||
@export
|
||||
def get_magnet_info(self, uri):
|
||||
"""
|
||||
Return information about a magnet link.
|
||||
|
||||
:param uri: the magnet link
|
||||
:type uri: string
|
||||
|
||||
:returns: information about the magnet link:
|
||||
|
||||
::
|
||||
|
||||
{
|
||||
"name": the torrent name,
|
||||
"info_hash": the torrents info_hash,
|
||||
"files_tree": empty value for magnet links
|
||||
}
|
||||
|
||||
:rtype: dictionary
|
||||
"""
|
||||
magnet_scheme = 'magnet:?'
|
||||
xt_param = 'xt=urn:btih:'
|
||||
dn_param = 'dn='
|
||||
if uri.startswith(magnet_scheme):
|
||||
name = None
|
||||
info_hash = None
|
||||
for param in uri[len(magnet_scheme):].split('&'):
|
||||
if param.startswith(xt_param):
|
||||
xt_hash = param[len(xt_param):]
|
||||
if len(xt_hash) == 32:
|
||||
info_hash = base64.b32decode(xt_hash).encode("hex")
|
||||
elif len(xt_hash) == 40:
|
||||
info_hash = xt_hash
|
||||
else:
|
||||
break
|
||||
elif param.startswith(dn_param):
|
||||
name = unquote_plus(param[len(dn_param):])
|
||||
|
||||
if info_hash:
|
||||
if not name:
|
||||
name = info_hash
|
||||
return {"name": name, "info_hash": info_hash, "files_tree": ''}
|
||||
return False
|
||||
return common.get_magnet_info(uri)
|
||||
|
||||
@export
|
||||
def add_torrents(self, torrents):
|
||||
|
@ -737,7 +692,7 @@ class WebApi(JSONComponent):
|
|||
|
||||
"""
|
||||
for torrent in torrents:
|
||||
if is_magnet(torrent["path"]):
|
||||
if common.is_magnet(torrent["path"]):
|
||||
log.info("Adding torrent from magnet uri `%s` with options `%r`",
|
||||
torrent["path"], torrent["options"])
|
||||
client.core.add_torrent_magnet(torrent["path"], torrent["options"])
|
||||
|
@ -769,7 +724,7 @@ class WebApi(JSONComponent):
|
|||
return host_id, host, port, status, info
|
||||
|
||||
try:
|
||||
host_id, host, port, user, password = self.get_host(host_id)
|
||||
host_id, host, port, user, password = self._get_host(host_id)
|
||||
except TypeError:
|
||||
host = None
|
||||
port = None
|
||||
|
@ -808,8 +763,8 @@ class WebApi(JSONComponent):
|
|||
@export
|
||||
def start_daemon(self, port):
|
||||
"""
|
||||
Starts a local daemon.
|
||||
"""
|
||||
Starts a local daemon.
|
||||
"""
|
||||
client.start_daemon(port, get_config_dir())
|
||||
|
||||
@export
|
||||
|
@ -821,7 +776,7 @@ class WebApi(JSONComponent):
|
|||
:type host_id: string
|
||||
"""
|
||||
main_deferred = Deferred()
|
||||
host = self.get_host(host_id)
|
||||
host = self._get_host(host_id)
|
||||
if not host:
|
||||
main_deferred.callback((False, _("Daemon doesn't exist")))
|
||||
return main_deferred
|
||||
|
@ -864,7 +819,7 @@ class WebApi(JSONComponent):
|
|||
# Check to see if there is already an entry for this host and return
|
||||
# if thats the case
|
||||
for entry in self.host_list["hosts"]:
|
||||
if (entry[0], entry[1], entry[2]) == (host, port, username):
|
||||
if (entry[1], entry[2], entry[3]) == (host, port, username):
|
||||
return (False, "Host already in the list")
|
||||
|
||||
try:
|
||||
|
@ -877,7 +832,7 @@ class WebApi(JSONComponent):
|
|||
self.host_list["hosts"].append([connection_id, host, port, username,
|
||||
password])
|
||||
self.host_list.save()
|
||||
return (True,)
|
||||
return True, connection_id
|
||||
|
||||
@export
|
||||
def remove_host(self, connection_id):
|
||||
|
@ -887,7 +842,7 @@ class WebApi(JSONComponent):
|
|||
:param host_id: the hash id of the host
|
||||
:type host_id: string
|
||||
"""
|
||||
host = self.get_host(connection_id)
|
||||
host = self._get_host(connection_id)
|
||||
if host is None:
|
||||
return False
|
||||
|
||||
|
@ -919,6 +874,9 @@ class WebApi(JSONComponent):
|
|||
"""
|
||||
web_config = component.get("DelugeWeb").config
|
||||
for key in config.keys():
|
||||
if key in ["sessions", "pwd_salt", "pwd_sha1"]:
|
||||
log.warn("Ignored attempt to overwrite web config key '%s'" % key)
|
||||
continue
|
||||
if isinstance(config[key], basestring):
|
||||
config[key] = config[key].encode("utf8")
|
||||
web_config[key] = config[key]
|
||||
|
|
|
@ -16,7 +16,7 @@ import tempfile
|
|||
|
||||
from OpenSSL.crypto import FILETYPE_PEM
|
||||
from twisted.application import internet, service
|
||||
from twisted.internet import defer, error, reactor
|
||||
from twisted.internet import defer, reactor
|
||||
from twisted.internet.ssl import SSL, Certificate, CertificateOptions, KeyPair
|
||||
from twisted.web import http, resource, server, static
|
||||
|
||||
|
@ -533,7 +533,6 @@ class DelugeWeb(component.Component):
|
|||
def __init__(self):
|
||||
super(DelugeWeb, self).__init__("DelugeWeb")
|
||||
self.config = configmanager.ConfigManager("web.conf", CONFIG_DEFAULTS)
|
||||
|
||||
self.socket = None
|
||||
self.top_level = TopLevel()
|
||||
self.site = server.Site(self.top_level)
|
||||
|
@ -544,7 +543,7 @@ class DelugeWeb(component.Component):
|
|||
self.cert = self.config["cert"]
|
||||
self.base = self.config["base"]
|
||||
self.web_api = WebApi()
|
||||
self.auth = Auth()
|
||||
self.auth = Auth(self.config)
|
||||
|
||||
# Initalize the plugins
|
||||
self.plugins = PluginManager()
|
||||
|
@ -568,17 +567,15 @@ class DelugeWeb(component.Component):
|
|||
return 1
|
||||
SetConsoleCtrlHandler(win_handler)
|
||||
|
||||
def start(self, start_reactor=True):
|
||||
def start(self):
|
||||
log.info("%s %s.", _("Starting server in PID"), os.getpid())
|
||||
if self.https:
|
||||
self.start_ssl()
|
||||
else:
|
||||
self.start_normal()
|
||||
|
||||
component.get("JSON").enable()
|
||||
|
||||
if start_reactor:
|
||||
reactor.run()
|
||||
component.get("Web").enable()
|
||||
reactor.run()
|
||||
|
||||
def start_normal(self):
|
||||
self.socket = reactor.listenTCP(self.port, self.site, interface=self.interface)
|
||||
|
@ -600,7 +597,7 @@ class DelugeWeb(component.Component):
|
|||
|
||||
def stop(self):
|
||||
log.info("Shutting down webserver")
|
||||
component.get("JSON").disable()
|
||||
component.get("Web").disable()
|
||||
|
||||
self.plugins.disable_plugins()
|
||||
log.debug("Saving configuration file")
|
||||
|
@ -616,10 +613,8 @@ class DelugeWeb(component.Component):
|
|||
|
||||
def shutdown(self, *args):
|
||||
self.stop()
|
||||
try:
|
||||
reactor.stop()
|
||||
except error.ReactorNotRunning:
|
||||
log.debug("Reactor not running")
|
||||
reactor.stop()
|
||||
|
||||
|
||||
if __name__ == "__builtin__":
|
||||
deluge_web = DelugeWeb()
|
||||
|
|
|
@ -16,7 +16,7 @@ frameworks = CoreFoundation, Foundation, AppKit
|
|||
|
||||
[isort]
|
||||
known_standard_library=unicodedata
|
||||
known_third_party=pygtk,gtk,gobject,gtk.gdk,pango,cairo,pangocairo,twisted,pytest,OpenSSL,pkg_resources,chardet,bbfreeze,win32verstamp
|
||||
known_third_party=pygtk,gtk,gobject,gtk.gdk,pango,cairo,pangocairo,twisted,pytest,OpenSSL,pkg_resources,chardet,bbfreeze,win32verstamp,mock
|
||||
known_first_party=msgfmt
|
||||
order_by_type=true
|
||||
line_length=120
|
||||
|
|
1
tox.ini
1
tox.ini
|
@ -28,6 +28,7 @@ deps =
|
|||
pyopenssl
|
||||
pyxdg
|
||||
pytest
|
||||
mock
|
||||
whitelist_externals = py.test
|
||||
commands = {envpython} setup.py test
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue