From 8ca760ee2bf57269b666c0e683ba760546adf9da Mon Sep 17 00:00:00 2001 From: Murtuza Zabuawala Date: Sun, 15 May 2016 15:59:32 +0530 Subject: [PATCH] Addd support for taking backup for the server. Tweaked by Ashesh Vashi to integrate the backgroud process, and also with some improvements as stated below: * Resolved an issue loading existing preference. * Improved the background process observer/executor for supporting detalied view. * Added the utility path preferences in the ServerType class. --- TODO.txt | 5 + .../browser/server_groups/servers/__init__.py | 2 +- .../browser/server_groups/servers/types.py | 55 +- web/pgadmin/misc/__init__.py | 7 +- web/pgadmin/misc/bgprocess/processes.py | 13 +- .../misc/bgprocess/static/js/bgprocess.js | 118 +-- web/pgadmin/static/css/overrides.css | 43 +- web/pgadmin/tools/backup/__init__.py | 440 ++++++++++++ .../backup/templates/backup/js/backup.js | 670 ++++++++++++++++++ web/pgadmin/utils/preferences.py | 2 +- 10 files changed, 1282 insertions(+), 73 deletions(-) create mode 100644 web/pgadmin/tools/backup/__init__.py create mode 100644 web/pgadmin/tools/backup/templates/backup/js/backup.js diff --git a/TODO.txt b/TODO.txt index 7ec3ef0cb..17c473703 100644 --- a/TODO.txt +++ b/TODO.txt @@ -26,3 +26,8 @@ Query Tool updateable recordset support Add smarts to the Query Tool to allow it to recognise if a query produces a data set that would be updateable (e.g. from a single table, all primary key columns present), and if so, allow editing. + +Backup Object +------------- + +Allow to select/deselect objects under the object backup operation. diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 0c7bf3946..6f3453b42 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -170,7 +170,7 @@ class ServerModule(sg.ServerGroupPluginModule): Override it so that - it does not register the show_node preference for server type. """ - pass + ServerType.register_preferences() class ServerMenuItem(MenuItem): def __init__(self, **kwargs): diff --git a/web/pgadmin/browser/server_groups/servers/types.py b/web/pgadmin/browser/server_groups/servers/types.py index 999aa053f..f576ee4b7 100644 --- a/web/pgadmin/browser/server_groups/servers/types.py +++ b/web/pgadmin/browser/server_groups/servers/types.py @@ -7,8 +7,10 @@ # ########################################################################## +import os from flask import render_template -from flask.ext.babel import gettext +from flask.ext.babel import gettext as _ +from pgadmin.utils.preferences import Preferences class ServerType(object): @@ -26,6 +28,7 @@ class ServerType(object): self.stype = server_type self.desc = description self.spriority = priority + self.utility_path = None assert(server_type not in ServerType.registry) ServerType.registry[server_type] = self @@ -38,6 +41,24 @@ class ServerType(object): def description(self): return self.desc + @classmethod + def register_preferences(cls): + paths = Preferences('paths', _('Paths')) + + for key in cls.registry: + st = cls.registry[key] + + st.utility_path = paths.register( + 'bin_paths', st.stype + '_bin_dir', + _("{0} bin path").format(st.stype.upper()), + 'text', "", category_label=_('Binary paths'), + help_str=_( + "Set the PATH where the {0} binary utilities can be found...".format( + st.desc + ) + ) + ) + @property def priority(self): return self.spriority @@ -70,17 +91,29 @@ class ServerType(object): reverse=True ) - @classmethod - def utility(cls, operation, sverion): - if operation == 'backup': - return 'pg_dump' - if operation == 'backup_server': - return 'pg_dumpall' - if operation == 'restore': - return 'pg_restore' + def utility(self, operation, sverion): + res = None - return None + if operation == 'backup': + res = 'pg_dump' + elif operation == 'backup_server': + res = 'pg_dumpall' + elif operation == 'restore': + res = 'pg_restore' + elif operation == 'sql': + res = 'psql' + else: + raise Exception( + _("Couldn't find the utility for the operation '%s'".format( + operation + )) + ) + + return os.path.join( + self.utility_path.get(), + (res if os.name != 'nt' else (res + '.exe')) + ) # Default Server Type -ServerType('pg', gettext("PostgreSQL"), -1) +ServerType('pg', _("PostgreSQL"), -1) diff --git a/web/pgadmin/misc/__init__.py b/web/pgadmin/misc/__init__.py index f461cbeee..0c4de3a40 100644 --- a/web/pgadmin/misc/__init__.py +++ b/web/pgadmin/misc/__init__.py @@ -9,16 +9,15 @@ """A blueprint module providing utility functions for the application.""" -import datetime -from flask import session, current_app from pgadmin.utils import PgAdminModule import pgadmin.utils.driver as driver MODULE_NAME = 'misc' # Initialise the module -blueprint = PgAdminModule(MODULE_NAME, __name__, - url_prefix='') +blueprint = PgAdminModule( + MODULE_NAME, __name__, url_prefix='' +) ########################################################################## # A special URL used to "ping" the server diff --git a/web/pgadmin/misc/bgprocess/processes.py b/web/pgadmin/misc/bgprocess/processes.py index 5004dbb28..9414cc373 100644 --- a/web/pgadmin/misc/bgprocess/processes.py +++ b/web/pgadmin/misc/bgprocess/processes.py @@ -12,7 +12,7 @@ Introduce a function to run the process executor in detached mode. """ from __future__ import print_function, unicode_literals -from abc import ABCMeta, abstractproperty +from abc import ABCMeta, abstractproperty, abstractmethod import csv from datetime import datetime from dateutil import parser @@ -50,8 +50,8 @@ class IProcessDesc(object): def message(self): pass - @abstractproperty - def details(self): + @abstractmethod + def details(self, cmd, args): pass @@ -322,7 +322,12 @@ class BatchProcess(object): details = desc if isinstance(desc, IProcessDesc): - details = desc.details + args = [] + args_csv = StringIO(p.arguments) + args_reader = csv.reader(args_csv, delimiter=str(',')) + for arg in args_reader: + args = args + arg + details = desc.details(p.command, args) desc = desc.message res.append({ diff --git a/web/pgadmin/misc/bgprocess/static/js/bgprocess.js b/web/pgadmin/misc/bgprocess/static/js/bgprocess.js index 6caf084ef..b08fde67f 100644 --- a/web/pgadmin/misc/bgprocess/static/js/bgprocess.js +++ b/web/pgadmin/misc/bgprocess/static/js/bgprocess.js @@ -170,11 +170,6 @@ function(_, S, $, pgBrowser, alertify, pgMessages) { self.curr_status = pgMessages['running']; } - if ('execution_time' in data) { - self.execution_time = self.execution_time + ' ' + - pgMessages['seconds']; - } - if (!_.isNull(self.exit_code)) { if (self.exit_code == 0) { self.curr_status = pgMessages['successfully_finished']; @@ -238,12 +233,12 @@ function(_, S, $, pgBrowser, alertify, pgMessages) { if (!self.notifier) { var content = $('
').append( $('
', { - class: "col-xs-12 h3 pg-bg-notify-header" + class: "h5 pg-bg-notify-header" }).text( self.desc ) ).append( - $('
', {class: 'pg-bg-notify-body' }).append( + $('
', {class: 'pg-bg-notify-body h6' }).append( $('
', {class: 'pg-bg-start col-xs-12' }).append( $('
').text(self.stime.toString()) ).append( @@ -252,10 +247,10 @@ function(_, S, $, pgBrowser, alertify, pgMessages) { ) ), for_details = $('
', { - class: "col-xs-12 text-center pg-bg-click" + class: "col-xs-12 text-center pg-bg-click h6" }).text(pgMessages.CLICK_FOR_DETAILED_MSG).appendTo(content), status = $('
', { - class: "pg-bg-status col-xs-12 " + ((self.exit_code === 0) ? + class: "pg-bg-status col-xs-12 h5 " + ((self.exit_code === 0) ? 'bg-success': (self.exit_code == 1) ? 'bg-failed' : '') }).appendTo(content); @@ -287,22 +282,34 @@ function(_, S, $, pgBrowser, alertify, pgMessages) { }); } // TODO:: Formatted execution time - self.container.find('.pg-bg-etime').empty().text( - String(self.execution_time) + self.container.find('.pg-bg-etime').empty().append( + $('', {class: 'blink'}).text( + String(self.execution_time) + ) + ).append( + $('').text(' ' + pgMessages['seconds']) ); self.container.find('.pg-bg-status').empty().append( - self.curr_status - ) + self.curr_status + ); + } else { + self.show_detailed_view.apply(self) } }, show_detailed_view: function() { var self = this, - panel = this.panel = + panel = this.panel, + is_new = false; + + if (!self.panel) { + is_new = true; + panel = this.panel = pgBrowser.BackgroundProcessObsorver.create_panel(); - panel.title('Process Watcher - ' + self.desc); - panel.focus(); + panel.title('Process Watcher - ' + self.desc); + panel.focus(); + } var container = panel.$container, status_class = ( @@ -314,52 +321,61 @@ function(_, S, $, pgBrowser, alertify, pgMessages) { $header = container.find('.bg-process-details'), $footer = container.find('.bg-process-footer'); + if (is_new) { + // set logs + $logs.html(self.logs); - // set logs - $logs.html(self.logs); - - // set bgprocess detailed description - $header.find('.bg-detailed-desc').html(self.detailed_desc); + // set bgprocess detailed description + $header.find('.bg-detailed-desc').html(self.detailed_desc); + } // set bgprocess start time - $header.find('.bg-process-stats .bgprocess-start-time').html(self.stime); + $header.find('.bg-process-stats .bgprocess-start-time').html( + self.stime + ); // set status - $footer.find('.bg-process-status p').addClass( + $footer.find('.bg-process-status p').removeClass().addClass( status_class - ).html( - self.curr_status - ); + ).html(self.curr_status); // set bgprocess execution time - $footer.find('.bg-process-exec-time p').html(self.execution_time); - - self.details = true; - setTimeout( - function() { - self.status.apply(self); - }, 1000 + $footer.find('.bg-process-exec-time p').empty().append( + $('', {class: 'blink'}).text( + String(self.execution_time) + ) + ).append( + $('').text(' ' + pgMessages['seconds']) ); - var resize_log_container = function($logs, $header, $footer) { - var h = $header.outerHeight() + $footer.outerHeight(); - $logs.css('padding-bottom', h); - }.bind(panel, $logs, $header, $footer); + if (is_new) { + self.details = true; + setTimeout( + function() { + self.status.apply(self); + }, 1000 + ); - panel.on(wcDocker.EVENT.RESIZED, resize_log_container); - panel.on(wcDocker.EVENT.ATTACHED, resize_log_container); - panel.on(wcDocker.EVENT.DETACHED, resize_log_container); + var resize_log_container = function($logs, $header, $footer) { + var h = $header.outerHeight() + $footer.outerHeight(); + $logs.css('padding-bottom', h); + }.bind(panel, $logs, $header, $footer); - resize_log_container(); + panel.on(wcDocker.EVENT.RESIZED, resize_log_container); + panel.on(wcDocker.EVENT.ATTACHED, resize_log_container); + panel.on(wcDocker.EVENT.DETACHED, resize_log_container); - panel.on(wcDocker.EVENT.CLOSED, function(process) { - process.panel = null; + resize_log_container(); - process.details = false; - if (process.exit_code != null) { - process.acknowledge_server.apply(process); - } - }.bind(panel, this)); + panel.on(wcDocker.EVENT.CLOSED, function(process) { + process.panel = null; + + process.details = false; + if (process.exit_code != null) { + process.acknowledge_server.apply(process); + } + }.bind(panel, this)); + } }, acknowledge_server: function() { @@ -472,9 +488,9 @@ function(_, S, $, pgBrowser, alertify, pgMessages) { content: '
'+ '

'+ '
'+ - '' + pgMessages['START_TIME'] + ':'+ - '

'+ - '
'+ + '' + pgMessages['START_TIME'] + ': '+ + ''+ + '
'+ ''+ '
'+ '
'+ diff --git a/web/pgadmin/static/css/overrides.css b/web/pgadmin/static/css/overrides.css index 1d6fe2477..3d289f410 100755 --- a/web/pgadmin/static/css/overrides.css +++ b/web/pgadmin/static/css/overrides.css @@ -1098,6 +1098,7 @@ button.pg-alertify-button { word-break: break-all; word-wrap: break-word; } + div.backform_control_notes label.control-label { min-width: 0px; } @@ -1106,7 +1107,6 @@ form[name="change_password_form"] .help-block { color: #A94442 !important; } - .file_selection_ctrl .create_input span { padding-right: 10px; font-weight: bold; @@ -1165,3 +1165,44 @@ form[name="change_password_form"] .help-block { .dropzone .dz-preview .dz-progress .dz-upload { bottom: initial; } + +/* Fix Alertify dialog alignment for Backform controls */ +.alertify_tools_dialog_properties { + bottom: 0 !important; + left: 0 !important; + position: absolute !important; + right: 0 !important; + top: 35px !important; +} + +/* For Backup & Restore Dialog */ +.custom_switch_label_class { + min-width: 0px !important; + padding-bottom: 10px !important; + font-size: 13px !important; + font-weight: normal !important; +} + +.custom_switch_control_class { + min-width: 0px !important; + padding-bottom: 10px !important; +} + +/* animate blink */ +.blink { + animation: blink-animation 1s steps(5, start) infinite; + -webkit-animation: blink-animation 1s steps(5, start) infinite; + +} + +@keyframes blink-animation { + to { + visibility: hidden; + } +} + +@-webkit-keyframes blink-animation { + to { + visibility: hidden; + } +} diff --git a/web/pgadmin/tools/backup/__init__.py b/web/pgadmin/tools/backup/__init__.py new file mode 100644 index 000000000..38df62afe --- /dev/null +++ b/web/pgadmin/tools/backup/__init__.py @@ -0,0 +1,440 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2016, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Implements Backup Utility""" + +import json +import os +from flask import render_template, request, current_app, \ + url_for, Response +from flask.ext.babel import gettext as _ +from pgadmin.utils.ajax import make_json_response, bad_request +from pgadmin.utils import PgAdminModule, get_storage_directory +from flask.ext.security import login_required, current_user +from pgadmin.model import Server +from config import PG_DEFAULT_DRIVER +from pgadmin.misc.bgprocess.processes import BatchProcess, IProcessDesc + + +# set template path for sql scripts +MODULE_NAME = 'backup' +server_info = {} + + +class BackupModule(PgAdminModule): + """ + class BackupModule(Object): + + It is a utility which inherits PgAdminModule + class and define methods to load its own + javascript file. + """ + + LABEL = _('Backup') + + def get_own_javascripts(self): + """" + Returns: + list: js files used by this module + """ + return [{ + 'name': 'pgadmin.tools.backup', + 'path': url_for('backup.index') + 'backup', + 'when': None + }] + + def show_system_objects(self): + """ + return system preference objects + """ + return self.pref_show_system_objects + + +# Create blueprint for BackupModule class +blueprint = BackupModule( + MODULE_NAME, __name__, static_url_path='' +) + + +class BACKUP(object): + """ + Constants defined for Backup utilities + """ + GLOBALS = 1 + SERVER = 2 + OBJECT = 3 + + +class BackupMessage(IProcessDesc): + """ + BackupMessage(IProcessDesc) + + Defines the message shown for the backup operation. + """ + def __init__(self, _type, _sid, **kwargs): + self.backup_type = _type + self.sid = _sid + self.database = None + + if 'database' in kwargs: + self.database = kwargs['database'] + + @property + def message(self): + # Fetch the server details like hostname, port, roles etc + s = Server.query.filter_by( + id=self.sid, user_id=current_user.id + ).first() + + if self.backup_type == BACKUP.OBJECT: + return _( + "Backing up an object on the server - '{0}' on database '{1}'..." + ).format( + "{0} ({1}:{2})".format(s.name, s.host, s.port), + self.database + ) + if self.backup_type == BACKUP.GLOBALS: + return _("Backing up the globals for the server - '{0}'...").format( + "{0} ({1}:{2})".format(s.name, s.host, s.port) + ) + elif self.backup_type == BACKUP.SERVER: + return _("Backing up the server - '{0}'...").format( + "{0} ({1}:{2})".format(s.name, s.host, s.port) + ) + else: + # It should never reach here. + return "Unknown Backup" + + def details(self, cmd, args): + # Fetch the server details like hostname, port, roles etc + s = Server.query.filter_by( + id=self.sid, user_id=current_user.id + ).first() + + res = '
' + + if self.backup_type == BACKUP.OBJECT: + res += _( + "Backing up an object on the server - '{0}' on database '{1}'" + ).format( + "{0} ({1}:{2})".format(s.name, s.host, s.port), + self.database + ).encode('ascii', 'xmlcharrefreplace') + if self.backup_type == BACKUP.GLOBALS: + res += _("Backing up the globals for the server - '{0}'!").format( + "{0} ({1}:{2})".format(s.name, s.host, s.port) + ).encode('ascii', 'xmlcharrefreplace') + elif self.backup_type == BACKUP.SERVER: + res += _("Backing up the server - '{0}'!").format( + "{0} ({1}:{2})".format(s.name, s.host, s.port) + ).encode('ascii', 'xmlcharrefreplace') + else: + # It should never reach here. + res += "Backup" + + res += '
' + res += _("Running command:").encode('ascii', 'xmlcharrefreplace') + res += '
' + res += cmd.encode('ascii', 'xmlcharrefreplace') + + replace_next = False + + def cmdArg(x): + if x: + x = x.replace('\\', '\\\\') + x = x.replace('"', '\\"') + x = x.replace('""', '\\"') + + return ' "' + x.encode('ascii', 'xmlcharrefreplace') + '"' + + return '' + + for arg in args: + if arg and len(arg) >= 2 and arg[:2] == '--': + res += ' ' + arg + elif replace_next: + res += ' XXX' + else: + if arg == '--file': + replace_next = True + res += cmdArg(arg) + res += '
' + + return res + + +@blueprint.route("/") +@login_required +def index(): + return bad_request(errormsg=_("This URL can not be called directly!")) + + +@blueprint.route("/backup.js") +@login_required +def script(): + """render own javascript""" + return Response( + response=render_template( + "backup/js/backup.js", _=_ + ), + status=200, + mimetype="application/javascript" + ) + + +def filename_with_file_manager_path(file): + """ + Args: + file: File name returned from client file manager + + Returns: + Filename to use for backup with full path taken from preference + """ + # Set file manager directory from preference + file_manager_dir = get_storage_directory() + return os.path.join(file_manager_dir, file) + + +@blueprint.route('/create_job/', methods=['POST']) +@login_required +def create_backup_job(sid): + """ + Args: + sid: Server ID + + Creates a new job for backup task (Backup Server/Globals) + + Returns: + None + """ + if request.form: + # Convert ImmutableDict to dict + data = dict(request.form) + data = json.loads(data['data'][0]) + else: + data = json.loads(request.data.decode()) + + data['file'] = filename_with_file_manager_path(data['file']) + + # Fetch the server details like hostname, port, roles etc + server = Server.query.filter_by( + id=sid, user_id=current_user.id + ).first() + + if server is None: + return make_json_response( + success=0, + errormsg=_("Couldn't find the given server") + ) + + # To fetch MetaData for the server + from pgadmin.utils.driver import get_driver + driver = get_driver(PG_DEFAULT_DRIVER) + manager = driver.connection_manager(server.id) + conn = manager.connection() + connected = conn.connected() + + if not connected: + return make_json_response( + success=0, + errormsg=_("Please connect to the server first...") + ) + + utility = manager.utility('backup_server') + + args = [ + '--file', + data['file'], + '--host', + server.host, + '--port', + str(server.port), + '--username', + server.username, + '--no-password', + '--database', + driver.qtIdent(conn, server.maintenance_db) + ] + if 'role' in data and data['role']: + args.append('--role') + args.append(data['role']) + if 'verbose' in data and data['verbose']: + args.append('--verbose') + if 'dqoute' in data and data['dqoute']: + args.append('--quote-all-identifiers') + if data['type'] == 'global': + args.append('--globals-only') + + try: + p = BatchProcess( + desc=BackupMessage( + BACKUP.SERVER if data['type'] != 'global' else BACKUP.GLOBALS, + sid + ), + cmd=utility, args=args + ) + p.start() + jid = p.id + except Exception as e: + current_app.logger.exception(e) + return make_json_response( + status=410, + success=0, + errormsg=str(e) + ) + # Return response + return make_json_response( + data={'job_id': jid, 'success': 1} + ) + + +@blueprint.route('/create_job/backup_object/', methods=['POST']) +@login_required +def create_backup_objects_job(sid): + """ + Args: + sid: Server ID + + Creates a new job for backup task (Backup Database(s)/Schema(s)/Table(s)) + + Returns: + None + """ + if request.form: + # Convert ImmutableDict to dict + data = dict(request.form) + data = json.loads(data['data'][0]) + else: + data = json.loads(request.data.decode()) + + data['file'] = filename_with_file_manager_path(data['file']) + + # Fetch the server details like hostname, port, roles etc + server = Server.query.filter_by( + id=sid, user_id=current_user.id + ).first() + + if server is None: + return make_json_response( + success=0, + errormsg=_("Couldn't find the given server") + ) + + # To fetch MetaData for the server + from pgadmin.utils.driver import get_driver + driver = get_driver(PG_DEFAULT_DRIVER) + manager = driver.connection_manager(server.id) + conn = manager.connection() + connected = conn.connected() + + if not connected: + return make_json_response( + success=0, + errormsg=_("Please connect to the server first...") + ) + + utility = manager.utility('backup') + args = [ + '--file', + data['file'], + '--host', + server.host, + '--port', + str(server.port), + '--username', + server.username, + '--no-password' + ] + + def set_param(key, param): + if key in data: + args.append(param) + + def set_value(key, param, value): + if key in data: + args.append(param) + if value: + if value is True: + args.append(param[key]) + else: + args.append(value) + + set_param('verbose', '--verbose') + set_param('dqoute', '--quote-all-identifiers') + + if data['format'] is not None: + if data['format'] == 'custom': + args.extend(['--format', 'custom']) + + set_param('blobs', '--blobs') + set_value('ratio', '--compress', True) + + elif data['format'] == 'tar': + args.extend(['--format', 'tar']) + + set_param('blobs', '--blobs') + + elif data['format'] == 'plain': + args.extend(['--format', 'plain']) + if data['only_data']: + args.append('--data-only') + set_param('disable_trigger', '--disable-triggers') + else: + set_param('only_schema', '--schema-only') + set_param('dns_owner', '--no-owner') + set_param('include_create_database', '--create') + set_param('include_drop_database', '--clean') + elif data['format'] == 'directory': + args.extend(['--format', 'directory']) + + set_param('pre_data', '--section pre-data') + set_param('data', '--section data') + set_param('post_data', '--section post-data') + set_param('dns_privilege', '--no-privileges') + set_param('dns_tablespace', '--no-tablespaces') + set_param('dns_unlogged_tbl_data', '--no-unlogged-table-data') + set_param('use_insert_commands', '--inserts') + set_param('use_column_inserts', '--column-inserts') + set_param('disable_quoting', '--disable-dollar-quoting') + set_param('with_oids', '--oids') + set_param('use_set_session_auth', '--use-set-session-authorization') + + set_value('no_of_jobs', '--jobs', True) + + for s in data['schemas']: + args.extend(['--schema', driver.qtIdent(conn, s)]) + + for s, t in data['tables']: + args.extend([ + '--table', driver.qtIdent(conn, s) + '.' + driver.qtIdent(conn, t) + ]) + + args.append(driver.qtIdent(conn, data['database'])) + + try: + p = BatchProcess( + desc=BackupMessage( + BACKUP.OBJECT, + sid, database=data['database'] + ), + cmd=utility, args=args) + p.start() + jid = p.id + except Exception as e: + current_app.logger.exception(e) + return make_json_response( + status=410, + success=0, + errormsg=str(e) + ) + + # Return response + return make_json_response( + data={'job_id': jid, 'Success': 1} + ) diff --git a/web/pgadmin/tools/backup/templates/backup/js/backup.js b/web/pgadmin/tools/backup/templates/backup/js/backup.js new file mode 100644 index 000000000..ce70d53e5 --- /dev/null +++ b/web/pgadmin/tools/backup/templates/backup/js/backup.js @@ -0,0 +1,670 @@ +define([ + 'jquery', 'underscore', 'underscore.string', 'alertify', + 'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node' + ], + + // This defines Backup dialog + function($, _, S, alertify, pgBrowser, Backbone, Backgrid, Backform, pgNode) { + + // if module is already initialized, refer to that. + if (pgBrowser.Backup) { + return pgBrowser.Backup; + } + +/* +===================== +TODO LIST FOR BACKUP: +===================== +1) Add Object tree on object tab which allows user to select + objects which can be backed up +2) Allow user to select/deselect objects +3) If database is selected in browser + show all database children objects selected in Object tree +4) If schema is selected in browser + show all schema children objects selected in Object tree +5) If table is selected then show table/schema/database selected + in Object tree +6) if root objects like database/schema is not selected and their + children are selected then add them separately with in tables attribute + with schema. +*/ + + var CustomSwitchControl = Backform.CustomSwitchControl = Backform.SwitchControl.extend({ + template: _.template([ + '', + '
', + '
', + ' ', + '
', + '
', + '<% if (helpMessage && helpMessage.length) { %>', + ' <%=helpMessage%>', + '<% } %>' + ].join("\n")), + className: 'pgadmin-control-group form-group col-xs-6' + }); + + //Backup Model (Server Node) + var BackupModel = Backbone.Model.extend({ + idAttribute: 'id', + defaults: { + file: undefined, + role: undefined, + dqoute: false, + verbose: true, + type: undefined /* global, server */ + }, + schema: [{ + id: 'file', label: '{{ _('Filename') }}', + type: 'text', disabled: false, control: Backform.FileControl, + dialog_type: 'create_file', supp_types: ['*', 'sql'] + },{ + id: 'role', label: '{{ _('Role name') }}', + control: 'node-list-by-name', node: 'role', + select2: { allowClear: false } + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Miscellaneous') }}', + schema:[{ + id: 'verbose', label: '{{ _('Verbose messages') }}', + control: Backform.CustomSwitchControl, disabled: false, + group: '{{ _('Miscellaneous') }}' + },{ + id: 'dqoute', label: '{{ _('Force double quote on identifiers') }}', + control: Backform.CustomSwitchControl, disabled: false, + group: '{{ _('Miscellaneous') }}' + }] + },{ + id: 'server_note', label: '{{ _('Note') }}', + text: '{{ _('The backup format will be PLAIN') }}', + type: 'note', visible: function(m){ + return m.get('type') === 'server'; + } + },{ + id: 'globals_note', label: '{{ _('Note') }}', + text: '{{ _('Only objects global to the entire database will be backed up in PLAIN format') }}', + type: 'note', visible: function(m){ + return m.get('type') === 'globals'; + } + },{ + }], + validate: function() { + // TODO: HOW TO VALIDATE ??? + return null; + } + }); + + //Backup Model (Objects like Database/Schema/Table) + var BackupObjectModel = Backbone.Model.extend({ + idAttribute: 'id', + defaults: { + file: undefined, + role: 'postgres', + format: 'custom', + verbose: true, + blobs: true, + encoding: undefined, + schemas: [], + tables: [], + database: undefined + }, + schema: [{ + id: 'file', label: '{{ _('Filename') }}', + type: 'text', disabled: false, control: Backform.FileControl, + dialog_type: 'create_file', supp_types: ['*', 'sql'] + },{ + id: 'format', label: '{{ _('Format') }}', + type: 'text', disabled: false, + control: 'select2', select2: { + allowClear: false, + width: "100%" + }, + options: [ + {label: "Custom", value: "custom"}, + {label: "Tar", value: "tar"}, + {label: "Plain", value: "plain"}, + {label: "Directory", value: "directory"} + ] + },{ + id: 'ratio', label: '{{ _('Comprasion ratio') }}', + type: 'int', min: 0, max:9, disabled: false + },{ + id: 'encoding', label: '{{ _('Encoding') }}', + type: 'text', disabled: false, node: 'database', + control: 'node-ajax-options', url: 'get_encodings' + },{ + id: 'no_of_jobs', label: '{{ _('Number of jobs') }}', + type: 'int', deps: ['format'], disabled: function(m) { + return !(m.get('format') === "Directory"); + } + },{ + id: 'role', label: '{{ _('Role name') }}', + control: 'node-list-by-name', node: 'role', + select2: { allowClear: false } + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Sections') }}', + group: '{{ _('Dump options') }}', + schema:[{ + id: 'pre_data', label: '{{ _('Pre-data') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Sections') }}', + deps: ['only_data', 'only_schema'], disabled: function(m) { + return m.get('only_data') + || m.get('only_schema'); + } + },{ + id: 'data', label: '{{ _('Data') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Sections') }}', + deps: ['only_data', 'only_schema'], disabled: function(m) { + return m.get('only_data') + || m.get('only_schema'); + } + },{ + id: 'post_data', label: '{{ _('Post-data') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Sections') }}', + deps: ['only_data', 'only_schema'], disabled: function(m) { + return m.get('only_data') + || m.get('only_schema'); + } + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Type of objects') }}', + group: '{{ _('Dump options') }}', + schema:[{ + id: 'only_data', label: '{{ _('Only data') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Type of objects') }}', + deps: ['pre_data', 'data', 'post_data','only_schema'], disabled: function(m) { + return m.get('pre_data') + || m.get('data') + || m.get('post_data') + || m.get('only_schema'); + } + },{ + id: 'only_schema', label: '{{ _('Only schema') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Type of objects') }}', + deps: ['pre_data', 'data', 'post_data', 'only_data'], disabled: function(m) { + return m.get('pre_data') + || m.get('data') + || m.get('post_data') + || m.get('only_data'); + } + },{ + id: 'blobs', label: '{{ _('Blobs') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Type of objects') }}' + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Do not save') }}', + group: '{{ _('Dump options') }}', + schema:[{ + id: 'dns_owner', label: '{{ _('Owner') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Do not save') }}' + },{ + id: 'dns_privilege', label: '{{ _('Privilege') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Do not save') }}' + },{ + id: 'dns_tablespace', label: '{{ _('Tablespace') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Do not save') }}' + },{ + id: 'dns_unlogged_tbl_data', label: '{{ _('Unlogged table data') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Do not save') }}' + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Queries') }}', + group: '{{ _('Dump options') }}', + schema:[{ + id: 'use_column_inserts', label: '{{ _('Use Column Inserts') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Queries') }}' + },{ + id: 'use_insert_commands', label: '{{ _('Use Insert Commands') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Queries') }}' + },{ + id: 'include_create_database', label: '{{ _('Include CREATE DATABASE statement') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Queries') }}' + },{ + id: 'include_drop_database', label: '{{ _('Include DROP DATABASE statement') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Queries') }}' + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Disable') }}', + group: '{{ _('Dump options') }}', + schema:[{ + id: 'disable_trigger', label: '{{ _('Trigger') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Disable') }}', + deps: ['only_data'], disabled: function(m) { + return !(m.get('only_data')); + } + },{ + id: 'disable_quoting', label: '{{ _('$ quoting') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Disable') }}' + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Miscellaneous') }}', + group: '{{ _('Dump options') }}', + schema:[{ + id: 'with_oids', label: '{{ _('With OID(s)') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Miscellaneous') }}' + },{ + id: 'verbose', label: '{{ _('Verbose messages') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Miscellaneous') }}' + },{ + id: 'dqoute', label: '{{ _('Force double quote on identifiers') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Miscellaneous') }}' + },{ + id: 'use_set_session_auth', label: '{{ _('Use SET SESSION AUTHORIZATION') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Miscellaneous') }}' + }] + }], + validate: function() { + return null; + } + }); + + // Create an Object Backup of pgBrowser class + pgBrowser.Backup = { + init: function() { + if (this.initialized) + return; + + this.initialized = true; + + // Define list of nodes on which backup context menu option appears + var backup_supported_nodes = [ + 'database', 'schema', 'table' + ]; + + /** + Enable/disable backup menu in tools based + on node selected + if selected node is present in supported_nodes, + menu will be enabled otherwise disabled. + Also, hide it for system view in catalogs + */ + menu_enabled = function(itemData, item, data) { + var t = pgBrowser.tree, i = item, d = itemData; + var parent_item = t.hasParent(i) ? t.parent(i): null, + parent_data = parent_item ? t.itemData(parent_item) : null; + if(!_.isUndefined(d) && !_.isNull(d) && !_.isNull(parent_data)) + return ( + (_.indexOf(backup_supported_nodes, d._type) !== -1 && + parent_data._type != 'catalog') ? true: false + ); + else + return false; + }; + + menu_enabled_server = function(itemData, item, data) { + var t = pgBrowser.tree, i = item, d = itemData; + var parent_item = t.hasParent(i) ? t.parent(i): null, + parent_data = parent_item ? t.itemData(parent_item) : null; + // If server node selected && connected + if(!_.isUndefined(d) && !_.isNull(d)) + return (('server' === d._type) && d.connected); + else + false; + }; + + // Define the nodes on which the menus to be appear + var menus = [{ + name: 'backup_global', module: this, + applies: ['tools'], callback: 'start_backup_global', + priority: 10, label: '{{_("Backup Globals...") }}', + icon: 'fa fa-floppy-o', enable: menu_enabled_server + },{ + name: 'backup_server', module: this, + applies: ['tools'], callback: 'start_backup_server', + priority: 10, label: '{{_("Backup Server...") }}', + icon: 'fa fa-floppy-o', enable: menu_enabled_server + },{ + name: 'backup_global_ctx', module: this, node: 'server', + applies: ['context'], callback: 'start_backup_global', + priority: 10, label: '{{_("Backup Globals...") }}', + icon: 'fa fa-floppy-o', enable: menu_enabled_server + },{ + name: 'backup_server_ctx', module: this, node: 'server', + applies: ['context'], callback: 'start_backup_server', + priority: 10, label: '{{_("Backup Server...") }}', + icon: 'fa fa-floppy-o', enable: menu_enabled_server + },{ + name: 'backup_object', module: this, + applies: ['tools'], callback: 'backup_objects', + priority: 10, label: '{{_("Backup...") }}', + icon: 'fa fa-floppy-o', enable: menu_enabled + }]; + + for (var idx = 0; idx < backup_supported_nodes.length; idx++) { + menus.push({ + name: 'backup_' + backup_supported_nodes[idx], + node: backup_supported_nodes[idx], module: this, + applies: ['context'], callback: 'backup_objects', + priority: 10, label: '{{_("Backup...") }}', + icon: 'fa fa-floppy-o', enable: menu_enabled + }); + } + + pgAdmin.Browser.add_menus(menus); + return this; + }, + start_backup_global: function(action, item) { + var params = {'globals': true }; + this.start_backup_global_server.apply( + this, [action, item, params] + ); + }, + start_backup_server: function(action, item) { + var params = {'server': true }; + this.start_backup_global_server.apply( + this, [action, item, params] + ); + }, + + // Callback to draw Backup Dialog for globals/server + start_backup_global_server: function(action, item, params) { + + var of_type = undefined; + + // Set Notes according to type of backup + if (!_.isUndefined(params['globals']) && params['globals']) { + of_type = 'globals'; + } else { + of_type = 'server'; + } + + var DialogName = 'BackupDialog_' + of_type, + DialogTitle = ((of_type == 'globals') ? + '{{ _('Backup Globals...') }}' : + '{{ _('Backup Server...') }}'); + + if(!alertify[DialogName]) { + alertify.dialog(DialogName ,function factory() { + return { + main: function(title) { + this.set('title', title); + }, + setup:function() { + return { + buttons: [{ + text: '{{ _('Backup') }}', key: 27, className: 'btn btn-primary fa fa-lg fa-save pg-alertify-button' + },{ + text: '{{ _('Cancel') }}', key: 27, className: 'btn btn-danger fa fa-lg fa-times pg-alertify-button' + }], + // Set options for dialog + options: { + title: DialogTitle, + //disable both padding and overflow control. + padding : !1, + overflow: !1, + model: 0, + resizable: true, + maximizable: true, + pinnable: false + } + }; + }, + hooks: { + // Triggered when the dialog is closed + onclose: function() { + if (this.view) { + // clear our backform model/view + this.view.remove({data: true, internal: true, silent: true}); + } + } + }, + prepare: function() { + var self = this; + // Disable Backup button until user provides Filename + this.__internal.buttons[0].element.disabled = true; + + var $container = $("
"); + // Find current/selected node + var t = pgBrowser.tree, + i = t.selected(), + d = i && i.length == 1 ? t.itemData(i) : undefined, + node = d && pgBrowser.Nodes[d._type]; + + if (!d) + return; + // Create treeInfo + var treeInfo = node.getTreeNodeHierarchy.apply(node, [i]); + // Instance of backbone model + var newModel = new BackupModel( + {type: of_type}, {node_info: treeInfo} + ), + fields = Backform.generateViewSchema( + treeInfo, newModel, 'create', node, treeInfo.server, true + ); + + var view = this.view = new Backform.Dialog({ + el: $container, model: newModel, schema: fields + }); + // Add our class to alertify + $(this.elements.body.childNodes[0]).addClass( + 'alertify_tools_dialog_properties obj_properties' + ); + // Render dialog + view.render(); + + this.elements.content.appendChild($container.get(0)); + + // Listen to model & if filename is provided then enable Backup button + this.view.model.on('change', function() { + if (!_.isUndefined(this.get('file')) && this.get('file') !== '') { + this.errorModel.clear(); + self.__internal.buttons[0].element.disabled = false; + } else { + self.__internal.buttons[0].element.disabled = true; + this.errorModel.set('file', '{{ _('Please provide filename') }}') + } + }); + }, + // Callback functions when click on the buttons of the Alertify dialogs + callback: function(e) { + if (e.button.text === '{{ _('Backup') }}') { + // Fetch current server id + var t = pgBrowser.tree, + i = t.selected(), + d = i && i.length == 1 ? t.itemData(i) : undefined, + node = d && pgBrowser.Nodes[d._type]; + + if (!d) + return; + + var treeInfo = node.getTreeNodeHierarchy.apply(node, [i]); + + var self = this, + baseUrl = "{{ url_for('backup.index') }}" + + "create_job/" + treeInfo.server._id, + args = this.view.model.toJSON(); + + $.ajax({ + url: baseUrl, + method: 'POST', + data:{ 'data': JSON.stringify(args) }, + success: function(res) { + if (res.success) { + alertify.message('{{ _('Background process for taking backup has been created!') }}', 1); + pgBrowser.Events.trigger('pgadmin-bgprocess:created', self); + } + }, + error: function(xhr, status, error) { + try { + var err = $.parseJSON(xhr.responseText); + alertify.alert( + '{{ _('Backup failed...') }}', + err.errormsg + ); + } catch (e) {} + } + }); + } + } + }; + }); + } + alertify[DialogName](true).resizeTo('60%','50%'); + }, + + // Callback to draw Backup Dialog for objects + backup_objects: function(action, treeItem) { + var title = S('{{ 'Backup (%s: %s)' }}'), + tree = pgBrowser.tree, + item = treeItem || tree.selected(), + data = item && item.length == 1 && tree.itemData(item), + node = data && data._type && pgBrowser.Nodes[data._type]; + + if (!node) + return; + + title = title.sprintf(node.label, data.label).value(); + + if(!alertify.backup_objects) { + // Create Dialog title on the fly with node details + alertify.dialog('backup_objects' ,function factory() { + return { + main: function(title) { + this.set('title', title); + }, + setup:function() { + return { + buttons: [{ + text: '{{ _('Backup') }}', key: 27, className: 'btn btn-primary fa fa-lg fa-save pg-alertify-button' + },{ + text: '{{ _('Cancel') }}', key: 27, className: 'btn btn-danger fa fa-lg fa-times pg-alertify-button' + }], + // Set options for dialog + options: { + title: title, + //disable both padding and overflow control. + padding : !1, + overflow: !1, + model: 0, + resizable: true, + maximizable: true, + pinnable: false + } + }; + }, + hooks: { + // triggered when the dialog is closed + onclose: function() { + if (this.view) { + this.view.remove({data: true, internal: true, silent: true}); + } + } + }, + prepare: function() { + var self = this; + // Disable Backup button until user provides Filename + this.__internal.buttons[0].element.disabled = true; + var $container = $("
"); + var t = pgBrowser.tree, + i = t.selected(), + d = i && i.length == 1 ? t.itemData(i) : undefined, + node = d && pgBrowser.Nodes[d._type]; + + if (!d) + return; + + var treeInfo = node.getTreeNodeHierarchy.apply(node, [i]); + + var newModel = new BackupObjectModel( + {}, {node_info: treeInfo} + ), + fields = Backform.generateViewSchema( + treeInfo, newModel, 'create', node, treeInfo.server, true + ); + + var view = this.view = new Backform.Dialog({ + el: $container, model: newModel, schema: fields + }); + + $(this.elements.body.childNodes[0]).addClass( + 'alertify_tools_dialog_properties obj_properties' + ); + + view.render(); + + this.elements.content.appendChild($container.get(0)); + + // Listen to model & if filename is provided then enable Backup button + this.view.model.on('change', function() { + if (!_.isUndefined(this.get('file')) && this.get('file') !== '') { + this.errorModel.clear(); + self.__internal.buttons[0].element.disabled = false; + } else { + self.__internal.buttons[0].element.disabled = true; + this.errorModel.set('file', '{{ _('Please provide filename') }}') + } + }); + + }, + // Callback functions when click on the buttons of the Alertify dialogs + callback: function(e) { + if (e.button.text === "Backup") { + // Fetch current server id + var t = pgBrowser.tree, + i = t.selected(), + d = i && i.length == 1 ? t.itemData(i) : undefined, + node = d && pgBrowser.Nodes[d._type]; + + if (!d) + return; + + var treeInfo = node.getTreeNodeHierarchy.apply(node, [i]); + + // Set current database into model + this.view.model.set('database', treeInfo.database.label); + + // We will remove once object tree is implemented + // If selected node is Schema then add it in model + if(d._type == 'schema') { + var schemas = []; + schemas.push(d.label); + this.view.model.set('schemas', schemas); + } + // If selected node is Table then add it in model along with + // its schema + if(d._type == 'table') { + var tables = [], + selected_table = []; + selected_table.push(treeInfo.schema.label) + selected_table.push(d.label); + this.view.model.set('tables', selected_table); + } + + var self = this, + baseUrl = "{{ url_for('backup.index') }}" + + "create_job/backup_object/" + treeInfo.server._id, + args = this.view.model.toJSON(); + + $.ajax({ + url: baseUrl, + method: 'POST', + data:{ 'data': JSON.stringify(args) }, + success: function(res) { + if (res.success) { + alertify.message('{{ _('Background process for taking backup has been created!') }}', 1); + pgBrowser.Events.trigger('pgadmin-bgprocess:created', self); + } + }, + error: function(xhr, status, error) { + try { + var err = $.parseJSON(xhr.responseText); + alertify.alert( + '{{ _('Backup failed...') }}', + err.errormsg + ); + } catch (e) {} + } + }); + } + } + }; + }); + } + alertify.backup_objects(title).resizeTo('65%','60%'); + } + }; + return pgBrowser.Backup; + }); diff --git a/web/pgadmin/utils/preferences.py b/web/pgadmin/utils/preferences.py index ce431291b..862ccc866 100644 --- a/web/pgadmin/utils/preferences.py +++ b/web/pgadmin/utils/preferences.py @@ -264,7 +264,7 @@ class Preferences(object): self.mid = module.id if name in Preferences.modules: - m = Preferences.modules + m = Preferences.modules[name] self.categories = m.categories else: Preferences.modules[name] = self