forked from openlp/openlp
Add icons to see the state of the remote (on / SSL / authentication)
Stop restarting the server as this crashes it and should not be common. bzr-revno: 2370
This commit is contained in:
commit
9e8f0d82de
@ -30,7 +30,6 @@
|
|||||||
The actual plugin view form
|
The actual plugin view form
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
from PyQt4 import QtGui
|
from PyQt4 import QtGui
|
||||||
|
|
||||||
|
@ -149,11 +149,11 @@ class HttpRouter(RegistryProperties):
|
|||||||
"""
|
"""
|
||||||
Initialise the router stack and any other variables.
|
Initialise the router stack and any other variables.
|
||||||
"""
|
"""
|
||||||
authcode = "%s:%s" % (Settings().value('remotes/user id'), Settings().value('remotes/password'))
|
auth_code = "%s:%s" % (Settings().value('remotes/user id'), Settings().value('remotes/password'))
|
||||||
try:
|
try:
|
||||||
self.auth = base64.b64encode(authcode)
|
self.auth = base64.b64encode(auth_code)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
self.auth = base64.b64encode(authcode.encode()).decode()
|
self.auth = base64.b64encode(auth_code.encode()).decode()
|
||||||
self.routes = [
|
self.routes = [
|
||||||
('^/$', {'function': self.serve_file, 'secure': False}),
|
('^/$', {'function': self.serve_file, 'secure': False}),
|
||||||
('^/(stage)$', {'function': self.serve_file, 'secure': False}),
|
('^/(stage)$', {'function': self.serve_file, 'secure': False}),
|
||||||
@ -376,7 +376,6 @@ class HttpRouter(RegistryProperties):
|
|||||||
Examines the extension of the file and determines what the content_type should be, defaults to text/plain
|
Examines the extension of the file and determines what the content_type should be, defaults to text/plain
|
||||||
Returns the extension and the content_type
|
Returns the extension and the content_type
|
||||||
"""
|
"""
|
||||||
content_type = 'text/plain'
|
|
||||||
ext = os.path.splitext(file_name)[1]
|
ext = os.path.splitext(file_name)[1]
|
||||||
content_type = FILE_TYPES.get(ext, 'text/plain')
|
content_type = FILE_TYPES.get(ext, 'text/plain')
|
||||||
return ext, content_type
|
return ext, content_type
|
||||||
@ -439,7 +438,7 @@ class HttpRouter(RegistryProperties):
|
|||||||
if plugin.status == PluginStatus.Active:
|
if plugin.status == PluginStatus.Active:
|
||||||
try:
|
try:
|
||||||
text = json.loads(self.request_data)['request']['text']
|
text = json.loads(self.request_data)['request']['text']
|
||||||
except KeyError as ValueError:
|
except KeyError:
|
||||||
return self.do_http_error()
|
return self.do_http_error()
|
||||||
text = urllib.parse.unquote(text)
|
text = urllib.parse.unquote(text)
|
||||||
self.alerts_manager.emit(QtCore.SIGNAL('alerts_text'), [text])
|
self.alerts_manager.emit(QtCore.SIGNAL('alerts_text'), [text])
|
||||||
@ -453,6 +452,7 @@ class HttpRouter(RegistryProperties):
|
|||||||
"""
|
"""
|
||||||
Perform an action on the slide controller.
|
Perform an action on the slide controller.
|
||||||
"""
|
"""
|
||||||
|
log.debug("controller_text var = %s" % var)
|
||||||
current_item = self.live_controller.service_item
|
current_item = self.live_controller.service_item
|
||||||
data = []
|
data = []
|
||||||
if current_item:
|
if current_item:
|
||||||
@ -488,7 +488,7 @@ class HttpRouter(RegistryProperties):
|
|||||||
if self.request_data:
|
if self.request_data:
|
||||||
try:
|
try:
|
||||||
data = json.loads(self.request_data)['request']['id']
|
data = json.loads(self.request_data)['request']['id']
|
||||||
except KeyError as ValueError:
|
except KeyError:
|
||||||
return self.do_http_error()
|
return self.do_http_error()
|
||||||
log.info(data)
|
log.info(data)
|
||||||
# This slot expects an int within a list.
|
# This slot expects an int within a list.
|
||||||
@ -547,7 +547,7 @@ class HttpRouter(RegistryProperties):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
text = json.loads(self.request_data)['request']['text']
|
text = json.loads(self.request_data)['request']['text']
|
||||||
except KeyError as ValueError:
|
except KeyError:
|
||||||
return self.do_http_error()
|
return self.do_http_error()
|
||||||
text = urllib.parse.unquote(text)
|
text = urllib.parse.unquote(text)
|
||||||
plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
|
plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
|
||||||
@ -563,12 +563,12 @@ class HttpRouter(RegistryProperties):
|
|||||||
Go live on an item of type ``plugin``.
|
Go live on an item of type ``plugin``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
id = json.loads(self.request_data)['request']['id']
|
request_id = json.loads(self.request_data)['request']['id']
|
||||||
except KeyError as ValueError:
|
except KeyError:
|
||||||
return self.do_http_error()
|
return self.do_http_error()
|
||||||
plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
|
plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
|
||||||
if plugin.status == PluginStatus.Active and plugin.media_item:
|
if plugin.status == PluginStatus.Active and plugin.media_item:
|
||||||
plugin.media_item.emit(QtCore.SIGNAL('%s_go_live' % plugin_name), [id, True])
|
plugin.media_item.emit(QtCore.SIGNAL('%s_go_live' % plugin_name), [request_id, True])
|
||||||
return self.do_http_success()
|
return self.do_http_success()
|
||||||
|
|
||||||
def add_to_service(self, plugin_name):
|
def add_to_service(self, plugin_name):
|
||||||
@ -576,11 +576,11 @@ class HttpRouter(RegistryProperties):
|
|||||||
Add item of type ``plugin_name`` to the end of the service.
|
Add item of type ``plugin_name`` to the end of the service.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
id = json.loads(self.request_data)['request']['id']
|
request_id = json.loads(self.request_data)['request']['id']
|
||||||
except KeyError as ValueError:
|
except KeyError:
|
||||||
return self.do_http_error()
|
return self.do_http_error()
|
||||||
plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
|
plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
|
||||||
if plugin.status == PluginStatus.Active and plugin.media_item:
|
if plugin.status == PluginStatus.Active and plugin.media_item:
|
||||||
item_id = plugin.media_item.create_item_from_id(id)
|
item_id = plugin.media_item.create_item_from_id(request_id)
|
||||||
plugin.media_item.emit(QtCore.SIGNAL('%s_add_to_service' % plugin_name), [item_id, True])
|
plugin.media_item.emit(QtCore.SIGNAL('%s_add_to_service' % plugin_name), [item_id, True])
|
||||||
self.do_http_success()
|
self.do_http_success()
|
||||||
|
@ -40,7 +40,7 @@ import time
|
|||||||
|
|
||||||
from PyQt4 import QtCore
|
from PyQt4 import QtCore
|
||||||
|
|
||||||
from openlp.core.common import AppLocation, Settings
|
from openlp.core.common import AppLocation, Settings, RegistryProperties
|
||||||
|
|
||||||
from openlp.plugins.remotes.lib import HttpRouter
|
from openlp.plugins.remotes.lib import HttpRouter
|
||||||
|
|
||||||
@ -94,13 +94,18 @@ class HttpThread(QtCore.QThread):
|
|||||||
"""
|
"""
|
||||||
self.http_server.start_server()
|
self.http_server.start_server()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
log.debug("stop called")
|
||||||
|
self.http_server.stop = True
|
||||||
|
|
||||||
class OpenLPServer():
|
|
||||||
|
class OpenLPServer(RegistryProperties):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""
|
"""
|
||||||
Initialise the http server, and start the server of the correct type http / https
|
Initialise the http server, and start the server of the correct type http / https
|
||||||
"""
|
"""
|
||||||
log.debug('Initialise httpserver')
|
super(OpenLPServer, self).__init__()
|
||||||
|
log.debug('Initialise OpenLP')
|
||||||
self.settings_section = 'remotes'
|
self.settings_section = 'remotes'
|
||||||
self.http_thread = HttpThread(self)
|
self.http_thread = HttpThread(self)
|
||||||
self.http_thread.start()
|
self.http_thread.start()
|
||||||
@ -110,32 +115,49 @@ class OpenLPServer():
|
|||||||
Start the correct server and save the handler
|
Start the correct server and save the handler
|
||||||
"""
|
"""
|
||||||
address = Settings().value(self.settings_section + '/ip address')
|
address = Settings().value(self.settings_section + '/ip address')
|
||||||
if Settings().value(self.settings_section + '/https enabled'):
|
self.address = address
|
||||||
|
self.is_secure = Settings().value(self.settings_section + '/https enabled')
|
||||||
|
self.needs_authentication = Settings().value(self.settings_section + '/authentication enabled')
|
||||||
|
if self.is_secure:
|
||||||
port = Settings().value(self.settings_section + '/https port')
|
port = Settings().value(self.settings_section + '/https port')
|
||||||
self.httpd = HTTPSServer((address, port), CustomHandler)
|
self.port = port
|
||||||
log.debug('Started ssl httpd...')
|
self.start_server_instance(address, port, HTTPSServer)
|
||||||
else:
|
else:
|
||||||
port = Settings().value(self.settings_section + '/port')
|
port = Settings().value(self.settings_section + '/port')
|
||||||
loop = 1
|
self.port = port
|
||||||
while loop < 3:
|
self.start_server_instance(address, port, ThreadingHTTPServer)
|
||||||
try:
|
|
||||||
self.httpd = ThreadingHTTPServer((address, port), CustomHandler)
|
|
||||||
except OSError:
|
|
||||||
loop += 1
|
|
||||||
time.sleep(0.1)
|
|
||||||
except:
|
|
||||||
log.error('Failed to start server ')
|
|
||||||
log.debug('Started non ssl httpd...')
|
|
||||||
if hasattr(self, 'httpd') and self.httpd:
|
if hasattr(self, 'httpd') and self.httpd:
|
||||||
self.httpd.serve_forever()
|
self.httpd.serve_forever()
|
||||||
else:
|
else:
|
||||||
log.debug('Failed to start server')
|
log.debug('Failed to start server')
|
||||||
|
|
||||||
|
def start_server_instance(self, address, port, server_class):
|
||||||
|
"""
|
||||||
|
Start the server
|
||||||
|
|
||||||
|
:param address: The server address
|
||||||
|
:param port: The run port
|
||||||
|
:param server_class: the class to start
|
||||||
|
"""
|
||||||
|
loop = 1
|
||||||
|
while loop < 4:
|
||||||
|
try:
|
||||||
|
self.httpd = server_class((address, port), CustomHandler)
|
||||||
|
log.debug("Server started for class %s %s %d" % (server_class, address, port))
|
||||||
|
except OSError:
|
||||||
|
log.debug("failed to start http server thread state %d %s" %
|
||||||
|
(loop, self.http_thread.isRunning()))
|
||||||
|
loop += 1
|
||||||
|
time.sleep(0.1)
|
||||||
|
except:
|
||||||
|
log.error('Failed to start server ')
|
||||||
|
|
||||||
def stop_server(self):
|
def stop_server(self):
|
||||||
"""
|
"""
|
||||||
Stop the server
|
Stop the server
|
||||||
"""
|
"""
|
||||||
self.http_thread.exit(0)
|
if self.http_thread.isRunning():
|
||||||
|
self.http_thread.stop()
|
||||||
self.httpd = None
|
self.httpd = None
|
||||||
log.debug('Stopped the server.')
|
log.debug('Stopped the server.')
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ import os.path
|
|||||||
from PyQt4 import QtCore, QtGui, QtNetwork
|
from PyQt4 import QtCore, QtGui, QtNetwork
|
||||||
|
|
||||||
from openlp.core.common import AppLocation, Settings, translate
|
from openlp.core.common import AppLocation, Settings, translate
|
||||||
from openlp.core.lib import SettingsTab
|
from openlp.core.lib import SettingsTab, build_icon
|
||||||
|
|
||||||
ZERO_URL = '0.0.0.0'
|
ZERO_URL = '0.0.0.0'
|
||||||
|
|
||||||
@ -234,6 +234,7 @@ class RemoteTab(SettingsTab):
|
|||||||
"""
|
"""
|
||||||
Load the configuration and update the server configuration if necessary
|
Load the configuration and update the server configuration if necessary
|
||||||
"""
|
"""
|
||||||
|
self.is_secure = Settings().value(self.settings_section + '/https enabled')
|
||||||
self.port_spin_box.setValue(Settings().value(self.settings_section + '/port'))
|
self.port_spin_box.setValue(Settings().value(self.settings_section + '/port'))
|
||||||
self.https_port_spin_box.setValue(Settings().value(self.settings_section + '/https port'))
|
self.https_port_spin_box.setValue(Settings().value(self.settings_section + '/https port'))
|
||||||
self.address_edit.setText(Settings().value(self.settings_section + '/ip address'))
|
self.address_edit.setText(Settings().value(self.settings_section + '/ip address'))
|
||||||
@ -263,9 +264,7 @@ class RemoteTab(SettingsTab):
|
|||||||
Settings().value(self.settings_section + '/port') != self.port_spin_box.value() or \
|
Settings().value(self.settings_section + '/port') != self.port_spin_box.value() or \
|
||||||
Settings().value(self.settings_section + '/https port') != self.https_port_spin_box.value() or \
|
Settings().value(self.settings_section + '/https port') != self.https_port_spin_box.value() or \
|
||||||
Settings().value(self.settings_section + '/https enabled') != \
|
Settings().value(self.settings_section + '/https enabled') != \
|
||||||
self.https_settings_group_box.isChecked() or \
|
self.https_settings_group_box.isChecked():
|
||||||
Settings().value(self.settings_section + '/authentication enabled') != \
|
|
||||||
self.user_login_group_box.isChecked():
|
|
||||||
self.settings_form.register_post_process('remotes_config_updated')
|
self.settings_form.register_post_process('remotes_config_updated')
|
||||||
Settings().setValue(self.settings_section + '/port', self.port_spin_box.value())
|
Settings().setValue(self.settings_section + '/port', self.port_spin_box.value())
|
||||||
Settings().setValue(self.settings_section + '/https port', self.https_port_spin_box.value())
|
Settings().setValue(self.settings_section + '/https port', self.https_port_spin_box.value())
|
||||||
@ -275,6 +274,7 @@ class RemoteTab(SettingsTab):
|
|||||||
Settings().setValue(self.settings_section + '/authentication enabled', self.user_login_group_box.isChecked())
|
Settings().setValue(self.settings_section + '/authentication enabled', self.user_login_group_box.isChecked())
|
||||||
Settings().setValue(self.settings_section + '/user id', self.user_id.text())
|
Settings().setValue(self.settings_section + '/user id', self.user_id.text())
|
||||||
Settings().setValue(self.settings_section + '/password', self.password.text())
|
Settings().setValue(self.settings_section + '/password', self.password.text())
|
||||||
|
self.generate_icon()
|
||||||
|
|
||||||
def on_twelve_hour_check_box_changed(self, check_state):
|
def on_twelve_hour_check_box_changed(self, check_state):
|
||||||
"""
|
"""
|
||||||
@ -290,3 +290,25 @@ class RemoteTab(SettingsTab):
|
|||||||
Invert the HTTP group box based on Https group settings
|
Invert the HTTP group box based on Https group settings
|
||||||
"""
|
"""
|
||||||
self.http_settings_group_box.setEnabled(not self.https_settings_group_box.isChecked())
|
self.http_settings_group_box.setEnabled(not self.https_settings_group_box.isChecked())
|
||||||
|
|
||||||
|
def generate_icon(self):
|
||||||
|
"""
|
||||||
|
Generate icon for main window
|
||||||
|
"""
|
||||||
|
self.remote_server_icon.hide()
|
||||||
|
icon = QtGui.QImage(':/remote/network_server.png')
|
||||||
|
icon = icon.scaled(80, 80, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
|
||||||
|
if self.is_secure:
|
||||||
|
overlay = QtGui.QImage(':/remote/network_ssl.png')
|
||||||
|
overlay = overlay.scaled(60, 60, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
|
||||||
|
painter = QtGui.QPainter(icon)
|
||||||
|
painter.drawImage(0, 0, overlay)
|
||||||
|
painter.end()
|
||||||
|
if Settings().value(self.settings_section + '/authentication enabled'):
|
||||||
|
overlay = QtGui.QImage(':/remote/network_auth.png')
|
||||||
|
overlay = overlay.scaled(60, 60, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
|
||||||
|
painter = QtGui.QPainter(icon)
|
||||||
|
painter.drawImage(20, 0, overlay)
|
||||||
|
painter.end()
|
||||||
|
self.remote_server_icon.setPixmap(QtGui.QPixmap.fromImage(icon))
|
||||||
|
self.remote_server_icon.show()
|
||||||
|
@ -28,7 +28,8 @@
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
|
from PyQt4 import QtGui
|
||||||
|
|
||||||
from openlp.core.lib import Plugin, StringContent, translate, build_icon
|
from openlp.core.lib import Plugin, StringContent, translate, build_icon
|
||||||
from openlp.plugins.remotes.lib import RemoteTab, OpenLPServer
|
from openlp.plugins.remotes.lib import RemoteTab, OpenLPServer
|
||||||
@ -67,6 +68,21 @@ class RemotesPlugin(Plugin):
|
|||||||
log.debug('initialise')
|
log.debug('initialise')
|
||||||
super(RemotesPlugin, self).initialise()
|
super(RemotesPlugin, self).initialise()
|
||||||
self.server = OpenLPServer()
|
self.server = OpenLPServer()
|
||||||
|
if not hasattr(self, 'remote_server_icon'):
|
||||||
|
self.remote_server_icon = QtGui.QLabel(self.main_window.status_bar)
|
||||||
|
size_policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed)
|
||||||
|
size_policy.setHorizontalStretch(0)
|
||||||
|
size_policy.setVerticalStretch(0)
|
||||||
|
size_policy.setHeightForWidth(self.remote_server_icon.sizePolicy().hasHeightForWidth())
|
||||||
|
self.remote_server_icon.setSizePolicy(size_policy)
|
||||||
|
self.remote_server_icon.setFrameShadow(QtGui.QFrame.Plain)
|
||||||
|
self.remote_server_icon.setLineWidth(1)
|
||||||
|
self.remote_server_icon.setScaledContents(True)
|
||||||
|
self.remote_server_icon.setFixedSize(20, 20)
|
||||||
|
self.remote_server_icon.setObjectName('remote_server_icon')
|
||||||
|
self.main_window.status_bar.insertPermanentWidget(2, self.remote_server_icon)
|
||||||
|
self.settings_tab.remote_server_icon = self.remote_server_icon
|
||||||
|
self.settings_tab.generate_icon()
|
||||||
|
|
||||||
def finalise(self):
|
def finalise(self):
|
||||||
"""
|
"""
|
||||||
@ -104,9 +120,11 @@ class RemotesPlugin(Plugin):
|
|||||||
|
|
||||||
def config_update(self):
|
def config_update(self):
|
||||||
"""
|
"""
|
||||||
Called when Config is changed to restart the server on new address or port
|
Called when Config is changed to requests a restart with the server on new address or port
|
||||||
"""
|
"""
|
||||||
log.debug('remote config changed')
|
log.debug('remote config changed')
|
||||||
self.finalise()
|
QtGui.QMessageBox.information(self.main_window,
|
||||||
time.sleep(0.5)
|
translate('RemotePlugin', 'Server Config Change'),
|
||||||
self.initialise()
|
translate('RemotePlugin', 'Server configuration changes will require a restart '
|
||||||
|
'to take effect.'),
|
||||||
|
QtGui.QMessageBox.StandardButtons(QtGui.QMessageBox.Ok))
|
||||||
|
BIN
resources/images/network_auth.png
Normal file
BIN
resources/images/network_auth.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 608 B |
BIN
resources/images/network_server.png
Normal file
BIN
resources/images/network_server.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
resources/images/network_ssl.png
Normal file
BIN
resources/images/network_ssl.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 577 B |
@ -149,6 +149,11 @@
|
|||||||
<file>messagebox_info.png</file>
|
<file>messagebox_info.png</file>
|
||||||
<file>messagebox_warning.png</file>
|
<file>messagebox_warning.png</file>
|
||||||
</qresource>
|
</qresource>
|
||||||
|
<qresource prefix="remote">
|
||||||
|
<file>network_server.png</file>
|
||||||
|
<file>network_ssl.png</file>
|
||||||
|
<file>network_auth.png</file>
|
||||||
|
</qresource>
|
||||||
<qresource prefix="songusage">
|
<qresource prefix="songusage">
|
||||||
<file>song_usage_active.png</file>
|
<file>song_usage_active.png</file>
|
||||||
<file>song_usage_inactive.png</file>
|
<file>song_usage_inactive.png</file>
|
||||||
|
@ -148,7 +148,7 @@ class JenkinsTrigger(object):
|
|||||||
|
|
||||||
def get_repo_name():
|
def get_repo_name():
|
||||||
"""
|
"""
|
||||||
This returns the name of branch of the wokring directory. For example it returns *lp:~googol/openlp/render*.
|
This returns the name of branch of the working directory. For example it returns *lp:~googol/openlp/render*.
|
||||||
"""
|
"""
|
||||||
# Run the bzr command.
|
# Run the bzr command.
|
||||||
bzr = Popen(('bzr', 'info'), stdout=PIPE, stderr=PIPE)
|
bzr = Popen(('bzr', 'info'), stdout=PIPE, stderr=PIPE)
|
||||||
@ -198,7 +198,7 @@ def main():
|
|||||||
jenkins_trigger = JenkinsTrigger(token)
|
jenkins_trigger = JenkinsTrigger(token)
|
||||||
try:
|
try:
|
||||||
jenkins_trigger.trigger_build()
|
jenkins_trigger.trigger_build()
|
||||||
except HTTPError as e:
|
except HTTPError:
|
||||||
print('Wrong token.')
|
print('Wrong token.')
|
||||||
return
|
return
|
||||||
# Open the browser before printing the output.
|
# Open the browser before printing the output.
|
||||||
|
@ -53,8 +53,8 @@ class TestFileDialog(TestCase):
|
|||||||
self.mocked_os.rest()
|
self.mocked_os.rest()
|
||||||
self.mocked_qt_gui.reset()
|
self.mocked_qt_gui.reset()
|
||||||
|
|
||||||
# GIVEN: A List of known values as a return value from QFileDialog.getOpenFileNames and a list of valid
|
# GIVEN: A List of known values as a return value from QFileDialog.getOpenFileNames and a list of valid file
|
||||||
# file names.
|
# names.
|
||||||
self.mocked_qt_gui.QFileDialog.getOpenFileNames.return_value = [
|
self.mocked_qt_gui.QFileDialog.getOpenFileNames.return_value = [
|
||||||
'/Valid File', '/url%20encoded%20file%20%231', '/non-existing']
|
'/Valid File', '/url%20encoded%20file%20%231', '/non-existing']
|
||||||
self.mocked_os.path.exists.side_effect = lambda file_name: file_name in [
|
self.mocked_os.path.exists.side_effect = lambda file_name: file_name in [
|
||||||
|
Loading…
Reference in New Issue
Block a user