diff --git a/web/pgadmin/tools/restore/__init__.py b/web/pgadmin/tools/restore/__init__.py new file mode 100644 index 0000000..7596da9 --- /dev/null +++ b/web/pgadmin/tools/restore/__init__.py @@ -0,0 +1,207 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2016, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Implements Restore Utility""" + +import json +import os +from random import randint +from flask import render_template, request, current_app, \ + url_for, Response +from flask.ext.babel import gettext +from pgadmin.utils.ajax import make_json_response, \ + internal_server_error, bad_request +from pgadmin.utils import PgAdminModule +from flask.ext.security import login_required +from pgadmin.model import db, Jobs, Server +from config import PG_DEFAULT_DRIVER, UTILITIES +from pgadmin.utils.preferences import Preferences +from pgadmin.tools.backup import _format_qtIdents + +# set template path for sql scripts +MODULE_NAME = 'restore' +server_info = {} + + +class RestoreModule(PgAdminModule): + """ + class RestoreModule(Object): + + It is a utility which inherits PgAdminModule + class and define methods to load its own + javascript file. + + LABEL = gettext('Binary Paths') + """ + + LABEL = gettext('Utilities') + + def get_own_javascripts(self): + """" + Returns: + list: js files used by this module + """ + return [{ + 'name': 'pgadmin.tools.restore', + 'path': url_for('restore.index') + 'restore', + 'when': None + }] + + def register_preferences(self): + """ + Get storage directory preference + """ + self.storage_directory = Preferences.module('file_manager') + self.storage_dir = self.storage_directory.preference( + 'storage_dir' + ) + + self.bin_path = Preferences.module('backup') + # Register 'EDB specific utility binary directory' preference + self.edb_utility_dir = self.bin_path.preference( + 'edb_utilities_bin_dir' + ) + + # Register 'PG specific utility binary directory' preference + self.pg_utility_dir = self.bin_path.preference( + 'pg_utilities_bin_dir' + ) + +# Create blueprint for RestoreModule class +blueprint = RestoreModule( + MODULE_NAME, __name__, static_url_path='') + + +@blueprint.route("/") +@login_required +def index(): + return bad_request(errormsg=gettext("This URL can not be called directly!")) + + +@blueprint.route("/restore.js") +@login_required +def script(): + """render own javascript""" + return Response(response=render_template( + "restore/js/restore.js", _=gettext), + status=200, + mimetype="application/javascript") + + +def utility_with_bin_path(server_type): + """ + Args: + server_type: Server type (PG/PPAS) + + Returns: + Utility to use for restore with full path taken from preference + """ + if server_type == 'ppas': + return os.path.join(blueprint.edb_utility_dir.get(), + UTILITIES['EDB_RESTORE']) + elif server_type == 'pg': + return os.path.join(blueprint.pg_utility_dir.get(), + UTILITIES['PG_RESTORE']) + + +def filename_with_file_manager_path(file): + """ + Args: + file: File name returned from client file manager + + Returns: + Filename to use for restore with full path taken from preference + """ + # Set file manager directory from preference + file_manager_dir = blueprint.storage_dir.get() + return os.path.join(file_manager_dir, file) + + +@blueprint.route('/create_job/', methods=['POST']) +@login_required +def create_restore_job(sid): + """ + Args: + sid: Server ID + + Creates a new job for restore task + + 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).first() + + if server is None: + return make_json_response( + success=0, + errormsg=gettext("Couldn't find the given server") + ) + + # To fetch MetaData for the server + from pgadmin.utils.driver import get_driver + manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(server.id) + conn = manager.connection() + connected = conn.connected() + + if not connected: + return make_json_response( + success=0, + errormsg=gettext("Please connect to the server first...") + ) + + utility = utility_with_bin_path(manager.server_type) + + arguments = render_template( + 'arguments/restore_objects.args', + server=server, + data=data, + first_time=False + ) + + arguments = _format_qtIdents(arguments) + + try: + # Generate random job id + jid = randint(2001, 3000) + create_job = Jobs( + job_id=jid, + command=utility, + arguments=arguments + ) + # Save it + db.session.add(create_job) + db.session.commit() + + 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} + ) + +""" +TODO:// + Add browser tree +""" \ No newline at end of file diff --git a/web/pgadmin/tools/restore/templates/arguments/restore_objects.args b/web/pgadmin/tools/restore/templates/arguments/restore_objects.args new file mode 100644 index 0000000..3e57750 --- /dev/null +++ b/web/pgadmin/tools/restore/templates/arguments/restore_objects.args @@ -0,0 +1,33 @@ +--host {{server.host}} --port {{server.port}} --username "{{conn|qtIdent(server.username)}}" --role {% if data.role %} +"{{ conn|qtIdent(data.role) }}" {% else %}"{{ conn|qtIdent(server.role) }}" {% endif %}--no-password --dbname "{{ +conn|qtIdent(data.database) }}" {% if data.format == 'Directory' %}--format directory + {% endif %}{% if data.pre_data %} +--section pre-data {% endif %}{% if data.data %} +--section data {% endif %}{% if data.post_data %} +--section post-data {% endif %}{% if data.only_data %} +--data-only {% else %} +{% if data.dns_owner %} +--no-owner {% endif %}{% if data.dns_privilege %} +--no-privileges {% endif %}{% if data.dns_tablespace %} +--no-tablespaces {% endif %} +{% endif %}{% if data.only_schema %} +--schema-only {% else %} +{% if data.disable_trigger %}--disable-triggers {% endif %} +{% endif %}{% if data.include_create_database %} +--create {% endif %}{% if data.clean %} +--clean {% endif %}{% if data.single_transaction %} +--single-transaction {% endif %}{% if data.no_data_fail_table %} +--no-data-for-failed-tables {% endif %}{% if data.use_set_session_auth %} +--use-set-session-authorization {% endif %}{% if data.exit_on_error %} +--exit-on-error {% endif %}{% if data.no_of_jobs %} +--jobs {{ data.no_of_jobs }} {% endif %}{% if data.verbose %} +--verbose {% endif %}{% if data.schemas %} +--schema {{ conn|qtIdent(schemas) }} {% endif %}{% if data.tables %} +--table {{ conn|qtIdent(tables) }} {% endif %}{% if data.function_node %} +--function {{ conn|qtIdent(function_node) }} {% endif %}{% if data.trigger_node %} +--trigger {{ conn|qtIdent(trigger_node) }} {% endif %}{% if data.trigger_function_node %} +--function {{ conn|qtIdent(trigger_function_node) }} {% endif %}{% if data.index_node %} +--index {{ conn|qtIdent(index_node) }} {% endif %}{% if data.file %} +"{{data.file}}" +{% endif %}{% if first_time %} +--list {% endif %} \ No newline at end of file diff --git a/web/pgadmin/tools/restore/templates/restore/js/restore.js b/web/pgadmin/tools/restore/templates/restore/js/restore.js new file mode 100644 index 0000000..0d68f4f --- /dev/null +++ b/web/pgadmin/tools/restore/templates/restore/js/restore.js @@ -0,0 +1,442 @@ +define([ + 'jquery', 'underscore', 'underscore.string', 'alertify', + 'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node' + ], + + // This defines Restore dialog + function($, _, S, alertify, pgBrowser, Backbone, Backgrid, Backform, pgNode) { + + // if module is already initialized, refer to that. + if (pgBrowser.Restore) { + return pgBrowser.Restore; + } + + 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' + }); + + //Restore Model (Objects like Database/Schema/Table) + var RestoreObjectModel = Backbone.Model.extend({ + idAttribute: 'id', + defaults: { + file: undefined, + role: 'postgres', + format: 'Custom or tar', + verbose: true, + blobs: true, + encoding: undefined, + database: undefined, + schemas: undefined, + tables: undefined, + function_node: undefined, + trigger_node: undefined, + trigger_function_node: undefined, + index_node: undefined + }, + // Default values! + initialize: function(attrs, args) { + // Set default options according to node type selection by user + var node_type = attrs.node_data.type; + + if (node_type) { + // Only_Schema option + if (node_type === 'function' || node_type === 'index' + || node_type === 'trigger') { + this.set({'only_schema': true}, {silent: true}); + } + + // Only_Data option + if (node_type === 'table') { + this.set({'only_data': true}, {silent: true}); + } + + // Clean option + if (node_type === 'function' || node_type === 'trigger_function') { + this.set({'clean': true}, {silent: true}); + } + } + Backbone.Model.prototype.initialize.apply(this, arguments); + }, + schema: [{ + id: 'format', label: '{{ _('Format') }}', + type: 'text', disabled: false, + control: 'select2', select2: { + allowClear: false, + width: "100%" + }, + options: [ + {label: "Custom or tar", value: "Custom or tar"}, + {label: "Directory", value: "Directory"} + ] + },{ + id: 'file', label: '{{ _('Filename') }}', + type: 'text', disabled: false, control: Backform.FileControl, + dialog_type: 'select_file', supp_types: ['*', 'backup','sql', 'patch'] + },{ + id: 'no_of_jobs', label: '{{ _('Number of jobs') }}', + type: 'int' + },{ + id: 'role', label: '{{ _('Role name') }}', + control: 'node-list-by-name', node: 'role', + select2: { allowClear: false } + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Sections') }}', + group: '{{ _('Restore options') }}', + schema:[{ + id: 'pre_data', label: '{{ _('Pre-data') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Sections') }}', + deps: ['only_data', 'only_schema'], disabled: function(m) { + return this.node.type !== 'function' && this.node.type !== 'table' + && this.node.type !== 'trigger' + && this.node.type !== 'trigger_function' + && (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 this.node.type !== 'function' && this.node.type !== 'table' + && this.node.type !== 'trigger' + && this.node.type !== 'trigger_function' + && (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 this.node.type !== 'function' && this.node.type !== 'table' + && this.node.type !== 'trigger' + && this.node.type !== 'trigger_function' + && (m.get('only_data') || m.get('only_schema')); + } + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Type of objects') }}', + group: '{{ _('Restore 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 (this.node.type !== 'database' && this.node.type !== 'schema') + || ( 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 (this.node.type !== 'database' && this.node.type !== 'schema') + || ( m.get('pre_data') + || m.get('data') + || m.get('post_data') + || m.get('only_data') + ); + } + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Do not save') }}', + group: '{{ _('Restore 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') }}' + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Queries') }}', + group: '{{ _('Restore options') }}', + schema:[{ + id: 'include_create_database', label: '{{ _('Include CREATE DATABASE statement') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Queries') }}' + },{ + id: 'clean', label: '{{ _('Clean before restore') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Queries') }}', + disabled: function(m) { + return this.node.type === 'function' || + this.node.type === 'trigger_function'; + } + },{ + id: 'single_transaction', label: '{{ _('Single transaction') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Queries') }}' + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Disable') }}', + group: '{{ _('Restore options') }}', + schema:[{ + id: 'disable_trigger', label: '{{ _('Trigger') }}', + control: Backform.CustomSwitchControl, group: '{{ _('Disable') }}' + },{ + id: 'no_data_fail_table', label: '{{ _('No data for Failed Tables') }}', + control: Backform.CustomSwitchControl, disabled: false, group: '{{ _('Disable') }}' + }] + },{ + type: 'nested', control: 'fieldset', label: '{{ _('Miscellaneous / Behavior') }}', + group: '{{ _('Restore options') }}', + schema:[{ + id: 'verbose', label: '{{ _('Verbose messages') }}', + control: Backform.CustomSwitchControl, disabled: false, + group: '{{ _('Miscellaneous / Behavior') }}' + },{ + id: 'use_set_session_auth', label: '{{ _('Use SET SESSION AUTHORIZATION') }}', + control: Backform.CustomSwitchControl, disabled: false, + group: '{{ _('Miscellaneous / Behavior') }}' + },{ + id: 'exit_on_error', label: '{{ _('Exit on error') }}', + control: Backform.CustomSwitchControl, disabled: false, + group: '{{ _('Miscellaneous / Behavior') }}' + }] + },{ + id: 'todo', label: '{{ _('TODO') }}', text: '{{ _('Add Objects selection tree here') }}', + type: 'note', group: '{{ _('Objects') }}' + }], + validate: function() { + return null; + } + }); + + // Create an Object Restore of pgBrowser class + pgBrowser.Restore = { + init: function() { + if (this.initialized) + return; + + this.initialized = true; + + // Define list of nodes on which restore context menu option appears + var restore_supported_nodes = [ + 'database', 'schema', + 'table', 'function', + 'trigger', 'index' + ]; + + /** + Enable/disable restore 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(restore_supported_nodes, d._type) !== -1 && + is_parent_catalog(itemData, item, data) ) ? true: false + ); + else + return false; + }; + + is_parent_catalog = function(itemData, item, data) { + var t = pgBrowser.tree, i = item, d = itemData; + // To iterate over tree to check parent node + while (i) { + // If it is schema then allow user to restore + if (_.indexOf(['catalog'], d._type) > -1) + return false; + i = t.hasParent(i) ? t.parent(i) : null; + d = i ? t.itemData(i) : null; + } + // by default we do not want to allow create menu + return true; + } + + // Define the nodes on which the menus to be appear + var menus = [{ + name: 'restore_object', module: this, + applies: ['tools'], callback: 'restore_objects', + priority: 9, label: '{{_("Restore...") }}', + icon: 'fa fa-upload', enable: menu_enabled + }]; + + for (var idx = 0; idx < restore_supported_nodes.length; idx++) { + menus.push({ + name: 'restore_' + restore_supported_nodes[idx], + node: restore_supported_nodes[idx], module: this, + applies: ['context'], callback: 'restore_objects', + priority: 9, label: '{{_("Restore...") }}', + icon: 'fa fa-upload', enable: menu_enabled + }); + } + + pgAdmin.Browser.add_menus(menus); + return this; + }, + // Callback to draw Backup Dialog for objects + restore_objects: function(action, treeItem) { + var title = '{{ _('Restore') }}', + 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; + + if(!alertify.restore_objects) { + // Create Dialog title on the fly with node details + alertify.dialog('restore_objects' ,function factory() { + return { + main: function(title) { + this.set('title', title); + }, + setup:function() { + return { + buttons: [{ + text: '{{ _('Restore') }}', key: 27, className: 'btn btn-primary' + },{ + text: '{{ _('Cancel') }}', key: 27, className: 'btn btn-danger' + }], + // 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 RestoreObjectModel( + {node_data: node}, {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 === "Restore") { + // 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 node info into model + this.view.model.set('database', treeInfo.database.label); + if (treeInfo.schema) + this.view.model.set('schema', treeInfo.schema.label); + if (treeInfo.table) + this.view.model.set('table', treeInfo.table.label); + if (treeInfo.function) + this.view.model.set('function', treeInfo.function.label); + if (treeInfo.index) + this.view.model.set('index', treeInfo.index.label); + if (treeInfo.trigger) + this.view.model.set('trigger', treeInfo.trigger.label); + if (treeInfo.trigger_function) + this.view.model.set('trigger_function', treeInfo.trigger_function.label); + + var self = this, + baseUrl = "{{ url_for('restore.index') }}" + + "create_job/" + treeInfo.server._id, + args = this.view.model.toJSON(); + + $.ajax({ + url: baseUrl, + method: 'POST', + data:{ 'data': JSON.stringify(args) }, + success: function(res) { + var msg = alertify.message('{{ _('Restore job created (Click for more details)') }}', 10); + msg.callback = function (isClicked) { + if(isClicked) + console.log('Show detailed logs >>' + res); + } + }, + error: function(xhr, status, error) { + try { + var err = $.parseJSON(xhr.responseText); + alertify.alert( + '{{ _('Backup failed...') }}', + err.errormsg + ); + } catch (e) {} + } + }); + } + } + }; + }); + } + alertify.restore_objects(title).resizeTo('65%','60%'); + } + }; + return pgBrowser.Restore; + }); \ No newline at end of file