diff --git a/extras/openslides_gui/__init__.py b/extras/openslides_gui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extras/openslides_gui/__main__.py b/extras/openslides_gui/__main__.py new file mode 100644 index 000000000..14932bed3 --- /dev/null +++ b/extras/openslides_gui/__main__.py @@ -0,0 +1,4 @@ +from .gui import main + +if __name__ == "__main__": + main() diff --git a/extras/openslides_gui/data/openslides-logo_wide.png b/extras/openslides_gui/data/openslides-logo_wide.png new file mode 100644 index 000000000..d46f12df8 Binary files /dev/null and b/extras/openslides_gui/data/openslides-logo_wide.png differ diff --git a/extras/openslides_gui/data/openslides.ico b/extras/openslides_gui/data/openslides.ico new file mode 100644 index 000000000..0f562a295 Binary files /dev/null and b/extras/openslides_gui/data/openslides.ico differ diff --git a/extras/openslides_gui/gui.py b/extras/openslides_gui/gui.py new file mode 100644 index 000000000..2d69fef8f --- /dev/null +++ b/extras/openslides_gui/gui.py @@ -0,0 +1,695 @@ +from __future__ import unicode_literals + +import Queue +import datetime +import errno +import gettext +import itertools +import json +import locale +import os +import subprocess +import sys +import threading + +import wx + +import openslides +import openslides.main + +# NOTE: djangos translation module can't be used here since it requires +# a defined settings module +_translations = gettext.NullTranslations() +_ = lambda text: _translations.ugettext(text) +ungettext = lambda msg1, msg2, n: _translations.ungettext(msg1, msg2, n) + + +def get_data_path(*args): + return os.path.join(os.path.dirname(__file__), "data", *args) + + +class RunCmdEvent(wx.PyCommandEvent): + def __init__(self, evt_type, evt_id): + super(RunCmdEvent, self).__init__(evt_type, evt_id) + + self.running = False + self.exitcode = None + +EVT_RUN_CMD_ID = wx.NewEventType() +EVT_RUN_CMD = wx.PyEventBinder(EVT_RUN_CMD_ID, 1) + + +class RunCommandControl(wx.Panel): + UPDATE_INTERVAL = 500 + + def __init__(self, parent): + super(RunCommandControl, self).__init__(parent) + + self.child_process = None + self.output_queue = Queue.Queue() + self.output_read_thread = None + self.canceled = False + self.output_mutex = threading.RLock() + + vbox = wx.BoxSizer(wx.VERTICAL) + + self.te_output = wx.TextCtrl( + self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL) + vbox.Add(self.te_output, 1, wx.EXPAND) + + self.update_timer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self.on_update_timer, self.update_timer) + + self.SetSizerAndFit(vbox) + + def _read_output(self): + while True: + # NOTE: don't use iterator interface since it uses an + # internal buffer and we don't see output in a timely fashion + line = self.child_process.stdout.readline() + if not line: + break + self.output_queue.put(line) + + def is_alive(self): + if self.child_process is None: + return False + return self.child_process.poll() is None + + def run_command(self, *args): + if self.is_alive(): + raise ValueError("already running a command") + + cmd = [sys.executable, "-u", "-m", "openslides.main"] + cmd.extend(args) + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + self.child_process = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, creationflags=creationflags) + self.child_process.stdin.close() + self.output_read_thread = threading.Thread(target=self._read_output) + self.output_read_thread.start() + + self.update_timer.Start(self.UPDATE_INTERVAL) + + evt = RunCmdEvent(EVT_RUN_CMD_ID, self.GetId()) + evt.running = True + self.GetEventHandler().ProcessEvent(evt) + + def cancel_command(self): + if not self.is_alive(): + return + + # TODO: try sigint first, then get more aggressive if user insists + self.child_process.kill() + self.canceled = True + + def on_update_timer(self, evt): + is_alive = self.is_alive() + if not is_alive: + # join thread to make sure everything was read + self.output_read_thread.join() + self.output_read_thread = None + + for line_no in itertools.count(): + try: + data = self.output_queue.get(block=False) + except Queue.Empty: + break + else: + # XXX: check whether django uses utf-8 or locale for + # it's cli output + text = data.decode("utf-8", errors="replace") + with self.output_mutex: + self.te_output.AppendText(text) + + # avoid waiting too long here if child is still alive + if is_alive and line_no > 10: + break + + if not is_alive: + exitcode = self.child_process.returncode + self.update_timer.Stop() + self.child_process = None + + evt = RunCmdEvent(EVT_RUN_CMD_ID, self.GetId()) + evt.running = False + evt.exitcode = exitcode + self.GetEventHandler().ProcessEvent(evt) + + def append_message(self, text, newline="\n"): + with self.output_mutex: + self.te_output.AppendText(text + newline) + + +class SettingsDialog(wx.Dialog): + def __init__(self, parent): + super(SettingsDialog, self).__init__(parent, wx.ID_ANY, _("Settings")) + + grid = wx.GridBagSizer(5, 5) + row = 0 + + lb_host = wx.StaticText(self, label=_("&Host:")) + grid.Add(lb_host, pos=(row, 0)) + self.tc_host = wx.TextCtrl(self) + grid.Add(self.tc_host, pos=(row, 1), flag=wx.EXPAND) + + row += 1 + + lb_port = wx.StaticText(self, label=_("&Port:")) + grid.Add(lb_port, pos=(row, 0)) + self.tc_port = wx.TextCtrl(self) + grid.Add(self.tc_port, pos=(row, 1), flag=wx.EXPAND) + + row += 1 + + sizer = self.CreateButtonSizer(wx.OK | wx.CANCEL) + if not sizer is None: + grid.Add((0, 0), pos=(row, 0), span=(1, 2)) + row += 1 + grid.Add(sizer, pos=(row, 0), span=(1, 2)) + + box = wx.BoxSizer(wx.VERTICAL) + box.Add( + grid, flag=wx.EXPAND | wx.ALL | wx.ALIGN_CENTER_VERTICAL, + border=5, proportion=1) + + self.SetSizerAndFit(box) + + @property + def host(self): + return self.tc_host.GetValue() + + @host.setter + def host(self, host): + self.tc_host.SetValue(host) + + @property + def port(self): + return self.tc_port.GetValue() + + @port.setter + def port(self, port): + self.tc_port.SetValue(port) + + +class BackupSettingsDialog(wx.Dialog): + # NOTE: keep order in sync with _update_interval_choices() + _INTERVAL_UNITS = ["second", "minute", "hour"] + + def __init__(self, parent): + super(BackupSettingsDialog, self).__init__( + parent, wx.ID_ANY, _("Database backup")) + + self._interval_units = {} + + grid = wx.GridBagSizer(5, 5) + row = 0 + + self.cb_backup = wx.CheckBox( + self, label=_("&Regularly backup database")) + self.cb_backup.SetValue(True) + self.cb_backup.Bind(wx.EVT_CHECKBOX, self.on_backup_checked) + grid.Add(self.cb_backup, pos=(row, 0), span=(1, 3)) + row += 1 + + lb_dest = wx.StaticText(self, label=_("&Destination:")) + grid.Add(lb_dest, pos=(row, 0)) + style = wx.FLP_SAVE | wx.FLP_USE_TEXTCTRL + self.fp_dest = wx.FilePickerCtrl(self, style=style) + grid.Add(self.fp_dest, pos=(row, 1), span=(1, 2), flag=wx.EXPAND) + row += 1 + + lb_interval = wx.StaticText(self, label=_("&Every")) + grid.Add(lb_interval, pos=(row, 0)) + self.sb_interval = wx.SpinCtrl(self, min=1, initial=1) + self.sb_interval.Bind(wx.EVT_SPINCTRL, self.on_interval_changed) + grid.Add(self.sb_interval, pos=(row, 1)) + self.ch_interval_unit = wx.Choice(self) + grid.Add(self.ch_interval_unit, pos=(row, 2)) + row += 1 + + grid.AddGrowableCol(1) + + sizer = self.CreateButtonSizer(wx.OK | wx.CANCEL) + if not sizer is None: + grid.Add((0, 0), pos=(row, 0), span=(1, 3)) + row += 1 + grid.Add(sizer, pos=(row, 0), span=(1, 3)) + + box = wx.BoxSizer(wx.VERTICAL) + box.Add( + grid, flag=wx.EXPAND | wx.ALL | wx.ALIGN_CENTER_VERTICAL, + border=5, proportion=1) + + self.SetSizerAndFit(box) + self._update_interval_choices() + self._update_backup_enabled() + + @property + def backupdb_enabled(self): + return self.cb_backup.GetValue() + + @backupdb_enabled.setter + def backupdb_enabled(self, enabled): + self.cb_backup.SetValue(enabled) + self._update_backup_enabled() + + @property + def backupdb_destination(self): + return self.fp_dest.GetPath() + + @backupdb_destination.setter + def backupdb_destination(self, path): + self.fp_dest.SetPath(path) + + @property + def interval(self): + return self.sb_interval.GetValue() + + @interval.setter + def interval(self, value): + self.sb_interval.SetValue(value) + self._update_interval_choices() + + @property + def interval_unit(self): + return self._INTERVAL_UNITS[self.ch_interval_unit.GetSelection()] + + @interval_unit.setter + def interval_unit(self, unit): + try: + idx = self._INTERVAL_UNITS.index(unit) + except IndexError: + raise ValueError("Unknown unit {0}".format(unit)) + + self.ch_interval_unit.SetSelection(idx) + + def _update_interval_choices(self): + count = self.sb_interval.GetValue() + choices = [ + ungettext("second", "seconds", count), + ungettext("minute", "minutes", count), + ungettext("hour", "hours", count), + ] + + current = self.ch_interval_unit.GetSelection() + if current == wx.NOT_FOUND: + current = 2 # default to hour + + self.ch_interval_unit.Clear() + self.ch_interval_unit.AppendItems(choices) + self.ch_interval_unit.SetSelection(current) + + def _update_backup_enabled(self): + checked = self.cb_backup.IsChecked() + self.fp_dest.Enable(checked) + self.sb_interval.Enable(checked) + self.ch_interval_unit.Enable(checked) + + def on_backup_checked(self, evt): + self._update_backup_enabled() + + def on_interval_changed(self, evt): + self._update_interval_choices() + + # TODO: validate settings on close (e.g. non-empty path if backup is + # enabled) + + +class MainWindow(wx.Frame): + def __init__(self, parent=None): + super(MainWindow, self).__init__(parent, title="OpenSlides") + icons = wx.IconBundleFromFile( + get_data_path("openslides.ico"), + wx.BITMAP_TYPE_ICO) + self.SetIcons(icons) + + self.server_running = False + if openslides.main.is_portable(): + self.gui_settings_path = openslides.main.get_portable_path( + "openslides", "gui_settings.json") + else: + self.gui_settings_path = openslides.main.get_user_config_path( + "openslides", "gui_settings.json") + + self.backupdb_enabled = False + self.backupdb_destination = "" + self.backupdb_interval = 15 + self.backupdb_interval_unit = "minute" + self.last_backup = None + + self.backup_timer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self.on_backup_timer, self.backup_timer) + + spacing = 5 + + panel = wx.Panel(self) + grid = wx.GridBagSizer(spacing, spacing) + + # logo & about button + logo_box = wx.BoxSizer(wx.HORIZONTAL) + grid.Add(logo_box, pos=(0, 0), flag=wx.EXPAND) + row = 0 + + fp = get_data_path("openslides-logo_wide.png") + with open(fp, "rb") as f: + logo_wide_bmp = wx.ImageFromStream(f).ConvertToBitmap() + + logo_wide = wx.StaticBitmap(panel, wx.ID_ANY, logo_wide_bmp) + logo_box.AddSpacer(2 * spacing) + logo_box.Add(logo_wide) + logo_box.AddStretchSpacer() + + version_str = _("Version {0}").format(openslides.get_version()) + lb_version = wx.StaticText(panel, label=version_str) + font = lb_version.GetFont() + font.SetPointSize(8) + lb_version.SetFont(font) + logo_box.Add(lb_version, flag=wx.ALIGN_CENTER_VERTICAL) + + self.bt_about = wx.Button(panel, label=_("&About...")) + self.bt_about.Bind(wx.EVT_BUTTON, self.on_about_clicked) + grid.Add(self.bt_about, pos=(row, 1), flag=wx.ALIGN_CENTER_VERTICAL) + row += 1 + + grid.Add((0, spacing), pos=(row, 0), span=(1, 2)) + row += 1 + + # server settings + server_settings = wx.StaticBox(panel, wx.ID_ANY, _("Server Settings")) + server_box = wx.StaticBoxSizer(server_settings, wx.VERTICAL) + grid.Add(server_box, pos=(row, 0), flag=wx.EXPAND) + + self._host = None + self._port = None + hbox = wx.BoxSizer(wx.HORIZONTAL) + server_box.Add(hbox, flag=wx.EXPAND) + self.lb_host = wx.StaticText(panel) + hbox.Add(self.lb_host, flag=wx.ALIGN_CENTER_VERTICAL) + hbox.AddStretchSpacer() + self.lb_port = wx.StaticText(panel) + hbox.Add(self.lb_port, flag=wx.ALIGN_CENTER_VERTICAL) + hbox.AddStretchSpacer() + self.bt_settings = wx.Button(panel, label=_("S&ettings...")) + self.bt_settings.Bind(wx.EVT_BUTTON, self.on_settings_clicked) + hbox.Add(self.bt_settings) + + server_box.AddSpacer(spacing) + self.cb_start_browser = wx.CheckBox( + panel, label=_("Automatically open &browser")) + self.cb_start_browser.SetValue(True) + server_box.Add(self.cb_start_browser) + server_box.AddStretchSpacer() + + server_box.AddSpacer(spacing) + self.bt_server = wx.Button(panel, label=_("&Start server")) + self.bt_server.Bind(wx.EVT_BUTTON, self.on_start_server_clicked) + server_box.Add(self.bt_server, flag=wx.EXPAND) + + host, port = openslides.main.detect_listen_opts() + self.host = host + self.port = unicode(port) + + # "action" buttons + action_vbox = wx.BoxSizer(wx.VERTICAL) + action_vbox.AddSpacer(3 * spacing) + grid.Add(action_vbox, pos=(row, 1)) + self.bt_backup = wx.Button(panel, label=_("&Backup database...")) + self.bt_backup.Bind(wx.EVT_BUTTON, self.on_backup_clicked) + action_vbox.Add(self.bt_backup) + action_vbox.AddSpacer(spacing) + self.bt_sync_db = wx.Button(panel, label=_("S&ync database")) + self.bt_sync_db.Bind(wx.EVT_BUTTON, self.on_syncdb_clicked) + action_vbox.Add(self.bt_sync_db) + action_vbox.AddSpacer(spacing) + self.bt_reset_admin = wx.Button(panel, label=_("&Reset admin")) + self.bt_reset_admin.Bind(wx.EVT_BUTTON, self.on_reset_admin_clicked) + action_vbox.Add(self.bt_reset_admin) + row += 1 + + # command output + self.cmd_run_ctrl = RunCommandControl(panel) + self.cmd_run_ctrl.Bind(EVT_RUN_CMD, self.on_run_cmd_changed) + grid.Add( + self.cmd_run_ctrl, + pos=(row, 0), span=(1, 2), + flag=wx.EXPAND) + + grid.AddGrowableCol(0) + grid.AddGrowableRow(3) + + box = wx.BoxSizer(wx.VERTICAL) + box.Add( + grid, flag=wx.EXPAND | wx.ALL | wx.ALIGN_CENTER_VERTICAL, + border=spacing, proportion=1) + panel.SetSizerAndFit(box) + self.Fit() + self.SetMinSize(self.ClientToWindowSize(box.GetMinSize())) + self.SetInitialSize(wx.Size(500, 400)) + + self.Bind(wx.EVT_CLOSE, self.on_close) + + self.load_gui_settings() + self.apply_backup_settings() + self.Show() + + @property + def backup_interval_seconds(self): + if self.backupdb_interval_unit == "second": + factor = 1 + elif self.backupdb_interval_unit == "minute": + factor = 60 + elif self.backupdb_interval_unit == "hour": + factor = 3600 + + return self.backupdb_interval * factor + + @property + def host(self): + return self._host + + @host.setter + def host(self, host): + self._host = host + self.lb_host.SetLabel(_("Host: {0}").format(host)) + + @property + def port(self): + return self._port + + @port.setter + def port(self, port): + self._port = port + self.lb_port.SetLabel(_("Port: {0}").format(port)) + + def load_gui_settings(self): + try: + f = open(self.gui_settings_path, "rb") + except IOError as e: + if e.errno == errno.ENOENT: + return + raise + + with f: + settings = json.load(f) + + def setattr_unless_none(attr, value): + if not value is None: + setattr(self, attr, value) + + backup_settings = settings.get("database_backup", {}) + setattr_unless_none("backupdb_enabled", backup_settings.get("enabled")) + setattr_unless_none( + "backupdb_destination", backup_settings.get("destination")) + setattr_unless_none( + "backupdb_interval", backup_settings.get("interval")) + setattr_unless_none( + "backupdb_interval_unit", backup_settings.get("interval_unit")) + last_backup = backup_settings.get("last_backup") + if not last_backup is None: + self.last_backup = datetime.datetime.strptime( + last_backup, "%Y-%m-%d %H:%M:%S") + + def save_gui_settings(self): + if self.last_backup is None: + last_backup = None + else: + last_backup = self.last_backup.strftime("%Y-%m-%d %H:%M:%S") + settings = { + "database_backup": { + "enabled": self.backupdb_enabled, + "destination": self.backupdb_destination, + "internal": self.backupdb_interval, + "interval_unit": self.backupdb_interval_unit, + "last_backup": last_backup + }, + } + + dp = os.path.dirname(self.gui_settings_path) + if not os.path.exists(dp): + os.makedirs(dp) + with open(self.gui_settings_path, "wb") as f: + json.dump(settings, f, ensure_ascii=False, indent=4) + + def apply_backup_settings(self): + if self.backupdb_enabled and self.server_running: + now = datetime.datetime.utcnow() + delta = datetime.timedelta(seconds=self.backup_interval_seconds) + ref = self.last_backup + if ref is None: + ref = now + ref += delta + + d = ref - now + seconds = d.days * 86400 + d.seconds + if seconds < 1: + seconds = 30 # avoid backup immediatly after start + self.backup_timer.Start(seconds * 1000, True) + else: + self.backup_timer.Stop() + + def do_backup(self): + cmd = [ + sys.executable, "-u", "-m", "openslides.main", + "--no-run", + "--backupdb={0}".format(self.backupdb_destination), + ] + p = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + p.stdin.close() + output = p.stdout.read().strip() + exitcode = p.wait() + if output: + self.cmd_run_ctrl.append_message(output) + + time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if exitcode == 0: + self.cmd_run_ctrl.append_message( + _("{0}: Database backup successful.").format(time)) + else: + self.cmd_run_ctrl.append_message( + _("{0}: Database backup failed!").format(time)) + + self.last_backup = datetime.datetime.utcnow() + + def on_syncdb_clicked(self, evt): + self.cmd_run_ctrl.append_message(_("Syncing database...")) + self.cmd_run_ctrl.run_command("--no-run", "--syncdb") + + def on_reset_admin_clicked(self, evt): + self.cmd_run_ctrl.append_message(_("Resetting admin user...")) + self.cmd_run_ctrl.run_command("--no-run", "--reset-admin") + + def on_about_clicked(self, evt): + info = wx.AboutDialogInfo() + info.SetName("OpenSlides") + info.SetVersion(openslides.get_version()) + info.SetDescription(_( + "OpenSlides is a free web based presentation and " + "assembly system.\n" + "OpenSlides is free software; licensed under the GNU GPL v2+." + ).replace(u" ", u"\u00a0")) + info.SetCopyright(_(u"\u00a9 2011-2013 by OpenSlides team")) + info.SetWebSite(("http://www.openslides.org/", "www.openslides.org")) + + # XXX: at least on wxgtk this has no effect + info.SetIcon(self.GetIcon()) + wx.AboutBox(info) + + def on_start_server_clicked(self, evt): + if self.server_running: + self.cmd_run_ctrl.cancel_command() + return + + args = ["--address", self._host, "--port", self._port] + if not self.cb_start_browser.GetValue(): + args.append("--no-browser") + + self.server_running = True + self.cmd_run_ctrl.run_command(*args) + + # initiate backup_timer if backup is enabled + self.apply_backup_settings() + + self.bt_server.SetLabel(_("&Stop server")) + + def on_settings_clicked(self, evt): + dlg = SettingsDialog(self) + dlg.host = self._host + dlg.port = self._port + + if dlg.ShowModal() == wx.ID_OK: + self.host = dlg.host + self.port = dlg.port + + def on_backup_clicked(self, evt): + dlg = BackupSettingsDialog(self) + dlg.backupdb_enabled = self.backupdb_enabled + dlg.backupdb_destination = self.backupdb_destination + dlg.interval = self.backupdb_interval + dlg.interval_unit = self.backupdb_interval_unit + if dlg.ShowModal() == wx.ID_OK: + self.backupdb_enabled = dlg.backupdb_enabled + self.backupdb_destination = dlg.backupdb_destination + self.backupdb_interval = dlg.interval + self.backupdb_interval_unit = dlg.interval_unit + self.apply_backup_settings() + + def on_run_cmd_changed(self, evt): + show_completion_msg = not evt.running + if self.server_running and not evt.running: + self.bt_server.SetLabel(_("&Start server")) + self.server_running = False + + self.backup_timer.Stop() + if self.backupdb_enabled: + self.do_backup() + + # no operation completed msg when stopping server + show_completion_msg = False + + self.bt_settings.Enable(not evt.running) + self.bt_backup.Enable(not evt.running) + self.bt_sync_db.Enable(not evt.running) + self.bt_reset_admin.Enable(not evt.running) + self.bt_server.Enable(self.server_running or not evt.running) + + if show_completion_msg: + if evt.exitcode == 0: + text = _("Operation successfully completed.") + else: + text = _("Operation failed (exit code = {0})").format( + evt.exitcode) + self.cmd_run_ctrl.append_message(text) + + def on_backup_timer(self, evt): + if not self.backupdb_enabled: + return + + self.do_backup() + self.backup_timer.Start(1000 * self.backup_interval_seconds, True) + + def on_close(self, ev): + self.cmd_run_ctrl.cancel_command() + self.save_gui_settings() + self.Destroy() + + +def main(): + locale.setlocale(locale.LC_ALL, "") + lang = locale.getdefaultlocale()[0] + if lang: + global _translations + localedir = os.path.dirname(openslides.__file__) + localedir = os.path.join(localedir, "locale") + _translations = gettext.translation( + "django", localedir, [lang], fallback=True) + + app = wx.App(False) + window = MainWindow() + app.MainLoop() + +if __name__ == "__main__": + main() diff --git a/openslides/main.py b/openslides/main.py index 6dc1757f1..75306a284 100644 --- a/openslides/main.py +++ b/openslides/main.py @@ -250,7 +250,7 @@ def setup_django_environment(settings_path): os.environ[ENVIRONMENT_VARIABLE] = '%s' % settings_module_name -def detect_listen_opts(address, port): +def detect_listen_opts(address=None, port=None): if address is None: try: address = socket.gethostbyname(socket.gethostname()) @@ -370,13 +370,17 @@ def get_user_data_path(*args): return os.path.join(fs2unicode(data_home), *args) +def is_portable(): + exename = os.path.basename(sys.executable).lower() + return exename == "openslides.exe" + + def get_portable_path(*args): # NOTE: sys.executable will be the path to openslides.exe # since it is essentially a small wrapper that embeds the # python interpreter - exename = os.path.basename(sys.executable).lower() - if exename != "openslides.exe": + if not is_portable(): raise Exception( "Cannot determine portable path when " "not running as portable") @@ -413,4 +417,7 @@ def win32_get_app_data_path(*args): if __name__ == "__main__": - main() + if is_portable(): + win32_portable_main() + else: + main()